dsh-vscode-mode 0.21.3 → 0.22.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.21.3",
3
+ "version": "0.22.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -281,28 +281,78 @@ interface SessionBridge {
281
281
  scope?: (id: string) => unknown
282
282
  }
283
283
 
284
- /** @private @author ddj 2026年10月28号 @param ctx Lifecycle owner. @param sessions Public Controller. @param id Fixed target. @param text Task body. @returns Acceptance; failures never retry another transport. @sideEffects Retains one generation until completion or unload. */
285
- async function sendOwned(ctx: CtxLike, sessions: SessionBridge, id: string, text: string): Promise<boolean> {
284
+ /** Detailed admission evidence; none of these states proves task completion. */
285
+ export interface TaskSendResult {
286
+ status: 'accepted' | 'rejected' | 'unavailable' | 'unknown' | 'submitted' | 'cancelled'
287
+ /** Historical boolean from this same attempt, not an admission marker. */
288
+ legacyOk: boolean
289
+ }
290
+
291
+ /** @private @author ddj 2026年10月29号 @param receipt Raw selected-channel receipt. @returns Explicit admission evidence and the historical boolean. */
292
+ function taskReceipt(receipt: unknown): TaskSendResult {
293
+ const ok = (receipt as { ok?: unknown } | null)?.ok
294
+ if (ok === true) return { status: 'accepted', legacyOk: true }
295
+ if (ok === false) return { status: 'rejected', legacyOk: false }
296
+ return { status: 'unknown', legacyOk: false }
297
+ }
298
+
299
+ /** @private @author ddj 2026年10月29号 @param ctx Lifecycle owner. @param sessions Public Controller. @param id Fixed target. @param text Task body. @returns Admission evidence without switching transport. @sideEffects Retains and releases exactly one generation. */
300
+ async function sendOwned(ctx: CtxLike, sessions: SessionBridge, id: string, text: string): Promise<TaskSendResult> {
286
301
  const controller = new AbortController()
287
302
  const reference = sessions.retain!(id, { source: 'controllerOperation', signal: controller.signal })
288
303
  let released = false
289
- /** @private @author ddj 2026年10月28号 Cancel pending admission and release this exact generation once. */
304
+ let cleanupFailed = false
305
+ /** @private @author ddj 2026年10月29号 Cancels and releases this exact generation once; remembers unload cleanup failures. */
290
306
  function release(): void {
291
307
  if (released) return
292
308
  released = true
293
309
  controller.abort()
294
- reference.release()
310
+ try { reference.release() } catch (error) { cleanupFailed = true; throw error }
295
311
  }
296
312
  let remove: (() => void) | undefined
313
+ let started = false
314
+ let result: TaskSendResult = { status: 'unavailable', legacyOk: false }
297
315
  try {
298
316
  remove = ctx.effect?.(() => release, 'vscode-mode: task session reference')
299
317
  const binding = await reference.ready
300
- if (controller.signal.aborted) return false
301
- return (await binding.session.prompt([{ type: 'text', text }], 'queue', controller.signal)).ok === true
302
- } finally {
303
- release()
304
- remove?.()
318
+ if (controller.signal.aborted) result = { status: 'cancelled', legacyOk: false }
319
+ else {
320
+ started = true
321
+ result = taskReceipt(await binding.session.prompt([{ type: 'text', text }], 'queue', controller.signal))
322
+ }
323
+ } catch {
324
+ result = { status: started ? 'unknown' : 'unavailable', legacyOk: false }
325
+ }
326
+
327
+ // Snapshot cancellation before normal cleanup aborts our own signal.
328
+ const cancelled = controller.signal.aborted
329
+ try { release() } catch { cleanupFailed = true }
330
+ try { remove?.() } catch { cleanupFailed = true }
331
+ if (cleanupFailed) result = { status: started ? 'unknown' : 'unavailable', legacyOk: false }
332
+ if (cancelled) result = { status: 'cancelled', legacyOk: result.legacyOk }
333
+ return result
334
+ }
335
+
336
+ /** @private @author ddj 2026年10月29号 @param ctx Service owner. @param id Fixed target. @param text Task body. @returns One legacy-channel attempt; no cross-channel retry. */
337
+ async function sendLegacy(ctx: CtxLike, id: string, text: string): Promise<TaskSendResult> {
338
+ const input = inputFor(ctx, id) as { setDraft?: (task: string) => void; submit?: () => void } | undefined
339
+ if (typeof input?.setDraft === 'function' && typeof input.submit === 'function') {
340
+ let submitted = false
341
+ try {
342
+ input.setDraft(text)
343
+ submitted = true
344
+ input.submit()
345
+ return { status: 'submitted', legacyOk: true }
346
+ } catch { return { status: submitted ? 'unknown' : 'unavailable', legacyOk: false } }
305
347
  }
348
+
349
+ const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
350
+ if (typeof remote?.prompt !== 'function') return httpTaskResult(id, text)
351
+ try {
352
+ const payload = { sessionId: id, mode: 'queue', content: [{ type: 'text', text }] }
353
+ const receipt = await remote.prompt(slotArg(payload))
354
+ return taskReceipt((receipt as { result?: unknown } | null)?.result ?? receipt)
355
+ } catch { return { status: 'unknown', legacyOk: false } }
306
356
  }
307
357
 
308
358
  /** 新建会话结果(ok=false 供调用方降级回当前对话注入)。 */
@@ -386,6 +436,11 @@ async function apiSessionCreate(cwd: string | undefined): Promise<string> {
386
436
  * @returns 是否受理
387
437
  */
388
438
  export async function apiSessionPrompt(sessionId: string, text: string): Promise<boolean> {
439
+ return (await httpTaskResult(sessionId, text)).legacyOk
440
+ }
441
+
442
+ /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns HTTP receipt evidence; errors after request entry are never safe rejection. */
443
+ async function httpTaskResult(sessionId: string, text: string): Promise<TaskSendResult> {
389
444
  try {
390
445
  const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
391
446
  const res = await fetch(appUrl(SESSION_PROMPT_PATH), {
@@ -399,9 +454,9 @@ export async function apiSessionPrompt(sessionId: string, text: string): Promise
399
454
  }),
400
455
  })
401
456
  const json = await res.json().catch(() => null) as { result?: { ok?: boolean } } | null
402
- return json?.result?.ok === true
457
+ return taskReceipt(json?.result)
403
458
  } catch {
404
- return false
459
+ return { status: 'unknown', legacyOk: false }
405
460
  }
406
461
  }
407
462
 
@@ -466,8 +521,10 @@ export interface AddToConversation {
466
521
  appendText(sessionId: string | undefined, text: string): Promise<AddOutcome>
467
522
  /** 新建独立会话并切换(旁路任务隔离;失败 ok:false 供降级回当前对话)。 */
468
523
  startDraftSession(cwd?: string): Promise<DraftSessionResult>
469
- /** 发送任务给目标会话 agent(点击即发送;失败 false 供降级)。 */
524
+ /** Historical compatibility result, not proof that a task was accepted. */
470
525
  sendTask(sessionId: string | undefined, text: string): Promise<boolean>
526
+ /** Optional on older injected facades; detailed callers must not infer acceptance from sendTask. */
527
+ sendTaskResult?(sessionId: string | undefined, text: string): Promise<TaskSendResult>
471
528
  }
472
529
 
473
530
  /**
@@ -640,32 +697,22 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
640
697
  /**
641
698
  * 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
642
699
  * 新版持有目标 Controller 到 prompt 结束;旧版按能力选择唯一通道,不跨通道重发。
643
- * @author ddj 2026年09月23号 / 2026年10月28号
700
+ * @author ddj 2026年10月29号
644
701
  * @param sessionId 目标会话 id
645
702
  * @param text 任务文本
646
- * @returns 是否受理
703
+ * @returns 同一次调用的历史boolean语义,不能作为受理事实
647
704
  */
648
- const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => {
649
- if (!sessionId) return false
705
+ const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => (await sendTaskResult(sessionId, text)).legacyOk
706
+
707
+ /** @private @author ddj 2026年10月29号 @param sessionId Fixed target. @param text Task body. @returns Detailed evidence from exactly one selected transport. */
708
+ async function sendTaskResult(sessionId: string | undefined, text: string): Promise<TaskSendResult> {
709
+ if (!sessionId) return { status: 'unavailable', legacyOk: false }
650
710
  try {
651
711
  const sessions = ctx.get('sessions') as SessionBridge | undefined
652
712
  if (typeof sessions?.retain === 'function') return await sendOwned(ctx, sessions, sessionId, text)
653
-
654
- // Legacy composer remains an exclusive channel; never overwrite the draft and then retry elsewhere.
655
- const input = inputFor(ctx, sessionId) as { setDraft?: (task: string) => void; submit?: () => void } | undefined
656
- if (typeof input?.setDraft === 'function' && typeof input.submit === 'function') {
657
- input.setDraft(text)
658
- input.submit()
659
- return true
660
- }
661
- const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
662
- if (typeof remote?.prompt === 'function') {
663
- const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
664
- return rpcOk(await remote.prompt(slotArg(payload))).ok
665
- }
666
- return await apiSessionPrompt(sessionId, text)
667
- } catch { return false }
713
+ return await sendLegacy(ctx, sessionId, text)
714
+ } catch { return { status: 'unavailable', legacyOk: false } }
668
715
  }
669
716
 
670
- return { appendReference, appendText, startDraftSession, sendTask }
717
+ return { appendReference, appendText, startDraftSession, sendTask, sendTaskResult }
671
718
  }
@@ -39,6 +39,7 @@ import type { ContextMenuEntry } from '../../ui/ContextMenu.js'
39
39
  import { SvnAiPlanDialog } from '../../ui/SvnAiPlanDialog.js'
40
40
  import { budgetGroupsOf } from './svnListBudget.js'
41
41
  import type { SidebarCtx } from '../types.js'
42
+ import type { DraftSessionResult, TaskSendResult } from '../../addToConversation.js'
42
43
 
43
44
  /** 未分组条目的分组键(changelist 为空)。 */
44
45
  const NO_CHANGELIST = '(未分组)'
@@ -845,10 +846,9 @@ export function SvnPanel(props) {
845
846
 
846
847
  /**
847
848
  * 深度分析入口(02-deep-session-prompt 修正版):**新建独立会话**承载任务
848
- * (不污染当前对话),草稿箱填入分析任务(变更清单由 agent 自跑只读 svn status
849
- * 取数,prompt 定长)→ 用户在新会话发送 → agent 投递方案回面板收件箱。
850
- * 降级:不支持自动新建会话时回落当前对话注入 + notify 注明。
851
- * @author ddj 2026年09月23号
849
+ * (不污染当前对话),显式受理才等待方案;不明回执不重发或填入另一草稿。
850
+ * 未发送或明确拒绝时仅填当前草稿,提示用户手动发送,prompt使用实际草稿会话。
851
+ * @author ddj 2026年10月29号
852
852
  */
853
853
  const runDeepPlan = React.useCallback(() => {
854
854
  const live = ctxRef.current
@@ -858,34 +858,42 @@ export function SvnPanel(props) {
858
858
  const since = Date.now()
859
859
  const add = live?.addToConversation
860
860
  if (!add?.appendText) { live?.notify?.('无法填入任务(无会话或输入框不可用)'); return }
861
- /** 降级:当前对话注入(不支持自动新建会话时兜底)。 */
862
- const degrade = (prompt) => {
863
- void add.appendText(sessionId, prompt).then((outcome) => {
864
- if (outcome === 'ok' || outcome === 'busy') {
865
- live?.notify?.('已填入当前对话(降级),任务已自动发送,请在本对话查看')
866
- startDeepPoll(since)
867
- } else {
868
- live?.notify?.('发起失败:' + (outcome === 'unavailable' ? '无会话或输入框不可用' : '输入框忙,请重试'))
869
- }
870
- })
861
+
862
+ /** @private @author ddj 2026年10月29号 @returns Draft insertion only; never submits or starts a task poll. */
863
+ async function draftCurrent(): Promise<void> {
864
+ if (!sessionId) { live?.notify?.('无法填入任务(无会话或输入框不可用)'); return }
865
+ try {
866
+ const outcome = await add.appendText(sessionId, buildAgentPrompt(wcRoot, sessionId))
867
+ if (outcome === 'ok' || outcome === 'busy') live?.notify?.('已填入当前草稿,请手动发送')
868
+ else live?.notify?.('发起失败:' + (outcome === 'unavailable' ? '无会话或输入框不可用' : '输入框忙,请重试'))
869
+ } catch { live?.notify?.('草稿填入失败,请检查当前输入框') }
871
870
  }
872
- /** 统一收尾(点击即发送方案):create 后构建 prompt(内嵌新会话 id)→ 发送任务。 */
873
- const sendAndWatch = (id) => {
874
- const prompt = buildAgentPrompt(wcRoot, id || sessionId || '')
875
- if (!id || typeof add.sendTask !== 'function') { degrade(prompt); return }
876
- void add.sendTask(id, prompt).then((sent) => {
877
- if (sent) {
878
- live?.notify?.('已在新会话「AI 深度分析」发起任务并自动发送,请在左侧会话列表点开查看进度')
879
- startDeepPoll(since)
880
- } else {
881
- degrade(prompt)
882
- }
883
- })
871
+ /** @private @author ddj 2026年10月29号 @param id Created target. @returns One detailed send decision; ambiguous receipts never retry. */
872
+ async function sendAndWatch(id): Promise<void> {
873
+ if (!id) { await draftCurrent(); return }
874
+ if (typeof add.sendTaskResult !== 'function') {
875
+ live?.notify?.('发送接口不支持受理确认,本次未自动发送,请手动处理')
876
+ return
877
+ }
878
+ let result: TaskSendResult | undefined
879
+ try { result = await add.sendTaskResult(id, buildAgentPrompt(wcRoot, id)) }
880
+ catch { result = { status: 'unknown', legacyOk: false } }
881
+ if (result?.status === 'accepted') {
882
+ live?.notify?.('已在新会话「AI 深度分析」受理任务,请在左侧会话列表点开查看进度')
883
+ startDeepPoll(since)
884
+ } else if (result?.status === 'rejected' || result?.status === 'unavailable') await draftCurrent()
885
+ else if (result?.status === 'cancelled') live?.notify?.('发送操作已取消,任务可能已提交,请检查目标会话:' + id)
886
+ else if (result?.status === 'submitted') live?.notify?.('已调用提交但受理未确认,请检查目标会话,不要重复发送:' + id)
887
+ else live?.notify?.('任务受理未确认,请检查目标会话,不要重复发送:' + id)
888
+ }
889
+ /** @private @author ddj 2026年10月29号 @returns Creates a target before sending; creation failure can only insert a manual draft. */
890
+ async function beginDeep(): Promise<void> {
891
+ let result: DraftSessionResult | undefined
892
+ try { result = await add.startDraftSession?.(wcRoot || undefined) }
893
+ catch { result = { ok: false } }
894
+ await sendAndWatch(result?.ok ? result.id : undefined)
884
895
  }
885
- void (add.startDraftSession
886
- ? add.startDraftSession(wcRoot || undefined)
887
- : Promise.resolve({ ok: false })
888
- ).then((result) => { sendAndWatch(result?.ok ? result.id : undefined) })
896
+ void beginDeep()
889
897
  }, [aiBusy, aiRun, deepWaiting, entries, sessionId, startDeepPoll])
890
898
 
891
899
  // --endregion
@@ -1,10 +1,17 @@
1
1
  /** Source-aware metadata projection; official patches never become legacy hunks. @author ddj 2026年10月26号 */
2
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
2
3
  import type { ReviewPage } from '../../shared/review.js'
3
4
  import type { RecordView } from '../../shared/types.js'
4
5
  import type { FileSummary, Summary } from './records.js'
5
6
  import { summarize } from './records.js'
6
7
 
7
- export interface DiffFile extends FileSummary { sourceKind: 'official' | 'legacy' }
8
+ export interface DiffFile extends FileSummary {
9
+ sourceKind: 'official' | 'legacy' | 'pending'
10
+ /** 待审行的增删行数;官方汇总行可能缺省。 */
11
+ added?: number
12
+ /** 待审行的删除行数。 */
13
+ deleted?: number
14
+ }
8
15
  export interface DiffSummary extends Summary { files: DiffFile[]; pendingFiles: DiffFile[] }
9
16
 
10
17
  /** @public @author ddj 2026年10月26号 @param path Functional local or remote identity. @returns Comparison key without rewriting remote case. */
@@ -20,10 +27,42 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
20
27
  return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
21
28
  }
22
29
 
23
- /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Official-first file counts; legacy paths remain explicitly labelled. */
30
+ /**
31
+ * @author ddj 2026年10月30号
32
+ * @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
33
+ * @param page 审查页
34
+ * @returns 待审文件行与它们覆盖的路径键
35
+ */
36
+ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Set<string> } {
37
+ const rows: DiffFile[] = []
38
+ const covered = new Set<string>()
39
+ for (const item of page?.items ?? []) {
40
+ if (!item.id.startsWith(PENDING_PREFIX)) continue
41
+ const path = item.currentPath || item.path
42
+ covered.add(diffPathKey(path))
43
+ rows.push({
44
+ path,
45
+ label: item.display || path,
46
+ recs: [],
47
+ pending: item.decision === 'pending' ? 1 : 0,
48
+ added: item.added,
49
+ deleted: item.deleted,
50
+ sourceKind: 'pending',
51
+ })
52
+ }
53
+ return { rows, covered }
54
+ }
55
+
56
+ /** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
24
57
  export function diffSummary(records: RecordView[], page: ReviewPage | null): DiffSummary {
25
- const legacy = summarize(records.filter((record) => !officialPath(record.path, page)))
26
- const files: DiffFile[] = (page?.overview?.files ?? []).map((file) => ({ ...file, recs: [] }))
58
+ const pending = pendingRowsOf(page)
59
+ // 同一路径只留一个权威:待审接管后,官方汇总行与历史留痕行都让位。
60
+ const legacy = summarize(records.filter((record) => !pending.covered.has(diffPathKey(record.path)) && !officialPath(record.path, page)))
61
+ const files: DiffFile[] = [...pending.rows]
62
+ for (const file of page?.overview?.files ?? []) {
63
+ if (pending.covered.has(diffPathKey(file.path))) continue
64
+ files.push({ ...file, recs: [] })
65
+ }
27
66
  for (const file of legacy.files) files.push({ ...file, sourceKind: 'legacy' })
28
67
  const pendingFiles = files.filter((file) => file.pending > 0)
29
68
  return { files, pendingFiles, totalFiles: pendingFiles.length }
@@ -106,7 +106,7 @@ export function DiffLauncher(props) {
106
106
  }, [tab, sessionId, source])
107
107
 
108
108
  let body
109
- const legacyFiles = sum.pendingFiles.filter((file) => file.sourceKind !== 'official')
109
+ const legacyFiles = sum.pendingFiles.filter((file) => file.sourceKind === 'legacy')
110
110
  if (source === 'official') {
111
111
  body = React.createElement(React.Fragment, null,
112
112
  React.createElement('div', { className: 'edrv-search-empty' }, '官方轮次净差异 · 原始历史只读;编辑按钮打开当前文件,保存不代表已审。'),
@@ -26,6 +26,7 @@ import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
26
  import { runReviewBatch } from '../reviewBatch.js'
27
27
  import { ReviewAllConfirm } from './ReviewAllConfirm.js'
28
28
  import { reviewRegions } from '../../shared/reviewRegions.js'
29
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
29
30
  import { diffSummary, officialPath } from '../state/diffSummary.js'
30
31
  import { emitFileChanged, emitRefresh } from '../events.js'
31
32
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
@@ -579,7 +580,15 @@ export function EditorView(props) {
579
580
  }, [sessionId, active, selectedReview])
580
581
  React.useEffect(() => {
581
582
  const controller = new AbortController()
582
- if (!active || !review.page?.overview?.coveredPaths.some((path) => sameFile(path, active))) {
583
+ // 待审条目也走同一条行内差异通道:它由累计基线投影而来,形状与官方条目一致,
584
+ // 因此编辑器里天然就能画行内差异与 Keep/Undo 药丸,不必另开一个窄面板。
585
+ const pendingPath = (review.page?.items ?? [])
586
+ .filter((item) => String(item.id).startsWith(PENDING_PREFIX))
587
+ .map((item) => item.currentPath || item.path)
588
+ .find((path) => sameFile(path, active))
589
+ const coveredPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
590
+ const reviewPath = coveredPath ?? pendingPath
591
+ if (!active || !reviewPath) {
583
592
  setOfficialDetail(null)
584
593
  setOfficialRecords([])
585
594
  return () => controller.abort()
@@ -588,7 +597,6 @@ export function EditorView(props) {
588
597
  async function loadReview() {
589
598
  try {
590
599
  // Tabs use workspace-relative paths; the metadata index owns the backend identity.
591
- const reviewPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active)) ?? active
592
600
  const page = await rpc('edrv.review.list', { sessionId, path: reviewPath }, controller.signal)
593
601
  if (controller.signal.aborted) return
594
602
  if (!page.ok) throw new Error(page.error)
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
3
3
  import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
4
- import { ReviewLedger } from './OfficialReview.js'
5
4
  import { reviewBarView } from '../reviewBar.js'
6
5
  import { ReviewAllRow } from './reviewAllRow.js'
7
6
 
@@ -55,11 +54,9 @@ export function barIcon(mark: Mark): React.ReactElement {
55
54
  /** @public @author ddj 2026年10月27号 @param props Existing review callbacks and immutable record metadata. @returns Responsive review toolbar; no disk or decision side effects until an explicit action. */
56
55
  export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
57
56
  const { official, pendingRegions = [], staleRegions = [], diffIdx = 0 } = props
58
- const [expanded, setExpanded] = React.useState(false)
59
- const [menu, setMenu] = React.useState<'records' | 'more' | null>(null)
57
+ const [menu, setMenu] = React.useState<'more' | null>(null)
60
58
  const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
61
59
  const detail = official.detail
62
- const records = official.records ?? []
63
60
  const count = pendingRegions.length
64
61
  const view = reviewBarView({ jumps: count, stale: staleRegions.length, index: diffIdx, ready: props.contentReady === true,
65
62
  pendingRecords: official.pending, canKeep: detail?.capability.keep === true,
@@ -67,8 +64,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
67
64
 
68
65
  /** @private @author ddj 2026年10月27号 @returns Close current popup without changing selection. */
69
66
  function closeMenu(): void { setMenu(null) }
70
- /** @private @author ddj 2026年10月27号 @param id Immutable record ID. @returns Select record and dismiss popup. */
71
- function selectRecord(id: string): void { official.onSelect(id); closeMenu() }
72
67
  /** @private @author ddj 2026年10月27号 @param tab Existing launcher tab. @returns Open existing overview, never create another review entry. */
73
68
  function openOverview(tab: string): void { props.onOpenLauncher(tab); closeMenu() }
74
69
  /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @param title Hover reason, defaults to the label. @returns Compact keyboard-focusable navigation control. */
@@ -76,9 +71,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
76
71
  return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={title || label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
77
72
  }
78
73
 
79
- const recordTrigger = <Button size="sm" variant="ghost" className="edrv-review-record" aria-label="选择官方差异记录" aria-haspopup="menu" aria-expanded={menu === 'records'} disabled={!records.length} onClick={() => setMenu(menu === 'records' ? null : 'records')}>
80
- {detail ? `轮次 ${detail.item.turn}` : '选择记录'}{barIcon('down')}
81
- </Button>
82
74
  const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
83
75
 
84
76
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="official" role="region" aria-label="文件差异审查">
@@ -99,7 +91,6 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
99
91
  <span className="edrv-review-filemark">{barIcon('file')}</span>
100
92
  <span className="edrv-review-filename" title={props.activePath}>{name}</span>
101
93
  <span className="edrv-review-pending" title={view.pendingTitle}>待审 <strong>{official.pending}</strong></span>
102
- <Menu open={menu === 'records'} anchor={recordTrigger} items={records.map((item) => ({ id: item.id, label: `轮次 ${item.turn} / 事件 ${item.seq}` }))} selectedId={detail?.item.id} onSelect={selectRecord} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
103
94
  </div>
104
95
  <div className="edrv-review-tools">
105
96
  <div className="edrv-review-nav" role="group" aria-label="文件导航">
@@ -114,12 +105,10 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
114
105
  <div className="edrv-review-actions">
115
106
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
116
107
  <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title={detail?.capability.keep ? view.keepHint : detail?.capability.reason}>Keep</Button>
117
- <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
118
108
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
119
109
  </div>
120
110
  </div>
121
111
  {detail && view.notice ? <div className={view.noticeTone === 'warn' ? 'edrv-review-notice edrv-review-notice-warn' : 'edrv-review-notice'} role="status">{view.notice}</div> : null}
122
112
  {props.reviewError ? <div className={props.reviewErrorTone === 'info' ? 'edrv-review-notice' : 'edrv-review-notice edrv-review-error'} role={props.reviewErrorTone === 'info' ? 'status' : 'alert'}>{props.reviewError}</div> : null}
123
- {expanded ? <div className="edrv-review-body"><ReviewLedger key={`${official.sessionId}:${official.path}`} sessionId={official.sessionId} first={official.page} path={official.path} /></div> : null}
124
113
  </div>
125
114
  }
@@ -3,6 +3,7 @@ import React from 'react'
3
3
  import { rpc } from '../rpc.js'
4
4
  import { openEditorView } from '../events.js'
5
5
  import { refreshReview } from '../state/reviewFeed.js'
6
+ import { PENDING_PREFIX } from '../../shared/pendingReview.js'
6
7
  import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
7
8
  const h = React.createElement
8
9
  /** @private @author ddj 2026年10月26号 @param props Latest first-page metadata. @returns Paginated rows independent of expanded historical patch. */
@@ -37,18 +38,27 @@ export function ReviewLedger(props: { sessionId: string; first: ReviewPage | nul
37
38
  else setError(result.error)
38
39
  } catch (cause) { if (!controller.signal.aborted) setError(String(cause)) }
39
40
  }
41
+ const gaps = page?.gaps ?? []
40
42
  return h('div', { style: { marginTop: 8 } },
41
43
  h('label', null, h('input', { type: 'checkbox', checked: history, onChange: () => { setHistory(!history); setCursor(0) } }), '显示已处理及被替换的历史'),
42
- page?.gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status' }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)),
43
44
  error ? h('div', { role: 'alert' }, error) : null,
44
45
  !page ? h('p', { role: 'status' }, '正在读取官方审查记录…') : null,
45
46
  page && !page.items.length ? h('p', null, history ? '暂无已落盘历史。' : '没有待处理的官方记录;可切换查看历史。') : null,
46
47
  h('div', { style: { maxHeight: 210, overflow: 'auto' } }, page?.items.map((item) => h('button', {
47
48
  key: item.id, type: 'button', onClick: () => { void select(item) },
48
- style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px' },
49
- }, `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
49
+ title: item.currentPath || item.path,
50
+ style: { display: 'block', width: '100%', textAlign: 'left', padding: '6px 4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
51
+ }, item.id.startsWith(PENDING_PREFIX)
52
+ ? `【待审】${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.kind === 'oversized' ? ' · 过大' : ''}`
53
+ : `轮次 ${item.turn} · ${item.display} +${item.added} −${item.deleted} · ${item.decision}${item.current ? '' : '(已被同轮新版本替换)'}`))),
50
54
  cursor > 0 ? h('button', { type: 'button', onClick: () => setCursor(Math.max(0, cursor - 50)) }, '上一页') : null,
51
55
  page?.nextCursor !== null && page?.nextCursor !== undefined ? h('button', { type: 'button', onClick: () => setCursor(page.nextCursor!) }, '下一页') : null,
56
+ // 采集缺口是诊断信息,排在条目之后折叠起来:放最前面会把真正要点的待审条目挤出可视区。
57
+ gaps.length
58
+ ? h('details', { key: 'gaps', style: { marginTop: 6 } },
59
+ h('summary', { style: { cursor: 'pointer', fontSize: 11 } }, `采集不完整 ${gaps.length} 条(诊断信息)`),
60
+ gaps.map((gap) => h('div', { key: `${gap.sessionId}:${gap.seq}`, role: 'status', style: { fontSize: 11 } }, `轮次 ${gap.turn} 采集不完整:${gap.error}`)))
61
+ : null,
52
62
  detail ? h(PatchDetail, { key: detail.item.id, sessionId: props.sessionId, detail, onChanged: () => { setDetail(null); refreshReview(props.sessionId) } }) : null)
53
63
  }
54
64
 
@@ -62,7 +62,6 @@ export interface SidecarBarProps {
62
62
  export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
63
63
  const pendingRegions = props.pendingRegions ?? []
64
64
  const staleRegions = props.staleRegions ?? []
65
- const [expanded, setExpanded] = React.useState(false)
66
65
  const [menu, setMenu] = React.useState<'more' | null>(null)
67
66
  const name = String(props.activePath || '').split(/[\\/]/).pop() || '当前文件'
68
67
  const count = pendingRegions.length
@@ -85,25 +84,6 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
85
84
 
86
85
  const moreTrigger = <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label="更多审查操作" title="更多审查操作" aria-haspopup="menu" aria-expanded={menu === 'more'} onClick={() => setMenu(menu === 'more' ? null : 'more')}>{barIcon('more')}</Button>
87
86
 
88
- const body = expanded
89
- ? <div className="edrv-review-body">
90
- {pendingRegions.map((region) => {
91
- const key = callIdAttr(region.callId, region.idx)
92
- return <div key={key} className="edrv-diffrow" data-edrv-hunk={key} title={'跳转到 L' + (region.start ?? '?')} onClick={() => props.onJump?.(region)}>
93
- <span className="edrv-diffrow-l">{'L' + (region.start ?? '?') + '-' + (region.end ?? '?')}</span>
94
- {(region.oldLines?.length ?? 0) > 0 ? <span className="edrv-diffrow-o">{'-' + (region.oldLines?.length ?? 0)}</span> : null}
95
- {(region.newLines?.length ?? 0) > 0 ? <span className="edrv-diffrow-n">{'+' + (region.newLines?.length ?? 0)}</span> : null}
96
- {region.create ? <span className="edrv-diffrow-tag">新建</span> : null}
97
- </div>
98
- })}
99
- {staleRegions.map((region) => <div key={'stale' + callIdAttr(region.callId, region.idx)} className="edrv-diffrow edrv-diffrow-stale" title="差异无法定位(文件可能已被手动修改)">
100
- <span className="edrv-diffrow-l">{'L' + (region.start ?? '?') + ' 无法定位'}</span>
101
- <span className="edrv-diffrow-tag">冲突/需刷新</span>
102
- <span className="edrv-diffrow-tag">{badgeOf(region.status ?? '')}</span>
103
- </div>)}
104
- </div>
105
- : null
106
-
107
87
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="sidecar" role="region" aria-label="文件差异审查">
108
88
  <ReviewAllRow
109
89
  total={props.fileTotal ?? 0}
@@ -141,7 +121,6 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
141
121
  <div className="edrv-review-actions">
142
122
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={!canDecide} title={props.missingDeletion ? '恢复展示的完整历史快照(不会覆盖重新出现的文件)' : SIDECAR_UNDO_TITLE}>Undo</Button>
143
123
  <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={!canDecide} title={props.missingDeletion ? '保持文件删除并结清覆盖的待审历史记录,不写文件' : SIDECAR_KEEP_TITLE}>Keep</Button>
144
- <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
145
124
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }, { id: 'rollback', label: '回滚当前文件' }, { id: 'refresh', label: '刷新' }]} onSelect={(id: string) => {
146
125
  if (id === 'rollback') { setMenu(null); props.onRollback?.(); return }
147
126
  if (id === 'refresh') { setMenu(null); props.onRefresh?.(); return }
@@ -151,6 +130,5 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
151
130
  </div>
152
131
  {view.notice ? <div className={view.noticeTone === 'warn' ? 'edrv-review-notice edrv-review-notice-warn' : 'edrv-review-notice'} role="status">{view.notice}</div> : null}
153
132
  {props.reviewError ? <div className={props.reviewErrorTone === 'info' ? 'edrv-review-notice' : 'edrv-review-notice edrv-review-error'} role={props.reviewErrorTone === 'info' ? 'status' : 'alert'}>{props.reviewError}</div> : null}
154
- {body}
155
133
  </div>
156
134
  }
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@ import { registerRoutes } from './routes.js'
3
3
  import { captureToolResult } from './capture.js'
4
4
  import { installCapture } from './officialCapture.js'
5
5
  import { installEvidence } from './reviewCapture.js'
6
+ import { installPendingCapture } from './pending/capture.js'
7
+ import { recordWrite } from './pending/store.js'
8
+ import { trackPendingCover } from './pendingCompat.js'
6
9
  import { closeArchiveDbs } from './archiveDb.js'
7
10
  import { handleRpc, integrationBaseUrlOf } from './rpc.js'
8
11
  import { newSearcher } from './search/orchestrator.js'
@@ -71,6 +74,11 @@ function createRuntime(ctx: Ctx, config: unknown) {
71
74
  function attachRuntime(ctx: Ctx, runtime: Runtime): void {
72
75
  const evidence = installEvidence(ctx)
73
76
  const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
77
+ // 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
78
+ const pending = installPendingCapture(ctx, (write) => {
79
+ try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
80
+ try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
81
+ })
74
82
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
75
83
  function onResult(exec: unknown, result: unknown): void {
76
84
  void captureToolResult(ctx, runtime.registry, exec, result)
@@ -90,6 +98,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
90
98
  async function cleanup(): Promise<void> {
91
99
  await stopCapture()
92
100
  evidence.dispose()
101
+ pending.dispose()
93
102
  runtime.fileVersions.dispose()
94
103
  closeArchiveDbs()
95
104
  disposeAllServers()