Merge remote-tracking branch 'origin/master' into worktree/composer-scrollbar-gutter

This commit is contained in:
creatixchu
2026-08-05 16:19:52 +08:00
71 changed files with 1102 additions and 156 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/connection/README.md
README.md: faf093964a740092983e13bf88f2cccd853c3e36
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c
README.md: be521feb4826f9ad804f61532dee0aa85f39de7e
README.zh.md: bd58ce649c75143b94c1e5dea0241e1f9a934e69

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`, `settings.openDocument`, `settings.update`, `settings.replace`, `settings.mutate`, `credentials.describe`, `credentials.set`, and `credentials.unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。真实浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocketfixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。真实浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocketfixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe``settings.openDocument``settings.update``settings.replace``settings.mutate``credentials.describe``credentials.set``credentials.unset`读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
hasDocument: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
@@ -2399,6 +2400,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
revision: 0,
}],
}),
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
openDocument: request => ok(request, { opened: true as const }),
update: request => err(request, {
code: 'settings-rejected',
message: 'fixture: the minimal readiness settings descriptor is read-only',
@@ -2549,6 +2552,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'goal.complete': return this.api.goals.complete(request)
case 'goal.clear': return this.api.goals.clear(request)
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
case 'settings.update': return this.api.settings.update(request)
case 'settings.replace': return this.api.settings.replace(request)
case 'settings.mutate': return this.api.settings.mutate(request)

View File

@@ -53,6 +53,7 @@ const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
'host.openPath',
'settings.describe',
'settings.openDocument',
'settings.update',
'settings.replace',
'settings.mutate',

View File

@@ -181,7 +181,8 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -134,7 +134,7 @@ describe('connection node half', () => {
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
@@ -218,7 +218,7 @@ describe('connection node half over a real HTTP server', () => {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {

View File

@@ -216,7 +216,8 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -156,7 +156,7 @@ function scriptedFace(overrides: {
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
@@ -975,6 +975,7 @@ describe('ModelsSection', () => {
const { face } = await mountSection()
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
writable: false,
hasDocument: false,
namespaces: wireNamespaces(),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)

View File

@@ -51,7 +51,7 @@ function api(overrides: {
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
},
settings: {
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
update: () => Promise.resolve(fail('unused')),
replace: () => Promise.resolve(fail('unused')),
},
@@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
...NAMESPACES[0],
secrets: [
@@ -195,6 +196,7 @@ describe('edge joins', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-pi-ai',
schema: {},
@@ -221,6 +223,7 @@ describe('edge joins', () => {
const { face, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
})),
providers: () => Promise.resolve(ok({

View File

@@ -48,7 +48,7 @@ async function bench() {
settings: {
describe: () => Promise.resolve({
rpcId: 'describe',
result: { ok: true as const, value: { writable: true, namespaces: [] } },
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
}),
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
},

View File

@@ -60,7 +60,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -87,7 +87,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -111,7 +111,7 @@ describe('PermissionRow', () => {
it('hides an unavailable namespace and disables a read-only provider', async () => {
const absent = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
mutate: vi.fn(),
} as never,
})
@@ -121,7 +121,7 @@ describe('PermissionRow', () => {
const readonly = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
} as never,
})
@@ -148,7 +148,7 @@ describe('PermissionRow', () => {
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))

View File

@@ -88,6 +88,7 @@ describe('permission settings store', () => {
it('loads and writes defaultPreset with optimistic concurrency', async () => {
const describe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only', 4)],
})))
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
@@ -115,7 +116,7 @@ describe('permission settings store', () => {
})
it('hides the row when the namespace is absent and contains write failures', async () => {
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
const controller = new PermissionSettingsController({
settings: { describe, mutate: vi.fn() } as never,
})
@@ -124,7 +125,7 @@ describe('permission settings store', () => {
const failing = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
@@ -146,14 +147,14 @@ describe('permission settings store', () => {
}>>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
const mutate = vi.fn()
const controller = new PermissionSettingsController({
settings: { describe, mutate } as never,
})
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
await stale
expect(controller.store.getSnapshot()).toMatchObject({
currentValue: 'read-only',
@@ -200,7 +201,7 @@ describe('permission settings store', () => {
expect(describe).not.toHaveBeenCalled()
const loading = idle.load()
idle.dispose()
read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
await loading
expect(idle.store.getSnapshot().status).toBe('loading')
@@ -220,6 +221,7 @@ describe('permission settings store', () => {
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const activeDescribe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only')],
})))
const active = new PermissionSettingsController({
@@ -240,7 +242,7 @@ describe('permission settings store', () => {
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const disposedWrite = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => rejectedMutation.promise,
} as never,
})

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-general/README.md
README.md: 0202d596f509feeba39a38254e8bab2fae27b649
README.zh.md: adec73edda00d34e209772f0bcc54a994f593997
README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。

View File

@@ -0,0 +1,16 @@
.action {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.error {
max-width: 180px;
overflow: hidden;
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,50 @@
/** Optional settings-header action for opening a file-backed Host document. */
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts'
import css from './SettingsDocumentAction.module.css'
/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */
export interface SettingsDocumentActionInjected {
/** Provider metadata and action state owner. */
controller: SettingsDocumentStore
/** Bound selector hook for the controller snapshot. */
useSnapshot: SnapshotSelectorHook<SettingsDocumentState>
}
/** Header-action owner share, localized copy, and the registrant's state face. */
export type SettingsDocumentActionProps =
PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected
/**
* Render the open-document action only after Host metadata confirms document availability.
* @param props - header owner props, localized copy, and injected document state.
* @returns the action, or null while unavailable or unresolved.
*/
export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode {
const state = useSnapshot(snapshot => snapshot)
useEffect(() => {
void controller.load()
}, [controller])
if (state.status !== 'ready') return null
return (
<div className={css.action}>
{state.error === null ? null : <span className={css.error} role="alert">{t('openDocument.error')}</span>}
<Button
variant="outline"
size="sm"
disabled={state.opening}
onClick={() => { void controller.open() }}
>
{t('openDocument')}
</Button>
</div>
)
}

View File

@@ -1,8 +1,8 @@
/**
* Settings ownerless-copy plugin, browser half: registers everything on the
* Settings surface that belongs to no single feature — the trigger/header
* chrome content, the General section, and the `settings` dictionaries.
* Feature-owned rows and sections stay with their features.
* chrome content, local-document action, General section, and `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
@@ -27,6 +30,9 @@ export type {
export type {
GeneralSectionComponentProps,
} from './GeneralSection.tsx'
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
export type { SettingsDocumentState } from './settings-document-store.ts'
export { SettingsDocumentStore } from './settings-document-store.ts'
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
export type { WelcomeNoticeState } from './welcome-store.ts'
export type { SettingsKey } from './locales.ts'
@@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void {
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const documentController = connection.isLoopback
? new SettingsDocumentStore(connection.api)
: undefined
const documentInjected = documentController === undefined
? undefined
: (() => {
const useSnapshot = bindSnapshotSelector(documentController.store)
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
})()
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
@@ -75,15 +90,28 @@ export function apply(ctx: ClientContext): void {
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
ctx.on('connection/reset', () => {
refresh()
refreshDocumentIfLoaded(documentController)
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: welcome invalidations')
}, 'ui-settings-general: metadata invalidations')
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
const action = documentInjected === undefined
? undefined
: deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () =>
ctx.slots.register({
name: 'settings.action',
id: 'open-document',
order: 0,
locale: NS,
inject: documentInjected,
}, SettingsDocumentAction))
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
@@ -106,9 +134,10 @@ export function apply(ctx: ClientContext): void {
return () => {
trigger.dispose()
header.dispose()
action?.dispose()
close.dispose()
general.dispose()
welcome.dispose()
}
}, 'ui-settings-general: chrome, section, and onboarding registrations')
}, 'ui-settings-general: chrome, action, section, and onboarding registrations')
}

View File

@@ -6,6 +6,8 @@ export const zh = {
'trigger': '设置',
'title': '设置',
'close': '关闭',
'openDocument': '打开配置文件',
'openDocument.error': '无法打开配置文件',
'general.nav': '通用设置',
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
@@ -24,6 +26,8 @@ export const en = {
'trigger': 'Settings',
'title': 'Settings',
'close': 'Close',
'openDocument': 'Open configuration file',
'openDocument.error': 'Could not open configuration file',
'general.nav': 'General',
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],

View File

@@ -0,0 +1,96 @@
/** State owner for the optional local settings-document action. */
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Browser state of the Host-owned settings document. */
export interface SettingsDocumentState {
/** Metadata-loading phase; unavailable means the provider has no local document or the read failed. */
status: 'idle' | 'loading' | 'ready' | 'unavailable'
/** Whether one native-open request is in flight. */
opening: boolean
/** Last metadata/native-open diagnostic; UI exposes only localized copy. */
error: string | null
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Loads local-document availability and invokes the pathless Host-owned open operation. */
export class SettingsDocumentStore {
/** uSES-safe state source shared by the registered header action. */
readonly store: SnapshotStore<SettingsDocumentState> = createSnapshotStore({
status: 'idle', opening: false, error: null,
})
private generation = 0
/**
* @param api - loopback settings wire face that reports and opens the provider document.
*/
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* Load whether the current provider owns a local document.
* @returns after the latest metadata response updates the store.
*/
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((state) => {
state.status = 'loading'
state.error = null
})
try {
const { result } = await this.api.settings.describe({})
if (generation !== this.generation) return
if (!result.ok) {
this.store.update((state) => {
state.status = 'unavailable'
state.error = result.error.message
})
return
}
this.store.update((state) => {
state.status = result.value.hasDocument ? 'ready' : 'unavailable'
state.error = null
})
} catch (error) {
if (generation !== this.generation) return
this.store.update((state) => {
state.status = 'unavailable'
state.error = messageOf(error)
})
}
}
/**
* Open the loaded document once; concurrent gestures collapse behind the in-flight action.
* @returns after the native-open request settles, or immediately when unavailable/already opening.
*/
async open(): Promise<void> {
const current = this.store.getSnapshot()
if (current.status !== 'ready' || current.opening) return
this.store.update((state) => {
state.opening = true
state.error = null
})
try {
const response = await this.api.settings.openDocument({})
if (!response.result.ok) throw new Error(response.result.error.message)
} catch (error) {
this.store.update((state) => { state.error = messageOf(error) })
} finally {
this.store.update((state) => { state.opening = false })
}
}
}
/**
* Refresh document availability after reconnect only when a surface has already requested it.
* @param controller - optional loopback document state owner.
*/
export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -16,8 +16,9 @@ export const inject = ['invariants']
/**
* No runtime invariant: the settings seam validates and publishes the durable
* welcome section, while slot conflicts fail loud in the slot core; this
* package owns no additional event/data relationship between those systems.
* welcome section, while slot conflicts fail loud in the slot core. The local
* document action is browser state over typed RPC responses and is covered by
* store/component tests rather than a Cordis runtime relationship.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,4 +1,4 @@
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
@@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/** The five seats this plugin fills (slot name → expected component). */
/** The seats this plugin fills for a loopback browser (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
['settings.header', HeaderContent],
['settings.action', SettingsDocumentAction],
['settings.close', CloseLabel],
['settings.section', GeneralSection],
['settings.onboarding', WelcomeNotice],
@@ -36,6 +39,7 @@ async function bench(isLoopback = true) {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
@@ -47,11 +51,18 @@ async function bench(isLoopback = true) {
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
const settingsOpenDocument = vi.fn(() => Promise.resolve({
rpcId: 'settings-open' as never,
result: { ok: true as const, value: { opened: true as const } },
}))
ctx.provide('connection', {
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
isLoopback,
} as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument }
}
/** Declare the shell's four child slots the way ui-settings' entry does. */
/** Declare the shell's six child slots the way ui-settings' entry does. */
function declare(slots: SlotsService): () => void {
return slots.register(
{
@@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void {
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' },
@@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('fills all five seats for declarations before or after apply', async () => {
it('fills all six seats for declarations before or after apply', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
@@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => {
expect(before.slots.entries('settings.general.item')).toEqual([])
const welcome = before.slots.entries('settings.onboarding')[0]!
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
const action = before.slots.entries('settings.action')[0]!
const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
expect(actionInjected.useSnapshot).toEqual(expect.any(Function))
// Copy rides the standard locale seat: every seat declares the namespace.
for (const [name] of SEATS) {
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
@@ -159,10 +175,25 @@ describe('ui-settings-general apply', () => {
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.action')[0]!
const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
b.ctx.emit('connection/reset')
expect(b.settingsDescribe).not.toHaveBeenCalled()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
})
it('keeps remote welcome acknowledgement process-local', async () => {
const b = await bench(false)
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
@@ -170,6 +201,9 @@ describe('ui-settings-general apply', () => {
await expect(controller.acknowledge()).resolves.toBe(true)
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
expect(b.settingsDescribe).not.toHaveBeenCalled()
expect(b.slots.entries('settings.action')).toEqual([])
await fiber.dispose()
for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {

View File

@@ -1,10 +1,13 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import type { TriggerContentProps } from '../src/client/chrome.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -54,3 +57,95 @@ describe('GeneralSection', () => {
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
})
})
describe('SettingsDocumentAction', () => {
it('appears only for a file-backed provider and requests its Host-owned document', async () => {
const openDocument = vi.fn(() => Promise.resolve({
rpcId: 'document-open' as never,
result: { ok: true as const, value: { opened: true as const } },
}))
const controller = new SettingsDocumentStore({
settings: {
describe: vi.fn(() => Promise.resolve({
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
},
})),
openDocument,
},
} as never)
render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
/>)
const action = await screen.findByRole('button', { name: 'Open configuration file' })
fireEvent.click(action)
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
})
it('stays absent without a document and retries availability after remount', async () => {
const describe = vi.fn()
.mockResolvedValueOnce({
rpcId: 'document-action-absent' as never,
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
})
.mockResolvedValueOnce({
rpcId: 'document-action-ready' as never,
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
})
const controller = new SettingsDocumentStore({
settings: {
describe,
openDocument: vi.fn(),
},
} as never)
const first = render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
/>)
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
first.unmount()
render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
/>)
expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
expect(describe).toHaveBeenCalledTimes(2)
})
it('keeps the action available and reports a native-open failure', async () => {
const controller = new SettingsDocumentStore({
settings: {
describe: vi.fn(() => Promise.resolve({
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [] },
},
})),
openDocument: vi.fn(() => Promise.resolve({
rpcId: 'document-open-failed' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
})),
},
} as never)
render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
/>)
fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' }))
expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file')
expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy()
})
})

View File

@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
function response(hasDocument = false): RpcResponse<{
writable: boolean
hasDocument: boolean
namespaces: []
}> {
return {
rpcId: 'settings-document' as never,
result: {
ok: true,
value: { writable: true, hasDocument, namespaces: [] },
},
}
}
function opened(): RpcResponse<{ opened: true }> {
return {
rpcId: 'settings-open' as never,
result: { ok: true, value: { opened: true } },
}
}
function describeFailed(message: string): RpcResponse<never> {
return {
rpcId: 'settings-document-failed' as never,
result: { ok: false, error: { code: 'internal', message, details: {} } },
}
}
describe('SettingsDocumentStore', () => {
it('loads provider metadata and asks the settings domain to open its document', async () => {
const describe = vi.fn(() => Promise.resolve(response(true)))
const openDocument = vi.fn(() => Promise.resolve(opened()))
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
await controller.load()
expect(controller.store.getSnapshot()).toEqual({
status: 'ready', opening: false, error: null,
})
await controller.open()
expect(openDocument).toHaveBeenCalledWith({})
})
it('marks absent or failed metadata unavailable without opening anything', async () => {
const openDocument = vi.fn(() => Promise.resolve(opened()))
const absent = new SettingsDocumentStore({
settings: { describe: () => Promise.resolve(response()), openDocument },
} as never)
await absent.load()
await absent.open()
expect(absent.store.getSnapshot().status).toBe('unavailable')
expect(openDocument).not.toHaveBeenCalled()
const failed = new SettingsDocumentStore({
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
} as never)
await failed.load()
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
const rejected = new SettingsDocumentStore({
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
} as never)
await rejected.load()
expect(rejected.store.getSnapshot()).toMatchObject({
status: 'unavailable', error: 'provider failed',
})
})
it('collapses concurrent open gestures and recovers after a failure', async () => {
let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
const controller = new SettingsDocumentStore({
settings: { describe: () => Promise.resolve(response(true)), openDocument },
} as never)
await controller.load()
const first = controller.open()
const second = controller.open()
expect(openDocument).toHaveBeenCalledOnce()
resolveOpen({
rpcId: 'settings-open-failed' as never,
result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } },
})
await Promise.all([first, second])
expect(controller.store.getSnapshot()).toMatchObject({
status: 'ready', opening: false, error: 'no default editor',
})
})
it('ignores stale metadata completions and reports non-Error native failures', async () => {
let resolveFirst!: (value: ReturnType<typeof response>) => void
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
const describe = vi.fn()
.mockReturnValueOnce(first)
.mockResolvedValueOnce(response(true))
let rejectOpen!: (reason?: unknown) => void
const controller = new SettingsDocumentStore({
settings: {
describe,
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
},
} as never)
const stale = controller.load()
await controller.load()
resolveFirst(response())
await stale
expect(controller.store.getSnapshot().status).toBe('ready')
const opening = controller.open()
rejectOpen('native unavailable')
await opening
expect(controller.store.getSnapshot()).toMatchObject({
status: 'ready', opening: false, error: 'native unavailable',
})
let rejectFirst!: (error: Error) => void
const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject })
const caught = new SettingsDocumentStore({
settings: {
describe: vi.fn()
.mockReturnValueOnce(rejectedFirst)
.mockResolvedValueOnce(response(true)),
openDocument: vi.fn(),
},
} as never)
const staleRejection = caught.load()
await caught.load()
rejectFirst(new Error('stale offline'))
await staleRejection
expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
})
})

View File

@@ -23,6 +23,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
settings: {
describe: () => Promise.resolve(response({
writable: true,
hasDocument: false,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},

View File

@@ -53,7 +53,7 @@ describe('WelcomeNoticeStore', () => {
] as const) {
const api = {
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))),
},
}
const controller = new WelcomeNoticeStore(api as never)
@@ -101,7 +101,7 @@ describe('WelcomeNoticeStore', () => {
rpcId: 'failed' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
}),
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
]) {
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
await controller.load()
@@ -112,6 +112,7 @@ describe('WelcomeNoticeStore', () => {
const controller = new WelcomeNoticeStore({
settings: { describe: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{ ...namespace(), value }],
})) },
} as never)
@@ -133,18 +134,20 @@ describe('WelcomeNoticeStore', () => {
const first = deferred<ReturnType<typeof ok>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
await stale
expect(controller.store.getSnapshot().acknowledged).toBe(false)
const failed = deferred<ReturnType<typeof ok>>()
describe
.mockImplementationOnce(() => failed.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
.mockImplementationOnce(() => Promise.resolve(ok({
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
})))
const staleFailure = controller.load()
await controller.load()
failed.reject('stale failure')
@@ -154,7 +157,7 @@ describe('WelcomeNoticeStore', () => {
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
const write = deferred<ReturnType<typeof ok>>()
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({
settings: { mutate: () => write.promise, describe },
} as never)

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: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
README.zh.md: 9ca4810faccaa119bb194c0e41bb8232b6aff630
README.md: de78d599b7833179339ceeb680fbd665b056bd83
README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9

View File

@@ -2,7 +2,7 @@
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.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 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).
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 shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. 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, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`由各功能持有、显示在全视口展示层中的有序页面。外壳不自带文案所有文本都来自注册方ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `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 依赖)。
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。

View File

@@ -167,12 +167,22 @@
flex: none;
display: flex;
align-items: flex-start;
justify-content: flex-end;
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;

View File

@@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
</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>

View File

@@ -2,8 +2,8 @@
* 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, close aria, section content)
* arrives from registrants. A feature owns its settings surface — adding a
* 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.
*/
@@ -29,6 +29,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* Absent contribution leaves the heading empty.
*/
'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
/**
* Optional actions rendered in the content-column header before Close.
* Registrants own visibility, behavior, copy, and failure presentation;
* the shell supplies only the ordered render site.
*/
'settings.action': { kind: 'list'; scope: 'root'; owner: SettingsHeaderOwnerProps }
/**
* The close button's visually-hidden label text (the button itself —
* icon, geometry, focus — is shell chrome). Absent contribution leaves
@@ -125,6 +131,11 @@ export type SettingsRootInjected = {
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<
'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section' | 'settings.onboarding'
| 'settings.trigger'
| 'settings.header'
| 'settings.action'
| 'settings.close'
| 'settings.section'
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>

View File

@@ -103,6 +103,7 @@ export function apply(ctx: ClientContext): void {
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' },

View File

@@ -24,10 +24,11 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's five child declarations (chrome, sections, and onboarding overlays). */
/** 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' },
@@ -38,7 +39,7 @@ describe('ui-settings apply', () => {
expect(inject).toEqual(['slots'])
})
it('registers the shell and declares the five child slots, before or after the declaration', async () => {
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()
@@ -124,7 +125,7 @@ describe('ui-settings apply', () => {
}
})
it('unregisters the shell and collapses all five child slots on teardown', async () => {
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 })

View File

@@ -14,6 +14,7 @@ type Step = { id: string; order: number }
const SEAT_CONTENT: Record<string, string> = {
'settings.trigger': 'Settings',
'settings.header': 'Settings Title',
'settings.action': 'Open configuration file',
'settings.close': 'Close',
}
@@ -114,6 +115,13 @@ describe('SettingsPanel chrome seats', () => {
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', () => {