@anionex/dsh-turn-rewind 0.2.0 → 0.2.2

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": "@anionex/dsh-turn-rewind",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,7 +64,11 @@
64
64
  "dshReleases": {
65
65
  "0.1.0-rc.8": "compatible",
66
66
  "0.1.1-rc.1": "compatible",
67
- "0.1.1-rc.2": "compatible"
67
+ "0.1.1-rc.2": "compatible",
68
+ "0.1.2-alpha.1": "compatible",
69
+ "0.1.2-alpha.3": "compatible",
70
+ "0.1.2-alpha.4": "compatible",
71
+ "0.1.2-alpha.5": "compatible"
68
72
  },
69
73
  "profiles": [
70
74
  "web"
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
4
  Button,
@@ -18,15 +18,33 @@ interface ConversationChatNodeLike {
18
18
  readonly data: ConversationNodeLike
19
19
  }
20
20
 
21
- interface ConversationSnapshotLike {
22
- readonly nodes: readonly ConversationNodeLike[]
23
- readonly chat?: {
24
- readonly nodes: {
25
- values(): readonly ConversationChatNodeLike[]
26
- }
27
- }
21
+ /**
22
+ * Chat node store exposed by the Chat target. DSH 0.1.2+ publishes a mutable
23
+ * keyed store; older clients published a Map with the same `get` contract.
24
+ */
25
+ interface ChatNodeStoreLike {
26
+ get(key: string): RewindNodeLike | undefined
27
+ values?(): Iterable<RewindNodeLike>
28
+ }
29
+
30
+ /** Chat projection shared by `session.chat` (0.1.1) and the `useChat` hook (0.1.2+). */
31
+ interface ChatSnapshotLike {
32
+ readonly order?: readonly string[]
33
+ readonly nodes?: ChatNodeStoreLike | readonly RewindNodeLike[]
34
+ }
35
+
36
+ /**
37
+ * Snapshot seen by a session-scoped slot entry. 0.1.1 exposes the chat
38
+ * projection as `snapshot.chat`; 0.1.2+ moved it to the separate `useChat`
39
+ * hook, so the same snapshot is the chat projection itself.
40
+ */
41
+ interface ConversationSnapshotLike extends ChatSnapshotLike {
42
+ readonly chat?: ChatSnapshotLike
28
43
  }
29
44
 
45
+ /** Selector hook shape shared by `useSession` and `useChat`. */
46
+ type SnapshotSelectorHook = <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T
47
+
30
48
  type RewindNodeLike = ConversationNodeLike | ConversationChatNodeLike
31
49
 
32
50
  interface RewindMatch {
@@ -43,7 +61,8 @@ interface RewindMessageActionProps {
43
61
  interface RewindPortalBridgeProps {
44
62
  readonly sessionId: string
45
63
  readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
46
- readonly useSession: <T>(selector: (snapshot: ConversationSnapshotLike) => T) => T
64
+ readonly useSession: SnapshotSelectorHook
65
+ readonly useChat?: SnapshotSelectorHook
47
66
  }
48
67
 
49
68
  interface RewindPortalTarget {
@@ -281,9 +300,50 @@ export function apply(ctx: ClientContextLike): void {
281
300
  }, TurnRewindSettingsCard))
282
301
  }
283
302
 
303
+ /** Narrow a chat node source to the keyed store form. */
304
+ function isNodeStore(value: ChatNodeStoreLike | readonly RewindNodeLike[]): value is ChatNodeStoreLike {
305
+ return typeof (value as ChatNodeStoreLike).get === 'function'
306
+ }
307
+
308
+ /**
309
+ * Resolve the ordered chat node list from one chat projection.
310
+ *
311
+ * Both inputs keep a stable identity across renders: `order` is republished only
312
+ * when the node set changes and the store is a mutable handle, so the caller can
313
+ * memoize the list instead of allocating a new array on every render (a fresh
314
+ * array per render makes `useSyncExternalStore` loop forever).
315
+ * @param order - ordered node keys, or null when the projection is absent.
316
+ * @param store - keyed node store, a legacy node array, or null.
317
+ * @returns the chat nodes in render order.
318
+ */
319
+ export function collectChatNodes(
320
+ order: readonly string[] | null,
321
+ store: ChatNodeStoreLike | readonly RewindNodeLike[] | null,
322
+ ): readonly RewindNodeLike[] {
323
+ if (store === null) return []
324
+ if (Array.isArray(store)) return store as readonly RewindNodeLike[]
325
+ if (order !== null && isNodeStore(store)) {
326
+ const nodes: RewindNodeLike[] = []
327
+ for (const key of order) {
328
+ const node = store.get(key)
329
+ if (node !== undefined) nodes.push(node)
330
+ }
331
+ return nodes
332
+ }
333
+ return typeof store.values === 'function' ? Array.from(store.values() as Iterable<RewindNodeLike>) : []
334
+ }
335
+
284
336
  /** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
285
- export function RewindMessagePortals({ sessionId, openRestoredSession, useSession }: RewindPortalBridgeProps): ReactNode {
286
- const nodes = useSession<readonly RewindNodeLike[]>(snapshot => snapshot.chat?.nodes.values() ?? snapshot.nodes)
337
+ export function RewindMessagePortals({ sessionId, openRestoredSession, useSession, useChat }: RewindPortalBridgeProps): ReactNode {
338
+ // DSH 0.1.2+ moved the Chat projection out of the Session snapshot into its own
339
+ // session-scoped `useChat` hook; `snapshot.chat` remains the 0.1.1 shape.
340
+ const readSnapshot = useChat ?? useSession
341
+ const chatOf = (snapshot: ConversationSnapshotLike): ChatSnapshotLike => snapshot.chat ?? snapshot
342
+ const chatOrder = readSnapshot<readonly string[] | null>(snapshot => chatOf(snapshot).order ?? null)
343
+ const chatStore = readSnapshot<ChatNodeStoreLike | readonly RewindNodeLike[] | null>(
344
+ snapshot => chatOf(snapshot).nodes ?? null,
345
+ )
346
+ const nodes = useMemo(() => collectChatNodes(chatOrder, chatStore), [chatOrder, chatStore])
287
347
  const [targets, setTargets] = useState<readonly RewindPortalTarget[]>([])
288
348
 
289
349
  useLayoutEffect(() => {
@@ -533,7 +593,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
533
593
  {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
534
594
  {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。</p>}
535
595
  {preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}仍可只回溯消息。</p>}
536
- {preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}仍可只回溯消息。</p>}
596
+ {preview?.status === 'failed' && <p className="dcl-rewind-error">{explainCheckpointFailure(preview.error)}</p>}
537
597
  {preview !== null && (
538
598
  <div className="dcl-rewind-options">
539
599
  <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying || !hasFileChanges}>
@@ -1017,9 +1077,12 @@ function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly Rewind
1017
1077
  const target = selectRewindMessageTarget(value)
1018
1078
  if (target === null) continue
1019
1079
  const row = rows.get(target.rowKey)
1020
- const messageRoot = row?.querySelector<HTMLElement>('[data-time-hover-root="true"]')
1021
- const actions = messageRoot?.lastElementChild
1022
- if (!(actions instanceof HTMLElement) || actions.querySelector(':scope > button') === null) continue
1080
+ const messageRoot = row?.querySelector?.<HTMLElement>('[data-time-hover-root="true"]')
1081
+ // Match the actions container by class or last child element
1082
+ const actions = (typeof messageRoot?.querySelector === 'function'
1083
+ ? messageRoot.querySelector<HTMLElement>('[class*="actions"]')
1084
+ : undefined) ?? messageRoot?.lastElementChild
1085
+ if (!(actions instanceof HTMLElement)) continue
1023
1086
  targets.push({ container: actions, matched: target.matched })
1024
1087
  }
1025
1088
  return targets
@@ -1057,15 +1120,40 @@ async function openSessionWithDraft(ctx: ClientContextLike, sessionId: string, p
1057
1120
  throw lastError
1058
1121
  }
1059
1122
 
1060
- async function responseJson(response: Response): Promise<unknown> {
1061
- const value = await response.json() as unknown
1123
+ /**
1124
+ * Read one rewind response body without ever leaking a raw parse error.
1125
+ *
1126
+ * A missing route, a restarted Host, or a proxy answering before the plugin
1127
+ * loads all produce a body that is not the plugin's JSON envelope; those must
1128
+ * surface as an explained failure instead of `Failed to execute 'json' …`.
1129
+ * @param response - fetch response from the rewind endpoint.
1130
+ * @returns the decoded JSON body.
1131
+ */
1132
+ export async function responseJson(response: Response): Promise<unknown> {
1133
+ const text = await response.text()
1134
+ let value: unknown
1135
+ try {
1136
+ value = text === '' ? undefined : JSON.parse(text) as unknown
1137
+ } catch {
1138
+ throw new RewindRequestError(
1139
+ response.ok ? 'REWIND_INVALID_RESPONSE' : 'REWIND_ENDPOINT_UNAVAILABLE',
1140
+ `回退服务返回了无法解析的内容(HTTP ${String(response.status)})。`,
1141
+ )
1142
+ }
1143
+ const record = value !== null && typeof value === 'object' && !Array.isArray(value)
1144
+ ? value as Record<string, unknown>
1145
+ : undefined
1062
1146
  if (!response.ok) {
1063
- const record = recordOf(value)
1064
1147
  throw new RewindRequestError(
1065
- typeof record.code === 'string' ? record.code : 'REWIND_FAILED',
1066
- typeof record.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
1148
+ typeof record?.code === 'string'
1149
+ ? record.code
1150
+ : response.status === 404 ? 'REWIND_ENDPOINT_UNAVAILABLE' : 'REWIND_FAILED',
1151
+ typeof record?.error === 'string' ? record.error : `请求失败:${String(response.status)}`,
1067
1152
  )
1068
1153
  }
1154
+ if (value === undefined) {
1155
+ throw new RewindRequestError('REWIND_INVALID_RESPONSE', '回退服务返回了空响应。')
1156
+ }
1069
1157
  return value
1070
1158
  }
1071
1159
 
@@ -1123,6 +1211,8 @@ function RewindIcon({ size }: { readonly size: number }): ReactNode {
1123
1211
  function friendlyError(error: unknown): string {
1124
1212
  if (!(error instanceof RewindRequestError)) return messageOf(error)
1125
1213
  switch (error.code) {
1214
+ case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
1215
+ case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
1126
1216
  case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1127
1217
  case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
1128
1218
  case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
@@ -1138,6 +1228,33 @@ function friendlyError(error: unknown): string {
1138
1228
  }
1139
1229
  }
1140
1230
 
1231
+ /**
1232
+ * Explain one recorded checkpoint failure in user terms.
1233
+ *
1234
+ * The Host records the raw `[CODE] diagnostic` line; a non-Git project
1235
+ * directory is the common case and deserves a plain sentence instead of a
1236
+ * `git rev-parse` transcript.
1237
+ * @param message - recorded checkpoint failure message.
1238
+ * @returns one user-facing sentence.
1239
+ */
1240
+ export function explainCheckpointFailure(message: string): string {
1241
+ const code = /^\[([A-Z_]+)\]/.exec(message)?.[1]
1242
+ switch (code) {
1243
+ case 'GIT_COMMAND_FAILED':
1244
+ return /not a git repository/i.test(message)
1245
+ ? '这个项目目录不是 Git 仓库,回退功能无法保存文件检查点。仍可只回溯消息。'
1246
+ : `无法读取这个项目目录的 Git 状态:${message}仍可只回溯消息。`
1247
+ case 'FILE_TOO_LARGE':
1248
+ case 'SNAPSHOT_TOO_LARGE':
1249
+ case 'TOO_MANY_FILES':
1250
+ return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
1251
+ case 'INVALID_PATH':
1252
+ return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
1253
+ default:
1254
+ return `没能保存这条消息发送前的文件:${message}仍可只回溯消息。`
1255
+ }
1256
+ }
1257
+
1141
1258
  function messageOf(error: unknown): string {
1142
1259
  return error instanceof Error ? error.message : String(error)
1143
1260
  }
package/src/index.ts CHANGED
@@ -30,7 +30,9 @@ export class ChangeLedgerService {
30
30
  this.engine = new ChangeLedgerEngine(config)
31
31
  const checkpoints = new TurnCheckpointCoordinator(this.engine)
32
32
  ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
33
- ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope: Context) => {
33
+ // `apiProxy` is deliberately absent: DSH 0.1.2-alpha carriers (DSH Desktop 2.x)
34
+ // provide `sessionController` instead, and the route must exist on both.
35
+ ctx.inject(['webServer', 'sessions', 'sessionQuery', 'agents'], (scope: Context) => {
34
36
  installRewindHttp(scope, this.engine, checkpoints)
35
37
  installManageHttp(scope, this.engine)
36
38
  })