dsh-turn-undo 0.0.5 → 0.0.6

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 +91 -17
  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
@@ -768,11 +776,26 @@ function resolveForkBoundary(source, messageSeq, promptText) {
768
776
  return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
769
777
  }
770
778
  const previousEnd = events.findLast(e => e.type === 'turn/end' && e.seq < start.seq)
779
+
780
+ // 第一条消息(无前一个 turn/end)也必须 fork:边界取 turn/start 前
781
+ // 最近的一个「索引==seq」有效事件(会话初始化完成处),子会话继承
782
+ // workspace/preset/cwd,而不是 create() 一个空白会话 —— 这正是
783
+ // 「点击撤销后直接创建新会话、没有 fork 出会话」的根因。
784
+ let boundary = previousEnd ? previousEnd.seq : null
785
+ if (boundary !== null && !(events[boundary] && events[boundary].seq === boundary)) {
786
+ boundary = null
787
+ }
788
+ if (boundary === null) {
789
+ const startIdx = events.indexOf(start)
790
+ for (let i = startIdx - 1; i >= 0; i--) {
791
+ if (events[i] && events[i].seq === i) { boundary = i; break }
792
+ }
793
+ }
771
794
  return {
772
- boundary: previousEnd ? previousEnd.seq : null,
795
+ boundary,
773
796
  turn,
774
797
  cwd,
775
- reason: previousEnd ? 'ok' : 'no-previous-end',
798
+ reason: boundary !== null ? 'ok' : 'no-previous-end',
776
799
  }
777
800
  }
778
801
 
@@ -1038,20 +1061,55 @@ async function executeSurfaceRewind(agent, targetSeq) {
1038
1061
  * no completed turn before it (e.g. the very first user message).
1039
1062
  * @returns the new child session id.
1040
1063
  */
1064
+ /**
1065
+ * Find the workspace that owns `sessionId`, so a `cwd`-less `create()` can pass
1066
+ * `workspaceId` and get attached to the workspace catalogue (visible in the
1067
+ * sidebar). Mirrors upstream `SessionController.forkWorkspace`: only sessions
1068
+ * already indexed as live are visible through `WorkspaceRegistry.list()`.
1069
+ * @returns the owning workspace id, or `undefined` when the session is not
1070
+ * indexed (caller then falls back to a `cwd`-only create).
1071
+ */
1072
+ function findWorkspaceIdForSession(ctx, sessionId) {
1073
+ try {
1074
+ const registry = ctx.workspaceRegistry
1075
+ if (!registry || typeof registry.list !== 'function') return undefined
1076
+ for (const workspace of registry.list()) {
1077
+ const ids = workspace && workspace.sessionIds
1078
+ if (Array.isArray(ids) && ids.includes(sessionId)) return workspace.id
1079
+ }
1080
+ } catch (e) {
1081
+ console.warn('[turn-undo] Workspace lookup failed (falling back to cwd):', e.message)
1082
+ }
1083
+ return undefined
1084
+ }
1085
+
1041
1086
  async function forkAndMarkUndone(ctx, sessionId, messageSeq, promptText) {
1042
1087
  const source = await readSession(ctx, sessionId)
1043
1088
  if (!source) throw new Error('source session not found')
1044
1089
  const b = resolveForkBoundary(source, messageSeq, promptText)
1045
1090
 
1091
+ console.info(
1092
+ '[turn-undo] resolveBoundary session=' + sessionId,
1093
+ 'messageSeq=' + messageSeq,
1094
+ 'boundary=' + String(b.boundary),
1095
+ 'turn=' + String(b.turn),
1096
+ 'reason=' + b.reason,
1097
+ )
1046
1098
  let childId
1047
1099
  if (typeof b.boundary === 'number') {
1100
+ // fork() 会自动把子会话 attach 到 source 所在的 workspace(见上游
1101
+ // commands.ts forkWorkspace),所以 fork 分支在侧栏可见。
1048
1102
  const { sessionId: cid } = await ctx.sessionController.fork({ sessionId, atSeq: b.boundary })
1049
1103
  childId = cid
1050
1104
  } else {
1105
+ // create() 只有显式给 workspaceId 才会 attachSession;只给 cwd 的会话
1106
+ // 不会进 workspace 目录,GUI 侧栏看不到 —— 这正是「原会话改名了但没有
1107
+ // 新会话出现」的根因。所以先反查 source 所属 workspace。
1051
1108
  const cwd = source.header?.cwd
1052
1109
  if (!cwd) throw new Error('cannot undo first message without a cwd')
1110
+ const workspaceId = findWorkspaceIdForSession(ctx, sessionId)
1053
1111
  const created = await ctx.sessionController.create({
1054
- cwd,
1112
+ ...(workspaceId !== undefined ? { workspaceId } : { cwd }),
1055
1113
  ...(source.header?.agentPreset ? { agentPreset: source.header.agentPreset } : {}),
1056
1114
  })
1057
1115
  childId = created.sessionId
@@ -1123,7 +1181,20 @@ function createHandler(ctx, runtime, sessions, agents) {
1123
1181
  })
1124
1182
  }
1125
1183
 
1126
- const preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
1184
+ let preview
1185
+ try {
1186
+ preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
1187
+ } catch (e) {
1188
+ logger?.warn?.('[turn-undo] preview failed:', e.message)
1189
+ preview = {
1190
+ ok: false,
1191
+ status: 'preview-failed',
1192
+ error: '预览失败:' + e.message,
1193
+ targetTurn,
1194
+ totalChanges: 0,
1195
+ changes: [],
1196
+ }
1197
+ }
1127
1198
  return json(response, 200, preview)
1128
1199
  }
1129
1200
 
@@ -1260,17 +1331,20 @@ export function apply(ctx, config = {}) {
1260
1331
  })
1261
1332
 
1262
1333
  // 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
- })
1334
+ ctx.inject(
1335
+ ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController', 'workspaceRegistry'],
1336
+ (scope) => {
1337
+ scope.effect(() => {
1338
+ const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
1339
+ scope.webServer.register({
1340
+ kind: 'exact',
1341
+ path: API_PATH,
1342
+ handler,
1343
+ })
1344
+ return () => {}
1345
+ }, 'turn-undo: http-api')
1346
+ },
1347
+ )
1274
1348
  }
1275
1349
 
1276
1350
  /**
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.6",
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",