dsh-sidenote 0.3.1 → 0.3.2

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.
@@ -140,6 +140,10 @@ export interface NodeCardInput {
140
140
  meta?: unknown;
141
141
  /** 结果正文(已拼 text 块)。 */
142
142
  rawText: string;
143
+ /** 失败标记(0.1.2 节点 isError)——失败结果无 meta(presentationMeta 只投
144
+ * 影成功路径),但 write 的空-meta 回退必须先判它,否则失败的 write 也
145
+ * 会被渲染成 diff 卡(官方 diffCardModel 显式 isError → null)。 */
146
+ isError?: boolean | undefined;
143
147
  cwdBase?: string | undefined;
144
148
  }
145
149
  /**
@@ -38,8 +38,18 @@ export declare const en: {
38
38
  permFullAccess: string;
39
39
  pendingNotice: string;
40
40
  writeNotice: string;
41
+ pendingApprovalTitle: string;
42
+ pendingApprovalDesc: string;
43
+ pendingReject: string;
44
+ pendingAllowOnce: string;
45
+ pendingQuestionTitle: string;
46
+ pendingPlanReview: string;
47
+ pendingQuestionSubmit: string;
48
+ pendingQuestionCancel: string;
49
+ pendingCustomPlaceholder: string;
41
50
  modelFollowsMain: string;
42
51
  stopReply: string;
52
+ sideChatDone: string;
43
53
  stopReplyTitle: string;
44
54
  send: string;
45
55
  attachTitle: string;
@@ -0,0 +1,4 @@
1
+ import type { PendingItem } from './pending.ts';
2
+ export declare function PendingCardList({ items }: {
3
+ items: readonly PendingItem[];
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 完成通知(WI-03,L3 常驻监听层):侧聊子会话 running true→false 翻转时
3
+ * toast 提醒——用户焦点在主对话时侧聊长任务跑完能被看见(Zed
4
+ * notify_when_agent_waiting 先例)。
5
+ *
6
+ * 事件源是 session.subscribe 快照推送(非轮询);宿主 notifier 入账不依赖
7
+ * 订阅者——面板不可见(SideChatPanel 的 uSES 断订阅)期间事件不丢。
8
+ * 本层是常驻 ctx.effect:Tab 开闭由 betterSidebar.subscribeState 重扫驱动。
9
+ */
10
+ import type { Context } from '../host/contracts.ts';
11
+ /**
12
+ * 翻转侦测核心(抽出来纯可测):running true→false 且距挂接超过保护窗
13
+ * 时返回 true。调用方持状态。
14
+ */
15
+ export declare function shouldNotify(wasRunning: boolean, running: boolean, attachedAt: number, now: number): boolean;
16
+ export declare function registerCompletionNotify(ctx: Context): void;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * R8 侧边审批/提问答复(L0/L1:双版本数据源探测链 + 视图模型归一)。
3
+ *
4
+ * 侦察依据(.vibe Delivery_04/Workitem_02 design.md):
5
+ * - pending 不在 projections.faceOf 体系——0.1.1 在 Session 快照顶层
6
+ * `pending: PendingWait[]`(mux 帧铸造,重连重放 rpcId 不变);0.1.2 移入
7
+ * `ctx.get('uiSession').pendingInteractions`(Map<SessionId, Pending>,
8
+ * 每会话至多一项,precedence 仲裁)。
9
+ * - 写路径都是公开对象的公开方法:0.1.1 `wait.respond({ok,value})`(审批
10
+ * value={sessionId, approvalId, outcome};提问整批 {sessionId, answer},
11
+ * 取消走 {ok:false,error:{code:'cancelled',…}});0.1.2 域对象
12
+ * `answer(outcome|answer)` / `cancel()`。
13
+ * - 竞答安全:先答先得,settle 幂等——后答方 throw,UI catch 吞掉并回退
14
+ * 按钮态(宿主同款 `.catch(() => setAnswered(false))`)。
15
+ *
16
+ * off-face 纪律:两面都 feature-check;全缺席返回 undefined(面板回退
17
+ * pendingBar 跳转行为,不崩)。
18
+ */
19
+ export interface PendingApprovalView {
20
+ kind: 'approval';
21
+ key: string;
22
+ toolName: string;
23
+ reason?: string | undefined;
24
+ answer: (outcome: 'allowed-once' | 'rejected') => Promise<void>;
25
+ }
26
+ export interface PendingQuestionOption {
27
+ label: string;
28
+ description?: string | undefined;
29
+ }
30
+ export interface PendingQuestionView {
31
+ kind: 'question';
32
+ key: string;
33
+ /** plan-review 按通用提问流降级(提交值逐字回传 asker 原 label)。 */
34
+ planReview: boolean;
35
+ questions: readonly {
36
+ id: string;
37
+ question: string;
38
+ header?: string | undefined;
39
+ detail?: string | undefined;
40
+ options: readonly PendingQuestionOption[];
41
+ multiSelect: boolean;
42
+ }[];
43
+ /** 整批提交(不可拆单题):answers = [{id, selected: label[], custom?}]。 */
44
+ answer: (answers: readonly {
45
+ id: string;
46
+ selected: readonly string[];
47
+ custom?: string;
48
+ }[]) => Promise<void>;
49
+ cancel: () => Promise<void>;
50
+ }
51
+ export type PendingItem = PendingApprovalView | PendingQuestionView;
52
+ export interface PendingStore {
53
+ readonly available: boolean;
54
+ subscribe(fn: () => void): () => void;
55
+ /** 缓存快照:底层引用不变则返回同一数组(uSES 纪律)。 */
56
+ getSnapshot(): readonly PendingItem[];
57
+ }
58
+ interface CtxLike {
59
+ get?: (key: string) => unknown;
60
+ }
61
+ interface SessionLike {
62
+ subscribe(fn: () => void): () => void;
63
+ getSnapshot(): unknown;
64
+ }
65
+ /**
66
+ * 双版本探测链(chatSourceOf 同款纪律):0.1.2 uiSession.pendingInteractions
67
+ * (getSnapshot() 是 Map 才认)优先;0.1.1 Session 快照 pending 数组回退;
68
+ * 双缺席 undefined(面板回退 pendingBar 跳转)。
69
+ */
70
+ export declare function createPendingStore(ctx: CtxLike, session: SessionLike | undefined, childId: string): PendingStore | undefined;
71
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidenote",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "DSH web plugin: Codex-style side chat (fork the current session into a persistent side panel) + selection annotations with numbered badges and a composer chip. A thin consumer of dsh-better-sidebar. / Codex 风格侧边聊天 + 划选注释的 DSH 插件。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -356,8 +356,111 @@ function readMetaOf(meta: unknown): { path: string; lines: { number: number; tex
356
356
  }
357
357
  }
358
358
 
359
+ /**
360
+ * FsDiffMeta 校验(0.1.2 diff 卡,dsh-tool-fs computeHunkDiffs 产物的忠实子集)。
361
+ * 返回值三态:{diffs 非空} / 'empty'(空数组——write create/同内容覆写是合法值,
362
+ * 客户端回退 args 整文件 diff)/ undefined(malformed——降级)。
363
+ * 逐项校验,一项违规整卡降级(官方 narrowDiffs 同款)。
364
+ */
365
+ function diffMetaOf(meta: unknown): { diffs: readonly FileDiff[] } | 'empty' | undefined {
366
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
367
+ const m = meta as { diffs?: unknown }
368
+ if (!Array.isArray(m.diffs)) return undefined
369
+ if (m.diffs.length === 0) return 'empty'
370
+ const diffs: FileDiff[] = []
371
+ for (const raw of m.diffs) {
372
+ const d = raw as { path?: unknown; oldText?: unknown; newText?: unknown }
373
+ if (typeof d?.path !== 'string') return undefined
374
+ if (d.oldText !== null && typeof d.oldText !== 'string') return undefined
375
+ if (typeof d.newText !== 'string') return undefined
376
+ diffs.push({ path: d.path, oldText: d.oldText, newText: d.newText } as FileDiff)
377
+ }
378
+ return { diffs }
379
+ }
380
+
381
+ /** SearchMeta 校验(0.1.2 search 卡;truncated/total 是硬字段;空结果合法)。 */
382
+ function searchMetaOf(meta: unknown):
383
+ | { shape: 'matches'; files: readonly { path: string; matches: readonly { lineNumber: number; line: string }[] }[]; truncated: boolean; total: number }
384
+ | { shape: 'paths'; paths: readonly string[]; truncated: boolean; total: number }
385
+ | undefined {
386
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
387
+ const m = meta as Record<string, unknown>
388
+ if (typeof m.truncated !== 'boolean') return undefined
389
+ if (typeof m.total !== 'number' || !Number.isInteger(m.total) || m.total < 0) return undefined
390
+ if (m.shape === 'matches') {
391
+ if (!Array.isArray(m.files)) return undefined
392
+ const files: { path: string; matches: { lineNumber: number; line: string }[] }[] = []
393
+ for (const rawF of m.files) {
394
+ const f = rawF as { path?: unknown; matches?: unknown }
395
+ if (typeof f?.path !== 'string' || !Array.isArray(f.matches)) return undefined
396
+ const matches: { lineNumber: number; line: string }[] = []
397
+ for (const rawM of f.matches) {
398
+ const mm = rawM as { lineNumber?: unknown; line?: unknown }
399
+ if (typeof mm?.lineNumber !== 'number' || !Number.isInteger(mm.lineNumber) || mm.lineNumber < 1) return undefined
400
+ if (typeof mm.line !== 'string') return undefined
401
+ matches.push({ lineNumber: mm.lineNumber, line: mm.line })
402
+ }
403
+ files.push({ path: f.path, matches })
404
+ }
405
+ return { shape: 'matches', files, truncated: m.truncated, total: m.total }
406
+ }
407
+ if (m.shape === 'paths') {
408
+ if (!Array.isArray(m.paths) || !m.paths.every(p => typeof p === 'string')) return undefined
409
+ return { shape: 'paths', paths: m.paths as readonly string[], truncated: m.truncated, total: m.total }
410
+ }
411
+ return undefined
412
+ }
413
+
414
+ /** WebSearchMeta 校验(sources 逐项 url 必填、title/snippet 可选;publishedAt 校验后丢弃)。 */
415
+ function webSearchMetaOf(meta: unknown): { sources: readonly { url: string; title?: string; snippet?: string }[]; truncated: boolean; answer?: string } | undefined {
416
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
417
+ const m = meta as Record<string, unknown>
418
+ if (typeof m.truncated !== 'boolean') return undefined
419
+ if (!Array.isArray(m.sources)) return undefined
420
+ const sources: { url: string; title?: string; snippet?: string }[] = []
421
+ for (const raw of m.sources) {
422
+ const s = raw as { url?: unknown; title?: unknown; snippet?: unknown; publishedAt?: unknown }
423
+ if (typeof s?.url !== 'string') return undefined
424
+ if (s.title !== undefined && typeof s.title !== 'string') return undefined
425
+ if (s.snippet !== undefined && typeof s.snippet !== 'string') return undefined
426
+ if (s.publishedAt !== undefined && typeof s.publishedAt !== 'string') return undefined
427
+ sources.push({
428
+ url: s.url,
429
+ ...(s.title !== undefined ? { title: s.title as string } : {}),
430
+ ...(s.snippet !== undefined ? { snippet: s.snippet as string } : {}),
431
+ })
432
+ }
433
+ if (m.answer !== undefined && typeof m.answer !== 'string') return undefined
434
+ return { sources, truncated: m.truncated, ...(m.answer !== undefined ? { answer: m.answer as string } : {}) }
435
+ }
436
+
437
+ /** WebFetchMeta 校验(url + 整数 statusCode + truncated 硬字段)。 */
438
+ function webFetchMetaOf(meta: unknown): { url: string; statusCode: number; truncated: boolean } | undefined {
439
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
440
+ const m = meta as Record<string, unknown>
441
+ if (typeof m.url !== 'string') return undefined
442
+ if (typeof m.statusCode !== 'number' || !Number.isInteger(m.statusCode)) return undefined
443
+ if (typeof m.truncated !== 'boolean') return undefined
444
+ return { url: m.url, statusCode: m.statusCode, truncated: m.truncated }
445
+ }
446
+
359
447
  /** argsRaw(JSON 字符串)里尽力提取展示字段。 */
360
- function argsSummary(argsRaw: string | undefined): { command?: string; description?: string; path?: string; todos?: unknown } {
448
+ function argsSummary(argsRaw: string | undefined): {
449
+ command?: string
450
+ description?: string
451
+ path?: string
452
+ todos?: unknown
453
+ /** diff 回退/标题:write 的 content、edit 的 old_string/new_string。 */
454
+ content?: string
455
+ oldString?: string
456
+ newString?: string
457
+ /** search 标题:pattern/include。 */
458
+ pattern?: string
459
+ include?: string
460
+ /** web 标题:queries(search)/ url(fetch)。 */
461
+ queries?: readonly string[]
462
+ url?: string
463
+ } {
361
464
  if (argsRaw === undefined || argsRaw === '') return {}
362
465
  try {
363
466
  const parsed: unknown = JSON.parse(argsRaw)
@@ -368,6 +471,13 @@ function argsSummary(argsRaw: string | undefined): { command?: string; descripti
368
471
  ...(typeof a.description === 'string' ? { description: a.description } : {}),
369
472
  ...(typeof a.file_path === 'string' ? { path: a.file_path } : typeof a.path === 'string' ? { path: a.path } : {}),
370
473
  ...(Array.isArray(a.todos) ? { todos: a.todos } : {}),
474
+ ...(typeof a.content === 'string' ? { content: a.content } : {}),
475
+ ...(typeof a.old_string === 'string' ? { oldString: a.old_string } : {}),
476
+ ...(typeof a.new_string === 'string' ? { newString: a.new_string } : {}),
477
+ ...(typeof a.pattern === 'string' ? { pattern: a.pattern } : {}),
478
+ ...(typeof a.include === 'string' ? { include: a.include } : {}),
479
+ ...(Array.isArray(a.queries) && a.queries.every(q => typeof q === 'string') ? { queries: a.queries as readonly string[] } : {}),
480
+ ...(typeof a.url === 'string' ? { url: a.url } : {}),
371
481
  }
372
482
  } catch {
373
483
  return {}
@@ -383,6 +493,10 @@ export interface NodeCardInput {
383
493
  meta?: unknown
384
494
  /** 结果正文(已拼 text 块)。 */
385
495
  rawText: string
496
+ /** 失败标记(0.1.2 节点 isError)——失败结果无 meta(presentationMeta 只投
497
+ * 影成功路径),但 write 的空-meta 回退必须先判它,否则失败的 write 也
498
+ * 会被渲染成 diff 卡(官方 diffCardModel 显式 isError → null)。 */
499
+ isError?: boolean | undefined
386
500
  cwdBase?: string | undefined
387
501
  }
388
502
 
@@ -431,6 +545,71 @@ export function cardModelFromNode(input: NodeCardInput): ToolCardModel {
431
545
  if (items !== undefined) return { kind: 'todo', title: todoTitleOf(items), items }
432
546
  }
433
547
 
548
+ // write/edit → diff 卡(0.1.2 meta 逆向:FsDiffMeta {diffs: FileDiff[]})。
549
+ // 官方语义(diffCardModel 实证):失败结果不走 diff 卡;write 的 meta
550
+ // 缺失/malformed/空数组 → 回退 args 整文件 diff(create 语义);edit 的
551
+ // meta 缺失/空 → 降级 generic。str_replace_editor settled 无 meta,官方走
552
+ // generic——不在此列。
553
+ if ((name === 'write' || name === 'edit') && input.isError !== true) {
554
+ const path = args.path
555
+ if (path !== undefined && path.trim() !== '') {
556
+ const dm = diffMetaOf(input.meta)
557
+ if (dm !== undefined && dm !== 'empty') {
558
+ return {
559
+ kind: 'diff',
560
+ title: displayTitle(`${name === 'write' ? 'Write' : 'Edit'} ${path}`),
561
+ diffs: dm.diffs,
562
+ locations: [{ path } as FileLocation],
563
+ }
564
+ }
565
+ // write 回退:整文件 diff(create/同内容覆写/meta 缺席同形)。
566
+ if (name === 'write' && args.content !== undefined) {
567
+ return {
568
+ kind: 'diff',
569
+ title: displayTitle(`Write ${path}`),
570
+ diffs: [{ path, oldText: null, newText: args.content } as FileDiff],
571
+ locations: [{ path } as FileLocation],
572
+ }
573
+ }
574
+ }
575
+ }
576
+
577
+ // grep/glob → search 卡(SearchMeta:shape 与工具名交叉校验;空结果合法)。
578
+ if ((name === 'grep' || name === 'glob') && input.isError !== true) {
579
+ const sm = searchMetaOf(input.meta)
580
+ const wantShape = name === 'grep' ? 'matches' : 'paths'
581
+ if (sm !== undefined && sm.shape === wantShape && args.pattern !== undefined) {
582
+ const where = args.path !== undefined && args.path.trim() !== '' ? ` in ${shortPath(args.path)}` : ''
583
+ const inc = name === 'grep' && args.include !== undefined && args.include.trim() !== '' ? ` (${args.include})` : ''
584
+ const title = `${name === 'grep' ? 'Grep' : 'Glob'} ${args.pattern}${where}${inc}`
585
+ return sm.shape === 'matches'
586
+ ? { kind: 'search', title, shape: 'matches', files: sm.files, truncated: sm.truncated, total: sm.total }
587
+ : { kind: 'search', title, shape: 'paths', paths: sm.paths, truncated: sm.truncated, total: sm.total }
588
+ }
589
+ }
590
+
591
+ // web_search/web_fetch → web 卡(meta 直读;publishedAt 校验后降采样丢弃)。
592
+ if (name === 'web_search' && input.isError !== true) {
593
+ const wm = webSearchMetaOf(input.meta)
594
+ const queries = args.queries?.filter(q => q.trim() !== '') ?? []
595
+ if (wm !== undefined && queries.length > 0) {
596
+ return {
597
+ kind: 'web',
598
+ title: queries.join(', '),
599
+ webKind: 'search',
600
+ sources: wm.sources,
601
+ truncated: wm.truncated,
602
+ ...(wm.answer !== undefined ? { answer: wm.answer } : {}),
603
+ }
604
+ }
605
+ }
606
+ if (name === 'web_fetch' && input.isError !== true) {
607
+ const wm = webFetchMetaOf(input.meta)
608
+ if (wm !== undefined && args.url !== undefined && args.url.trim() !== '') {
609
+ return { kind: 'web', title: args.url, webKind: 'fetch', url: wm.url, statusCode: wm.statusCode, truncated: wm.truncated }
610
+ }
611
+ }
612
+
434
613
  // 其余:generic(标题尽力从参数提取可读形态)。
435
614
  const title = args.path !== undefined
436
615
  ? `${name} · ${shortPath(args.path)}`
@@ -443,19 +622,22 @@ export function cardModelFromNode(input: NodeCardInput): ToolCardModel {
443
622
  }
444
623
  }
445
624
 
446
- /** 工具名 → 图标类别的静态映射(0.1.2 没有 callView.kind 可用)。 */
625
+ /** 工具名 → 图标类别的静态映射(0.1.2 没有 callView.kind 可用)。
626
+ * wire 名实证:str_replace_editor 是下划线(uitool client.js L381);
627
+ * web_search 的 0.1.1 wire kind 是 'search'(presentSearchCall),
628
+ * 'fetch' 是 web_fetch 的(presentFetchCall)。 */
447
629
  const KIND_BY_NAME: Record<string, ToolCallKind> = {
448
630
  read: 'read',
449
631
  write: 'edit',
450
632
  edit: 'edit',
451
- 'str-replace-editor': 'edit',
633
+ str_replace_editor: 'edit',
452
634
  bash: 'execute',
453
635
  'bash-persistent': 'execute',
454
636
  pwsh: 'execute',
455
637
  'pwsh-persistent': 'execute',
456
638
  grep: 'search',
457
639
  glob: 'search',
458
- web_search: 'fetch',
640
+ web_search: 'search',
459
641
  web_fetch: 'fetch',
460
642
  }
461
643
 
@@ -133,6 +133,7 @@ export function nodeToMessage(node: unknown): ChatMessage | null {
133
133
  argsRaw: typeof call?.argsRaw === 'string' ? call.argsRaw : undefined,
134
134
  meta: n.meta,
135
135
  rawText: text,
136
+ ...(n.isError === true ? { isError: true } : {}),
136
137
  })
137
138
  return {
138
139
  key: seqKey('t', n),
@@ -40,10 +40,20 @@ export const en = {
40
40
  modelSwitchTitle: 'Switch the side chat model',
41
41
  permChipTitle: 'Permission mode',
42
42
  permFullAccess: 'Full access',
43
- pendingNotice: '⚠ 侧边会话有审批/提问待处理——点击跳到主视图处理',
44
- writeNotice: '侧边会话与主会话同权限,可以修改文件(当前模式见输入框旁 chip)',
43
+ pendingNotice: '⚠ The side session has a pending approval/question — click to handle it in the main view',
44
+ writeNotice: 'The side session shares the main session\'s permissions and can modify files (current mode: chip next to the input)',
45
+ pendingApprovalTitle: 'Waiting for approval',
46
+ pendingApprovalDesc: 'Tool {name} requests elevated execution',
47
+ pendingReject: 'Reject',
48
+ pendingAllowOnce: 'Allow once',
49
+ pendingQuestionTitle: 'Question',
50
+ pendingPlanReview: 'Plan review',
51
+ pendingQuestionSubmit: 'Submit',
52
+ pendingQuestionCancel: 'Cancel',
53
+ pendingCustomPlaceholder: 'Custom answer…',
45
54
  modelFollowsMain: 'follows main session',
46
55
  stopReply: 'Stop',
56
+ sideChatDone: '「{title}」finished replying',
47
57
  stopReplyTitle: 'Stop the current reply',
48
58
  send: 'Send',
49
59
  attachTitle: 'Attach images',
@@ -198,8 +208,18 @@ export const zh: Record<CopyKey, string> = {
198
208
  permFullAccess: 'Full access',
199
209
  pendingNotice: '⚠ 侧边会话有审批/提问待处理——点击跳到主视图处理',
200
210
  writeNotice: '侧边会话与主会话同权限,可以修改文件(当前模式见输入框旁 chip)',
211
+ pendingApprovalTitle: '等待审批',
212
+ pendingApprovalDesc: '工具 {name} 请求越权执行',
213
+ pendingReject: '拒绝',
214
+ pendingAllowOnce: '允许一次',
215
+ pendingQuestionTitle: '提问',
216
+ pendingPlanReview: '计划审批',
217
+ pendingQuestionSubmit: '提交回答',
218
+ pendingQuestionCancel: '取消',
219
+ pendingCustomPlaceholder: '自定义回答…',
201
220
  modelFollowsMain: '跟随主会话',
202
221
  stopReply: '停止',
222
+ sideChatDone: '「{title}」回复完成',
203
223
  stopReplyTitle: '停止当前回复',
204
224
  send: '发送',
205
225
  attachTitle: '添加图片附件',
@@ -0,0 +1,145 @@
1
+ /**
2
+ * R8 审批/提问答复卡(L2 视图):钉在 composer 上方(原 pendingBar 位置)。
3
+ * 自绘 + primitives 图标(材质同源,不求像素级复刻宿主审批卡)。
4
+ *
5
+ * 竞答纪律:先答先得——点击后置 answered 本地态;answer 失败(已被主视图
6
+ * 抢答/settle)catch 吞错并回退按钮态(宿主 `.catch(() => setAnswered(false))`
7
+ * 同款);卡片消失由快照驱动(pending 项消失即卸载),UI 不自造状态机。
8
+ */
9
+ import { useState } from 'react'
10
+ import { IconWarningOutline16, IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import type { PendingItem, PendingQuestionView } from './pending.ts'
12
+ import { t } from '../locales.ts'
13
+ import { useLocaleTick } from '../locale-tick.ts'
14
+ import css from './sidechat.module.css'
15
+
16
+ export function PendingCardList({ items }: { items: readonly PendingItem[] }) {
17
+ return (
18
+ <>
19
+ {items.map(item => item.kind === 'approval'
20
+ ? <ApprovalCard key={item.key} item={item} />
21
+ : <QuestionCard key={item.key} item={item} />)}
22
+ </>
23
+ )
24
+ }
25
+
26
+ /** 审批卡:reason + 拒绝 / 允许一次。 */
27
+ function ApprovalCard({ item }: { item: Extract<PendingItem, { kind: 'approval' }> }) {
28
+ useLocaleTick()
29
+ const [answered, setAnswered] = useState(false)
30
+ const answer = (outcome: 'allowed-once' | 'rejected'): void => {
31
+ setAnswered(true)
32
+ item.answer(outcome).catch(() => { setAnswered(false) })
33
+ }
34
+ return (
35
+ <div className={css.pendingCard} data-kind="approval">
36
+ <div className={css.pendingHead}>
37
+ <IconWarningOutline16 size={14} />
38
+ <span className={css.pendingTitle}>{t('pendingApprovalTitle')}</span>
39
+ </div>
40
+ <div className={css.pendingDesc}>{t('pendingApprovalDesc', { name: item.toolName })}</div>
41
+ {item.reason !== undefined && item.reason !== '' && <div className={css.pendingReason}>{item.reason}</div>}
42
+ <div className={css.pendingActions}>
43
+ <button type="button" className={css.pendingBtnGhost} disabled={answered} onClick={() => { answer('rejected') }}>
44
+ {t('pendingReject')}
45
+ </button>
46
+ <button type="button" className={css.pendingBtnPrimary} disabled={answered} onClick={() => { answer('allowed-once') }}>
47
+ {t('pendingAllowOnce')}
48
+ </button>
49
+ </div>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ /** 提问卡:选项(单选即点即答语义并入整批提交)+ 自定义 + 整批提交/取消。 */
55
+ function QuestionCard({ item }: { item: PendingQuestionView }) {
56
+ useLocaleTick()
57
+ // 每题的选择集合(多选为 Set;单选为单元素 Set)+ 自定义文本。
58
+ const [selections, setSelections] = useState<ReadonlyMap<string, ReadonlySet<string>>>(new Map())
59
+ const [customs, setCustoms] = useState<ReadonlyMap<string, string>>(new Map())
60
+ const [answered, setAnswered] = useState(false)
61
+
62
+ const toggle = (qid: string, label: string, multi: boolean): void => {
63
+ const next = new Map(selections)
64
+ const cur = new Set(next.get(qid) ?? [])
65
+ if (multi) {
66
+ if (cur.has(label)) cur.delete(label)
67
+ else cur.add(label)
68
+ } else {
69
+ cur.clear()
70
+ cur.add(label)
71
+ }
72
+ next.set(qid, cur)
73
+ setSelections(next)
74
+ }
75
+
76
+ // 提交门槛:每题至少有选中项或自定义文本(整批语义,不留空题)。
77
+ const ready = item.questions.every(q =>
78
+ (selections.get(q.id)?.size ?? 0) > 0 || (customs.get(q.id)?.trim() ?? '') !== '',
79
+ )
80
+
81
+ const submit = (): void => {
82
+ setAnswered(true)
83
+ item.answer(item.questions.map(q => ({
84
+ id: q.id,
85
+ selected: [...(selections.get(q.id) ?? [])],
86
+ ...((customs.get(q.id)?.trim() ?? '') !== '' ? { custom: customs.get(q.id)!.trim() } : {}),
87
+ }))).catch(() => { setAnswered(false) })
88
+ }
89
+ const cancel = (): void => {
90
+ setAnswered(true)
91
+ item.cancel().catch(() => { setAnswered(false) })
92
+ }
93
+
94
+ return (
95
+ <div className={css.pendingCard} data-kind="question">
96
+ <div className={css.pendingHead}>
97
+ <IconQuestionOutline14 size={14} />
98
+ <span className={css.pendingTitle}>
99
+ {item.planReview ? t('pendingPlanReview') : t('pendingQuestionTitle')}
100
+ </span>
101
+ </div>
102
+ {item.questions.map(q => (
103
+ <div key={q.id} className={css.pendingQuestion}>
104
+ {q.header !== undefined && q.header !== '' && <div className={css.pendingQHeader}>{q.header}</div>}
105
+ <div className={css.pendingQText}>{q.question}</div>
106
+ {q.detail !== undefined && q.detail !== '' && <div className={css.pendingReason}>{q.detail}</div>}
107
+ {q.options.map(o => {
108
+ const active = selections.get(q.id)?.has(o.label) === true
109
+ return (
110
+ <button
111
+ key={o.label}
112
+ type="button"
113
+ className={active ? css.pendingOptionActive : css.pendingOption}
114
+ disabled={answered}
115
+ onClick={() => { toggle(q.id, o.label, q.multiSelect) }}
116
+ >
117
+ {o.label}
118
+ {o.description !== undefined && o.description !== '' && <span className={css.pendingOptionDesc}>{o.description}</span>}
119
+ </button>
120
+ )
121
+ })}
122
+ <input
123
+ className={css.pendingCustom}
124
+ placeholder={t('pendingCustomPlaceholder')}
125
+ disabled={answered}
126
+ value={customs.get(q.id) ?? ''}
127
+ onChange={(event) => {
128
+ const next = new Map(customs)
129
+ next.set(q.id, event.target.value)
130
+ setCustoms(next)
131
+ }}
132
+ />
133
+ </div>
134
+ ))}
135
+ <div className={css.pendingActions}>
136
+ <button type="button" className={css.pendingBtnGhost} disabled={answered} onClick={cancel}>
137
+ {t('pendingQuestionCancel')}
138
+ </button>
139
+ <button type="button" className={css.pendingBtnPrimary} disabled={answered || !ready} onClick={submit}>
140
+ {t('pendingQuestionSubmit')}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ )
145
+ }
@@ -27,11 +27,15 @@ import { FoldCard } from '../chat/FoldCard.tsx'
27
27
  import { createFoldStore } from '../chat/viewState.ts'
28
28
  import type { ReflowStore } from '../reflow.ts'
29
29
  import { showToast } from './toast.tsx'
30
+ import { createPendingStore } from './pending.ts'
31
+ import { PendingCardList } from './PendingCard.tsx'
30
32
  import { t } from '../locales.ts'
31
33
  import { useLocaleTick } from '../locale-tick.ts'
32
34
  import css from './sidechat.module.css'
33
35
 
34
36
  const NOOP_UNSUBSCRIBE = (): void => {}
37
+ /** uSES 空快照常量(禁产新引用——`?? []` 字面量每次新数组会无限重渲)。 */
38
+ const NO_PENDING: readonly import('./pending.ts').PendingItem[] = []
35
39
 
36
40
  /** D2 写型工具名(无 diff 卡时的名字兜底)。 */
37
41
  const WRITE_TOOLS = new Set(['edit', 'write', 'str-replace-editor', 'str_replace_editor'])
@@ -133,6 +137,23 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
133
137
  ),
134
138
  () => (parentSession === undefined ? null : parentSession.getSnapshot()),
135
139
  )
140
+ // R8:审批/提问数据源(双版本探测链——0.1.2 uiSession.pendingInteractions
141
+ // 优先,0.1.1 Session 快照 pending 回退;面缺席 undefined → 回退纯跳转
142
+ // pendingBar)。hooks 纪律:必须在相位早退之前(#310 教训)。
143
+ const pendingStore = useMemo(
144
+ () => (childId === undefined || session === undefined ? undefined : createPendingStore(ctx, session, childId)),
145
+ [ctx, session, childId],
146
+ )
147
+ const pendingItems = useSyncExternalStore(
148
+ useCallback(
149
+ (notify: () => void) => (visible && pendingStore !== undefined ? pendingStore.subscribe(notify) : NOOP_UNSUBSCRIBE),
150
+ [visible, pendingStore],
151
+ ),
152
+ // NO_PENDING 常量:uSES getSnapshot 禁产新引用(`?? []` 每次新数组 →
153
+ // 无限重渲——实证教训)。
154
+ () => pendingStore?.getSnapshot() ?? NO_PENDING,
155
+ )
156
+
136
157
  // D2 提示的消除态(hooks 纪律:必须在相位早退之前——#310 教训)。
137
158
  const [writeNoticeDismissed, setWriteNoticeDismissed] = useState(
138
159
  () => typeof localStorage !== 'undefined' && localStorage.getItem(WRITE_NOTICE_KEY) === '1',
@@ -321,7 +342,12 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
321
342
  >×</button>
322
343
  </div>
323
344
  )}
324
- {pendingCount > 0 && childId !== undefined && (
345
+ {/* R8:数据源在场 → 面板内审批/提问答复卡;面缺席(老宿主)→ 回退
346
+ 纯跳转 pendingBar(原行为不变)。 */}
347
+ {pendingStore !== undefined && pendingItems.length > 0 && (
348
+ <PendingCardList items={pendingItems} />
349
+ )}
350
+ {pendingStore === undefined && pendingCount > 0 && childId !== undefined && (
325
351
  <button
326
352
  type="button"
327
353
  className={css.pendingBar}
@@ -29,6 +29,7 @@ import { registerHeaderEntry } from './header.tsx'
29
29
  import { registerSideCommand } from './slash.ts'
30
30
  import { registerSideChatReferenceSource } from './referenceSource.ts'
31
31
  import { registerToastHost } from './toast.tsx'
32
+ import { registerCompletionNotify } from './notify.ts'
32
33
 
33
34
  export function registerSideChat(ctx: Context, reflow: ReflowStore): void {
34
35
  ctx.effect(
@@ -79,4 +80,6 @@ export function registerSideChat(ctx: Context, reflow: ReflowStore): void {
79
80
  registerHeaderEntry(ctx)
80
81
  // 全局瞬态通知宿主(P4:转正确认等跨 Tab 寿命的反馈)。
81
82
  registerToastHost(ctx)
83
+ // 完成通知(WI-03):侧聊 running 翻转 → toast。
84
+ registerCompletionNotify(ctx)
82
85
  }