dsh-turn-undo 0.0.5 → 0.0.7

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 (3) hide show
  1. package/client.js +18 -32
  2. package/index.js +105 -23
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -146,6 +146,10 @@ window.__ModuleLoader__.load({
146
146
  '.dtu-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
147
147
  '.dtu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
148
148
  '.dtu-trigger:disabled{cursor:not-allowed;opacity:.5}',
149
+ // 点击后浏览器保留 :focus,hover 底色会「粘住」不恢复 —— 鼠标移开时
150
+ // 显式清掉 focus/active 态的背景与描边。
151
+ '.dtu-trigger:focus{outline:none}',
152
+ '.dtu-trigger:focus:not(:hover),.dtu-trigger:focus-visible:not(:hover),.dtu-trigger:active:not(:hover){background:transparent;color:var(--dsw-alias-label-tertiary)}',
149
153
  '.dtu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}',
150
154
  '.dtu-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
151
155
  '.dtu-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box;padding:18px}',
@@ -197,33 +201,7 @@ window.__ModuleLoader__.load({
197
201
  document.head.appendChild(styleEl)
198
202
  }
199
203
 
200
- function openRestoredSession(sessionId, draftText) {
201
- try {
202
- ctx.sessions.open(sessionId)
203
- } catch (error) {
204
- console.warn('[turn-undo] openRestoredSession open failed:', error.message)
205
- return
206
- }
207
- if (!draftText) return
208
- function trySetDraft(remaining) {
209
- if (remaining <= 0) return
210
- try {
211
- var scope = ctx.sessions.scope(sessionId)
212
- if (scope !== undefined) {
213
- ctx.conversation.input.for(scope).setDraft(draftText)
214
- return
215
- }
216
- } catch (error) {
217
- if (remaining <= 1) {
218
- console.warn('[turn-undo] openRestoredSession setDraft failed:', error.message)
219
- }
220
- }
221
- setTimeout(function () { trySetDraft(remaining - 1) }, 100)
222
- }
223
- trySetDraft(20)
224
- }
225
-
226
- ctx.inject(['slots', 'sessions', 'conversation'], function (scope) {
204
+ ctx.inject(['slots', 'conversation', 'uiWorkspace'], function (scope) {
227
205
  scope.effect(function () {
228
206
  return scope.slots.inject('conversation.session.header.actions', function () {
229
207
  return scope.slots.register({
@@ -231,13 +209,21 @@ window.__ModuleLoader__.load({
231
209
  id: 'turn-undo-portals',
232
210
  order: 100,
233
211
  inject: function () {
234
- var sessionsSvc = scope.sessions
212
+ var workspaceSvc = scope.uiWorkspace
235
213
  var conversationSvc = scope.conversation
236
214
  return {
237
215
  openRestoredSession: function (newSessionId, draftText) {
238
- // Open first: the input shell only exists after the session binding is materialized.
239
- if (sessionsSvc && sessionsSvc.open) {
240
- sessionsSvc.open(newSessionId)
216
+ // 导航必须走 uiWorkspace:ISessions 没有 open(),
217
+ // 旧的 sessionsSvc.open(...) 永远被 if 守卫静默跳过 —— 这正是
218
+ // 「改名了但新会话没打开」的根因。
219
+ try {
220
+ if (workspaceSvc && typeof workspaceSvc.openSession === 'function') {
221
+ workspaceSvc.openSession(newSessionId)
222
+ } else {
223
+ console.warn('[turn-undo] uiWorkspace.openSession unavailable; new session not opened')
224
+ }
225
+ } catch (e) {
226
+ console.warn('[turn-undo] Failed to open restored session:', e.message)
241
227
  }
242
228
  if (!conversationSvc || !conversationSvc.input || !draftText) return
243
229
  function trySetDraft(remaining) {
@@ -773,7 +759,7 @@ window.__ModuleLoader__.load({
773
759
  }
774
760
 
775
761
  exports.apply = apply
776
- exports.inject = ['slots', 'sessions', 'conversation']
762
+ exports.inject = ['slots', 'conversation', 'uiWorkspace']
777
763
  return module.exports
778
764
  },
779
765
  })
package/index.js CHANGED
@@ -41,8 +41,16 @@ export const name = 'turn-undo'
41
41
  export { SnapshotStore }
42
42
 
43
43
  // Injected services (via ctx.inject in apply()): webServer, sessions,
44
- // sessionQuery, agents, sessionController.
45
- export const inject = ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController']
44
+ // sessionQuery, agents, sessionController, workspaceRegistry.
45
+ export const inject = [
46
+ 'webServer',
47
+ 'sessions',
48
+ 'sessionQuery',
49
+ 'agents',
50
+ 'sessionTitle',
51
+ 'sessionController',
52
+ 'workspaceRegistry',
53
+ ]
46
54
 
47
55
  // ---- Configuration defaults ----
48
56
  const DEFAULT_SNAPSHOT_TTL_DAYS = 7
@@ -270,6 +278,7 @@ class SnapshotStore {
270
278
  sessionId,
271
279
  turn,
272
280
  timestamp: new Date().toISOString(),
281
+ cwd,
273
282
  totalBytes,
274
283
  manifest,
275
284
  }
@@ -538,6 +547,10 @@ class SnapshotStore {
538
547
  */
539
548
  restore(cwd, sessionId, targetTurn) {
540
549
  const chain = this.loadChain(sessionId)
550
+ // Use the cwd from the latest snapshot (captures the workspace root at
551
+ // capture time). This avoids cross-workspace bugs when the session's
552
+ // header.cwd has changed or is wrong (e.g. forked from another workspace).
553
+ const snapCwd = chain.length ? (chain[chain.length - 1].cwd ?? cwd) : cwd
541
554
  // Find the NEWEST snapshot whose turn <= targetTurn (best available state
542
555
  // at/before the boundary). Filter to only completed turns present.
543
556
  let target = null
@@ -551,7 +564,8 @@ class SnapshotStore {
551
564
  }
552
565
 
553
566
  // Restore files to the target manifest state.
554
- const ignore = makeIgnore(cwd, this.excludes)
567
+ const resolveCwd = snapCwd || cwd
568
+ const ignore = makeIgnore(resolveCwd, this.excludes)
555
569
  const manifest = target.manifest
556
570
  const rels = Object.keys(manifest)
557
571
 
@@ -561,7 +575,7 @@ class SnapshotStore {
561
575
  // 1. Write back / refresh files present in target manifest.
562
576
  for (const rel of rels) {
563
577
  const entry = manifest[rel]
564
- const abs = resolve(cwd, rel)
578
+ const abs = resolve(resolveCwd, rel)
565
579
  if (ignore(abs)) continue
566
580
  try {
567
581
  ensureDir(dirname(abs))
@@ -582,11 +596,11 @@ class SnapshotStore {
582
596
  // 2. Delete files present on disk but absent from the target manifest,
583
597
  // except excluded dirs. Only within cwd. Uses a boundary-unlimited walk
584
598
  // (unlike scan, whose caps would silently stop the pruning early).
585
- const current = this.walkAll(cwd, ignore)
599
+ const current = this.walkAll(resolveCwd, ignore)
586
600
  const failedDeletions = []
587
601
  for (const abs of current) {
588
602
  if (ignore(abs)) continue
589
- const rel = relative(cwd, abs).split('\\').join('/')
603
+ const rel = relative(resolveCwd, abs).split('\\').join('/')
590
604
  if (!(rel in manifest)) {
591
605
  try {
592
606
  rmSync(abs, { force: true })
@@ -768,11 +782,26 @@ function resolveForkBoundary(source, messageSeq, promptText) {
768
782
  return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
769
783
  }
770
784
  const previousEnd = events.findLast(e => e.type === 'turn/end' && e.seq < start.seq)
785
+
786
+ // 第一条消息(无前一个 turn/end)也必须 fork:边界取 turn/start 前
787
+ // 最近的一个「索引==seq」有效事件(会话初始化完成处),子会话继承
788
+ // workspace/preset/cwd,而不是 create() 一个空白会话 —— 这正是
789
+ // 「点击撤销后直接创建新会话、没有 fork 出会话」的根因。
790
+ let boundary = previousEnd ? previousEnd.seq : null
791
+ if (boundary !== null && !(events[boundary] && events[boundary].seq === boundary)) {
792
+ boundary = null
793
+ }
794
+ if (boundary === null) {
795
+ const startIdx = events.indexOf(start)
796
+ for (let i = startIdx - 1; i >= 0; i--) {
797
+ if (events[i] && events[i].seq === i) { boundary = i; break }
798
+ }
799
+ }
771
800
  return {
772
- boundary: previousEnd ? previousEnd.seq : null,
801
+ boundary,
773
802
  turn,
774
803
  cwd,
775
- reason: previousEnd ? 'ok' : 'no-previous-end',
804
+ reason: boundary !== null ? 'ok' : 'no-previous-end',
776
805
  }
777
806
  }
778
807
 
@@ -1038,20 +1067,55 @@ async function executeSurfaceRewind(agent, targetSeq) {
1038
1067
  * no completed turn before it (e.g. the very first user message).
1039
1068
  * @returns the new child session id.
1040
1069
  */
1070
+ /**
1071
+ * Find the workspace that owns `sessionId`, so a `cwd`-less `create()` can pass
1072
+ * `workspaceId` and get attached to the workspace catalogue (visible in the
1073
+ * sidebar). Mirrors upstream `SessionController.forkWorkspace`: only sessions
1074
+ * already indexed as live are visible through `WorkspaceRegistry.list()`.
1075
+ * @returns the owning workspace id, or `undefined` when the session is not
1076
+ * indexed (caller then falls back to a `cwd`-only create).
1077
+ */
1078
+ function findWorkspaceIdForSession(ctx, sessionId) {
1079
+ try {
1080
+ const registry = ctx.workspaceRegistry
1081
+ if (!registry || typeof registry.list !== 'function') return undefined
1082
+ for (const workspace of registry.list()) {
1083
+ const ids = workspace && workspace.sessionIds
1084
+ if (Array.isArray(ids) && ids.includes(sessionId)) return workspace.id
1085
+ }
1086
+ } catch (e) {
1087
+ console.warn('[turn-undo] Workspace lookup failed (falling back to cwd):', e.message)
1088
+ }
1089
+ return undefined
1090
+ }
1091
+
1041
1092
  async function forkAndMarkUndone(ctx, sessionId, messageSeq, promptText) {
1042
1093
  const source = await readSession(ctx, sessionId)
1043
1094
  if (!source) throw new Error('source session not found')
1044
1095
  const b = resolveForkBoundary(source, messageSeq, promptText)
1045
1096
 
1097
+ console.info(
1098
+ '[turn-undo] resolveBoundary session=' + sessionId,
1099
+ 'messageSeq=' + messageSeq,
1100
+ 'boundary=' + String(b.boundary),
1101
+ 'turn=' + String(b.turn),
1102
+ 'reason=' + b.reason,
1103
+ )
1046
1104
  let childId
1047
1105
  if (typeof b.boundary === 'number') {
1106
+ // fork() 会自动把子会话 attach 到 source 所在的 workspace(见上游
1107
+ // commands.ts forkWorkspace),所以 fork 分支在侧栏可见。
1048
1108
  const { sessionId: cid } = await ctx.sessionController.fork({ sessionId, atSeq: b.boundary })
1049
1109
  childId = cid
1050
1110
  } else {
1111
+ // create() 只有显式给 workspaceId 才会 attachSession;只给 cwd 的会话
1112
+ // 不会进 workspace 目录,GUI 侧栏看不到 —— 这正是「原会话改名了但没有
1113
+ // 新会话出现」的根因。所以先反查 source 所属 workspace。
1051
1114
  const cwd = source.header?.cwd
1052
1115
  if (!cwd) throw new Error('cannot undo first message without a cwd')
1116
+ const workspaceId = findWorkspaceIdForSession(ctx, sessionId)
1053
1117
  const created = await ctx.sessionController.create({
1054
- cwd,
1118
+ ...(workspaceId !== undefined ? { workspaceId } : { cwd }),
1055
1119
  ...(source.header?.agentPreset ? { agentPreset: source.header.agentPreset } : {}),
1056
1120
  })
1057
1121
  childId = created.sessionId
@@ -1123,7 +1187,20 @@ function createHandler(ctx, runtime, sessions, agents) {
1123
1187
  })
1124
1188
  }
1125
1189
 
1126
- const preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
1190
+ let preview
1191
+ try {
1192
+ preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
1193
+ } catch (e) {
1194
+ logger?.warn?.('[turn-undo] preview failed:', e.message)
1195
+ preview = {
1196
+ ok: false,
1197
+ status: 'preview-failed',
1198
+ error: '预览失败:' + e.message,
1199
+ targetTurn,
1200
+ totalChanges: 0,
1201
+ changes: [],
1202
+ }
1203
+ }
1127
1204
  return json(response, 200, preview)
1128
1205
  }
1129
1206
 
@@ -1260,17 +1337,20 @@ export function apply(ctx, config = {}) {
1260
1337
  })
1261
1338
 
1262
1339
  // HTTP API endpoints.
1263
- ctx.inject(['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController'], (scope) => {
1264
- scope.effect(() => {
1265
- const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
1266
- scope.webServer.register({
1267
- kind: 'exact',
1268
- path: API_PATH,
1269
- handler,
1270
- })
1271
- return () => {}
1272
- }, 'turn-undo: http-api')
1273
- })
1340
+ ctx.inject(
1341
+ ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController', 'workspaceRegistry'],
1342
+ (scope) => {
1343
+ scope.effect(() => {
1344
+ const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
1345
+ scope.webServer.register({
1346
+ kind: 'exact',
1347
+ path: API_PATH,
1348
+ handler,
1349
+ })
1350
+ return () => {}
1351
+ }, 'turn-undo: http-api')
1352
+ },
1353
+ )
1274
1354
  }
1275
1355
 
1276
1356
  /**
@@ -1322,6 +1402,8 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1322
1402
  return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
1323
1403
  }
1324
1404
  const chain = this.loadChain(sessionId)
1405
+ // Use cwd from latest snapshot to avoid cross-workspace path issues
1406
+ const snapCwd = chain.length ? (chain[chain.length - 1].cwd ?? cwd) : cwd
1325
1407
  if (chain.length === 0) {
1326
1408
  return { ok: true, status: 'no-snapshot', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
1327
1409
  }
@@ -1353,8 +1435,8 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1353
1435
  // preview shows meaningful changes even when the active turn has no snapshot.
1354
1436
  let latestManifest = latest.manifest
1355
1437
  let latestIsLive = false
1356
- if (targetTurn > latest.turn && cwd) {
1357
- const live = this.buildLiveManifest(cwd, latest.manifest)
1438
+ if (targetTurn > latest.turn && snapCwd) {
1439
+ const live = this.buildLiveManifest(snapCwd, latest.manifest)
1358
1440
  if (live) {
1359
1441
  latestManifest = live
1360
1442
  latestIsLive = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-undo",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Undo to any point in a DSH conversation — revert files and fork a new session",
5
5
  "type": "module",
6
6
  "main": "index.js",