@xrn1997/dsh-session-delete 0.3.0 → 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 (42) hide show
  1. package/README.md +3 -3
  2. package/lib/client.js +1496 -985
  3. package/lib/index.js +821 -710
  4. package/lib/types/client/batch-bar.d.ts +2 -1
  5. package/lib/types/client/batch-confirm.d.ts +1 -1
  6. package/lib/types/client/busy-gate.d.ts +6 -0
  7. package/lib/types/client/copy.d.ts +2 -2
  8. package/lib/types/client/host-rows.d.ts +18 -3
  9. package/lib/types/client/main-view.d.ts +7 -2
  10. package/lib/types/client/orphan-maintenance.d.ts +27 -0
  11. package/lib/types/client/receipt.d.ts +33 -3
  12. package/lib/types/client/remote.d.ts +18 -22
  13. package/lib/types/client/store.d.ts +31 -0
  14. package/lib/types/client/trash-nav.d.ts +30 -0
  15. package/lib/types/client/undo-toast.d.ts +11 -3
  16. package/lib/types/client/use-trash-panel-controller.d.ts +7 -20
  17. package/lib/types/client/use-trash-query.d.ts +20 -9
  18. package/lib/types/client/write-seats.d.ts +52 -0
  19. package/lib/types/client/write-settlement.d.ts +72 -0
  20. package/lib/types/client/write.d.ts +51 -0
  21. package/lib/types/deps.d.ts +2 -2
  22. package/lib/types/index.d.ts +2 -1
  23. package/lib/types/ports/host-port.d.ts +4 -18
  24. package/lib/types/shared/wire.d.ts +7 -0
  25. package/lib/types/trash/detail.d.ts +5 -2
  26. package/lib/types/trash/groups.d.ts +17 -6
  27. package/lib/types/trash/ledger.d.ts +20 -0
  28. package/lib/types/trash/manifest.d.ts +2 -1
  29. package/lib/types/trash/reconcile.d.ts +4 -3
  30. package/lib/types/verbs/batch-carried.d.ts +20 -0
  31. package/lib/types/verbs/delete-session.d.ts +4 -3
  32. package/lib/types/verbs/errors.d.ts +2 -10
  33. package/lib/types/verbs/live-admission.d.ts +13 -0
  34. package/lib/types/verbs/member-journal.d.ts +22 -3
  35. package/lib/types/verbs/member-presence.d.ts +38 -6
  36. package/lib/types/verbs/move-group.d.ts +20 -0
  37. package/lib/types/verbs/orphan-subagents.d.ts +8 -3
  38. package/lib/types/verbs/permanent-removal.d.ts +7 -6
  39. package/lib/types/verbs/purge-trash.d.ts +14 -6
  40. package/lib/types/verbs/restore-session.d.ts +8 -6
  41. package/package.json +3 -2
  42. package/lib/types/client/write-commit.d.ts +0 -10
@@ -10,7 +10,8 @@
10
10
  * all belong here")。
11
11
  *
12
12
  * 那个 K 是**勾选变化后防抖 300ms 的一次批量 count**(`/apply` 的 `action:'count'`,一组 id 一次),
13
- * 并且带**序号守卫**:在飞的旧响应不许盖掉新名单的读数(与 2026-10-17 `c66f082` 那条 `reloadSeq` 同一个道理)。
13
+ * 并且带**最新票守卫**:在飞的旧响应不许盖掉新名单的读数(compare-and-set 住 `createLatestWins`,
14
+ * 防抖本身仍在下面这个 effect 里)。
14
15
  * 读不到就整句不说——K 是合计,缺一条就是假数(「不许拿 0 冒充没有」)。
15
16
  */
16
17
  import { type ReactElement } from 'react';
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { type ReactElement } from 'react';
13
13
  import { type Translate } from './copy.js';
14
- import { type SessionDeleteRemote } from './remote.js';
14
+ import type { SessionDeleteRemote } from './remote.js';
15
15
  import { type PickItem } from './batch-selection-store.js';
16
16
  export interface BatchDeleteRequest {
17
17
  /** 每次发布递增:换一批就重挂载,busy / 失败面不带着上一批的记忆。 */
@@ -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,8 +1,8 @@
1
1
  /**
2
2
  * 可见文案(界面标签与提示)+接上宿主 Client locale 服务的那一步。
3
3
  *
4
- * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id、`src/client/remote.ts` 的
5
- * 幂等空值与取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
4
+ * **例外:渠道错误原文。** `src/client/receipt.ts` 的 missing-id 与单条幂等空值、
5
+ * `src/client/remote.ts` 的取数通道错误是协议诊断原文,固定中文、不随 locale;它们不进本字典。
6
6
  *
7
7
  * ## 为什么不是散在各组件里的中文字面量
8
8
  *
@@ -5,14 +5,29 @@ export interface HostRowInfo {
5
5
  /** 在跑的行点整行也不选:与置灰的勾选框同口径(级联遇在跑成员整组拒绝)。 */
6
6
  readonly live: boolean;
7
7
  }
8
- /** 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。 */
8
+ /**
9
+ * 行尾兜底勾选开关登记一行:模式内与模式外都要登记(守卫进模式的那一帧就得有标题)。
10
+ * 只有标题/运行态**真的变了**才标脏:重渲染时的同值登记不该把快照翻新一遍。
11
+ */
9
12
  export declare function registerHostRowInfo(sessionId: string, info: HostRowInfo): void;
10
13
  /**
11
14
  * 「当前可见」的可选行:侧栏里**已渲染**的会话行(`data-row-key="session:…"`)——折叠组、
12
15
  * 归档过滤外的行不在 DOM 里,宿主隐藏的 subagent 也不在;在跑的行跳过(与置灰勾选框同口径)。
13
16
  * 标题/运行态来自行尾兜底登记的 `rowInfo`(模式内它对每个非空行都渲染);DOM 序就是侧栏顺序。
17
+ *
18
+ * 这是**当场读**:事件路径(点「全选」那一刻的页面)与测试用它;渲染侧读下面的快照。
14
19
  */
15
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[];
16
31
  /** 全选:把当前可见可选行按 DOM 序追加;已在名单里的由 `addPicks` 自己跳过,原有选中保持在前。 */
17
32
  export declare function selectAllVisible(): void;
18
33
  /**
@@ -21,7 +36,7 @@ export declare function selectAllVisible(): void;
21
36
  * - 有选中、但当前没有可见可选行(全折叠/过滤掉)⇒ 只能清空,也显示取消全选;
22
37
  * - 其余(含 0 条)⇒ 全选。
23
38
  */
24
- export declare function allVisibleSelected(): boolean;
39
+ export declare function areAllVisibleRowsSelected(rows: readonly PickItem[], picks: readonly PickItem[]): boolean;
25
40
  /**
26
41
  * 多选态内的整行点击守卫(document 捕获阶段)。
27
42
  *
@@ -31,5 +46,5 @@ export declare function allVisibleSelected(): boolean;
31
46
  * 勾选框本身仍在。模式外不挂监听:这时点行必须还是宿主的"打开会话"。
32
47
  */
33
48
  export declare function useSelectionRowClickGuard(): void;
34
- /** 测试专用:登记表清空(会话 id 不跨用例复用,名字/运行态别串味)。 */
49
+ /** 测试专用:登记表清空并标脏(会话 id 不跨用例复用,名字/运行态别串味);观察者归订阅者管。 */
35
50
  export declare function resetHostRows(): void;
@@ -11,7 +11,7 @@
11
11
  * 所以关闭/重开是**一段生命周期**(2026-10-21),不是一个 id:删除成功后 `afterDelete` 关掉当前
12
12
  * 主视图并记下"这一条是我们关掉的";撤销成功后 `afterRestore` 只重开自己记过的那些。调用方不再
13
13
  * 各自判定该不该关、也不再把会话 id 塞进撤销提示传一圈——判据全在这一处。两次重开排队时只有最新
14
- * 那条会打开(token 守卫),`resetMainView()` 清掉这份记忆并让在等的重开作废。
14
+ * 那条会打开(最新票守卫),`resetMainView()` 清掉这份记忆并让在等的重开作废。
15
15
  *
16
16
  * **不 import 宿主客户端包**(AGENTS.md 红线):只通过 `ctx.get('uiWorkspace')` 可选读服务,
17
17
  * 形状不符/服务缺席都安静退回,绝不影响插件激活。字段与方法名来自上面两个包的产物原文;
@@ -31,7 +31,12 @@ export declare function afterDelete(sessionIds: readonly string[]): void;
31
31
  * 撤销恢复成功后调用:把**我们自己关掉的**那几个会话重新打开(只认 `afterDelete` 记过的)。
32
32
  *
33
33
  * 一批里只有最后一条会开(`targets[targets.length - 1]`):主视图一次只装一个会话,而撤销恢复
34
- * 的成功条数可能多于一条;`openWhenListed` 的 token 守卫保证前一条的等待不会后落地抢焦点。
34
+ * 的成功条数可能多于一条;`openWhenListed` 的最新票守卫保证前一条的等待不会后落地抢焦点。
35
35
  * 任何更新的删除/恢复都会先作废旧等待;这里只负责「这一次要开哪一条」。
36
36
  */
37
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;
@@ -1,17 +1,19 @@
1
1
  import { type ApplyAction, type ApplyOutcome, type ApplyValueByAction, type RemoteFailure } from '../shared/wire.js';
2
2
  /**
3
3
  * 一条 `/apply` 结果对一个请求 id 的含义。各写入面不许再各自推导:
4
- * 缺 id 是通道错误,`null` 是幂等成功,orphan-clean 的 `not-found` 是中性提示而非失败。
4
+ * 缺 id 是通道错误,`null` 是幂等成功,orphan-clean 的 `not-found` 是中性提示而非失败,
5
+ * **N=1 的 `null` 是协议错**——最后这条也在这里判,`applyOne` 与 `runWrite` 只消费同一份 verdict。
5
6
  *
6
7
  * 各 action 可能出现的 kind(类型面故意保持同一个联合,消费方按 action 自己收窄):
7
8
  * - delete / restore:`ok` / `already-gone` / `failed` / `missing`
8
9
  * - count / purge:`ok` / `failed` / `missing`
9
10
  * - orphan-clean:`ok` / `neutral` / `failed` / `missing`
11
+ * - 单条读法(`readSingleReceipt`):不含 `already-gone`;null 折成 `failed`
10
12
  */
11
13
  export type ReceiptVerdict<A extends ApplyAction> = {
12
14
  readonly kind: 'ok';
13
15
  readonly id: string;
14
- readonly value: ApplyValueByAction[A];
16
+ readonly value: Exclude<ApplyValueByAction[A], null>;
15
17
  } | {
16
18
  readonly kind: 'already-gone';
17
19
  readonly id: string;
@@ -32,6 +34,19 @@ export interface Receipt<A extends ApplyAction> {
32
34
  readonly verdicts: readonly ReceiptVerdict<A>[];
33
35
  readonly byId: ReadonlyMap<string, ReceiptVerdict<A>>;
34
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
+ }
35
50
  export declare function missingOutcomeMessage(action: ApplyAction, id: string): string;
36
51
  /** 三种“这一条没成”的 verdict;`failureMessage` 是它们原文取值的唯一入口。 */
37
52
  export type FailureVerdict<A extends ApplyAction> = Extract<ReceiptVerdict<A>, {
@@ -40,4 +55,19 @@ export type FailureVerdict<A extends ApplyAction> = Extract<ReceiptVerdict<A>, {
40
55
  export declare function isFailureVerdict<A extends ApplyAction>(verdict: ReceiptVerdict<A>): verdict is FailureVerdict<A>;
41
56
  /** failed / neutral 取服务端原文 `error.message`;missing 取通道自己写的那句。 */
42
57
  export declare function failureMessage<A extends ApplyAction>(verdict: FailureVerdict<A>): string;
43
- export declare function readReceipt<A extends ApplyAction>(action: A, requestedIds: readonly string[], outcomes: readonly ApplyOutcome[]): Receipt<A>;
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
  * 所以浏览器半不再自己抄一遍字段;下面几处"只读面"的镜像同理。
@@ -110,25 +110,23 @@ export declare function createHttpRemote(prefix?: string): RawSessionDeleteRemot
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
- }>;
118
113
  /**
119
- * N=1 的成功支:`delete` / `restore` 的幂等 `null` 已被 `applyOne` 判成通道错,
120
- * 所以它的 `value` 必然非空;其余动作就是 `ApplySuccess`。
114
+ * N=1 的成功支:`null` 已被 `receipt.ts` 的单条读法折成协议错,所以 `value` 必然非空。
115
+ * 不再需要条件类型把 `delete` / `restore` 的 null 抠掉——那份判据只住回执读法一处。
121
116
  */
122
- export type OneShotSuccess<A extends ApplyAction> = A extends 'delete' | 'restore' ? Omit<ApplySuccess<A>, 'value'> & {
123
- readonly value: NonNullable<ApplySuccess<A>['value']>;
124
- } : ApplySuccess<A>;
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
+ };
125
123
  /**
126
124
  * 注入面(`deps`)的 interface:**与六个段名一一对应的五个方法**(`ping` 只给探测用,留在 raw 层)。
127
125
  *
128
126
  * **不再有 N=1 的便捷方法**:`delete` / `restore` / `countSubagents` 曾是同一段
129
127
  * `/apply` 的三种写法,于是一个 `module` 里同一个动作有两条路(回收站面板的 deps 同时躺着
130
- * `applyOutcomes` 与 `restore`)。现在它们收成一个自由函数 {@link applyOne}——要旧形状就显式调它,
131
- * 而不是让 interface 多背三个方法。
128
+ * `applyOutcomes` 与 `restore`)。现在单条非写调用(如 `count`)走自由函数 {@link applyOne};
129
+ * 写动作走 `src/client/write.ts` 的 `runWrite`,而不是让 interface 多背三个方法。
132
130
  */
133
131
  export interface SessionDeleteRemote {
134
132
  list(): Promise<TrashEntryLike[]>;
@@ -141,18 +139,15 @@ export interface SessionDeleteRemote {
141
139
  /** 清空回收站(聚合回执)。 */
142
140
  emptyTrash(): Promise<PurgeResultLike>;
143
141
  }
144
- /** 需要"单条便捷支"的组件只要这一段 deps(同一段 `/apply` 的 N=1 写法,见 {@link applyOne})。 */
142
+ /** 需要那一段 `/apply` 的组件只要这一段 deps(单条非写调用见 {@link applyOne},写动作走 `./write.js` 的 `runWrite`)。 */
145
143
  export type OneShotDeps = Pick<SessionDeleteRemote, 'applyOutcomes'>;
146
144
  /**
147
- * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
145
+ * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 必然非空)。
148
146
  *
149
- * 三条判据都在这一处:
147
+ * 三件事各归一个家:
150
148
  * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
151
- * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
152
- * - `null` 是批量里"已被同批前一条带走"的幂等值——N=1 永远不会命中它,回 `null` 只能是通道答错了,
153
- * 所以同样按通道错抛,不冒充成功。
154
- *
155
- * 成/败/幂等/缺失的判读只走 {@link readReceipt}(一个家)。
149
+ * - 成/败/幂等/缺失与「单条 null 是协议错」的判读只走 {@link readSingleReceipt};
150
+ * - 这里只把 verdict 折成「拿到值 / 抛失败」。
156
151
  */
157
152
  export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, action: A, id: string): Promise<OneShotSuccess<A>>;
158
153
  /**
@@ -163,5 +158,6 @@ export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, actio
163
158
  * 所以在这里展开一次:`ok ? value : throw`。
164
159
  */
165
160
  export declare function buildDeps(raw: RawSessionDeleteRemote): SessionDeleteRemote;
166
- /** 「删除」确认框的宿主只需要那一段 `/apply`:提交删除与删除前那一次计数都走 {@link applyOne}。 */
161
+ /** 「删除」确认框的宿主只需要那一段 `/apply`:删除前那一次计数走 {@link applyOne};
162
+ * 提交删除走 `src/client/write.ts` 的 `runWrite`。 */
167
163
  export type DeleteConfirmDeps = OneShotDeps;
@@ -15,3 +15,34 @@ export interface ClientStore<T> {
15
15
  reset(): void;
16
16
  }
17
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;
@@ -0,0 +1,30 @@
1
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
2
+ import type { TrashEntryLike } from './remote.js';
3
+ /**
4
+ * 导航要的面。`ordered` 是**屏幕上的顺序**(只含展开的组),由面板/选择面算好传进来——
5
+ * 布局常量与本 module 无关。`drillEnabled` 是这一档支不支持整行下钻(窄档为真;宽档 Enter/Space
6
+ * 只移动选中,与整行点击的 `{ drill: !split }` 同一条判据)。四个动作都由调用方注入:
7
+ * `select` 只移动选中(带 `drill` 才下钻),`exitDrill` 是控制器那条「← 返回清单」的同一动作,
8
+ * `focusRow` 把焦点交给某一行。
9
+ */
10
+ export interface TrashNavInput {
11
+ ordered: readonly TrashEntryLike[];
12
+ selectedId: string | null;
13
+ selectMode: boolean;
14
+ drillEnabled: boolean;
15
+ drilled: boolean;
16
+ select(entry: TrashEntryLike, options?: {
17
+ drill?: boolean;
18
+ }): Promise<void>;
19
+ toggleCheck(entryId: string): void;
20
+ exitDrill(): void;
21
+ focusRow(entryId: string): void;
22
+ }
23
+ export interface TrashNav {
24
+ /** `aria-activedescendant` 的那条 id:选中行不在 DOM 里时是 `null`。 */
25
+ readonly activeDescendantId: string | null;
26
+ /** 清单里唯一拿着 `tabIndex=0` 的那一行;清单空时是 `null`。 */
27
+ readonly tabStopId: string | null;
28
+ onListKeyDown(event: ReactKeyboardEvent<HTMLElement>): void;
29
+ }
30
+ export declare function useTrashNav(input: TrashNavInput): TrashNav;
@@ -13,11 +13,12 @@
13
13
  * 不引 `@deepseek-ai/dsh-client-store`(它在平台表内,但这里用不着;React 就够,少一条平台依赖)。
14
14
  */
15
15
  import { type Translate } from './copy.js';
16
- import { type OneShotDeps } from './remote.js';
16
+ import type { OneShotDeps } from './remote.js';
17
17
  export type UndoToastDeps = OneShotDeps;
18
18
  /**
19
19
  * 一次待撤销的事。两形(2026-10-17 起):单条删除(主语是会话名)/批量删除(主语是条数)。
20
- * 同一个 Toast 宿主、同一停留时长;撤销动作按形走——单条 `restore`,批量**一次** `applyOutcomes`。
20
+ * 同一个 Toast 宿主、同一停留时长;撤销动作按形分派(单条 / 批量),两条都走同一个外部恢复座位
21
+ * (`write-seats.ts` 的 `restoreFromUndo`),单条带 `single: true` 的读法(null 是协议错,不冒充幂等成功)。
21
22
  */
22
23
  export type UndoNotice = {
23
24
  /** 每次发布递增:Toast 靠它重挂载,同一段文案连续出现两次也能重新计一次停留。 */
@@ -47,8 +48,15 @@ export declare function publishUndoBatchNotice(input: {
47
48
  entryIds: readonly string[];
48
49
  n: number;
49
50
  }): void;
50
- /** 撤销成功后把提示收掉(用户点「撤销」、或横幅自己走完停留时间)。 */
51
+ /** 整条横幅不再留:撤销成了、停留时间走完,或清空回收站把所有落点一起清了。
52
+ * 与下面 `dismissUndoNotice` 的分别是「整条不再留」与「按条目匹配」——清空之后没有条目还能撤销,
53
+ * 所以那一路不需要名单。 */
51
54
  export declare function clearUndoNotice(): void;
55
+ /**
56
+ * 某个条目已经在别处恢复:把指向它的横幅熄掉。
57
+ * 批量里任一命中就清整条——名单少了、计数没变,留着就是一条会骗人的「撤销」动作。
58
+ */
59
+ export declare function dismissUndoNotice(entryIds: readonly string[]): void;
52
60
  /** 供组件订阅。 */
53
61
  export declare function useUndoNotice(): UndoNotice | null;
54
62
  /** 测试专用:现场与 seq 归零,用例之间互不串味。 */
@@ -1,33 +1,20 @@
1
+ import type { BusyGate } from './busy-gate.js';
1
2
  import { type SessionDeleteRemote, type TrashEntryLike } from './remote.js';
2
3
  import type { PendingAction } from './trash-confirm.js';
3
4
  import type { TrashDetailState } from './trash-detail.js';
4
5
  import type { TrashQuery } from './use-trash-query.js';
5
- /** 面板的一枚全局闸:写动作在飞时 `busy` 为真,`around` 把一次任务整个包在"这一趟还算忙"里。 */
6
- export interface TrashPanelGate {
7
- readonly busy: boolean;
8
- around<T>(task: () => Promise<T>): Promise<T>;
9
- }
10
- /**
11
- * 闸本体(从 `trash-panel.tsx` 的函数体原样收进来):任务落地或抛错都把 `busy` 放回 false。
12
- * 不做引用计数:面板同一时刻只允许一趟写动作(按钮在 `busy` 期间全被按住),多一层计数只会
13
- * 把"这一趟什么时候算完"变成第二个判据。
14
- *
15
- * 返回值经 `useMemo` 稳定:`gate` 进 `restore` / `confirmApply` 的 `useCallback` 依赖,每次渲染
16
- * 换个新对象会让那两个回调跟着失效,进而把引用它们的 effect 也带着重跑。
17
- */
18
- export declare function useTrashPanelGate(): TrashPanelGate;
19
6
  /**
20
- * 控制器要的面。`query` 只取三样:清单(选中从它里面找)、重读(失效通告与写动作之后用)、
21
- * 清空(面板的写动作那一半)。`ordered` 是键盘/自动选中用的"屏幕上的顺序"(只含展开的组),
7
+ * 控制器要的面。`query` 取四样:清单(选中从它里面找)、`refreshAll`(挂载与显式重试)、
8
+ * `refreshAfterWrite`(失效与写后)、清空(面板的写动作那一半)。`ordered` 是键盘/自动选中用的
9
+ * "屏幕上的顺序"(只含展开的组),
22
10
  * 由面板算好传进来——布局常量不搬进控制器。
23
11
  */
24
12
  export interface TrashPanelControllerInput {
25
13
  deps: Pick<SessionDeleteRemote, 'detail' | 'applyOutcomes'>;
26
- gate: TrashPanelGate;
27
- query: Pick<TrashQuery, 'entries' | 'refresh' | 'emptyTrash'>;
14
+ gate: BusyGate;
15
+ query: Pick<TrashQuery, 'entries' | 'refreshAll' | 'refreshAfterWrite' | 'emptyTrash'>;
28
16
  ordered: readonly TrashEntryLike[];
29
17
  canAutoSelect: boolean;
30
- focusRow(entryId: string): void;
31
18
  }
32
19
  export interface TrashPanelController {
33
20
  readonly busy: boolean;
@@ -45,7 +32,7 @@ export interface TrashPanelController {
45
32
  retry(): void;
46
33
  back(): void;
47
34
  setPending(pending: PendingAction | null): void;
48
- /** 单条恢复(`/apply` 的 N=1):成功后丢缓存并重读清单,失败原文落回这一行。 */
35
+ /** 单条恢复(`/apply` 的 N=1):成功后丢缓存,写完无论成败都重读清单,失败原文落回这一行。 */
49
36
  restore(entry: TrashEntryLike): Promise<void>;
50
37
  /** 确认框上的「确认」:按 `pending.kind` 分派到空清或 `confirmApply`。 */
51
38
  confirmPurge(): Promise<void>;
@@ -1,6 +1,11 @@
1
+ import type { BusyGate } from './busy-gate.js';
1
2
  import { type OrphanReportWire, type SessionDeleteRemote, type TrashEntryLike } from './remote.js';
3
+ import type { WriteOutcome } from './write.js';
2
4
  import type { Translate } from './copy.js';
5
+ import type { ApplyAction } from '../shared/wire.js';
3
6
  export type TrashQueryDeps = Pick<SessionDeleteRemote, 'list' | 'scanOrphans' | 'applyOutcomes' | 'emptyTrash'>;
7
+ /** 一次清单读的结局:`stale` = 被后发的读顶掉(什么都没落地,调用方不许拿它当失败清场)。 */
8
+ export type ReloadResult = 'ok' | 'failed' | 'stale';
4
9
  export interface TrashQuery {
5
10
  /** `null` = 还没读到(第一次读或读失败);与"空的"是两件事。 */
6
11
  readonly entries: readonly TrashEntryLike[] | null;
@@ -14,19 +19,25 @@ export interface TrashQuery {
14
19
  setOrphanConfirm(open: boolean): void;
15
20
  /** 「查看并清理」:把上一条失败/中性提示清掉再开预览框(旧提示不该跟新一次预览并排出现)。 */
16
21
  openOrphanPreview(): void;
17
- /** 交回**这一次读数是否真的落地**:孤儿扫描吃的是对账后的清册,清单没读成就不能扫。 */
18
- reload(): Promise<boolean>;
19
- scanOrphans(): Promise<void>;
20
- /** "重读一遍" = 先对账并读清单,再扫残留;清单没读成就不扫。 */
21
- refresh(): Promise<void>;
22
+ /** 只重读清单(对账 + 读)。交回**这一次读的结局**:落地 / 读失败 / 被后发的读顶掉。
23
+ * 残留报告与提示的联动不在这里;组合清场归 `refreshAll` / `refreshAfterWrite`。 */
24
+ reloadRows(): Promise<ReloadResult>;
25
+ /** 只扫残留。调用方负责先让清单对过账(`refreshAll` / `refreshAfterWrite` 已经排好序)。 */
26
+ rescanOrphans(): Promise<void>;
27
+ /** 先读清单、成了再扫残留;挂载与显式「重试」用它。 */
28
+ refreshAll(): Promise<void>;
29
+ /** 写后/失效:只重读清单;是否顺带重扫残留由这次的 `outcome` 从 action / ids / 当前清单自行派生
30
+ * (判据住 `needsOrphanRescan`)。失效通告不带 outcome:报告未知或非空才重扫。输给后发读时由赢家补扫;
31
+ * 清单读失败时清空报告与提示。 */
32
+ refreshAfterWrite(outcome?: WriteOutcome<ApplyAction>): Promise<void>;
22
33
  /** 清理那批无主子会话(一段 `/apply` 的第五个动作),逐条结果落成"已不在了 / N 组未清理"。 */
23
34
  cleanOrphans(): Promise<void>;
24
- /** 清空回收站(聚合回执):成功就重读,失败就把原文摆到清单那块。交回是否成功。 */
35
+ /** 清空回收站(聚合回执):成功就熄掉撤销横幅并重读,失败就把原文摆到清单那块。交回是否成功。 */
25
36
  emptyTrash(): Promise<boolean>;
26
37
  }
27
- export declare function useTrashQuery({ deps, t, around, }: {
38
+ export declare function useTrashQuery({ deps, t, gate, }: {
28
39
  deps: TrashQueryDeps;
29
40
  t: Translate;
30
- /** 把"这一趟还算忙"的一枚全局闸套在一次动作外(面板的 `busy`)。 */
31
- around: <T>(task: () => Promise<T>) => Promise<T>;
41
+ /** 面板的一枚全局闸:清空与无主子会话清理这两条写动作把整趟包在里面(面板的 `busy`)。 */
42
+ gate: BusyGate;
32
43
  }): TrashQuery;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 逐条 `/apply` 的**写入座位**:一次写入发生在哪个界面位置,就在哪个函数里把呈现材质、闸与重读通道绑好。
3
+ *
4
+ * 为什么要有它:六个调用点此前各自拼 `runWrite({ presentation: {…} })`,并各自决定要不要外包一层闸。
5
+ * 收尾顺序(`write-settlement.ts`,ADR-0013)与重读策略(`use-trash-query.ts`,ADR-0016)不动;
6
+ * 座位只拥有「这次是谁在写、交哪些材质、闸在哪」。
7
+ *
8
+ * 边界:不判回执(`receipt.ts`)、不决定收尾顺序、不拥有各面的本地状态(`pending` / 详情缓存 / 行失败)。
9
+ * 面板座位的闸由面板传入:`busy` 是它的 UI 状态,座位不另造第二枚。
10
+ */
11
+ import type { BusyGate } from './busy-gate.js';
12
+ import type { OneShotDeps, TrashEntryLike } from './remote.js';
13
+ import { type WriteOutcome } from './write.js';
14
+ /** 会话行菜单的单删座位:一条会话、`single` 读法、成功发单条撤销横幅。交回这条会话的失败原文。 */
15
+ export declare function deleteSessionFromMenu(deps: OneShotDeps, input: {
16
+ sessionId: string;
17
+ title: string;
18
+ }, gate: BusyGate): Promise<string | null>;
19
+ /** 批量删除座位(宿主会话列表的多选 → 批量确认框):一次 `/apply`,撤销名单只收 ok 支的条目 id(跟着组走的幂等条不单列)。
20
+ * 只收 id:标题是浮条的呈现,不参与这一次写入。 */
21
+ export declare function deleteSessionsFromBatch(deps: OneShotDeps, ids: readonly string[], gate: BusyGate): Promise<ReadonlyMap<string, string>>;
22
+ /** 外部恢复座位(撤销横幅):没有 busy 面,不传闸;主视图重开归 settlement,这里只让清单失效。 */
23
+ export declare function restoreFromUndo(deps: OneShotDeps, input: {
24
+ ids: readonly string[];
25
+ single: boolean;
26
+ }): Promise<WriteOutcome<'restore'>>;
27
+ /** 面板两个 `/apply` 座位共用的那一半:依赖、条目 id、闸、重读与缓存失效。 */
28
+ interface PanelWriteInput<A extends 'restore' | 'purge'> {
29
+ deps: OneShotDeps;
30
+ ids: readonly string[];
31
+ gate: BusyGate;
32
+ refreshAfterWrite(outcome: WriteOutcome<A>): Promise<void>;
33
+ clearDetailCache(ids: readonly string[]): void;
34
+ }
35
+ /** 面板恢复另要两样:单条读法,以及 already-gone 回执缺行时反查 sessionId 的当前清单。 */
36
+ interface PanelRestoreInput extends PanelWriteInput<'restore'> {
37
+ single?: boolean;
38
+ entries: readonly TrashEntryLike[] | null;
39
+ }
40
+ /** 面板恢复座位:面板已替这条完成「回来」的结算 ⇒ `panelSeat`;撤销横幅与详情缓存都按落地条目收。 */
41
+ export declare function restoreFromPanel(input: PanelRestoreInput): Promise<WriteOutcome<'restore'>>;
42
+ /** 面板彻底删除座位:永久删除也让指向这些条目的撤销横幅熄灭(settlement 的 purge 支负责)。 */
43
+ export declare function purgeFromPanel(input: PanelWriteInput<'purge'>): Promise<WriteOutcome<'purge'>>;
44
+ interface OrphanCleanSeatInput {
45
+ deps: OneShotDeps;
46
+ ids: readonly string[];
47
+ gate: BusyGate;
48
+ refreshAfterWrite(outcome: WriteOutcome<'orphan-clean'>): Promise<void>;
49
+ }
50
+ /** 无主子会话清理座位:不碰回收站失效与撤销,回执判读仍归 `use-trash-query.ts`。 */
51
+ export declare function cleanOrphansFromPanel(input: OrphanCleanSeatInput): Promise<WriteOutcome<'orphan-clean'>>;
52
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 一次逐条 `/apply` 写入成功后的**收尾顺序**,按 action 各一条固定序列。
3
+ *
4
+ * 调用方只交呈现材质(`WritePresentation`),顺序与 `anySuccess` 门只住这里;`runWrite` 在拿到
5
+ * `WriteSuccess` 后、`refresh` 前调一次。为什么按 action 收成一处见 ADR-0013。
6
+ *
7
+ * 四条判据:
8
+ * - **跨槽位在信封成功时就跑**:`afterDelete` / `afterRestore` 与 `invalidate` 不管逐条成败都跑
9
+ * (回滚失败可能留下半套,回收站清单不能继续摆旧行);呈现钩子(发布/熄灭撤销、遗忘主视图、
10
+ * 清详情缓存)只在至少一条落地(`anySuccess`)时跑——没有落点的撤销是假动作。delete 的撤销
11
+ * 另要求落地名单非空:全部被同批前一条带走时没有可撤销的条目。
12
+ * - **面板恢复用 `panelSeat: true` 明说**(「缺席」不再承载语义):外部恢复(撤销横幅)由 settlement
13
+ * 调 `afterRestore` 重开;面板恢复自带「回来」的结算,只 `forgetMainView`、不重开、不抢焦点。
14
+ * 判据住 `src/client/main-view.ts` 的 `forgetMainView`。
15
+ * - **撤销名单由 settlement 算、座位发布**:delete 支把 `landedEntryIds` 与 `successIds.length` 算成
16
+ * `{ entryIds, n }` 交给座位,座位只决定发布哪一形(单条带标题、批量带条数)——那句文案与它的键
17
+ * 住 `undo-toast.tsx` 与 `copy.ts`;purge 支在
18
+ * `anySuccess` 时熄灭指向这些条目的横幅——留着就是一条注定失败的「撤销」。
19
+ * - **`count` 不结算**;`orphan-clean` 没有跨槽位收尾(清完由 query 自己的写后刷新收尾)。
20
+ * 不直接读 DOM、不问宿主:主视图收尾只经 `main-view.ts` 的 `afterDelete` / `afterRestore`
21
+ * (服务缺席或抛错都静默退回);重读仍归各调用方的 `refresh`(面板那侧的最新票在 `use-trash-query.ts`)。
22
+ */
23
+ import type { ApplyAction } from '../shared/wire.js';
24
+ import { type Receipt } from './receipt.js';
25
+ /** 一次写入里与渲染无关的那部分:动作、请求 id、回执、成功名单与“至少一条落地”。 */
26
+ export interface SettledFacts<A extends ApplyAction> {
27
+ readonly action: A;
28
+ readonly ids: readonly string[];
29
+ readonly receipt: Receipt<A>;
30
+ /** 成功支(`ok` / `already-gone`)的请求 id,按请求顺序。 */
31
+ readonly successIds: readonly string[];
32
+ /** 至少一条真的落地(`ok` / `already-gone`;N=1 的协议空值不算)。 */
33
+ readonly anySuccess: boolean;
34
+ }
35
+ /**
36
+ * 按 action 切开的一族呈现材质:每个座位只能拿到自己动作那几手,形状对不上的钩子在类型面就不存在。
37
+ * - `delete` 的 `publishUndo` 存在即「这个座位要发撤销横幅」;它拿到的是 settlement 算好的落地名单。
38
+ * - `restore` 的 `panelSeat: true` 才是「面板已替这条完成『回来』的结算」,不再靠缺席某个钩子暗示。
39
+ * - `count` / `orphan-clean` 没有呈现材质(用 `noPresentation`)。
40
+ * 钩子都是同步的:settlement 在 `refresh` 前一次跑完,不 await。
41
+ */
42
+ export type WritePresentationByAction = {
43
+ delete: {
44
+ /** 外部写入座位:让回收站面板重读。面板写不传(自己的 `refreshAfterWrite` 就是重读)。 */
45
+ invalidate?(): void;
46
+ /** 撤销发布:settlement 把落地的条目 id 与请求名单算好后交来;名单**非空**——全被同批前一条带走时
47
+ * 没有落点,settlement 不发布没有可撤销动作的横幅。座位只决定发布哪一形与那一形带的标题。 */
48
+ publishUndo?(landed: {
49
+ readonly entryIds: readonly [string, ...string[]];
50
+ readonly n: number;
51
+ }): void;
52
+ };
53
+ restore: {
54
+ invalidate?(): void;
55
+ /** 面板恢复座位:true = 面板已替这条完成「回来」的结算,只遗忘、不重开、不抢焦点。 */
56
+ panelSeat?: true;
57
+ dismissUndo?(entryIds: readonly string[]): void;
58
+ clearDetailCache?(entryIds: readonly string[]): void;
59
+ /** already-gone 的回执不带行,它的 sessionId 只能由调用方从当前清单里查。 */
60
+ sessionIdOfEntry?(entryId: string): string | undefined;
61
+ };
62
+ purge: {
63
+ dismissUndo?(entryIds: readonly string[]): void;
64
+ clearDetailCache?(entryIds: readonly string[]): void;
65
+ };
66
+ count: Record<string, never>;
67
+ 'orphan-clean': Record<string, never>;
68
+ };
69
+ export type WritePresentation<A extends ApplyAction> = WritePresentationByAction[A];
70
+ /** 没有呈现材质的座位用它:抛掉按 action 的联合,调用点不用各自造一个空对象字面量。 */
71
+ export declare function noPresentation<A extends ApplyAction>(action: A): WritePresentation<A>;
72
+ export declare function settleWrite<A extends ApplyAction>(success: SettledFacts<A>, presentation: WritePresentation<A>): void;