@anionex/dsh-turn-rewind 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +182 -0
  3. package/README.zh.md +180 -0
  4. package/SECURITY.md +53 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/FORMAT.md +101 -0
  7. package/docs/IMPLEMENTATION_PLAN.md +56 -0
  8. package/docs/assets/turn-rewind-action.png +0 -0
  9. package/docs/assets/turn-rewind-dialog.png +0 -0
  10. package/lib/client.js +473 -0
  11. package/lib/client.js.map +1 -0
  12. package/lib/engine.js +616 -0
  13. package/lib/errors.js +20 -0
  14. package/lib/git.js +155 -0
  15. package/lib/index.js +71 -0
  16. package/lib/path-utils.js +262 -0
  17. package/lib/rewind-host.js +421 -0
  18. package/lib/snapshot.js +183 -0
  19. package/lib/store.js +391 -0
  20. package/lib/types/client/index.d.ts +81 -0
  21. package/lib/types/engine.d.ts +90 -0
  22. package/lib/types/errors.d.ts +15 -0
  23. package/lib/types/git.d.ts +14 -0
  24. package/lib/types/index.d.ts +43 -0
  25. package/lib/types/path-utils.d.ts +34 -0
  26. package/lib/types/rewind-host.d.ts +137 -0
  27. package/lib/types/snapshot.d.ts +35 -0
  28. package/lib/types/store.d.ts +39 -0
  29. package/lib/types/types.d.ts +177 -0
  30. package/lib/types/validate.d.ts +11 -0
  31. package/lib/types.js +2 -0
  32. package/lib/validate.js +242 -0
  33. package/package.json +91 -0
  34. package/src/client/index.tsx +648 -0
  35. package/src/client/platform.d.ts +27 -0
  36. package/src/engine.ts +770 -0
  37. package/src/errors.ts +23 -0
  38. package/src/git.ts +178 -0
  39. package/src/index.ts +100 -0
  40. package/src/path-utils.ts +255 -0
  41. package/src/rewind-host.ts +584 -0
  42. package/src/snapshot.ts +229 -0
  43. package/src/store.ts +383 -0
  44. package/src/types.ts +195 -0
  45. package/src/validate.ts +245 -0
@@ -0,0 +1,648 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import {
4
+ Button,
5
+ Modal,
6
+ Tooltip,
7
+ } from '@deepseek-ai/dsh-client-ui-primitives'
8
+
9
+ interface ConversationNodeLike {
10
+ readonly kind: string
11
+ readonly seq: number
12
+ readonly content?: readonly { readonly type: string; readonly text?: string }[]
13
+ }
14
+
15
+ interface ConversationChatNodeLike {
16
+ readonly key: string
17
+ readonly kind: string
18
+ readonly data: ConversationNodeLike
19
+ }
20
+
21
+ interface ConversationSnapshotLike {
22
+ readonly nodes: readonly ConversationNodeLike[]
23
+ readonly chat?: {
24
+ readonly nodes: {
25
+ values(): readonly ConversationChatNodeLike[]
26
+ }
27
+ }
28
+ }
29
+
30
+ type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike
31
+
32
+ interface RewindMatch {
33
+ readonly messageSeq: number
34
+ readonly promptText: string
35
+ }
36
+
37
+ interface RewindMessageActionProps {
38
+ readonly matched: RewindMatch
39
+ readonly sessionId: string
40
+ readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
41
+ }
42
+
43
+ interface RewindPortalBridgeProps {
44
+ readonly sessionId: string
45
+ readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
46
+ readonly useSession: <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T
47
+ }
48
+
49
+ interface RewindPortalTarget {
50
+ readonly container: HTMLElement
51
+ readonly matched: RewindMatch
52
+ }
53
+
54
+ interface SlotsLike {
55
+ inject(name: string, install: () => unknown): void
56
+ register(
57
+ entry: {
58
+ readonly name: string
59
+ readonly id: string
60
+ readonly order: number
61
+ readonly inject: () => {
62
+ readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
63
+ }
64
+ },
65
+ component: (props: RewindPortalBridgeProps) => ReactNode,
66
+ ): () => void
67
+ }
68
+
69
+ interface ClientContextLike {
70
+ readonly slots: SlotsLike
71
+ readonly sessions: {
72
+ open(sessionId: string): void
73
+ scope(sessionId: string): unknown | undefined
74
+ }
75
+ readonly conversation: {
76
+ readonly input: {
77
+ for(scope: unknown): { setDraft(text: string): void }
78
+ }
79
+ }
80
+ effect(setup: () => (() => void), label?: string): unknown
81
+ }
82
+
83
+ type RewindMode = 'both' | 'code'
84
+ type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed'
85
+
86
+ interface ReadyPreview {
87
+ readonly status: 'ready'
88
+ readonly sessionId: string
89
+ readonly messageSeq: number
90
+ readonly turn: number
91
+ readonly checkpointId: string
92
+ readonly turnStartSeq: number
93
+ readonly totalChanges: number
94
+ readonly changes: readonly { readonly path: string; readonly kind: ChangeKind }[]
95
+ readonly offset: number
96
+ readonly truncated: boolean
97
+ readonly headChanged: boolean
98
+ readonly operationChanged: boolean
99
+ readonly checkpointHead?: string
100
+ readonly checkpointBranch?: string
101
+ readonly checkpointOperation?: string
102
+ readonly currentHead?: string
103
+ readonly currentBranch?: string
104
+ readonly currentOperation?: string
105
+ readonly activeSessionIds: readonly string[]
106
+ readonly restoreBlocked: boolean
107
+ readonly planId?: string
108
+ readonly confirmation?: string
109
+ }
110
+
111
+ type Preview = ReadyPreview
112
+ | { readonly status: 'pending' }
113
+ | { readonly status: 'missing' }
114
+ | { readonly status: 'failed'; readonly error: string }
115
+
116
+ const PATH = '/turn-rewind'
117
+ const STYLE_ID = '@anionex/dsh-turn-rewind'
118
+ const styles = `
119
+ .dcl-rewind-tail{display:inline-flex;align-items:center;align-self:center;order:0;height:24px;margin-left:2px}
120
+ .dcl-rewind-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}
121
+ .dcl-rewind-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
122
+ .dcl-rewind-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px)}
123
+ .dcl-rewind-content{min-width:0;min-height:0;overflow-y:auto;overscroll-behavior:contain}
124
+ .dcl-rewind-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}
125
+ .dcl-rewind-options{display:flex;flex-direction:column;gap:8px;min-width:0;max-width:100%}
126
+ .dcl-rewind-option{display:flex;align-items:flex-start;gap:10px;width:100%;min-width:0;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);cursor:pointer}
127
+ .dcl-rewind-option[data-selected="true"]{border-color:var(--dsw-alias-state-business-primary)}
128
+ .dcl-rewind-option[data-disabled="true"]{cursor:not-allowed;opacity:.52}
129
+ .dcl-rewind-option input{flex:none;margin:2px 0 0}
130
+ .dcl-rewind-option-content{display:block;flex:1;min-width:0}
131
+ .dcl-rewind-option strong{display:block;color:var(--dsw-alias-label-primary);font-size:14px}
132
+ .dcl-rewind-option-description{display:block;margin-top:3px;overflow-wrap:anywhere;word-break:break-word;color:var(--dsw-alias-label-tertiary);font-size:12px}
133
+ .dcl-rewind-summary{display:flex;flex-wrap:wrap;column-gap:16px;row-gap:4px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:13px}
134
+ .dcl-rewind-files{min-width:0;max-width:100%;box-sizing:border-box;max-height:220px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}
135
+ .dcl-rewind-file{display:flex;justify-content:space-between;gap:16px;min-width:0;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}
136
+ .dcl-rewind-file:last-child{border-bottom:0}.dcl-rewind-file code{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary)}
137
+ .dcl-rewind-kind{flex:none;color:var(--dsw-alias-label-tertiary)}
138
+ .dcl-rewind-file-actions{display:flex;justify-content:flex-start}
139
+ .dcl-rewind-status{margin:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
140
+ .dcl-rewind-warning,.dcl-rewind-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border-radius:10px;font-size:12px;line-height:18px}
141
+ .dcl-rewind-warning{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary)}
142
+ .dcl-rewind-error{border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);color:var(--dsw-alias-state-error-primary)}
143
+ .dcl-rewind-backup{box-sizing:border-box;margin:0;padding:10px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
144
+ .dcl-rewind-retry{align-self:flex-start}
145
+ `
146
+
147
+ /** Return the rewind anchor and editable text owned by one direct user message. */
148
+ export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | null {
149
+ if (node.kind !== 'user' || !Number.isSafeInteger(node.seq) || node.seq < 0) return null
150
+ const promptText = (node.content ?? [])
151
+ .filter((block): block is { readonly type: string; readonly text: string } => block.type === 'text' && typeof block.text === 'string')
152
+ .map(block => block.text)
153
+ .join('\n')
154
+ return { messageSeq: node.seq, promptText }
155
+ }
156
+
157
+ /** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
158
+ export const inject = ['slots', 'sessions', 'conversation']
159
+ export function apply(ctx: ClientContextLike): void {
160
+ ctx.effect(() => {
161
+ if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
162
+ const tag = document.createElement('style')
163
+ tag.dataset.plugin = '@anionex/dsh-turn-rewind'
164
+ tag.dataset.pluginCss = STYLE_ID
165
+ tag.textContent = styles
166
+ document.head.appendChild(tag)
167
+ return () => { tag.remove() }
168
+ }, 'turn-rewind: styles')
169
+ ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
170
+ name: 'conversation.session.header.actions',
171
+ id: 'turn-rewind-portals',
172
+ order: 100,
173
+ inject: () => ({
174
+ openRestoredSession: async (sessionId: string, promptText: string) => {
175
+ await openSessionWithDraft(ctx, sessionId, promptText)
176
+ },
177
+ }),
178
+ }, RewindMessagePortals))
179
+ }
180
+
181
+ /** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
182
+ export function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode {
183
+ const nodes = useSession<readonly RewindNodeLike[]>(snapshot => snapshot.chat?.nodes.values() ?? snapshot.nodes)
184
+ const [targets, setTargets] = useState<readonly RewindPortalTarget[]>([])
185
+
186
+ useLayoutEffect(() => {
187
+ let active = true
188
+ let queued = false
189
+ const refresh = (): void => {
190
+ if (!active) return
191
+ const next = collectPortalTargets(nodes)
192
+ setTargets(current => samePortalTargets(current, next) ? current : next)
193
+ }
194
+ const queueRefresh = (): void => {
195
+ if (queued || !active) return
196
+ queued = true
197
+ queueMicrotask(() => {
198
+ queued = false
199
+ refresh()
200
+ })
201
+ }
202
+ refresh()
203
+ const observer = new MutationObserver(queueRefresh)
204
+ observer.observe(document.body, { childList: true, subtree: true })
205
+ return () => {
206
+ active = false
207
+ observer.disconnect()
208
+ }
209
+ }, [nodes])
210
+
211
+ return targets.map(target => createPortal(
212
+ <RewindMessageAction matched={target.matched} sessionId={sessionId} openRestoredSession={openRestoredSession} />,
213
+ target.container,
214
+ `${sessionId}:${String(target.matched.messageSeq)}`,
215
+ ))
216
+ }
217
+
218
+ /** User-message action and its review-first file/conversation restore dialog. */
219
+ export function RewindMessageAction({ matched, sessionId, openRestoredSession }: RewindMessageActionProps): ReactNode {
220
+ const [open, setOpen] = useState(false)
221
+ const [loading, setLoading] = useState(false)
222
+ const [preview, setPreview] = useState<Preview | null>(null)
223
+ const [mode, setMode] = useState<RewindMode>('both')
224
+ const [applying, setApplying] = useState(false)
225
+ const [loadingDetails, setLoadingDetails] = useState(false)
226
+ const [stale, setStale] = useState(false)
227
+ const [error, setError] = useState<string | null>(null)
228
+ const [completed, setCompleted] = useState<string | null>(null)
229
+ const loadAbort = useRef<AbortController | null>(null)
230
+ const applyPending = useRef(false)
231
+
232
+ useEffect(() => () => {
233
+ loadAbort.current?.abort()
234
+ loadAbort.current = null
235
+ }, [])
236
+
237
+ const load = useCallback(async () => {
238
+ loadAbort.current?.abort()
239
+ const controller = new AbortController()
240
+ loadAbort.current = controller
241
+ setLoading(true)
242
+ setStale(false)
243
+ setError(null)
244
+ setCompleted(null)
245
+ try {
246
+ const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}`, {
247
+ method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store', signal: controller.signal,
248
+ })
249
+ const value = await responseJson(response)
250
+ if (loadAbort.current === controller) setPreview(decodePreview(value))
251
+ } catch (caught) {
252
+ if (!controller.signal.aborted) setError(messageOf(caught))
253
+ } finally {
254
+ if (loadAbort.current === controller) {
255
+ loadAbort.current = null
256
+ setLoading(false)
257
+ }
258
+ }
259
+ }, [matched.messageSeq, sessionId])
260
+
261
+ const show = (): void => {
262
+ setOpen(true)
263
+ setPreview(null)
264
+ setMode('both')
265
+ setStale(false)
266
+ void load()
267
+ }
268
+ const close = (): void => {
269
+ if (applying) return
270
+ loadAbort.current?.abort()
271
+ loadAbort.current = null
272
+ setLoading(false)
273
+ setOpen(false)
274
+ }
275
+ const chooseMode = (next: RewindMode): void => {
276
+ if (applying) return
277
+ setMode(next)
278
+ setError(null)
279
+ setCompleted(null)
280
+ }
281
+ const ready = preview?.status === 'ready' ? preview : null
282
+ const hasFileChanges = ready !== null && ready.totalChanges > 0
283
+ const driftBlocked = hasFileChanges && ready?.operationChanged === true
284
+ const sharedBlocked = (ready?.activeSessionIds.length ?? 0) > 0
285
+ const planMissing = hasFileChanges && ready !== null && !sharedBlocked && !driftBlocked
286
+ && (ready.planId === undefined || ready.confirmation === undefined)
287
+ const canApply = ready !== null
288
+ && !loading
289
+ && !applying
290
+ && !loadingDetails
291
+ && completed === null
292
+ && hasFileChanges
293
+ && !driftBlocked
294
+ && !sharedBlocked
295
+ && !planMissing
296
+ && !stale
297
+
298
+ const loadAllChanges = async (): Promise<void> => {
299
+ if (ready === null || loadingDetails || !ready.truncated) return
300
+ setLoadingDetails(true)
301
+ setError(null)
302
+ try {
303
+ const collected = [...ready.changes]
304
+ let offset = collected.length
305
+ while (offset < ready.totalChanges) {
306
+ const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}&details=1&offset=${String(offset)}&limit=200`, {
307
+ method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store',
308
+ })
309
+ const page = decodePreview(await responseJson(response))
310
+ if (page.status !== 'ready'
311
+ || page.checkpointId !== ready.checkpointId
312
+ || page.totalChanges !== ready.totalChanges
313
+ || page.offset !== offset) {
314
+ throw new RewindRequestError('PLAN_STALE', '项目文件在展开列表时发生了变化。')
315
+ }
316
+ collected.push(...page.changes)
317
+ offset += page.changes.length
318
+ if (page.changes.length === 0) break
319
+ }
320
+ if (offset !== ready.totalChanges) throw new RewindRequestError('PLAN_STALE', '无法读取完整的文件列表。')
321
+ setPreview({ ...ready, changes: collected, truncated: false })
322
+ } catch (caught) {
323
+ if (caught instanceof RewindRequestError && caught.code === 'PLAN_STALE') setStale(true)
324
+ setError(friendlyError(caught))
325
+ } finally {
326
+ setLoadingDetails(false)
327
+ }
328
+ }
329
+
330
+ const applyRestore = async (): Promise<void> => {
331
+ if (ready === null || !canApply || applyPending.current) return
332
+ const body: Record<string, unknown> = {
333
+ mode,
334
+ sessionId,
335
+ messageSeq: ready.messageSeq,
336
+ checkpointId: ready.checkpointId,
337
+ }
338
+ if (ready.planId === undefined || ready.confirmation === undefined) return
339
+ body.planId = ready.planId
340
+ body.confirmation = ready.confirmation
341
+ applyPending.current = true
342
+ setApplying(true)
343
+ setError(null)
344
+ try {
345
+ const response = await fetch(PATH, {
346
+ method: 'POST',
347
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
348
+ body: JSON.stringify(body),
349
+ })
350
+ const result = recordOf(await responseJson(response))
351
+ const resultMode = requiredString(result.mode, 'mode')
352
+ if (resultMode !== mode) throw new Error(`服务器返回了不匹配的回退模式:${resultMode}`)
353
+ if (mode === 'code') {
354
+ requiredString(result.rescuePointId, 'rescuePointId')
355
+ setCompleted('项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。')
356
+ return
357
+ }
358
+ const childSessionId = requiredString(result.sessionId, 'sessionId')
359
+ requiredString(result.rescuePointId, 'rescuePointId')
360
+ setCompleted('项目文件已恢复,并已创建新对话。恢复前的文件已自动备份。')
361
+ try {
362
+ await openRestoredSession(childSessionId, matched.promptText)
363
+ setOpen(false)
364
+ } catch (navigationError) {
365
+ setError(`文件已经恢复,新对话也已创建,但没能自动打开:${messageOf(navigationError)}`)
366
+ }
367
+ } catch (caught) {
368
+ if (caught instanceof RewindRequestError && (caught.code === 'PLAN_STALE' || caught.code === 'WORKSPACE_IN_USE')) {
369
+ setStale(true)
370
+ }
371
+ setError(friendlyError(caught))
372
+ } finally {
373
+ applyPending.current = false
374
+ setApplying(false)
375
+ }
376
+ }
377
+
378
+ const actionLabel = mode === 'both' ? '恢复并从这里继续' : '恢复文件'
379
+ const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
380
+ const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
381
+
382
+ return (
383
+ <div className="dcl-rewind-tail">
384
+ <Tooltip label="恢复到发送这条消息之前" side="bottom">
385
+ <button type="button" className="dcl-rewind-trigger" onClick={show} aria-label="恢复到发送这条消息之前">
386
+ <RewindIcon size={16} />
387
+ </button>
388
+ </Tooltip>
389
+ <Modal
390
+ open={open}
391
+ onClose={close}
392
+ title="恢复到发送这条消息之前"
393
+ closeLabel="关闭"
394
+ description="查看恢复的文件,选择适合你的回退方式。当前会话不受影响。"
395
+ className="dcl-rewind-dialog"
396
+ contentClassName="dcl-rewind-content"
397
+ footer={(
398
+ <>
399
+ <Button variant="outline" onClick={close} disabled={applying}>取消</Button>
400
+ <Button variant="primary" onClick={() => { void applyRestore() }} disabled={!canApply}>
401
+ {applying ? '正在恢复…' : completed === null ? actionLabel : '已完成'}
402
+ </Button>
403
+ </>
404
+ )}
405
+ >
406
+ <div className="dcl-rewind-body">
407
+ {loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
408
+ {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
409
+ {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能,或记录已超过保留期限。</p>}
410
+ {preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}</p>}
411
+ {ready !== null && (
412
+ <>
413
+ <div className="dcl-rewind-options">
414
+ <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying}>
415
+ <input type="radio" name={radioName} checked={mode === 'both'} disabled={applying} onChange={() => { chooseMode('both') }} />
416
+ <span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
417
+ </label>
418
+ <label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying}>
419
+ <input type="radio" name={radioName} checked={mode === 'code'} disabled={applying} onChange={() => { chooseMode('code') }} />
420
+ <span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
421
+ </label>
422
+ </div>
423
+ <div className="dcl-rewind-summary">
424
+ <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>
425
+ <span>{mode === 'both' ? '恢复后在新对话里继续' : '当前对话保持不变'}</span>
426
+ </div>
427
+ {sharedBlocked && (
428
+ <p className="dcl-rewind-error">这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。</p>
429
+ )}
430
+ {ready.headChanged && !ready.operationChanged && (
431
+ <p className="dcl-rewind-warning">{branchChanged
432
+ ? '当前所在的 Git 分支和发送这条消息时不同。恢复不会切换分支,只会把当时的文件内容恢复到当前分支。'
433
+ : '这条消息之后有了新的 Git 提交。恢复只会改文件,不会撤销提交;完成后这些文件会显示为未提交修改。'}</p>
434
+ )}
435
+ {driftBlocked && <p className="dcl-rewind-warning">Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。</p>}
436
+ {planMissing && <p className="dcl-rewind-error">恢复信息已经失效,请重新检查。</p>}
437
+ {stale && <p className="dcl-rewind-error">项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。</p>}
438
+ {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。</p>}
439
+ {ready.changes.length > 0 && (
440
+ <div className="dcl-rewind-files">
441
+ {ready.changes.map(change => <div className="dcl-rewind-file" key={change.path}><code>{change.path}</code><span className="dcl-rewind-kind">{fileRecoveryLabel(change.kind)}</span></div>)}
442
+ </div>
443
+ )}
444
+ {ready.truncated && (
445
+ <div className="dcl-rewind-file-actions"><Button variant="outline" size="sm" onClick={() => { void loadAllChanges() }} disabled={loadingDetails}>{loadingDetails ? '正在读取全部文件…' : `查看全部 ${String(ready.totalChanges)} 个文件`}</Button></div>
446
+ )}
447
+ </>
448
+ )}
449
+ {completed !== null && <p className="dcl-rewind-status">{completed}</p>}
450
+ {error !== null && <p className="dcl-rewind-error">{error}</p>}
451
+ {error !== null && <p className="dcl-rewind-backup">恢复前会自动备份当前文件;若恢复失败会自动还原,项目不会停留在只恢复了一部分的状态。</p>}
452
+ {!loading && (preview?.status !== 'ready' || stale || planMissing || sharedBlocked || driftBlocked) && <Button className="dcl-rewind-retry" variant="outline" size="sm" onClick={() => { void load() }}>重新检查</Button>}
453
+ </div>
454
+ </Modal>
455
+ </div>
456
+ )
457
+ }
458
+
459
+ function decodePreview(value: unknown): Preview {
460
+ const record = recordOf(value)
461
+ const status = requiredString(record.status, 'status')
462
+ if (status === 'pending' || status === 'missing') return { status }
463
+ if (status === 'failed') return { status, error: requiredString(record.error, 'error') }
464
+ if (status !== 'ready') throw new Error(`未知回退状态:${status}`)
465
+ const changesValue = record.changes
466
+ if (!Array.isArray(changesValue)) throw new Error('回退预览缺少 changes')
467
+ const changes = changesValue.map((entry) => {
468
+ const change = recordOf(entry)
469
+ return { path: requiredString(change.path, 'path'), kind: requiredString(change.kind, 'kind') as ChangeKind }
470
+ })
471
+ const activeSessionIdsValue = record.activeSessionIds
472
+ if (!Array.isArray(activeSessionIdsValue) || !activeSessionIdsValue.every(value => typeof value === 'string')) {
473
+ throw new Error('回退预览缺少 activeSessionIds')
474
+ }
475
+ return {
476
+ status,
477
+ sessionId: requiredString(record.sessionId, 'sessionId'),
478
+ messageSeq: requiredInteger(record.messageSeq, 'messageSeq'),
479
+ turn: requiredInteger(record.turn, 'turn'),
480
+ checkpointId: requiredString(record.checkpointId, 'checkpointId'),
481
+ turnStartSeq: requiredInteger(record.turnStartSeq, 'turnStartSeq'),
482
+ totalChanges: requiredInteger(record.totalChanges, 'totalChanges'),
483
+ changes,
484
+ offset: requiredInteger(record.offset, 'offset'),
485
+ truncated: requiredBoolean(record.truncated, 'truncated'),
486
+ headChanged: requiredBoolean(record.headChanged, 'headChanged'),
487
+ operationChanged: requiredBoolean(record.operationChanged, 'operationChanged'),
488
+ ...optionalRecordString(record, 'checkpointHead'),
489
+ ...optionalRecordString(record, 'checkpointBranch'),
490
+ ...optionalRecordString(record, 'checkpointOperation'),
491
+ ...optionalRecordString(record, 'currentHead'),
492
+ ...optionalRecordString(record, 'currentBranch'),
493
+ ...optionalRecordString(record, 'currentOperation'),
494
+ activeSessionIds: activeSessionIdsValue as string[],
495
+ restoreBlocked: requiredBoolean(record.restoreBlocked, 'restoreBlocked'),
496
+ ...(typeof record.planId === 'string' ? { planId: record.planId } : {}),
497
+ ...(typeof record.confirmation === 'string' ? { confirmation: record.confirmation } : {}),
498
+ }
499
+ }
500
+
501
+ /** Resolve one conversation node to its DOM row key and rewind match. */
502
+ export function selectRewindMessageTarget(value: RewindNodeLike): { readonly matched: RewindMatch; readonly rowKey: string } | null {
503
+ const node = 'key' in value && 'data' in value ? value.data : value
504
+ const matched = selectRewindMessage(node)
505
+ if (matched === null) return null
506
+ return {
507
+ matched,
508
+ rowKey: 'key' in value && 'data' in value ? value.key : `node:${String(node.seq)}`,
509
+ }
510
+ }
511
+
512
+ function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly RewindPortalTarget[] {
513
+ const rows = new Map<string, HTMLElement>()
514
+ for (const element of Array.from(document.querySelectorAll<HTMLElement>(
515
+ '[data-chat-flow-kind="user"][data-chat-anchor-key]',
516
+ ))) {
517
+ const key = element.dataset.chatAnchorKey
518
+ if (key !== undefined) rows.set(key, element)
519
+ }
520
+ const targets: RewindPortalTarget[] = []
521
+ for (const value of nodes) {
522
+ const target = selectRewindMessageTarget(value)
523
+ if (target === null) continue
524
+ const row = rows.get(target.rowKey)
525
+ const messageRoot = row?.querySelector<HTMLElement>('[data-time-hover-root="true"]')
526
+ const actions = messageRoot?.lastElementChild
527
+ if (!(actions instanceof HTMLElement) || actions.querySelector(':scope > button') === null) continue
528
+ targets.push({ container: actions, matched: target.matched })
529
+ }
530
+ return targets
531
+ }
532
+
533
+ function samePortalTargets(
534
+ left: readonly RewindPortalTarget[],
535
+ right: readonly RewindPortalTarget[],
536
+ ): boolean {
537
+ return left.length === right.length && left.every((target, index) => {
538
+ const other = right[index]
539
+ return other !== undefined
540
+ && target.container === other.container
541
+ && target.matched.messageSeq === other.matched.messageSeq
542
+ && target.matched.promptText === other.matched.promptText
543
+ })
544
+ }
545
+
546
+ async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, promptText: string): Promise<void> {
547
+ let lastError: unknown = new Error('新对话还没有准备好')
548
+ for (let attempt = 0; attempt < 20; attempt += 1) {
549
+ try {
550
+ ctx.sessions.open(sessionId)
551
+ const scope = ctx.sessions.scope(sessionId)
552
+ if (scope !== undefined) {
553
+ ctx.conversation.input.for(scope).setDraft(promptText)
554
+ return
555
+ }
556
+ lastError = new Error('新对话还没有准备好')
557
+ } catch (error) {
558
+ lastError = error
559
+ }
560
+ await new Promise<void>(resolve => { setTimeout(resolve, 50) })
561
+ }
562
+ throw lastError
563
+ }
564
+
565
+ async function responseJson(response: Response): Promise<unknown> {
566
+ const value = await response.json() as unknown
567
+ if (!response.ok) {
568
+ const record = recordOf(value)
569
+ throw new RewindRequestError(
570
+ typeof record.code === 'string' ? record.code : 'REWIND_FAILED',
571
+ typeof record.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
572
+ )
573
+ }
574
+ return value
575
+ }
576
+
577
+ class RewindRequestError extends Error {
578
+ constructor(readonly code: string, message: string) {
579
+ super(message)
580
+ }
581
+ }
582
+
583
+ function recordOf(value: unknown): Record<string, unknown> {
584
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('服务器返回了无效对象')
585
+ return value as Record<string, unknown>
586
+ }
587
+
588
+ function requiredString(value: unknown, name: string): string {
589
+ if (typeof value !== 'string' || value === '') throw new Error(`${name} 无效`)
590
+ return value
591
+ }
592
+
593
+ function requiredInteger(value: unknown, name: string): number {
594
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} 无效`)
595
+ return value as number
596
+ }
597
+
598
+ function requiredBoolean(value: unknown, name: string): boolean {
599
+ if (typeof value !== 'boolean') throw new Error(`${name} 无效`)
600
+ return value
601
+ }
602
+
603
+ function optionalRecordString(record: Record<string, unknown>, name: string): Record<string, string> {
604
+ const value = record[name]
605
+ if (value === undefined) return {}
606
+ return { [name]: requiredString(value, name) }
607
+ }
608
+
609
+ /** Describe the user-visible result of restoring one changed file. */
610
+ export function fileRecoveryLabel(kind: ChangeKind): string {
611
+ switch (kind) {
612
+ case 'added': return '移除后来新增的文件'
613
+ case 'deleted': return '找回文件'
614
+ case 'modified': return '恢复之前的版本'
615
+ case 'mode-changed': return '恢复文件权限'
616
+ case 'type-changed': return '恢复之前的文件类型'
617
+ }
618
+ }
619
+
620
+ function RewindIcon({ size }: { readonly size: number }): ReactNode {
621
+ return (
622
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
623
+ <path d="M6.35 3.25 2.75 7l3.6 3.75M3.1 7h5.15a4.25 4.25 0 0 1 4.25 4.25v1.25" stroke="currentColor" strokeWidth="1.45" strokeLinecap="round" strokeLinejoin="round" />
624
+ </svg>
625
+ )
626
+ }
627
+
628
+ function friendlyError(error: unknown): string {
629
+ if (!(error instanceof RewindRequestError)) return messageOf(error)
630
+ switch (error.code) {
631
+ case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
632
+ case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
633
+ case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
634
+ case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
635
+ case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
636
+ case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
637
+ case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
638
+ case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
639
+ case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。'
640
+ case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
641
+ case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
642
+ default: return error.message
643
+ }
644
+ }
645
+
646
+ function messageOf(error: unknown): string {
647
+ return error instanceof Error ? error.message : String(error)
648
+ }
@@ -0,0 +1,27 @@
1
+ declare module '@deepseek-ai/dsh-client-ui-primitives' {
2
+ import type { ButtonHTMLAttributes, ComponentType, ReactNode } from 'react'
3
+
4
+ export const Tooltip: ComponentType<{ label: string; side?: string; children: ReactNode }>
5
+ export const Modal: ComponentType<{
6
+ open: boolean
7
+ onClose: () => void
8
+ title: string
9
+ closeLabel?: string
10
+ description?: string
11
+ children?: ReactNode
12
+ footer?: ReactNode
13
+ className?: string
14
+ contentClassName?: string
15
+ }>
16
+ export const Button: ComponentType<{
17
+ variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
18
+ size?: 'md' | 'sm'
19
+ icon?: ReactNode
20
+ } & ButtonHTMLAttributes<HTMLButtonElement>>
21
+ }
22
+
23
+ declare module 'react-dom' {
24
+ import type { ReactNode } from 'react'
25
+
26
+ export function createPortal(children: ReactNode, container: Element | DocumentFragment, key?: string | null): ReactNode
27
+ }