fix(client): separate developer tool policy and harden static previews
This commit is contained in:
1 parent
54e6aabf0c
commit
caaaf89b1e
81 files changed
+403
-209
No files matched your search
@@ -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 .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
|
||||
2026-09-17-developer-tools-settings.md: 694e2a09fc20ad3185ba9c09058e8684a2247e46
|
||||
2026-09-17-developer-tools-settings.zh.md: e03aa258675be8f49dc13a9b43f2e3e00c8b85fa
|
||||
2026-09-17-developer-tools-settings.md: 62ec235d8d21ad621956363898da26935e1af487
|
||||
2026-09-17-developer-tools-settings.zh.md: b320c973be0e8b4d4a061db00d871d7e95f5460e
|
||||
@@ -10,11 +10,11 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
|
||||
|
||||
## Decision
|
||||
|
||||
The settings owner exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
|
||||
The dedicated `ctx.developerTools` service binds the generic settings scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
|
||||
|
||||
Off exposes only Chat, hides tool Inspect actions whose trajectory target is unavailable, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
|
||||
Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
|
||||
|
||||
The builtin HTML renderer enforces its own preview policy. Off parses a complete inert document, preserves root styling, recursively sanitizes template contents, removes active documents, resource hints, SVG animation and navigation, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
|
||||
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,4 +26,4 @@ The builtin HTML renderer enforces its own preview policy. Off parses a complete
|
||||
|
||||
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
|
||||
|
||||
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold explicitly seeds developer mode for diagnostic scenarios; settings chrome, document preview and changed-file scenarios leave the shipped default untouched. Native Electron execution remains unverified.
|
||||
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
|
||||
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
设置所有者提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
|
||||
独立的 `ctx.developerTools` 服务绑定通用设置 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
|
||||
|
||||
关闭时仅展示 Chat,隐藏轨迹目标不可用的工具 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
|
||||
关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
|
||||
|
||||
内置 HTML 渲染器执行自己的预览策略。关闭时,内容被解析为完整的惰性文档,保留根节点样式,递归清理 template 内容,移除主动文档、资源提示、SVG 动画与导航,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
|
||||
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,4 +26,4 @@ Status: implemented
|
||||
|
||||
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
|
||||
|
||||
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架为诊断场景显式开启开发者模式;设置外壳、文档预览和改动文件场景保留产品默认值。原生 Electron 执行尚未验证。
|
||||
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
|
||||
@@ -76,6 +76,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`compression`](https://github.com/expressjs/compression) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
|
||||
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
|
||||
@@ -155,6 +155,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
<div><template shadowrootmode="open"><a id="shadow-link" href="https://blocked-preview.invalid/shadow">Shadow link</a><template><iframe src="https://blocked-preview.invalid/frame"></iframe></template></template></div>
|
||||
<svg><a id="svg-link" href="https://blocked-preview.invalid/svg"><text y="20">SVG link</text><set attributeName="href" to="https://blocked-preview.invalid/set"/><animate attributeName="href" values="https://blocked-preview.invalid/animate"/></a></svg>
|
||||
<img src="https://blocked-preview.invalid/image"><iframe src="https://blocked-preview.invalid/direct-frame"></iframe>
|
||||
<math id="math-link" href="https://blocked-preview.invalid/math"><mi>x</mi></math>
|
||||
<form><math><mtext></form><form><mglyph><style></math><a id="mutation-link" href="https://blocked-preview.invalid/mutation">Mutation link</a>
|
||||
</body></html>`),
|
||||
writeFile(join(cwd, 'smoke.md'), markdownText),
|
||||
writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')),
|
||||
@@ -293,11 +295,16 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
await hostileFrame.getByRole('heading', { name: 'Static adversarial preview' }).waitFor()
|
||||
expect(await hostileFrame.locator('html').getAttribute('class')).toBe('dark')
|
||||
expect(await hostileFrame.locator('body').evaluate(node => getComputedStyle(node).margin)).toBe('0px')
|
||||
for (const id of ['plain-link', 'shadow-link', 'svg-link']) {
|
||||
for (const id of ['plain-link', 'svg-link', 'math-link']) {
|
||||
const link = hostileFrame.locator(`#${id}`)
|
||||
expect(await link.getAttribute('href')).toBeNull()
|
||||
await link.click()
|
||||
}
|
||||
// DOMPurify leaves ordinary templates inert and removes declarative shadow roots.
|
||||
expect(await hostileFrame.locator('#shadow-link').count()).toBe(0)
|
||||
expect(await hostileFrame.locator('[href], [xlink\\:href]').count()).toBe(0)
|
||||
const mutationLink = hostileFrame.locator('#mutation-link')
|
||||
if (await mutationLink.count() > 0) await mutationLink.click()
|
||||
expect(await hostileFrame.locator('noscript, link, set, animate, iframe').count()).toBe(0)
|
||||
expect(blockedRequests).toEqual([])
|
||||
await hostileFrame.getByRole('heading', { name: 'Static adversarial preview' }).waitFor()
|
||||
|
||||
@@ -301,7 +301,7 @@ export interface WebScaffold {
|
||||
|
||||
/** Options for {@link launchWebScaffold}. */
|
||||
export interface LaunchOptions {
|
||||
/** Diagnostic scenarios enable developer tools by default; false leaves the shipped default untouched. */
|
||||
/** The scaffold enables developer tools unless false preserves the shipped default. */
|
||||
developerTools?: boolean
|
||||
/** Profile resolver backend used by this test Host; defaults to runtime coverage. */
|
||||
profileResolutionMode?: Extract<ProfileResolutionMode, 'dual' | 'runtime'>
|
||||
|
||||
@@ -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 docs/subsystems/conversation.md
|
||||
conversation.md: 61e76e1b057be7b47150bd623b3e04400b50e394
|
||||
conversation.zh.md: 5b54625fcfe9ac451e79a42c387a5cf0836ee39a
|
||||
conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098
|
||||
conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3
|
||||
@@ -24,7 +24,7 @@ Chat and Trajectory may recognize the same durable event family, but each keeps
|
||||
|
||||
Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it. The shell explicitly activates its persisted or newly selected View, while another consumer activates a target through its first source subscription. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one.
|
||||
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations.
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity.
|
||||
|
||||
## Replayable event families
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
|
||||
|
||||
每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它。shell 会显式激活持久化选择或新选择的 View,其他消费者则通过 target source 的首个订阅激活 target。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。
|
||||
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。
|
||||
|
||||
## 可回放 event family
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ import css from './AgentPresetSeat.module.css'
|
||||
/** Registration-side business face for the hero chip. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Shared developer-tool visibility. */
|
||||
developerTools: ObservableSnapshot<boolean>
|
||||
/** Whether this entry offers preset selection. */
|
||||
showPresetPicker: ObservableSnapshot<boolean>
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
}
|
||||
@@ -85,9 +85,9 @@ export type AgentPresetSeatProps =
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({
|
||||
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useDeveloperTools, t,
|
||||
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useShowPresetPicker, t,
|
||||
}: AgentPresetSeatProps) {
|
||||
const developerTools = useDeveloperTools(value => value)
|
||||
const showPresetPicker = useShowPresetPicker(value => value)
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const main = useSessionRetainInfo(info => sessionId === undefined
|
||||
|| (info?.retainedBy.mainView ?? 0) > 0)
|
||||
@@ -96,7 +96,7 @@ export function AgentPresetSeat({
|
||||
// it rather than leaving the first one silently in place.
|
||||
const toastSeq = useRef(0)
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const visible = developerTools && state.showPicker
|
||||
const visible = showPresetPicker && state.showPicker
|
||||
const pickerVisible = useRef(visible)
|
||||
pickerVisible.current = visible
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'developerTools',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -137,7 +137,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const binding = sessionId === undefined ? undefined : scope.sessions.binding(sessionId)
|
||||
const seat = binding === undefined ? unboundSeat : seatFor(scope, binding)
|
||||
return {
|
||||
hooks: { agentPresetSeat: seat.store, developerTools: ctx.settingsScope.developerTools.enabled },
|
||||
hooks: { agentPresetSeat: seat.store, showPresetPicker: ctx.developerTools.enabled },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
introduced: () => { seat.introduced() },
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('ui-agent-preset apply', () => {
|
||||
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'developerTools',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ function renderSeat(
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
sessionId: session === undefined ? undefined : SessionId(session.id),
|
||||
useDeveloperTools: bindSnapshotSelector(developerTools),
|
||||
useShowPresetPicker: bindSnapshotSelector(developerTools),
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
useSessionRetainInfo: session === undefined
|
||||
? useSessionRetainInfo
|
||||
|
||||
@@ -219,8 +219,8 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
|
||||
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
|
||||
useTranscriptView, useProjection, availableViews, t,
|
||||
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
useTranscriptView, useProjection, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const nodeStore = useChat(s => s.nodes)
|
||||
@@ -245,9 +245,6 @@ export function ChatView({
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const compactTranscript = useTranscriptView(mode => mode === 'compact')
|
||||
const inspectCall = useCallback((callId: string) => {
|
||||
openView('trajectory', callId)
|
||||
}, [openView])
|
||||
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
|
||||
const [fileOpenBusy, setFileOpenBusy] = useState(false)
|
||||
// Close/retry must ignore a settlement that started before the latest
|
||||
@@ -796,7 +793,7 @@ export function ChatView({
|
||||
cwd={cwd}
|
||||
openFile={requestOpenFile}
|
||||
openSkill={openSkill}
|
||||
inspectCall={availableViews.some(view => view.id === 'trajectory') ? inspectCall : undefined}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
loadImage={loadImage}
|
||||
renderMessageImages={renderMessageImages}
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface ChatNodeOwnerProps {
|
||||
/** Open the current source file of a skill referenced by a sent message. */
|
||||
openSkill: (name: string) => void
|
||||
openFile: (path: string, options?: OpenFileOptions) => void
|
||||
inspectCall?: ((callId: ToolCallId) => void) | undefined
|
||||
inspectCall: ((callId: ToolCallId) => void) | undefined
|
||||
forkAt: (seq: number) => void
|
||||
/**
|
||||
* Session-authorized image loader, down-threaded from the Chat view so a
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -48,7 +48,8 @@ function sessionFakeFor() {
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
|
||||
runtime.ctx.provide('layout', layout as never)
|
||||
const sidebarRight = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -37,8 +37,9 @@ const SID = 'session-1' as SessionId
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chatSettings = stubSettingsScope<ChatSettings>()
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', {
|
||||
developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } },
|
||||
|
||||
bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE
|
||||
? chatSettings.scope
|
||||
: stubSettingsScope().scope,
|
||||
|
||||
@@ -400,7 +400,7 @@ function makeHarness(
|
||||
useTranscriptView: bindSnapshotSelector(transcriptView),
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
availableViews: [{ id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }],
|
||||
inspectCall: (callId: string) => { openView('trajectory', callId) },
|
||||
viewRequest: null,
|
||||
openView,
|
||||
completeViewRequest: () => {},
|
||||
@@ -1323,7 +1323,7 @@ describe('ChatView', () => {
|
||||
|
||||
it('removes Inspect when trajectory is unavailable and restores it with the view', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} availableViews={[{ id: 'chat', label: 'Chat' }]} />)
|
||||
const view = render(<h.ChatView {...h.props} inspectCall={undefined} />)
|
||||
expect(h.toolOwners.at(-1)?.inspectCall).toBeUndefined()
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(h.toolOwners.at(-1)?.inspectCall).toBeTypeOf('function')
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: ac7c4b14d10acdf3addd7939244cd1f614daa42e
|
||||
README.zh.md: 5755358e22f59c31c02078fa8081e2cdd754f736
|
||||
README.md: fa3973b1ed751668adc46a7feeb48fac2d09bcad
|
||||
README.zh.md: d080a855128dfdfb30d0c6c4da08c68407e57740
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="conversation-assembly"></a>
|
||||
## Conversation assembly
|
||||
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target.
|
||||
|
||||
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="conversation-assembly"></a>
|
||||
## Conversation 组装
|
||||
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。
|
||||
|
||||
适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Services required by the Conversation plugin. */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope',
|
||||
'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', 'developerTools',
|
||||
]
|
||||
|
||||
/** Conversation runtime configuration. */
|
||||
@@ -158,7 +158,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
/* v8 ignore next -- list registration validates id at load. */
|
||||
if (entry.options.id === undefined) continue
|
||||
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue
|
||||
if (!ctx.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue
|
||||
tabs.push({
|
||||
id: entry.options.id,
|
||||
label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
|
||||
@@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
ctx.effect(() => {
|
||||
const disposeViews = slots.subscribe('conversation.view', refreshViews)
|
||||
const disposeLocale = ctx.locale.subscribe(refreshViews)
|
||||
const disposeDeveloperTools = ctx.settingsScope.developerTools.enabled.subscribe(refreshViews)
|
||||
const disposeDeveloperTools = ctx.developerTools.enabled.subscribe(refreshViews)
|
||||
return () => {
|
||||
disposeDeveloperTools()
|
||||
disposeLocale()
|
||||
@@ -296,15 +296,36 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: conversationStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
|
||||
hooks: { conversationViews },
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
openView: (view, focus) => {
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => {
|
||||
const openView = (view: string, focus: string): void => {
|
||||
if (!viewTabs().some(tab => tab.id === view)) return
|
||||
activateView(sessionId, view)
|
||||
actions.openView(view, focus)
|
||||
}
|
||||
const inspectionTarget = () => uiConversation.views.entries().find(definition =>
|
||||
definition.toolCallFocus !== undefined
|
||||
&& conversationViews.getSnapshot().some(view => view.id === definition.target),
|
||||
)
|
||||
const inspectCall = (callId: string): void => {
|
||||
const target = inspectionTarget()
|
||||
if (target?.toolCallFocus !== undefined) openView(target.target, target.toolCallFocus(callId))
|
||||
}
|
||||
return {
|
||||
hooks: {
|
||||
conversationViews,
|
||||
inspectCall: {
|
||||
getSnapshot: () => inspectionTarget() === undefined ? undefined : inspectCall,
|
||||
subscribe: (listener) => {
|
||||
const disposeViews = conversationViews.subscribe(listener)
|
||||
const disposeDefinitions = uiConversation.views.subscribe(listener)
|
||||
return () => { disposeViews(); disposeDefinitions() }
|
||||
},
|
||||
},
|
||||
},
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
openView,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}, ConversationSession)
|
||||
|
||||
const registerConversationHeader = () => slots.register({
|
||||
|
||||
@@ -276,6 +276,12 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
|
||||
export interface ConversationViewDefinition<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
|
||||
readonly target: string
|
||||
/**
|
||||
* Address a tool call in this target's inspector; absent for non-inspection views.
|
||||
* @param callId - tool-call identity from the Session.
|
||||
* @returns the target's opaque focus identity.
|
||||
*/
|
||||
toolCallFocus?(callId: string): string
|
||||
/** @returns a new Session-owned incremental builder. */
|
||||
create(): ConversationViewBuilder<Node, Snapshot>
|
||||
/**
|
||||
|
||||
@@ -299,8 +299,8 @@ export interface InputZone {
|
||||
|
||||
/** Conversation View entries obtain their data from registered standard hooks. */
|
||||
export interface ConvViewOwnerProps {
|
||||
/** Currently available navigation targets. */
|
||||
availableViews: readonly ViewTab[]
|
||||
/** Open a tool call's inspector when an inspection target is available. */
|
||||
inspectCall: ((callId: string) => void) | undefined
|
||||
/** Focus request addressed to the selected View. */
|
||||
viewRequest: import('./views.ts').ConversationViewRequest | null
|
||||
/** Select a View and address one opaque focus identity to it. */
|
||||
@@ -323,7 +323,10 @@ export interface ConversationInjected {
|
||||
/** Business callbacks injected into the strict Session body. */
|
||||
export interface ConversationSessionInjected {
|
||||
/** Package-owned View roster source bound only for the Conversation body. */
|
||||
readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
|
||||
readonly hooks: {
|
||||
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
|
||||
readonly inspectCall: ObservableSnapshot<ConvViewOwnerProps['inspectCall']>
|
||||
}
|
||||
/** Bind input draft persistence to the Session-owned store instance. */
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select and activate one View while addressing an opaque focus request to it. */
|
||||
|
||||
@@ -12,9 +12,10 @@ import css from './ConversationRoot.module.css'
|
||||
*/
|
||||
export function DefaultConversationViews({
|
||||
view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
|
||||
renderSlot, bindDraftMirror, openView,
|
||||
renderSlot, bindDraftMirror, openView, useInspectCall,
|
||||
}: ConversationSessionSlotProps) {
|
||||
const tabs = useConversationViews(value => value)
|
||||
const inspectCall = useInspectCall(value => value)
|
||||
const selectedId = useStore(s => s.view)
|
||||
const active = resolveActiveView(tabs, selectedId)
|
||||
const session = useSession(s => s)
|
||||
@@ -36,7 +37,7 @@ export function DefaultConversationViews({
|
||||
return (
|
||||
<div className={css.viewArea}>
|
||||
{viewId !== undefined && renderSlot('conversation.view', {
|
||||
availableViews: tabs,
|
||||
inspectCall,
|
||||
viewRequest,
|
||||
openView,
|
||||
completeViewRequest: actions.completeViewRequest,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-cl
|
||||
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -47,7 +47,8 @@ async function bench() {
|
||||
if (upload === undefined) throw new Error('test file upload has no Session fixture')
|
||||
return upload(...args)
|
||||
}
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const connectWorkspace = vi.fn(async () => ROOT)
|
||||
const references = new Map<SessionId, SessionReference>()
|
||||
const opened = vi.fn<(id: SessionId) => void>()
|
||||
@@ -185,6 +186,45 @@ describe('Conversation inject API', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('offers Inspect only while a registered tool-call inspector is visible', async () => {
|
||||
const b = await bench()
|
||||
const body = b.conversationApi(ROOT)
|
||||
const source = body.injected.hooks.inspectCall
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = source.subscribe(changed)
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
const removeDefinition = b.runtime.ctx.uiConversation.views.register({
|
||||
target: 'custom-inspector',
|
||||
toolCallFocus: callId => `tool:${callId}`,
|
||||
create: () => ({ empty: null, replace: () => null, apply: () => null }),
|
||||
})
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
const removeView = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never,
|
||||
)
|
||||
await b.runtime.flush()
|
||||
const inspect = source.getSnapshot()!
|
||||
expect(inspect).toBeTypeOf('function')
|
||||
expect(source.getSnapshot()).toBe(inspect)
|
||||
inspect('call-1')
|
||||
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' })
|
||||
await b.runtime.ctx.developerTools.setEnabled(false)
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
await b.runtime.ctx.developerTools.setEnabled(true)
|
||||
expect(source.getSnapshot()).toBe(inspect)
|
||||
removeView()
|
||||
await b.runtime.flush()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
inspect('stale-call')
|
||||
expect(body.instance.store.getSnapshot().viewRequest?.focus).not.toBe('tool:stale-call')
|
||||
expect(changed).toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
changed.mockClear()
|
||||
removeDefinition()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('activates a target before committing an explicit View selection', async () => {
|
||||
const b = await bench()
|
||||
const binding = b.runtime.ctx.uiConversation.binding(ROOT)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -21,7 +21,8 @@ async function bench(options: { declareConversation?: boolean } = {}) {
|
||||
}),
|
||||
openSession: vi.fn(),
|
||||
} as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: developerTools } } as never)
|
||||
runtime.ctx.provide('developerTools', { ...stubDeveloperTools(), enabled: developerTools } as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from 'react'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
RemoteError, SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
@@ -79,7 +79,8 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -102,7 +103,8 @@ describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -129,7 +131,8 @@ describe('resident composer', () => {
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -196,7 +199,8 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
@@ -233,6 +233,7 @@ function mount(
|
||||
useSession={useSession}
|
||||
useConversation={useConversation}
|
||||
useConversationViews={useConversationViews}
|
||||
useInspectCall={selector => selector(undefined)}
|
||||
useChat={useChat}
|
||||
useTrajectory={useTrajectory}
|
||||
useSessions={props.useSessions}
|
||||
|
||||
@@ -21,7 +21,7 @@ const COLLAPSED_PRESENTED_COUNT = 4
|
||||
/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
|
||||
export interface DeliverablesInjected {
|
||||
hooks: {
|
||||
developerTools: ObservableSnapshot<boolean>
|
||||
showCodeDiff: ObservableSnapshot<boolean>
|
||||
presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
|
||||
presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
|
||||
changesSummary: ObservableSnapshot<ReturnType<ChangesSummaryStore['state']['getSnapshot']>>
|
||||
@@ -64,16 +64,16 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
|
||||
*/
|
||||
export function Deliverables({
|
||||
matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary, useDeveloperTools,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff,
|
||||
}: Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: DeliverablesMatch
|
||||
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const developerTools = useDeveloperTools(value => value)
|
||||
const showCodeDiff = useShowCodeDiff(value => value)
|
||||
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
|
||||
const states = usePresentedOpen(value => value)
|
||||
const host = usePresentedHost(value => value)
|
||||
const announced = developerTools ? matched.changes : null
|
||||
const announced = showCodeDiff ? matched.changes : null
|
||||
const summary = useChangesSummary(value => announced === null ? undefined : value[changesSummaryUrl(sessionId, announced.seq)])
|
||||
useEffect(() => {
|
||||
if (announced !== null && summary === undefined) void loadChangesSummary(sessionId, announced.seq)
|
||||
|
||||
@@ -39,7 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Required services for the tail-slot and tab-type registrations and their dictionaries. */
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'settingsScope']
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'developerTools']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type.
|
||||
@@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
inject: (): DeliverablesInjected => ({
|
||||
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
|
||||
developerTools: ctx.settingsScope.developerTools.enabled },
|
||||
showCodeDiff: ctx.developerTools.enabled },
|
||||
reloadPresentedHost: () => opener.loadHost(),
|
||||
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
|
||||
openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
|
||||
import type { ReviewInjected } from '../src/client/ReviewTab.tsx'
|
||||
import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
|
||||
@@ -40,7 +40,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
|
||||
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
|
||||
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }
|
||||
return {
|
||||
useDeveloperTools: <T,>(select: (value: boolean) => T): T => select(true),
|
||||
useShowCodeDiff: <T,>(select: (value: boolean) => T): T => select(true),
|
||||
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
|
||||
reloadPresentedHost: vi.fn(() => controller.loadHost()),
|
||||
useChangesSummary: <T,>(select: (state: ReturnType<typeof summaries.state.getSnapshot>) => T): T =>
|
||||
@@ -498,7 +498,7 @@ describe('ChangedFiles card', () => {
|
||||
it('hides changed files and avoids summary reads when developer tools are off', () => {
|
||||
const props = openProps(new PresentedOpenController(), servedStore())
|
||||
const base = { ...props, matched: { changes, presented: [] }, openFile: vi.fn(), sessionId: SessionId('child-session'), t: makeTranslate(en) }
|
||||
const off = { useDeveloperTools: <T,>(select: (enabled: boolean) => T): T => select(false) }
|
||||
const off = { useShowCodeDiff: <T,>(select: (enabled: boolean) => T): T => select(false) }
|
||||
const view = render(<Deliverables {...base} {...off} />)
|
||||
expect(view.container.querySelector('[data-changed-files]')).toBeNull()
|
||||
expect(props.loadChangesSummary).not.toHaveBeenCalled()
|
||||
@@ -715,7 +715,8 @@ describe('plugin registration', () => {
|
||||
session,
|
||||
} as never)
|
||||
ctx.provide('remote.session', session as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"src/css-modules.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
@@ -34,7 +34,8 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
|
||||
// and the forwarded-event port.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
// These specs assert the shipped Chinese copy. There is no jsdom `window` in
|
||||
// this lane, so browser-language detection never runs and the locale comes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -60,7 +60,8 @@ async function bench() {
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
@@ -20,7 +20,8 @@ async function baseContext(): Promise<Context> {
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', 'developerTools']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -72,8 +72,8 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item', id: 'developer-tools', order: 15, locale: NS,
|
||||
inject: (): DeveloperToolsRowInjected => ({
|
||||
hooks: { developerTools: ctx.settingsScope.developerTools.enabled },
|
||||
setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled),
|
||||
hooks: { developerTools: ctx.developerTools.enabled },
|
||||
setEnabled: enabled => ctx.developerTools.setEnabled(enabled),
|
||||
}),
|
||||
}, DeveloperToolsRow))
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
|
||||
|
||||
@@ -89,7 +89,7 @@ function setPageUrl(url: string): void {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', 'developerTools'])
|
||||
})
|
||||
|
||||
it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
|
||||
@@ -106,9 +106,9 @@ describe('ui-settings-general apply', () => {
|
||||
expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
|
||||
const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')!
|
||||
const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)()
|
||||
expect(developer.hooks.developerTools).toBe(c.ctx.settingsScope.developerTools.enabled)
|
||||
expect(developer.hooks.developerTools).toBe(c.ctx.developerTools.enabled)
|
||||
expect(developer.hooks.developerTools.getSnapshot()).toBe(false)
|
||||
const setEnabled = vi.spyOn(c.ctx.settingsScope.developerTools, 'setEnabled').mockResolvedValue(undefined)
|
||||
const setEnabled = vi.spyOn(c.ctx.developerTools, 'setEnabled').mockResolvedValue(undefined)
|
||||
await developer.setEnabled(true)
|
||||
expect(setEnabled).toHaveBeenCalledExactlyOnceWith(true)
|
||||
const { controller, hooks } = actionInjectedOf(c)
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('ui-settings-general shell', () => {
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
it('declares its services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', 'developerTools'])
|
||||
})
|
||||
|
||||
it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
|
||||
|
||||
@@ -775,6 +775,7 @@ describe('SubagentModelSelectionCardController', () => {
|
||||
enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
|
||||
allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
|
||||
} })
|
||||
return true
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
|
||||
const face = controller.inject()
|
||||
@@ -1100,6 +1101,7 @@ describe('shared Subagent card actions', () => {
|
||||
const set = vi.spyOn(limits.scope, 'set').mockImplementationOnce(async () => {
|
||||
await pending.promise
|
||||
limits.publish({ value: { maxDepth: 2, maxActiveSubagents: 8 }, user: { maxDepth: 2 } })
|
||||
return true
|
||||
})
|
||||
face.editLimit('maxDepth', '2')
|
||||
face.toggleEnabled()
|
||||
|
||||
@@ -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: 7bb05738e7100daaec622f6e34269c78526cdbce
|
||||
README.zh.md: 6f332fcbc1c6ae8fa04818c65559c15043ee037b
|
||||
README.md: dffe69725ba32a7380d6a878f465c23f4d03aacc
|
||||
README.zh.md: e0a7cfa576e90c56df6ead854278e40e7b7d78b0
|
||||
@@ -29,9 +29,9 @@ Feature plugins use this package to store and edit their preferences without re-
|
||||
|
||||
### Binding a namespace
|
||||
|
||||
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording.
|
||||
`ctx.developerTools` is a separate preference service that owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording.
|
||||
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
|
||||
### Filling the settings slots
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ kind: "package-reference"
|
||||
|
||||
### 绑定命名空间
|
||||
|
||||
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。
|
||||
`ctx.developerTools` 是独立的偏好服务,管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。
|
||||
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
|
||||
### 填充设置 slot
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/** One accepted preference drives every developer-tool consumer. */
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { DeveloperToolsSettings } from '../developer-tools-settings.ts'
|
||||
import type { SettingsScope } from './settings-contract.ts'
|
||||
|
||||
/** Shared preference; no accepted value means developer tools are disabled. */
|
||||
export class DeveloperToolsPreference {
|
||||
export class DeveloperToolsPreference extends Service {
|
||||
/** Accepted enablement, observable through renderer-bound hooks. */
|
||||
readonly enabled: ObservableSnapshot<boolean>
|
||||
private readonly local = createSnapshotStore(false)
|
||||
|
||||
/** @param scope - settings-owned namespace controller. */
|
||||
constructor(private readonly scope: SettingsScope<DeveloperToolsSettings>) {
|
||||
/**
|
||||
* @param ctx - preference-owning plugin context.
|
||||
* @param scope - settings-owned namespace controller.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly scope: SettingsScope<DeveloperToolsSettings>) {
|
||||
super(ctx, 'developerTools')
|
||||
this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : {
|
||||
getSnapshot: () => scope.getSnapshot().value?.enabled ?? false,
|
||||
subscribe: (listener) => {
|
||||
@@ -28,13 +33,19 @@ export class DeveloperToolsPreference {
|
||||
/**
|
||||
* Persist a Host choice with ordered writes, or update the shared browser-local choice.
|
||||
* @param enabled - requested developer-tool mode.
|
||||
* @returns settlement after local publication, the Host write or its recovery read.
|
||||
* @returns settlement after local publication or Host acceptance; rejects after a refused write recovers.
|
||||
*/
|
||||
setEnabled(enabled: boolean): Promise<void> {
|
||||
async setEnabled(enabled: boolean): Promise<void> {
|
||||
if (this.scope.getSnapshot().mode === 'memory') {
|
||||
this.local.set(enabled)
|
||||
return Promise.resolve()
|
||||
return
|
||||
}
|
||||
return this.scope.set('enabled', enabled)
|
||||
if (!await this.scope.set('enabled', enabled)) throw new Error('Developer tools preference was not saved')
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
developerTools: DeveloperToolsPreference
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,15 @@ import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import { SettingsSchemaService } from './schema.ts'
|
||||
import { SettingsScopeBinder } from './settings-scope.ts'
|
||||
import { SettingsDescribeMirror } from './settings-mirror.ts'
|
||||
import { DeveloperToolsPreference } from './developer-tools.ts'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE } from '../developer-tools-settings.ts'
|
||||
|
||||
export type {
|
||||
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
|
||||
SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
export type { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
|
||||
export type { DeveloperToolsPreference } from './developer-tools.ts'
|
||||
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts'
|
||||
export type { SettingsSchemaService } from './schema.ts'
|
||||
export type { SchemaNode } from './schema.ts'
|
||||
@@ -70,4 +73,5 @@ export function apply(ctx: Context): void {
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings: describe mirror invalidations')
|
||||
new SettingsScopeBinder(ctx, { mirror, schema, persistence })
|
||||
new DeveloperToolsPreference(ctx, ctx.settingsScope.bind({ namespace: DEVELOPER_TOOLS_NAMESPACE }))
|
||||
}
|
||||
@@ -67,23 +67,26 @@ export interface SettingsScope<T> {
|
||||
* the latest queued or mirrored revision.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void>
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<boolean>
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @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.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
set(field: string, value: unknown): Promise<boolean>
|
||||
/**
|
||||
* Queue one field clear, so the field re-inherits the composition layer.
|
||||
* Shares {@link set}'s ordering, revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
unset(field: string): Promise<void>
|
||||
unset(field: string): Promise<boolean>
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE } from '../developer-tools-settings.ts'
|
||||
import { DeveloperToolsPreference } from './developer-tools.ts'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
SettingsNamespaceView, SettingsPathOpView,
|
||||
@@ -103,9 +101,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* 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.
|
||||
* @returns whether the Host accepted the write, after any recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
set(field: string, value: unknown): Promise<boolean> {
|
||||
return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }])
|
||||
}
|
||||
|
||||
@@ -113,9 +111,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* Queue one field clear; see {@link SettingsScope.unset} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
* @returns whether the Host accepted the clear, after any recovery read.
|
||||
*/
|
||||
unset(field: string): Promise<void> {
|
||||
unset(field: string): Promise<boolean> {
|
||||
return this.mutate([{ op: 'unset', path: [field] }])
|
||||
}
|
||||
|
||||
@@ -123,9 +121,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* Queue one atomic namespace mutation; see {@link SettingsScope.mutate}.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
* @returns whether the Host accepted the mutation, after any recovery read.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void> {
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<boolean> {
|
||||
const ownedOps = structuredClone(ops) as SettingsPathOpView[]
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
@@ -133,15 +131,16 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
const response = await this.ctx.remote.settings.mutate(this.spec.namespace, ownedOps, revision)
|
||||
if (!response.ok) {
|
||||
await this.recover(generation)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (this.disposed) return
|
||||
if (this.disposed) return true
|
||||
if (generation === this.writeGeneration) {
|
||||
this.pendingRevision = undefined
|
||||
this.mirror.acceptView(response.value)
|
||||
} else {
|
||||
this.pendingRevision = response.value.revision
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,15 +163,15 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
private enqueue(operation: () => Promise<boolean>): Promise<boolean> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve(false)
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
if (this.disposed) return false
|
||||
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(() => {})
|
||||
this.tail = task.then(() => {}, () => {})
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -232,8 +231,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
* (`packages/client/tsdown.client.ts`).
|
||||
*/
|
||||
export class SettingsScopeBinder extends Service {
|
||||
/** Shared developer-tool preference for Web and desktop consumers. */
|
||||
readonly developerTools: DeveloperToolsPreference
|
||||
private readonly mirror: SettingsDescribeMirror
|
||||
private readonly schema: SettingsSchemaService
|
||||
private readonly persistence: 'host' | 'memory'
|
||||
@@ -260,7 +257,6 @@ export class SettingsScopeBinder extends Service {
|
||||
this.schema = config.schema
|
||||
this.persistence = config.persistence
|
||||
this.owner = ctx
|
||||
this.developerTools = new DeveloperToolsPreference(this.bind({ namespace: DEVELOPER_TOOLS_NAMESPACE }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubSettingsScope, TestRemote, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE, type DeveloperToolsSettings } from '../src/developer-tools-settings.ts'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE, DeveloperToolsSettingsSchema, type DeveloperToolsSettings } from '../src/developer-tools-settings.ts'
|
||||
import { DeveloperToolsPreference } from '../src/client/developer-tools.ts'
|
||||
import { apply as clientApply, inject } from '../src/client/index.ts'
|
||||
|
||||
class MemorySettings extends SettingsProvider {
|
||||
readonly writable = true
|
||||
@@ -16,6 +17,44 @@ class MemorySettings extends SettingsProvider {
|
||||
}
|
||||
|
||||
describe('developer tools settings', () => {
|
||||
it('reports a refused Host write after recovering accepted state', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const describeCall = vi.fn().mockResolvedValue({ ok: true, value: {
|
||||
writable: true, hasDocument: true, namespaces: [{
|
||||
ns: DEVELOPER_TOOLS_NAMESPACE,
|
||||
schema: DeveloperToolsSettingsSchema.toJSON(),
|
||||
value: { enabled: false }, revision: 1, applies: 'live', secrets: [],
|
||||
}],
|
||||
} })
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
ok: false, error: new RemoteError('settings/rejected', 'conflict', { ns: DEVELOPER_TOOLS_NAMESPACE }),
|
||||
})
|
||||
new TestRemote(ctx, { settings: { describe: describeCall, mutate } })
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
await ctx.settingsScope.describe().ensure()
|
||||
await expect(ctx.developerTools.setEnabled(true)).rejects.toThrow('not saved')
|
||||
expect(mutate).toHaveBeenCalledWith(DEVELOPER_TOOLS_NAMESPACE, [{ op: 'set', path: ['enabled'], value: true }], 1)
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.developerTools.enabled.getSnapshot()).toBe(false)
|
||||
})
|
||||
|
||||
it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const describeCall = vi.fn()
|
||||
const remote = new TestRemote(ctx, { settings: { describe: describeCall } })
|
||||
remote.$host = { home: undefined, isLoopback: false }
|
||||
const fiber = ctx.plugin({ inject, apply: clientApply })
|
||||
await fiber.await()
|
||||
const preference = ctx.developerTools
|
||||
expect(fiber.ctx.developerTools.enabled).toBe(preference.enabled)
|
||||
await preference.setEnabled(true)
|
||||
expect(fiber.ctx.developerTools.enabled.getSnapshot()).toBe(true)
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('developerTools')).toBeUndefined()
|
||||
})
|
||||
it('defaults off, persists valid choices and removes its schema on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const provider = ctx.plugin(MemorySettings)
|
||||
@@ -34,7 +73,7 @@ describe('developer tools settings', () => {
|
||||
|
||||
it('stays off until accepted settings arrive and follows external changes', async () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const preference = new DeveloperToolsPreference(new Context(), host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
@@ -52,7 +91,7 @@ describe('developer tools settings', () => {
|
||||
it('keeps remote browser choices local and publishes only changed values', async () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
host.publish({ mode: 'memory' })
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const preference = new DeveloperToolsPreference(new Context(), host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
@@ -68,7 +107,7 @@ it('keeps remote browser choices local and publishes only changed values', async
|
||||
|
||||
it('ignores host revisions that do not change enablement', () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const preference = new DeveloperToolsPreference(new Context(), host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
host.publish({ revision: 1 })
|
||||
|
||||
@@ -337,8 +337,8 @@ describe('SettingsScopeController', () => {
|
||||
throw new Error('write subscriber failed')
|
||||
})
|
||||
|
||||
await expect(scope.set('preference', 'dark')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'dark')).resolves.toBe(true)
|
||||
await expect(scope.set('preference', 'light')).resolves.toBe(true)
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(2)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
|
||||
@@ -360,7 +360,7 @@ describe('SettingsScopeController', () => {
|
||||
})
|
||||
|
||||
await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed')
|
||||
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'light')).resolves.toBe(true)
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(2)
|
||||
expect(mutate).toHaveBeenNthCalledWith(2,
|
||||
@@ -555,12 +555,6 @@ describe('SettingsScopeBinder.bind', () => {
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
const preference = ctx.settingsScope.developerTools
|
||||
expect(fiber.ctx.settingsScope.developerTools).toBe(preference)
|
||||
await preference.setEnabled(true)
|
||||
expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true)
|
||||
await fiber.ctx.settingsScope.developerTools.setEnabled(false)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -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-sidebar-documentpreview/README.md
|
||||
README.md: 7fd9f288172fcf74977d8f389b674a9d3be6e9e3
|
||||
README.zh.md: 90ae7c8afcae1c5470a4b0c6a91ef3b478ce08e8
|
||||
README.md: 47f59c9561993d598dfe3ab65370412bfa3accf6
|
||||
README.zh.md: c0338a666a6a50aaf0d5203cbfcb2ac04ab2f0e8
|
||||
@@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
|
||||
<a id="how-it-reads"></a>
|
||||
## How it reads
|
||||
|
||||
Developer tools selects the HTML preview policy in both Web and desktop. Off uses an inertly parsed static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; navigation links and refresh directives are removed. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Other document formats retain their own policies.
|
||||
Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Other document formats retain their own policies.
|
||||
|
||||
The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads:
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
<a id="how-it-reads"></a>
|
||||
## 怎么读
|
||||
|
||||
Web 和桌面端均通过开发者工具选择 HTML 预览策略。关闭时,将惰性解析的静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;导航链接和刷新指令被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。其他文档格式保持各自策略。
|
||||
Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。其他文档格式保持各自策略。
|
||||
|
||||
正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
|
||||
|
||||
|
||||
@@ -61,20 +61,21 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"clsx": "^2.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^"
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Complete HTML rendered in a script-enabled opaque iframe, without parent application access. */
|
||||
/** Static or interactive HTML in an opaque iframe, without parent application access. */
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -20,7 +20,7 @@ export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> &
|
||||
|
||||
/** Related-file reader and accepted preview mode supplied by the plugin. */
|
||||
export interface HtmlBodyInjected {
|
||||
hooks: { developerTools: ObservableSnapshot<boolean> }
|
||||
hooks: { interactivePreview: ObservableSnapshot<boolean> }
|
||||
/** Ordinary Remote callback bound by this renderer's Slot inject. */
|
||||
readonly readRelated: ReadHtmlRelated
|
||||
}
|
||||
@@ -67,15 +67,15 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t
|
||||
* @param props - document bytes, hooks, related-file reader and locale.
|
||||
* @returns an isolated HTML document, or nothing for text delivery.
|
||||
*/
|
||||
export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, useDeveloperTools, t }: HtmlBodyProps): ReactNode {
|
||||
const developerTools = useDeveloperTools(value => value)
|
||||
export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, useInteractivePreview, t }: HtmlBodyProps): ReactNode {
|
||||
const interactivePreview = useInteractivePreview(value => value)
|
||||
const { tab } = useTabInfo()
|
||||
const readRelative = useMemo(
|
||||
() => createReadHtmlRelative(readRelated, resourceAddress, tab.signal),
|
||||
[readRelated, resourceAddress, tab.signal],
|
||||
)
|
||||
if (content.kind !== 'bytes') return null
|
||||
if (!developerTools) return <BasicHtmlFrame data={content.data} t={t} />
|
||||
if (!interactivePreview) return <BasicHtmlFrame data={content.data} t={t} />
|
||||
return <HtmlFrame key={resourceAddress} data={content.data} readRelative={readRelative} t={t} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Static HTML preview with no scripts, network resources, forms or nested frames. */
|
||||
import { decodeText } from './bytes.ts'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Prepare static content before the browser can load any document resources.
|
||||
@@ -7,19 +8,13 @@ import { decodeText } from './bytes.ts'
|
||||
* @returns document with the restrictive CSP first in its head.
|
||||
*/
|
||||
export function createBasicHtmlDocument(data: Uint8Array<ArrayBuffer>): string {
|
||||
const parsed = new DOMParser().parseFromString(decodeText(data), 'text/html')
|
||||
const sanitize = (root: Document | DocumentFragment): void => {
|
||||
for (const element of root.querySelectorAll('script, noscript, base, link, meta[http-equiv], iframe, frame, object, embed, set, animate, animateMotion, animateTransform')) {
|
||||
element.remove()
|
||||
}
|
||||
for (const link of root.querySelectorAll('a, area')) {
|
||||
link.removeAttribute('href')
|
||||
link.removeAttribute('xlink:href')
|
||||
}
|
||||
for (const template of root.querySelectorAll('template')) sanitize(template.content)
|
||||
}
|
||||
sanitize(parsed)
|
||||
const policy = document.createElement('meta')
|
||||
const clean = DOMPurify.sanitize(decodeText(data), {
|
||||
WHOLE_DOCUMENT: true,
|
||||
FORBID_TAGS: ['noscript', 'base', 'link', 'meta', 'iframe', 'frame', 'object', 'embed', 'set', 'animate', 'animateMotion', 'animateTransform'],
|
||||
FORBID_ATTR: ['href', 'xlink:href'],
|
||||
})
|
||||
const parsed = new DOMParser().parseFromString(clean, 'text/html')
|
||||
const policy = parsed.createElement('meta')
|
||||
policy.setAttribute('http-equiv', 'Content-Security-Policy')
|
||||
policy.setAttribute('content', "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'")
|
||||
parsed.head.prepend(policy)
|
||||
|
||||
@@ -32,7 +32,7 @@ export function apply(ctx: Context): void {
|
||||
{
|
||||
name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml',
|
||||
inject: (): HtmlBodyInjected => ({
|
||||
hooks: { developerTools: ctx.settingsScope.developerTools.enabled },
|
||||
hooks: { interactivePreview: ctx.developerTools.enabled },
|
||||
readRelated: (address, relativePath, signal) => {
|
||||
const file = hostFileOf(address)
|
||||
return ctx.remote.workspaceFiles.readRelated(file.sessionId, file.path, relativePath, signal)
|
||||
|
||||
@@ -78,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* Required browser services: the tab registry, the slot registry, copy, and the
|
||||
* Remote carrier with its `workspaceFiles` namespace.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope']
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'developerTools']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
|
||||
@@ -27,7 +27,7 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
|
||||
import { PDF_BODY_ID } from '../src/client/pdf/index.ts'
|
||||
import { CodeBody } from '../src/client/code/CodeBody.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { textFace } from '../src/client/face.ts'
|
||||
import type { TextStore } from '../src/client/store.ts'
|
||||
import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
|
||||
@@ -43,7 +43,7 @@ interface Recorded {
|
||||
|
||||
async function boot() {
|
||||
const ctx = new Context()
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
const tabs = new SidebarRightTabRegistry(ctx)
|
||||
const registered: Recorded[] = []
|
||||
const slots = {
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { createBasicHtmlDocument } from '../src/client/html/basic-document.ts'
|
||||
|
||||
it('removes MathML navigation and links exposed by parser mutation', () => {
|
||||
const cases = [
|
||||
'<math href="https://example.invalid/"><mi xlink:href="https://example.invalid/">x</mi></math>',
|
||||
'<form><math><mtext></form><form><mglyph><style></math><a href="https://example.invalid/">go</a>',
|
||||
]
|
||||
for (const source of cases) {
|
||||
const clean = createBasicHtmlDocument(new TextEncoder().encode(source))
|
||||
const reparsed = new DOMParser().parseFromString(clean, 'text/html')
|
||||
expect(reparsed.querySelector('[href], [xlink\\:href]')).toBeNull()
|
||||
expect(reparsed.querySelectorAll('meta[http-equiv]')).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves static content and removes navigation, policy overrides and active documents', () => {
|
||||
const html = createBasicHtmlDocument(new TextEncoder().encode(`<!doctype html><html><head>
|
||||
<meta http-equiv="refresh" content="0;url=https://example.invalid">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/** Document extension registration and dispatch through the production Sidebar and Slot renderer. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
@@ -36,7 +36,7 @@ afterEach(async () => {
|
||||
async function boot() {
|
||||
const rt = await SlotTestRuntime.create()
|
||||
runtime = rt
|
||||
rt.ctx.provide('settingsScope', { developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
rt.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
|
||||
const locale = new LocaleRuntime(rt.ctx)
|
||||
rt.ctx.provide('locale', locale)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */
|
||||
import { stubDeveloperTools } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
@@ -30,7 +31,7 @@ describe('HTML registration', () => {
|
||||
|
||||
it('registers its dictionary and matching keyed body, and removes all contributions on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
// No Session or Tab services are mounted; the global callback must use its file address.
|
||||
const registry = new DocumentPreviewRegistry()
|
||||
const dictionaries = new Map<string, unknown>()
|
||||
@@ -47,7 +48,7 @@ describe('HTML registration', () => {
|
||||
bind: () => (key: keyof typeof en) => en[key],
|
||||
register: (name: string, value: unknown) => { dictionaries.set(name, value); return () => { dictionaries.delete(name) } },
|
||||
} as never)
|
||||
const fiber = ctx.plugin({ inject: ['settingsScope'], apply })
|
||||
const fiber = ctx.plugin({ inject: ['developerTools'], apply })
|
||||
dispose = async () => { await fiber.dispose() }
|
||||
await fiber.await()
|
||||
expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID])
|
||||
|
||||
@@ -34,7 +34,7 @@ afterEach(() => {
|
||||
function props(text = '<p>hello</p>'): HtmlBodyProps {
|
||||
const signal = new AbortController().signal
|
||||
return {
|
||||
useDeveloperTools: select => select(true),
|
||||
useInteractivePreview: select => select(true),
|
||||
resourceAddress: 'dsh-resource://file/session/html/index.html',
|
||||
content: { kind: 'bytes', data: utf8(text) },
|
||||
wrap: false,
|
||||
@@ -51,7 +51,7 @@ const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode
|
||||
describe('HtmlBody', () => {
|
||||
it('renders static HTML without reading related files, running scripts or retaining an advanced frame', async () => {
|
||||
const initial = props('<h1>Preview</h1><script src="./script.js"></script><p>Static content</p>')
|
||||
const basic = { ...initial, useDeveloperTools: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useDeveloperTools'] }
|
||||
const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] }
|
||||
const view = render(<HtmlBody {...basic} />)
|
||||
const frame = screen.getByTitle(en.frame)
|
||||
expect(frame.getAttribute('sandbox')).toBe('')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** ui-subagent browser half: catalog actions and read-only composer routing. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionListState, SessionSnapshot, SessionSummary,
|
||||
@@ -78,7 +78,8 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
apply as applyChat, inject as injectChat, type ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
|
||||
|
||||
@@ -76,7 +76,8 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
|
||||
},
|
||||
})
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
@@ -80,7 +80,7 @@ function snapshotWith(
|
||||
/** Test-owned AppFrame role: declares and renders the Chat view list. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation.view'>
|
||||
const VIEW_OWNER: ConvViewOwnerProps = {
|
||||
availableViews: [{ id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }],
|
||||
inspectCall: undefined,
|
||||
viewRequest: null,
|
||||
openView: () => {},
|
||||
completeViewRequest: () => {},
|
||||
@@ -104,7 +104,8 @@ async function bench(snapshot: ChatSnapshot) {
|
||||
const chat = createSnapshotStore(snapshot)
|
||||
const events = new ConversationEventRegistry(ctx)
|
||||
const views = new ConversationViewRegistry(ctx)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('uiConversation', {
|
||||
events,
|
||||
views,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
apply as applyChat, inject as injectChat, type ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
@@ -67,7 +67,8 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
|
||||
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.ctx.provide('layout', layout)
|
||||
const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
|
||||
@@ -288,7 +289,8 @@ describe('registrant declaration injection', () => {
|
||||
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
|
||||
},
|
||||
})
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
runtime.ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
|
||||
@@ -306,6 +306,7 @@ export const trajectoryViewDefinition: ConversationViewDefinition<
|
||||
TrajectorySnapshot
|
||||
> = {
|
||||
target: 'trajectory',
|
||||
toolCallFocus: callId => callId,
|
||||
create: () => new TrajectorySnapshotBuilder(),
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -89,7 +89,8 @@ describe('tsdown client artifact', () => {
|
||||
const { events, views } = uiConversation
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = await import('@deepseek-ai/dsh-client-locale/client')
|
||||
ctx.plugin({ inject: [...locale.inject], apply: locale.apply })
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
|
||||
@@ -172,6 +172,10 @@ function systemMessage(text: string) {
|
||||
}
|
||||
|
||||
describe('Trajectory conversation Definitions', () => {
|
||||
it('addresses tool-call inspection with the persisted call identity', () => {
|
||||
expect(trajectoryViewDefinition.toolCallFocus?.('call-1')).toBe('call-1')
|
||||
})
|
||||
|
||||
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { stubDeveloperTools, bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
EMPTY_CONVERSATION_SNAPSHOT, UiConversation,
|
||||
@@ -234,7 +234,7 @@ function standaloneProps(
|
||||
useInput: bindSnapshotSelector(input),
|
||||
inputActions,
|
||||
useProjection,
|
||||
availableViews: [{ id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }],
|
||||
inspectCall: undefined,
|
||||
viewRequest: null,
|
||||
openView: () => {},
|
||||
completeViewRequest: () => {},
|
||||
@@ -291,7 +291,8 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
// The locale plugin backs the locale-aware view tab label ('locale' in
|
||||
// inject); its settings scope needs a connection handle.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await runtime.mount({ inject: [...localeInject], apply: localeApply })
|
||||
const provide = vi.spyOn(ctx.uiSession, 'provide')
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
@@ -309,10 +310,10 @@ function tabsOf(slots: SlotRegistry): ViewTab[] {
|
||||
.map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
|
||||
}
|
||||
|
||||
type ConvViewOwner = Pick<ConvViewProps, 'availableViews' | 'viewRequest' | 'openView' | 'completeViewRequest'>
|
||||
type ConvViewOwner = Pick<ConvViewProps, 'inspectCall' | 'viewRequest' | 'openView' | 'completeViewRequest'>
|
||||
|
||||
function isConvViewOwner(owner: object): owner is ConvViewOwner {
|
||||
return 'availableViews' in owner && 'viewRequest' in owner
|
||||
return 'inspectCall' in owner && 'viewRequest' in owner
|
||||
&& 'openView' in owner && typeof owner.openView === 'function'
|
||||
&& 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function'
|
||||
}
|
||||
@@ -412,6 +413,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
|
||||
renderSlot={renderSlot}
|
||||
bindDraftMirror={() => () => {}}
|
||||
openView={conversation.actions.openView}
|
||||
useInspectCall={selector => selector(undefined)}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
import { stubDeveloperTools,
|
||||
chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot,
|
||||
stubSettingsScope, TestSessions, workspaceSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -884,7 +884,8 @@ describe('plugin lifecycle', () => {
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope, developerTools: { enabled: { getSnapshot: () => true, subscribe: () => () => {} } } } as never)
|
||||
ctx.provide('developerTools', stubDeveloperTools() as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const sessions = new TestSessions(async (action) => { await action() }, ctx)
|
||||
ctx.provide('sessions', sessions)
|
||||
const openSession = vi.fn(async () => {})
|
||||
|
||||
@@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall?: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'MarkdownFileMentions',
|
||||
@@ -1506,11 +1506,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Currently available navigation targets. */\n availableViews: readonly ViewTab[]\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}',
|
||||
'/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Open a tool call\'s inspector when an inspection target is available. */\n inspectCall: ((callId: string) => void) | undefined\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'ConversationViewRequest',
|
||||
'ViewTab',
|
||||
],
|
||||
standardProps: [
|
||||
'useResource: UseResource',
|
||||
|
||||
@@ -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/test-support/client-runtime/README.md
|
||||
README.md: 819892dfb5b433c6b4dcbe40a445e974137eff71
|
||||
README.zh.md: ee63f4d930410f7f5069be9027327559249b56ca
|
||||
README.md: b640b94af0dc69f454e88af1a72807440a33f7c1
|
||||
README.zh.md: 2e263872b01e224d0734e20dfd1374e34bb83c74
|
||||
@@ -123,7 +123,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p
|
||||
| [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `<svg>` fingerprint) |
|
||||
| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export |
|
||||
| [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers |
|
||||
| [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy |
|
||||
| [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` and `stubDeveloperTools` with test-driven publications and write spies |
|
||||
| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` |
|
||||
| [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue |
|
||||
| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: instance-bound Connection, shared jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` |
|
||||
|
||||
@@ -123,7 +123,7 @@ test('registers into the sidebar', async ({ remote, start }) => {
|
||||
| [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`<svg>` 指纹) |
|
||||
| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 |
|
||||
| [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 |
|
||||
| [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` |
|
||||
| [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` 和 `stubDeveloperTools` |
|
||||
| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` |
|
||||
| [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 |
|
||||
| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:绑定实例的 Connection、共享 jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` |
|
||||
|
||||
@@ -46,7 +46,7 @@ import type { Stabilizer } from './fixtures.ts'
|
||||
export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export { stubSettingsScope, stubDeveloperTools } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { RemoteError, TestRemote } from './remote.ts'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Test double for the client settings-scope seam. */
|
||||
/** Test doubles for settings transport and developer-tool preferences. */
|
||||
import { vi } from 'vitest'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {
|
||||
SettingsScope, SettingsScopeSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
@@ -35,9 +36,9 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
revision: undefined, writable: false, mode: 'host',
|
||||
}
|
||||
const listeners = new Set<() => void>()
|
||||
const set = vi.fn(() => Promise.resolve())
|
||||
const mutate = vi.fn(() => Promise.resolve())
|
||||
const unset = vi.fn(() => Promise.resolve())
|
||||
const set = vi.fn(() => Promise.resolve(true))
|
||||
const mutate = vi.fn(() => Promise.resolve(true))
|
||||
const unset = vi.fn(() => Promise.resolve(true))
|
||||
return {
|
||||
scope: {
|
||||
getSnapshot: () => snapshot,
|
||||
@@ -59,3 +60,19 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the developer preference service used by UI composition tests.
|
||||
* @param enabled - initial developer-tool visibility.
|
||||
* @returns a shared observable and a writer that publishes the requested value.
|
||||
*/
|
||||
export function stubDeveloperTools(enabled = true): {
|
||||
enabled: SnapshotStore<boolean>
|
||||
setEnabled(enabled: boolean): Promise<void>
|
||||
} {
|
||||
const source = createSnapshotStore(enabled)
|
||||
return {
|
||||
enabled: source,
|
||||
setEnabled: vi.fn((next: boolean) => { source.set(next); return Promise.resolve() }),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '../src/settings-scope.ts'
|
||||
import { stubSettingsScope, stubDeveloperTools } from '../src/settings-scope.ts'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { createSnapshotStore, defineStore } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -833,6 +833,18 @@ describe('single-slot mounting edge arms', () => {
|
||||
})
|
||||
|
||||
describe('stubbed settings scope', () => {
|
||||
it('publishes developer preference choices through one observable', async () => {
|
||||
const preference = stubDeveloperTools()
|
||||
expect(preference.enabled.getSnapshot()).toBe(true)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
await preference.setEnabled(false)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
expect(notify).toHaveBeenCalledOnce()
|
||||
dispose()
|
||||
expect(stubDeveloperTools(false).enabled.getSnapshot()).toBe(false)
|
||||
})
|
||||
|
||||
it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
|
||||
const host = stubSettingsScope<{ preference: string }>()
|
||||
let notified = 0
|
||||
@@ -843,7 +855,8 @@ describe('stubbed settings scope', () => {
|
||||
})
|
||||
|
||||
await host.scope.set('preference', 'dark')
|
||||
await host.scope.unset('preference')
|
||||
await expect(host.scope.unset('preference')).resolves.toBe(true)
|
||||
await expect(host.scope.mutate([{ op: 'set', path: ['preference'], value: 'dark' }])).resolves.toBe(true)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
value: { preference: 'system' },
|
||||
|
||||
Generated
+3
@@ -4466,6 +4466,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
dompurify:
|
||||
specifier: ^3.4.11
|
||||
version: 3.4.11
|
||||
pdfjs-dist:
|
||||
specifier: 6.3.289
|
||||
version: 6.3.289
|
||||
|
||||
@@ -173,6 +173,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
uiWorkspace: 'client-side Workspace navigation adapter — packages/client/ui-workspace/README.md owns the API',
|
||||
settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API',
|
||||
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
|
||||
developerTools: 'client-side developer-tool preference service — packages/client/ui-settings/README.md owns the API',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
||||
|
||||
Reference in new issue
Block a user