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.
- package/client.js +18 -32
- package/index.js +105 -23
- 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
|
|
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
|
|
212
|
+
var workspaceSvc = scope.uiWorkspace
|
|
235
213
|
var conversationSvc = scope.conversation
|
|
236
214
|
return {
|
|
237
215
|
openRestoredSession: function (newSessionId, draftText) {
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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', '
|
|
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 = [
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
801
|
+
boundary,
|
|
773
802
|
turn,
|
|
774
803
|
cwd,
|
|
775
|
-
reason:
|
|
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
|
-
|
|
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(
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
scope.
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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 &&
|
|
1357
|
-
const live = this.buildLiveManifest(
|
|
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
|