feat(client): inject slot declaration lifetimes
This commit is contained in:
@@ -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/host/directory-picker-browse/README.md
|
||||
README.md: bd28be4119a542eda8c8ee2e53aa860f9c02c17d
|
||||
README.zh.md: 03306f2fd1c54f9c3c5dbc808920abce944141e5
|
||||
README.md: 11ddece6f68a752f8afc0392029c752013389d4a
|
||||
README.zh.md: c73ddeb52264bf7e2d2b9a74f0a7da9194e27c16
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即「不可进入」),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空白段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -22,8 +21,8 @@ export const inject = ['slots', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dialog's dictionaries and the browse flow
|
||||
* into both directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* into both directory-flow holes through `slots.inject()` because the
|
||||
* ui-workspace entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -78,16 +77,16 @@ export function apply(ctx: ClientContext): void {
|
||||
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
BrowseDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-browse: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, BrowseDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@ describe('directory-picker-browse client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -98,19 +98,21 @@ describe('directory-picker-browse client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
|
||||
@@ -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/host/directory-picker-native/README.md
|
||||
README.md: 0ff5524791094095550542de9bb525a4a633f5b5
|
||||
README.zh.md: 19915340c14249ae07d56001911165841dd550d2
|
||||
README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42
|
||||
README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
* code branches on a capability kind.
|
||||
*/
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -20,22 +19,22 @@ export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
ctx.effect(() => {
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
NativeDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-native: flow registrations')
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -56,7 +56,16 @@ describe('directory-picker-native client half', () => {
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
it('fails loudly instead of deduplicating a duplicate package row', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
@@ -64,15 +73,14 @@ describe('directory-picker-native client half', () => {
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
// (Duplicate rows of the SAME package converge silently — the deferral
|
||||
// skips a hole its own component already occupies; the conflict needs
|
||||
// a rival provider.)
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
@@ -97,11 +105,11 @@ describe('directory-picker-native client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the first deferral when the second hole is already occupied', async () => {
|
||||
it('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the first deferral installed its subscription.
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
|
||||
Reference in New Issue
Block a user