@xrn1997/dsh-session-delete 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +10 -28
  2. package/lib/client.js +2012 -1117
  3. package/lib/index.js +1271 -893
  4. package/lib/types/api/dispatch.d.ts +2 -2
  5. package/lib/types/api/route-table.d.ts +1 -1
  6. package/lib/types/api/trash-rows.d.ts +13 -6
  7. package/lib/types/client/batch-bar.d.ts +7 -6
  8. package/lib/types/client/batch-confirm.d.ts +6 -4
  9. package/lib/types/client/batch-menu-item.d.ts +2 -2
  10. package/lib/types/client/batch-select.d.ts +7 -50
  11. package/lib/types/client/batch-selection-store.d.ts +30 -0
  12. package/lib/types/client/busy-gate.d.ts +6 -0
  13. package/lib/types/client/copy.d.ts +4 -1
  14. package/lib/types/client/delete-confirm.d.ts +2 -0
  15. package/lib/types/client/host-rows.d.ts +50 -0
  16. package/lib/types/client/main-view.d.ts +42 -0
  17. package/lib/types/client/orphan-maintenance.d.ts +27 -0
  18. package/lib/types/client/receipt.d.ts +73 -0
  19. package/lib/types/client/remote.d.ts +25 -19
  20. package/lib/types/client/store.d.ts +48 -0
  21. package/lib/types/client/trash-nav.d.ts +30 -0
  22. package/lib/types/client/trash-panel.d.ts +1 -1
  23. package/lib/types/client/trash-store.d.ts +2 -0
  24. package/lib/types/client/ui/icons.d.ts +3 -3
  25. package/lib/types/client/undo-toast.d.ts +30 -6
  26. package/lib/types/client/use-column-resize.d.ts +2 -2
  27. package/lib/types/client/use-panel-width.d.ts +1 -1
  28. package/lib/types/client/use-trash-panel-controller.d.ts +42 -0
  29. package/lib/types/client/use-trash-query.d.ts +20 -9
  30. package/lib/types/client/write-seats.d.ts +52 -0
  31. package/lib/types/client/write-settlement.d.ts +72 -0
  32. package/lib/types/client/write.d.ts +51 -0
  33. package/lib/types/deps.d.ts +14 -0
  34. package/lib/types/index.d.ts +7 -13
  35. package/lib/types/log/zstd-frames.d.ts +1 -1
  36. package/lib/types/ports/agent-handles.d.ts +25 -0
  37. package/lib/types/ports/home.d.ts +19 -0
  38. package/lib/types/ports/host-port.d.ts +60 -44
  39. package/lib/types/remote/map-error.d.ts +2 -2
  40. package/lib/types/shared/wire.d.ts +18 -9
  41. package/lib/types/trash/detail.d.ts +6 -3
  42. package/lib/types/trash/entry.d.ts +4 -4
  43. package/lib/types/trash/groups.d.ts +17 -6
  44. package/lib/types/trash/ledger.d.ts +20 -0
  45. package/lib/types/trash/manifest.d.ts +4 -2
  46. package/lib/types/trash/reconcile.d.ts +6 -5
  47. package/lib/types/trash/storage-manifest.d.ts +12 -0
  48. package/lib/types/verbs/batch-carried.d.ts +20 -0
  49. package/lib/types/verbs/batch.d.ts +1 -1
  50. package/lib/types/verbs/delete-session.d.ts +11 -13
  51. package/lib/types/verbs/errors.d.ts +2 -6
  52. package/lib/types/verbs/live-admission.d.ts +13 -0
  53. package/lib/types/verbs/member-journal.d.ts +26 -6
  54. package/lib/types/verbs/member-presence.d.ts +67 -0
  55. package/lib/types/verbs/move-group.d.ts +20 -0
  56. package/lib/types/verbs/orphan-subagents.d.ts +21 -40
  57. package/lib/types/verbs/permanent-removal.d.ts +114 -0
  58. package/lib/types/verbs/purge-trash.d.ts +21 -9
  59. package/lib/types/verbs/restore-session.d.ts +14 -12
  60. package/package.json +3 -2
@@ -15,7 +15,7 @@
15
15
  * 仍住这里,因为它们是**传输**的事。
16
16
  *
17
17
  * 信封(口径 ②):`{ok:true,value}` / `{ok:false,error:{code,message,details?}}`。
18
- * `error.code` 走 `src/remote/map-error.ts` 的**同一张表**(设计稿 §6 的五个码),原文进
18
+ * `error.code` 走 `src/remote/map-error.ts` 的**同一张表**(src/shared/wire.ts 的五个码),原文进
19
19
  * `error.message`,结构化附带物进可选的 `error.details`。
20
20
  *
21
21
  * HTTP 状态不是第二套语义(口径 ③):**操作失败**时状态一律 200(含 `ok:false`)——我们与浏览器半之间的
@@ -31,5 +31,5 @@
31
31
  * 由路由表说,不在这里列)。
32
32
  */
33
33
  import type { IncomingMessage, ServerResponse } from 'node:http';
34
- import type { DeleteDeps } from '../verbs/delete-session.js';
34
+ import type { DeleteDeps } from '../deps.js';
35
35
  export declare function createApiHandler(deps: DeleteDeps): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -13,7 +13,7 @@
13
13
  * `wire.ts`(两半的共同契约),这张表只回答"怎么处理"。
14
14
  */
15
15
  import { ROUTES, type ApplyAction } from '../shared/wire.js';
16
- import type { DeleteDeps } from '../verbs/delete-session.js';
16
+ import type { DeleteDeps } from '../deps.js';
17
17
  /** 请求体:`json` 才读下来(GET 一律不读;读两遍会把流消费掉)。 */
18
18
  export type BodyKind = 'none' | 'json';
19
19
  /** 到达序:`pure` 不进链;`chained` 进链;函数形式给 `/apply` 用(`count` 是纯读)。 */
@@ -2,23 +2,30 @@
2
2
  * `GET /list` 那一行的装配:清册行(`TrashEntry`)→ 线上那一行(`TrashRowWire`),外加组视图的几个只读数。
3
3
  *
4
4
  * 它从 `api/dispatch.ts` 里搬出来,因为路由层的事是**传输**(栅栏、方法、纯度、信封),而"一行里放什么"
5
- * 是这一层的事。「折进谁、谁露面」那条判据读 `trash/groups.ts`(ADR-0001),这里只做装配。
5
+ * 是这一层的事。「折进谁、谁露面」那条判据读 `trash/groups.ts`(ADR-0001);一组会释放多少、盘上还剩谁
6
+ * 归 `verbs/permanent-removal.ts` 的只读计划 —— 这里只做装配,不再自己枚举盘上后代。
6
7
  */
7
- import type { DeleteDeps } from '../verbs/delete-session.js';
8
+ import type { CoreDeps } from '../deps.js';
8
9
  import type { TrashEntry } from '../trash/entry.js';
9
10
  import type { TrashRowWire } from '../shared/wire.js';
10
- /** 组视图那几个只读数的容器(行投影的第二个入参)。 */
11
- type TrashRowReadouts = Pick<TrashRowWire, 'subagentCount' | 'subagentBytes' | 'strandedSubagents' | 'strandedBytes' | 'workspaceMissing'>;
11
+ /** 组视图那几个只读数的容器(行投影的第二个入参)。`releaseBytes` 必填(计划里实测好的那一格);
12
+ * 其余缺席 = 这次没算,不是 0。 */
13
+ type TrashRowReadouts = {
14
+ readonly releaseBytes: number;
15
+ } & Partial<Pick<TrashRowWire, 'subagentCount' | 'subagentBytes' | 'strandedSubagents' | 'strandedBytes' | 'workspaceMissing'>>;
12
16
  /**
13
17
  * 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
14
18
  * 于是"线上多了一格却没人填"不可能悄悄发生(`...row` 会把它带过去,看上去一切正常)。
15
19
  */
16
- export declare function toWire(row: TrashEntry, readouts?: TrashRowReadouts): TrashRowWire;
20
+ export declare function toWire(row: TrashEntry, readouts: TrashRowReadouts): TrashRowWire;
17
21
  /**
18
22
  * 把清册的全部行装配成「一行 = 一组」:孩子折进父那一行、不单独列(宿主的客户端本来就藏孩子,
19
23
  * 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
20
24
  * 这些派生值只加在这次回信上,**不落清册**——清册该记的是文件里真有的事实,而"还剩几个孩子"
21
25
  * "工作区还在不在"是随盘上随时变的读数。取数失败照抛(响亮失败,不端一份少了这栏的清单当没事发生)。
26
+ *
27
+ * 一组要释放多少、盘上还剩谁**只读计划一处算**(`planVisibleTrash`,与 purge 同一条口径);
28
+ * 这里不再自己枚举盘上后代,也不碰 `isLive`(活体准入只属于写动词)。
22
29
  */
23
- export declare function assembleTrashRows(deps: DeleteDeps, rows: readonly TrashEntry[]): Promise<TrashRowWire[]>;
30
+ export declare function assembleTrashRows(deps: CoreDeps, rows: readonly TrashEntry[]): Promise<TrashRowWire[]>;
24
31
  export {};
@@ -1,17 +1,18 @@
1
1
  /**
2
- * 批量删除的浮条(第十三批①):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
2
+ * 批量删除的浮条(2026-10-17 `6f24fd4`):**多选模式内常驻**(0 条也渲染,既显示模式又给退出入口),
3
3
  * 汇总「已选 N 条」+(读得到时)「含 K 个子会话」;「查看已选」展开限高可滚动的标题清单
4
4
  * (逐条可移除),回答"到底选了哪些"——清单默认收起,不把底部输入区盖住。
5
- * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `batch-select.tsx` 的 `useSelectionRowClickGuard`)。
5
+ * 模式内的整行点击守卫也挂在这棵常驻宿主上(见 `host-rows.ts` 的 `useSelectionRowClickGuard`)。
6
6
  *
7
- * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"选中集 + 行开关"(被确认框 import),
8
- * 而浮条要点开确认框(要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
7
+ * 为什么单独一个文件而不是并进 `batch-select.tsx`:那份是"行开关控件",而浮条要点开确认框
8
+ * (要 import 它的现场)——两个方向合到一个文件上就是一圈。浮条住在
9
9
  * `shell.overlay` 上,与单删的撤销提示/确认框同类(槽位原文:"a toast stack or a status pill
10
10
  * all belong here")。
11
11
  *
12
12
  * 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
13
- * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与第十二批那条 `reloadSeq` 同一个道理)。
14
- * 读不到就整句不说——K 是合计,缺一条就是假数(10-15 稿 §10「不许拿 0 冒充没有」)。
13
+ * 并且带**最新票守卫**:在飞的旧响应不许盖掉新名单的读数(compare-and-set 住 `createLatestWins`,
14
+ * 防抖本身仍在下面这个 effect 里)。
15
+ * 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
15
16
  */
16
17
  import { type ReactElement } from 'react';
17
18
  import { type Translate } from './copy.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 批量删除的确认框(第十三批②)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
2
+ * 批量删除的确认框(2026-10-17)——挂在 `shell.overlay` 上的宿主,加它读的那个模块级现场。
3
3
  *
4
- * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(设计稿 §7 状态 ②,
4
+ * 为什么又开一个文件而不是把 `delete-confirm.tsx` 撑大:单删那张框是**已交付的面**(docs/ui-spec.md 状态 ②,
5
5
  * 用例钉着),批量形与它只有三件不同——标题随条数、目标卡逐条列、执行是一次批量 `apply`。共用一个壳
6
6
  * 意味着单删的 `busy` / 失败面要跟着学批量形,那是拿已交付的行为去换省一个文件。卡片样式(`TARGET_CARD`)
7
7
  * 与 hint、子会话那两句**复用**单删的,一句不造第二个键。
@@ -10,9 +10,9 @@
10
10
  * `shell.overlay` 上——它必须活在行开关那棵子树之外(槽位行随宿主重排而卸载,见 `delete-confirm.tsx` 头注)。
11
11
  */
12
12
  import { type ReactElement } from 'react';
13
- import { type PickItem } from './batch-select.js';
14
13
  import { type Translate } from './copy.js';
15
- import { type SessionDeleteRemote } from './remote.js';
14
+ import type { SessionDeleteRemote } from './remote.js';
15
+ import { type PickItem } from './batch-selection-store.js';
16
16
  export interface BatchDeleteRequest {
17
17
  /** 每次发布递增:换一批就重挂载,busy / 失败面不带着上一批的记忆。 */
18
18
  seq: number;
@@ -23,6 +23,8 @@ export declare function requestDeleteBatch(items?: readonly PickItem[]): void;
23
23
  export declare function clearDeleteBatchRequest(): void;
24
24
  /** 供浮条与装配面订阅。 */
25
25
  export declare function useDeleteBatchRequest(): BatchDeleteRequest | null;
26
+ /** 测试专用:现场与 seq 归零,用例之间互不串味。 */
27
+ export declare function resetBatchConfirm(): void;
26
28
  export type BatchConfirmDeps = Pick<SessionDeleteRemote, 'applyOutcomes'>;
27
29
  /** 常驻的批量确认框宿主。没有待确认的批量删除时什么都不渲染。 */
28
30
  export declare function BatchConfirmHost({ deps, t, }: {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 会话「⋯」菜单里的「多选」入口(第十三批①改版)。
2
+ * 会话「⋯」菜单里的「多选」入口(2026-10-17)。
3
3
  *
4
4
  * 常驻勾选框被用户打回后,入口改成显式模式:按下这一行才进入多选态(再按一次退出),
5
- * 模式内才渲染勾选框、才把整行点击变成"勾/取消"(守卫在 `batch-select.tsx`)。
5
+ * 模式内才渲染勾选框、才把整行点击变成"勾/取消"(守卫在 `host-rows.ts`)。
6
6
  * 菜单的开关归 owner(与 `delete-menu-item.tsx` 同一条契约):按下后先切模式、再收菜单。
7
7
  *
8
8
  * 文案复用回收站工具栏那两句(`trash.toolbar.select` / `selectExit`)——同一个"多选/退出多选"
@@ -1,69 +1,26 @@
1
1
  /**
2
- * 宿主列表批量删除的选择面(第十三批①):多选模式 + 选中集 + 行首勾选 + 行尾兜底 + 整行点击守卫。
2
+ * 宿主列表批量删除的**控件面**(2026-10-17,2026-10-20 拆出状态与行适配):行首勾选框、行尾兜底开关,
3
+ * 以及确认框/浮条读的那一份计数问句。
3
4
  *
4
5
  * 入口在每行的「⋯」菜单里(`batch-menu-item.tsx`):**按下才进入多选模式**,模式内才渲染勾选框、
5
- * 才把整行点击变成"勾/取消";退出模式清空名单。选中集是**模块级现场**(本仓既有的客户端模式,见
6
- * `delete-confirm.tsx` / `undo-toast.tsx`):主勾选框住在每行的 `sidebar.session.row.leading` 槽位里,
7
- * 行尾兜底住在 `sidebar.workspaces.session.row.action`,浮条与批量确认框住在 `shell.overlay` 上——
8
- * 四处隔着槽位边界,只有模块级 store 能让他们看见同一份名单。状态源用 React 自带的
9
- * `useSyncExternalStore`,不为此引新的状态库。
6
+ * 才把整行点击变成"勾/取消";退出模式清空名单。选中集只住 `batch-selection-store.ts`(模式 + 名单一份
7
+ * 合并快照);"哪些行现在可见、点行算不算选中"只住 `host-rows.ts`。四处隔着槽位边界,只有模块级
8
+ * store 能让他们看见同一份名单;本文件不再持有任何现场。
10
9
  *
11
10
  * **为什么主勾选在行首**:宿主对行尾 `.rowActions` 是 `display:none`,只有 `row:hover` / 菜单打开
12
11
  * 才出现(app.asar 0.2.0-rc.2 的 Rows 渲染);行首 16px 格在无状态点时是空的,模式内可见。
13
12
  * 宿主在「有状态点(运行中/待确认/未读完成)」与归档行不渲染行首格 ⇒ 这些行走行尾悬停兜底,
14
- * 但模式内**点整行**也能选(`useSelectionRowClickGuard`),所以不依赖那颗小框。
13
+ * 但模式内**点整行**也能选(`host-rows.ts` 的 `useSelectionRowClickGuard`),所以不依赖那颗小框。
15
14
  * 两条勾选不会同时出现(`ui.css` 的 `:has` 兜底隐藏规则,靠宿主行的 `data-row-key`)。
16
- *
17
- * 名单只收槽位投影原样给的 `sessionId` / `displayTitle`(浏览器半不自己拼 id),**顺序 = 勾选顺序**:
18
- * 宿主列表的 DOM 序不归我们读,`/apply` 那条「保持输入顺序」吃到的因此就是勾选序。
19
15
  */
20
16
  import { type ReactElement } from 'react';
21
17
  import { type Translate } from './copy.js';
22
18
  import { type SessionDeleteRemote } from './remote.js';
23
- export interface PickItem {
24
- /** 宿主槽位投影的会话 id,原样透给 `applyOutcomes('delete', …)`。 */
25
- sessionId: string;
26
- /** 删除那一刻的会话名(投影的 `displayTitle`):只用于文案与确认框,不参与请求。 */
27
- title: string;
28
- }
29
- export declare function getPicks(): readonly PickItem[];
30
- export declare function isSelecting(): boolean;
31
- /** 「⋯ → 多选」按下时进入;重复进入是幂等的。 */
32
- export declare function enterSelection(): void;
33
- /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
34
- export declare function exitSelection(): void;
35
- export declare function useSelecting(): boolean;
36
- /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
37
- export declare function togglePick(item: PickItem): void;
38
- export declare function clearPicks(): void;
39
- /** 全选:把当前可见可选行里还没选的按 DOM 序追加;原有选中保持在前。 */
40
- export declare function selectAllVisible(): void;
41
- /**
42
- * 同一个切换口该显示「全选」还是「取消全选」:
43
- * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
44
- * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
45
- * - 其余(含 0 条)⇒ 全选。
46
- */
47
- export declare function allVisibleSelected(): boolean;
48
- /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
49
- export declare function shrinkPicksToFailures(failedSessionIds: readonly string[]): void;
50
- export declare function usePicks(): readonly PickItem[];
51
- /**
52
- * 多选态内的整行点击守卫(document 捕获阶段)。
53
- *
54
- * 为什么必须在这里拦:宿主的会话行自己带 `onClick = open(sessionId)`,第三方没有官方的
55
- * "别切会话"开关;模式内点行要变成"勾/取消",只能抢在 React 根监听之前把事件吃掉。
56
- * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
57
- * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
58
- * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
59
- * 勾选框本身仍在。
60
- */
61
- export declare function useSelectionRowClickGuard(): void;
62
19
  /**
63
20
  * 一组会话的子会话计数(`/apply` 的 `action:'count'`,纯读、不进串行链)。
64
21
  *
65
22
  * **一条读不到就整份返回 null** ——界面上那两个数(浮条的 K、确认框的 K 与逐条 M)都是合计,
66
- * 缺一条就是假数;「不许拿 0 冒充没有」(10-15 稿 §10)。
23
+ * 缺一条就是假数;「不许拿 0 冒充没有」。
67
24
  */
68
25
  export declare function fetchCounts(deps: Pick<SessionDeleteRemote, 'applyOutcomes'>, ids: readonly string[]): Promise<Map<string, number> | null>;
69
26
  export interface BatchPickCheckboxProps {
@@ -0,0 +1,30 @@
1
+ export interface PickItem {
2
+ /** 宿主槽位投影的会话 id,原样透给 `applyOutcomes('delete', …)`。 */
3
+ readonly sessionId: string;
4
+ /** 删除那一刻的会话名(投影的 `displayTitle`):只用于文案与确认框,不参与请求。 */
5
+ readonly title: string;
6
+ }
7
+ export interface SelectionState {
8
+ /** 多选模式:入口在每一行的「⋯」菜单里;只有模式内才渲染勾选框、才吃整行点击。 */
9
+ readonly selecting: boolean;
10
+ readonly picks: readonly PickItem[];
11
+ }
12
+ export declare function getSelection(): SelectionState;
13
+ export declare function getPicks(): readonly PickItem[];
14
+ export declare function isSelecting(): boolean;
15
+ /** 供组件订阅:模式与名单是同一次交互的两半,读它们的组件订同一份快照。 */
16
+ export declare function useSelection(): SelectionState;
17
+ /** 「⋯ → 多选」按下时进入;重复进入是幂等的(同一份快照,不发通告)。 */
18
+ export declare function enterSelection(): void;
19
+ /** 退出模式并清空名单:多选态和选中集是同一次交互的两半,退出后不该留下看不见的名单。 */
20
+ export declare function exitSelection(): void;
21
+ /** 翻转一条:没选过就排队尾,选过就摘掉(列表里永远不会有重复 id)。 */
22
+ export declare function togglePick(item: PickItem): void;
23
+ /** 清空名单但**留在多选态**(「取消全选」那颗);已经空了就不发通告。 */
24
+ export declare function clearPicks(): void;
25
+ /** 只追加还没选过的条目;名单序 = 原有选中在前、新条目按传入序接在后面。没有新条目就不发通告。 */
26
+ export declare function addPicks(items: readonly PickItem[]): void;
27
+ /** 执行后收缩:只留失败名单(空数组 = 全清)。成功的随通告从侧栏消失,不该留在名单里。 */
28
+ export declare function shrinkPicksToFailures(failedSessionIds: readonly string[]): void;
29
+ /** 测试专用:模式与名单一起回初值(订阅者随组件卸载自行退订)。 */
30
+ export declare function resetSelection(): void;
@@ -0,0 +1,6 @@
1
+ export interface BusyGate {
2
+ readonly busy: boolean;
3
+ /** 把一次任务整个包进「这一趟还算忙」;落地与抛错都放回 false。 */
4
+ around<T>(task: () => Promise<T>): Promise<T>;
5
+ }
6
+ export declare function useBusyGate(): BusyGate;
@@ -1,5 +1,8 @@
1
1
  /**
2
- * 可见文案(**全部**住在这里)+接上宿主 Client locale 服务的那一步。
2
+ * 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
3
+ *
4
+ * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id 与单条幂等空值、
5
+ * `src/client/remote.ts` 的取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
3
6
  *
4
7
  * ## 为什么不是散在各组件里的中文字面量
5
8
  *
@@ -30,6 +30,8 @@ export declare function requestDelete(input: {
30
30
  export declare function clearDeleteRequest(): void;
31
31
  /** 供宿主组件订阅。 */
32
32
  export declare function useDeleteRequest(): DeleteRequest | null;
33
+ /** 测试专用:现场与 seq 归零,用例之间互不串味。 */
34
+ export declare function resetDeleteRequest(): void;
33
35
  /**
34
36
  * 常驻的确认框宿主。挂 `shell.overlay`(框架级浮层;槽位说明原文 "a toast stack or a status
35
37
  * pill all belong here"):没有待确认的删除时什么都不渲染。
@@ -0,0 +1,50 @@
1
+ import { type PickItem } from './batch-selection-store.js';
2
+ export interface HostRowInfo {
3
+ /** 槽位投影的 `displayTitle`(行首槽只投 id,所以守卫靠行尾那颗常驻登记)。 */
4
+ readonly title: string;
5
+ /** 在跑的行点整行也不选:与置灰的勾选框同口径(级联遇在跑成员整组拒绝)。 */
6
+ readonly live: boolean;
7
+ }
8
+ /**
9
+ * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
10
+ * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
11
+ */
12
+ export declare function registerHostRowInfo(sessionId: string, info: HostRowInfo): void;
13
+ /**
14
+ * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
15
+ * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
16
+ * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
17
+ *
18
+ * 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
19
+ */
20
+ export declare function readVisibleSelectableRows(): PickItem[];
21
+ /**
22
+ * 渲染侧的快照:脏了才查 DOM;干净时回同一个引用(React 的 `useSyncExternalStore` 契约)。
23
+ * 没变化时连续两次调用必须同引用,否则订阅方会被自己的快照更新推成死循环。
24
+ */
25
+ export declare function getVisibleSelectableRowsSnapshot(): readonly PickItem[];
26
+ /**
27
+ * 渲染侧只读快照 + DOM 观察订阅;首个订阅者起开始观察,最后一个走了断开。
28
+ * `enabled === false` 不订阅也不读 DOM——行列表只随多选态存在,模式外不必付观察的成本。
29
+ */
30
+ export declare function useVisibleSelectableRows(enabled: boolean): readonly PickItem[];
31
+ /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
32
+ export declare function selectAllVisible(): void;
33
+ /**
34
+ * 同一个切换口该显示「全选」还是「取消全选」:
35
+ * - 有选中、且当前可见可选行全都选中 ⇒ 取消全选;
36
+ * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
37
+ * - 其余(含 0 条)⇒ 全选。
38
+ */
39
+ export declare function areAllVisibleRowsSelected(rows: readonly PickItem[], picks: readonly PickItem[]): boolean;
40
+ /**
41
+ * 多选态内的整行点击守卫(document 捕获阶段)。
42
+ *
43
+ * 边界:行里的按钮 / 链接 / 输入 / 菜单项一律放行(否则 ⋯ 菜单、归档、置顶、单删都点不动);
44
+ * 双击的第二下与 dblclick 不翻转(第一下已经选中,第二下不能又反掉,也不能打开宿主的重命名)。
45
+ * 认行靠 `data-row-key="session:…"`——宿主将来改掉这个属性,最差是模式内点行不生效,
46
+ * 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
47
+ */
48
+ export declare function useSelectionRowClickGuard(): void;
49
+ /** 测试专用:登记表清空并标脏(会话 id 不跨用例复用,名字/运行态别串味);观察者归订阅者管。 */
50
+ export declare function resetHostRows(): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 宿主**主视图**的一处窄面:删掉当前打开的会话时把它关掉,撤销恢复后把它重开。
3
+ *
4
+ * 为什么需要:宿主客户端的常驻 `Session` 实例把 `removed` 记成**粘性**状态
5
+ * (`dsh-api-session-controller/lib/client.js` 的 `handleRemoved()` 只置位;`handleSessionAdded()`
6
+ * 只 merge 摘要,从不清它)。我们发 `api-session/removed` 之后,正在看的那个会话会一直停在
7
+ * `removed=true`:输入框禁用、占位文案是「会话不可用」。恢复再发 `api-session/added` 也不会把它
8
+ * 清掉——**除非把常驻实例本身丢掉**。宿主自己的归档走的就是这条路:`clearArchivedCurrent()`
9
+ * 发现当前会话进了归档集就 `clearMain()`(`dsh-client-ui-workspace/lib/client.js`)。
10
+ *
11
+ * 所以关闭/重开是**一段生命周期**(2026-10-21),不是一个 id:删除成功后 `afterDelete` 关掉当前
12
+ * 主视图并记下"这一条是我们关掉的";撤销成功后 `afterRestore` 只重开自己记过的那些。调用方不再
13
+ * 各自判定该不该关、也不再把会话 id 塞进撤销提示传一圈——判据全在这一处。两次重开排队时只有最新
14
+ * 那条会打开(最新票守卫),`resetMainView()` 清掉这份记忆并让在等的重开作废。
15
+ *
16
+ * **不 import 宿主客户端包**(AGENTS.md 红线):只通过 `ctx.get('uiWorkspace')` 可选读服务,
17
+ * 形状不符/服务缺席都安静退回,绝不影响插件激活。字段与方法名来自上面两个包的产物原文;
18
+ * 宿主换代时先复核 `UiWorkspaceService` 的 `selection` / `sessions` / `clearMain` / `openSession`。
19
+ */
20
+ import type { ClientContextLike } from './context.js';
21
+ /** 入口激活时绑一次;之后每次调用再读服务(客户端插件激活顺序早于 `uiWorkspace` 也没关系)。 */
22
+ export declare function bindMainView(ctx: ClientContextLike): void;
23
+ /** 测试专用:模块级现场归零(记忆清空、在等的重开作废),用例之间互不串味。 */
24
+ export declare function resetMainView(): void;
25
+ /**
26
+ * 删除成功后调用:当前主视图若在这批里,把它关掉并记下"这一条是我们关掉的"(供撤销重开)。
27
+ * 关不掉/不是当前会话都什么都不做——这只是界面收尾,绝不让删除结果变红。
28
+ */
29
+ export declare function afterDelete(sessionIds: readonly string[]): void;
30
+ /**
31
+ * 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
32
+ *
33
+ * 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
34
+ * 的成功条数可能多于一条;`openWhenListed` 的最新票守卫保证前一条的等待不会后落地抢焦点。
35
+ * 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
36
+ */
37
+ export declare function afterRestore(sessionIds: readonly string[]): void;
38
+ /**
39
+ * 面板恢复已经替这些会话完成了“回来”的结算:只忘掉我们关过的那几条,不重开、不抢焦点。
40
+ * 它不动 `reopen`:同一会话不会既在等重开、又躺在回收站里等面板恢复;在等的重开属于另一条动作。
41
+ */
42
+ export declare function forgetMainView(sessionIds: readonly string[]): void;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 无主子会话的**维护块**——警告条、失败块、中性提示与清理确认框,回收站面板上唯一的维护块 seam。
3
+ *
4
+ * 为什么收回一个 module:这三段与面板的清单渲染、选择、键盘、拖栏毫无关系,却同住回收站最大的那个文件;
5
+ * 改维护块的文案或确认框时,diff 会与布局改动互相踩。搬出来之后面板只留一个插入点
6
+ * (`<OrphanMaintenance />`,在 header 之后、toolbar 之前),本 module 的 interface 就是那四个读数与四个动作。
7
+ *
8
+ * 搬字时一处未改的三条界面规则(现状住 `docs/ui-spec.md`):
9
+ * - **报告非空才出警告条**:空报告与 `null` 都不出现——"检查过了,什么都没有"不是要给用户看的东西,
10
+ * 而"还没扫到"更不是"零条"。
11
+ * - **读不到就说读不到**:检查/清理失败在原位摆原文 + 重试,绝不静默折成一条都不出现
12
+ * ("没能回答有多少残留"与"没有残留"是两件事,不许折成空报告)。
13
+ * - **「已经不在」不是失败**:别处清掉或父已回到清册时单独一条中性提示,不计入失败,下次扫描/刷新时自然消失。
14
+ *
15
+ * 写动作仍走 `use-trash-query.ts` 那一条:警告条开预览(`openOrphanPreview`)、失败块重试(`refreshAll`)、
16
+ * 确认框清理(`cleanOrphans`)。`busy` 不归本 module 管——它是面板那枚全局闸的读数,通过 props 传进来。
17
+ */
18
+ import type { ReactElement } from 'react';
19
+ import type { Translate } from './copy.js';
20
+ import type { TrashQuery } from './use-trash-query.js';
21
+ export interface OrphanMaintenanceProps {
22
+ query: Pick<TrashQuery, 'orphanReport' | 'orphanFailure' | 'orphanNotice' | 'orphanConfirm' | 'setOrphanConfirm' | 'openOrphanPreview' | 'cleanOrphans' | 'refreshAll'>;
23
+ /** 面板那枚全局闸的读数:写动作在飞时按住本块里的每一颗按钮。 */
24
+ busy: boolean;
25
+ t: Translate;
26
+ }
27
+ export declare function OrphanMaintenance({ query, busy, t }: OrphanMaintenanceProps): ReactElement | null;
@@ -0,0 +1,73 @@
1
+ import { type ApplyAction, type ApplyOutcome, type ApplyValueByAction, type RemoteFailure } from '../shared/wire.js';
2
+ /**
3
+ * 一条 `/apply` 结果对一个请求 id 的含义。各写入面不许再各自推导:
4
+ * 缺 id 是通道错误,`null` 是幂等成功,orphan-clean 的 `not-found` 是中性提示而非失败,
5
+ * **N=1 的 `null` 是协议错**——最后这条也在这里判,`applyOne` 与 `runWrite` 只消费同一份 verdict。
6
+ *
7
+ * 各 action 可能出现的 kind(类型面故意保持同一个联合,消费方按 action 自己收窄):
8
+ * - delete / restore:`ok` / `already-gone` / `failed` / `missing`
9
+ * - count / purge:`ok` / `failed` / `missing`
10
+ * - orphan-clean:`ok` / `neutral` / `failed` / `missing`
11
+ * - 单条读法(`readSingleReceipt`):不含 `already-gone`;null 折成 `failed`
12
+ */
13
+ export type ReceiptVerdict<A extends ApplyAction> = {
14
+ readonly kind: 'ok';
15
+ readonly id: string;
16
+ readonly value: Exclude<ApplyValueByAction[A], null>;
17
+ } | {
18
+ readonly kind: 'already-gone';
19
+ readonly id: string;
20
+ } | {
21
+ readonly kind: 'failed';
22
+ readonly id: string;
23
+ readonly error: RemoteFailure;
24
+ } | {
25
+ readonly kind: 'neutral';
26
+ readonly id: string;
27
+ readonly error: RemoteFailure;
28
+ } | {
29
+ readonly kind: 'missing';
30
+ readonly id: string;
31
+ readonly message: string;
32
+ };
33
+ export interface Receipt<A extends ApplyAction> {
34
+ readonly verdicts: readonly ReceiptVerdict<A>[];
35
+ readonly byId: ReadonlyMap<string, ReceiptVerdict<A>>;
36
+ }
37
+ /** 浏览器半自己的未分类诊断码(通道打不通 / 非信封 / 单条协议错)。它不过线,线上失败码仍归 `shared/wire.ts`。 */
38
+ export declare const CLIENT_UNKNOWN_CODE = "client/unknown";
39
+ export interface ReceiptOptions {
40
+ /** N=1:null 只能是通道答错了,不冒充幂等成功。 */
41
+ readonly single?: boolean;
42
+ }
43
+ export type SingleReceiptVerdict<A extends ApplyAction> = Exclude<ReceiptVerdict<A>, {
44
+ kind: 'already-gone';
45
+ }>;
46
+ export interface SingleReceipt<A extends ApplyAction> {
47
+ readonly verdicts: readonly SingleReceiptVerdict<A>[];
48
+ readonly byId: ReadonlyMap<string, SingleReceiptVerdict<A>>;
49
+ }
50
+ export declare function missingOutcomeMessage(action: ApplyAction, id: string): string;
51
+ /** 三种“这一条没成”的 verdict;`failureMessage` 是它们原文取值的唯一入口。 */
52
+ export type FailureVerdict<A extends ApplyAction> = Extract<ReceiptVerdict<A>, {
53
+ kind: 'failed' | 'neutral' | 'missing';
54
+ }>;
55
+ export declare function isFailureVerdict<A extends ApplyAction>(verdict: ReceiptVerdict<A>): verdict is FailureVerdict<A>;
56
+ /** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
57
+ export declare function failureMessage<A extends ApplyAction>(verdict: FailureVerdict<A>): string;
58
+ export declare function readReceipt<A extends ApplyAction>(action: A, requestedIds: readonly string[], outcomes: readonly ApplyOutcome[], options?: ReceiptOptions): Receipt<A>;
59
+ /**
60
+ * N=1 的读法:null 折成协议错,类型面因此不含 `already-gone`(下面那次过滤只服务类型收窄,
61
+ * single 读法运行期不会产出它)。`applyOne` 用它之后只剩「按 id 取一条 + 抛失败」。
62
+ */
63
+ export declare function readSingleReceipt<A extends ApplyAction>(action: A, id: string, outcomes: readonly ApplyOutcome[]): SingleReceipt<A>;
64
+ /**
65
+ * `/apply` 成功支回执里的条目 id(delete / restore 的 `value.id`);purge 的聚合值与 count 的数字值跳过。
66
+ * 撤销名单只收 `ok` 支的条目 id:already-gone 是「已被同批前一条带走」的幂等条,跟着组走,不单列。
67
+ */
68
+ export declare function landedEntryIds<A extends ApplyAction>(receipt: Receipt<A>): string[];
69
+ /**
70
+ * restore 回执里真正回盘的会话 id:`ok` 支取条目上的 `sessionId`;`already-gone` 没有行,
71
+ * 由调用方给条目查找兜底。purge / count 的 `ok` 值没有 `sessionId`,会被跳过。
72
+ */
73
+ export declare function restoredSessionIds<A extends ApplyAction>(receipt: Receipt<A>, sessionIdOfEntry?: (entryId: string) => string | undefined): string[];
@@ -34,7 +34,7 @@
34
34
  * "第二条执行路径"。**清理无主子会话也不另开一段**:它是 `apply` 的第五个动作,`orphans` 只负责
35
35
  * "有多少、在哪、多大"。
36
36
  */
37
- import { type ApplyAction, type ApplyOutcome, type OrphanReportWire, type PurgeStatsWire, type RemoteFailure, type RemoteResult, type TrashDetailWire, type TrashRowWire } from '../shared/wire.js';
37
+ import { type ApplyAction, type ApplyOutcome, type ApplyValueByAction, type OrphanReportWire, type PurgeStatsWire, type RemoteFailure, type RemoteResult, type TrashDetailWire, type TrashRowWire } from '../shared/wire.js';
38
38
  /**
39
39
  * 回收站条目(`/list` 那一行)。**形状的家在 `shared/wire.ts`**(ADR-0003):这里只是旧名的别名,
40
40
  * 所以浏览器半不再自己抄一遍字段;下面几处"只读面"的镜像同理。
@@ -71,7 +71,7 @@ export type RemoteOutcome<T> = {
71
71
  * code,其余按原文兜底)。
72
72
  */
73
73
  export declare function callRemote<T>(run: () => Promise<T>): Promise<RemoteOutcome<T>>;
74
- /** 本插件前缀路由那套**响应信封**的窄镜像(不是宿主服务 `ctx.remote.*`——那条 typert 通道见 §10):
74
+ /** 本插件前缀路由那套**响应信封**的窄镜像(不是宿主服务 `ctx.remote.*`——那条 typert 通道为什么用不了,见 `AGENTS.md` 的 `remote.<namespace>` 那条):
75
75
  * 每个方法 resolve 成 `RemoteResult<T>`。
76
76
  *
77
77
  * 2026-10-17 批量重构:写动词收成**一个 `apply`**(一段管所有动作、一组 id 进、逐条结果出),
@@ -102,26 +102,31 @@ export declare function createHttpRemote(prefix?: string): RawSessionDeleteRemot
102
102
  *
103
103
  * 判据是**「拿到信封」而不是「操作成功」**:`{ok:false, error}` 也是一次成功的往返(路由在、依赖接上了、
104
104
  * 错误映射也生效了),那时该做的是把入口显示出来、让原文上屏;只有往返本身失败才算通道缺席 ⇒
105
- * 一个槽位贡献都不注册(设计稿 §7「未接通态」)。
105
+ * 一个槽位贡献都不注册(docs/architecture.md §3「通道探测决定入口出现与否」)。
106
106
  *
107
- * 2026-10-17 第四批:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
107
+ * 2026-10-17:探测从"读一遍清单"改成这一问——**每次页面加载都跑全表对账**是白付的钱,
108
108
  * 而"信封到不到"这件事一次零成本的 GET 就答得出来。
109
109
  */
110
110
  export declare function probeChannel(raw: RawSessionDeleteRemote): Promise<boolean>;
111
111
  /** `shared/wire.ts` 的 `ApplyOutcome` 与那份扫描报告从这里转出去——浏览器半只认 `remote.js` 这一扇门。 */
112
112
  export type { ApplyAction, ApplyOutcome, OrphanReportWire } from '../shared/wire.js';
113
- /** 一段管所有写动作的返回值(逐条)。`ApplySuccess<A>` = 某动作成功支(`value` 按动作收窄)。 */
114
- export type ApplySuccess<A extends ApplyAction> = Extract<ApplyOutcome, {
115
- action: A;
116
- ok: true;
117
- }>;
113
+ /**
114
+ * N=1 的成功支:`null` 已被 `receipt.ts` 的单条读法折成协议错,所以 `value` 必然非空。
115
+ * 不再需要条件类型把 `delete` / `restore` 的 null 抠掉——那份判据只住回执读法一处。
116
+ */
117
+ export type OneShotSuccess<A extends ApplyAction> = {
118
+ readonly id: string;
119
+ readonly action: A;
120
+ readonly ok: true;
121
+ readonly value: Exclude<ApplyValueByAction[A], null>;
122
+ };
118
123
  /**
119
124
  * 注入面(`deps`)的 interface:**与六个段名一一对应的五个方法**(`ping` 只给探测用,留在 raw 层)。
120
125
  *
121
- * **不再有 N=1 的便捷方法**(ADR 复核候选 6):`delete` / `restore` / `countSubagents` 曾是同一段
126
+ * **不再有 N=1 的便捷方法**:`delete` / `restore` / `countSubagents` 曾是同一段
122
127
  * `/apply` 的三种写法,于是一个 `module` 里同一个动作有两条路(回收站面板的 deps 同时躺着
123
- * `applyOutcomes` 与 `restore`)。现在它们收成一个自由函数 {@link applyOne}——要旧形状就显式调它,
124
- * 而不是让 interface 多背三个方法。
128
+ * `applyOutcomes` 与 `restore`)。现在单条非写调用(如 `count`)走自由函数 {@link applyOne};
129
+ * 写动作走 `src/client/write.ts` 的 `runWrite`,而不是让 interface 多背三个方法。
125
130
  */
126
131
  export interface SessionDeleteRemote {
127
132
  list(): Promise<TrashEntryLike[]>;
@@ -134,17 +139,17 @@ export interface SessionDeleteRemote {
134
139
  /** 清空回收站(聚合回执)。 */
135
140
  emptyTrash(): Promise<PurgeResultLike>;
136
141
  }
137
- /** 需要"单条便捷支"的组件只要这一段 deps(同一段 `/apply` 的 N=1 写法,见 {@link applyOne})。 */
142
+ /** 需要那一段 `/apply` 的组件只要这一段 deps(单条非写调用见 {@link applyOne},写动作走 `./write.js` 的 `runWrite`)。 */
138
143
  export type OneShotDeps = Pick<SessionDeleteRemote, 'applyOutcomes'>;
139
144
  /**
140
- * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
145
+ * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 必然非空)。
141
146
  *
142
- * 三条判据都在这一处:
147
+ * 三件事各归一个家:
143
148
  * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
144
- * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
145
- * - `null` 是批量里"已被同批前一条带走"的幂等值——单条调用(N=1)永远不会命中它。
149
+ * - 成/败/幂等/缺失与「单条 null 是协议错」的判读只走 {@link readSingleReceipt};
150
+ * - 这里只把 verdict 折成「拿到值 / 抛失败」。
146
151
  */
147
- export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, action: A, id: string): Promise<ApplySuccess<A>>;
152
+ export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, action: A, id: string): Promise<OneShotSuccess<A>>;
148
153
  /**
149
154
  * 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
150
155
  *
@@ -153,5 +158,6 @@ export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, actio
153
158
  * 所以在这里展开一次:`ok ? value : throw`。
154
159
  */
155
160
  export declare function buildDeps(raw: RawSessionDeleteRemote): SessionDeleteRemote;
156
- /** 「删除」确认框的宿主只需要那一段 `/apply`:提交删除与删除前那一次计数都走 {@link applyOne}。 */
161
+ /** 「删除」确认框的宿主只需要那一段 `/apply`:删除前那一次计数走 {@link applyOne};
162
+ * 提交删除走 `src/client/write.ts` 的 `runWrite`。 */
157
163
  export type DeleteConfirmDeps = OneShotDeps;
@@ -0,0 +1,48 @@
1
+ export interface ClientStore<T> {
2
+ get(): T;
3
+ set(next: T): void;
4
+ /** 回调回同一引用 ⇒ `false`(不发通告);新值 ⇒ `true`。 */
5
+ update(next: (current: T) => T): boolean;
6
+ /**
7
+ * 以本次递增的 seq(从 1 起)造新值并通告,值没变也通告。
8
+ * **`make` 回的值必须把 seq 带上**(放进快照字段,如 `{ seq, items }`):订阅方拿它当 React key
9
+ * 才会在"同一段文案连续出现两次"时重挂载;丢了 seq,重挂载语义会静默消失。
10
+ */
11
+ publish(make: (seq: number) => T): void;
12
+ subscribe(listener: () => void): () => void;
13
+ use(): T;
14
+ /** 测试专用:回初值并清零 seq。 */
15
+ reset(): void;
16
+ }
17
+ export declare function createStore<T>(initial: T): ClientStore<T>;
18
+ /** 懒读快照:`get()` 只在脏了时调用 `read()`,干净时回同一个引用(React snapshot 契约)。 */
19
+ export interface LazyStore<T> {
20
+ get(): T;
21
+ /** 标脏并通告;下一次 `get()` 重读。 */
22
+ invalidate(): void;
23
+ subscribe(listener: () => void): () => void;
24
+ }
25
+ export interface LazyStoreLifecycle {
26
+ /** 第一个订阅者到来时跑(观察者在这里接上)。 */
27
+ onFirstSubscribe?(): void;
28
+ /** 最后一个订阅者退订时跑(观察者在这里断开)。 */
29
+ onLastUnsubscribe?(): void;
30
+ }
31
+ export declare function createLazyStore<T>(initial: T, read: () => T, lifecycle?: LazyStoreLifecycle): LazyStore<T>;
32
+ /**
33
+ * 最新票句柄:每张票记着签发时的序号,只有序号仍等于当前的那张才 `current`。
34
+ * 调用方在发起异步读时 `issue()` 一张票,落地前问 `ticket.current`——旧读因此不会盖掉新状态。
35
+ *
36
+ * 组件里要拿到**同一枚**句柄时用 `useState(createLatestWins)`,不要用 `useMemo`:React 允许丢 memo
37
+ * 缓存,丢了就是两张各自记数的票台,守卫静默失效。模块级现场(`main-view.ts`)直接建一颗,没这个问题。
38
+ */
39
+ export interface LatestWinsTicket {
40
+ readonly current: boolean;
41
+ }
42
+ export interface LatestWins {
43
+ /** 签发一张新票;之前的票立刻作废。 */
44
+ issue(): LatestWinsTicket;
45
+ /** 只作废当前票,不签发新票(用于"这一拍整体作废")。 */
46
+ invalidate(): void;
47
+ }
48
+ export declare function createLatestWins(): LatestWins;