dsh-milestone 0.4.0 → 0.5.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.
- package/README.md +8 -1
- package/lib/client.js +567 -73
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -40,15 +40,20 @@
|
|
|
40
40
|
- **蓝色渐变** —— 最新最深、最早最浅,一眼看清提问的先后顺序,像 Git 提交图。
|
|
41
41
|
- **滚轮滑动** —— 长会话圆点超出可视区时,鼠标在里程碑条上滚轮即可滑动选点。
|
|
42
42
|
- **丰富悬停** —— 悬停展示消息预览、相对时间、第 N 轮、用时、结束原因、首字延迟(TTFT)、tokens/秒。
|
|
43
|
+
- **模型与用量** —— 悬停直接看这一轮用的哪个模型、输入/输出 token 数,调试成本一目了然。
|
|
44
|
+
- **turn 分组折叠** —— 圆点按轮次分组,一眼看清对话章节;长轮次可折叠成一条,减少干扰。
|
|
45
|
+
- **复制与 fork** —— 悬停一键复制该条提问全文,或「从此处 fork」开一个分支会话。
|
|
46
|
+
- **中英双语** —— 跟随 harness 界面语言自动切换中英文。
|
|
43
47
|
- **零侵入** —— 官方 slot 机制挂载,不修改 harness 源码,装完即用。
|
|
44
48
|
|
|
45
49
|
## 悬停能看到什么
|
|
46
50
|
|
|
47
51
|
```
|
|
48
52
|
┌─────────────────────────────────────────┐
|
|
49
|
-
│ 第 3 / 5 条 · 第 2 轮
|
|
53
|
+
│ 第 3 / 5 条 · 第 2 轮 ☆ 复制 ✂ │ ← 序号 + 轮次 + 收藏/复制/fork 动作
|
|
50
54
|
│ 帮我优化这段代码的性能 │ ← 消息预览(前 80 字)
|
|
51
55
|
│ 5 分钟前 · 用时 1m30s · 首字 1.2s · 12.4 tok/s │ ← 时间 · 耗时 · TTFT · 吞吐
|
|
56
|
+
│ v4 · continue · 1280 / 2560 tok │ ← 模型 · 用途 · token 用量
|
|
52
57
|
└─────────────────────────────────────────┘
|
|
53
58
|
```
|
|
54
59
|
|
|
@@ -93,6 +98,8 @@ shell.overlay (root scope)
|
|
|
93
98
|
- TTFT / tokens/秒 依赖 turn 位置数据,窗口外或未完成的 turn 不显示(自动隐藏)。
|
|
94
99
|
- 徽章的瞬态状态(运行中/等待输入)只点亮**最新一条可见提问**——若运行中/等待输入的轮次其提问在窗口外,则无脉冲。
|
|
95
100
|
- 书签按**会话**隔离(不跨会话共享)。
|
|
101
|
+
- 模型 / token 用量依赖该轮 assistant 节点的元数据,部分场景下缺失则自动隐藏该行。
|
|
102
|
+
- fork 从选中消息所在轮次开始分支,不会自动打开子会话(需在会话列表手动打开)。
|
|
96
103
|
- 尚无全局快捷键聚焦里程碑条(需 Tab 键切换到)。
|
|
97
104
|
|
|
98
105
|
## License
|
package/lib/client.js
CHANGED
|
@@ -126,6 +126,148 @@ window.__ModuleLoader__.load({
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
//#endregion
|
|
129
|
+
//#region src/client/clipboard-logic.ts
|
|
130
|
+
/**
|
|
131
|
+
* Clipboard helper for the milestone rail: copies text to the system
|
|
132
|
+
* clipboard via the async Clipboard API. Resolves false (never throws) when
|
|
133
|
+
* the API is unavailable or the write is rejected, so callers can treat the
|
|
134
|
+
* result as a plain boolean.
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* Copy text to the system clipboard.
|
|
138
|
+
* @param text - the text to copy.
|
|
139
|
+
* @returns a promise resolving to true when the clipboard write succeeded,
|
|
140
|
+
* false when the Clipboard API is unavailable or the write was rejected.
|
|
141
|
+
*/
|
|
142
|
+
async function copyText(text) {
|
|
143
|
+
if (typeof navigator === "undefined" || navigator.clipboard?.writeText === void 0) return false;
|
|
144
|
+
try {
|
|
145
|
+
await navigator.clipboard.writeText(text);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/client/label-logic.ts
|
|
153
|
+
const MINUTE_MS = 6e4;
|
|
154
|
+
const HOUR_MS = 36e5;
|
|
155
|
+
const DAY_MS = 864e5;
|
|
156
|
+
/**
|
|
157
|
+
* Bucket an elapsed duration into a relative-time label.
|
|
158
|
+
*
|
|
159
|
+
* Buckets on `now - time` in milliseconds: below 60s -> justNow (n=0), below
|
|
160
|
+
* 3600s -> minutes, below 86400s -> hours, otherwise days. `n` is the whole
|
|
161
|
+
* count of the bucket unit (floor). Deterministic for a given `now`.
|
|
162
|
+
*
|
|
163
|
+
* @param time - the event timestamp in ms since epoch.
|
|
164
|
+
* @param now - the reference clock in ms since epoch.
|
|
165
|
+
* @returns the label key and bucket count.
|
|
166
|
+
*/
|
|
167
|
+
function relativeTimeParts(time, now) {
|
|
168
|
+
const diff = now - time;
|
|
169
|
+
if (diff < MINUTE_MS) return {
|
|
170
|
+
key: "time.justNow",
|
|
171
|
+
n: 0
|
|
172
|
+
};
|
|
173
|
+
if (diff < HOUR_MS) return {
|
|
174
|
+
key: "time.minutes",
|
|
175
|
+
n: Math.floor(diff / MINUTE_MS)
|
|
176
|
+
};
|
|
177
|
+
if (diff < DAY_MS) return {
|
|
178
|
+
key: "time.hours",
|
|
179
|
+
n: Math.floor(diff / HOUR_MS)
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
key: "time.days",
|
|
183
|
+
n: Math.floor(diff / DAY_MS)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Map a harness end-reason string to a stable i18n key.
|
|
188
|
+
* @param kind - the raw end-reason string (e.g. 'max-tokens').
|
|
189
|
+
* @returns the i18n key, or the raw kind unchanged when unknown.
|
|
190
|
+
*/
|
|
191
|
+
function reasonKeyOf(kind) {
|
|
192
|
+
switch (kind) {
|
|
193
|
+
case "completed": return "reason.completed";
|
|
194
|
+
case "aborted": return "reason.aborted";
|
|
195
|
+
case "error": return "reason.error";
|
|
196
|
+
case "max-tokens": return "reason.maxTokens";
|
|
197
|
+
case "interrupted": return "reason.interrupted";
|
|
198
|
+
case "blocked": return "reason.blocked";
|
|
199
|
+
default: return kind;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/client/tooltip-logic.ts
|
|
204
|
+
const EMPTY_META = {
|
|
205
|
+
model: null,
|
|
206
|
+
purpose: null,
|
|
207
|
+
inputTokens: null,
|
|
208
|
+
outputTokens: null
|
|
209
|
+
};
|
|
210
|
+
/** True when the value is a plain (non-array, non-null) object. */
|
|
211
|
+
function isRecord(value) {
|
|
212
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Decode a `usage` payload structurally: only numeric `inputTokens` /
|
|
216
|
+
* `outputTokens` survive; anything else (absent, malformed, wrong types)
|
|
217
|
+
* degrades to null — the boundary owns trust, the callers get plain numbers.
|
|
218
|
+
* @param usage - untrusted usage payload (typed `unknown` at runtime).
|
|
219
|
+
* @returns the token counts with null for every missing/malformed field.
|
|
220
|
+
*/
|
|
221
|
+
function decodeUsage(usage) {
|
|
222
|
+
if (!isRecord(usage)) return {
|
|
223
|
+
inputTokens: null,
|
|
224
|
+
outputTokens: null
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
|
|
228
|
+
outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Resolve model/purpose from a request config, falling back to provenance. */
|
|
232
|
+
function metaFromRecord(record) {
|
|
233
|
+
return {
|
|
234
|
+
model: record.requestConfig?.model ?? record.provenance?.model ?? null,
|
|
235
|
+
purpose: record.requestConfig?.purpose ?? null,
|
|
236
|
+
...decodeUsage(record.usage)
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Derive the hover metadata for one turn. Sources, in priority order:
|
|
241
|
+
* 1. the `assistant-step` chat node(s) of the turn — their `data.finalNode`
|
|
242
|
+
* carries the recorded `requestConfig` / `provenance` / `usage`;
|
|
243
|
+
* 2. `trajectoryRequests` — the latest entry whose `turn` matches (used when
|
|
244
|
+
* no assistant-step node yields a model or purpose);
|
|
245
|
+
* 3. all-null when the turn is absent, no node matches, or everything is
|
|
246
|
+
* malformed. Never throws.
|
|
247
|
+
* @param nodes - stable per-key chat node reader (as exposed by the snapshot).
|
|
248
|
+
* @param locations - turn -> ordered node keys index.
|
|
249
|
+
* @param turn - owning turn; undefined yields all-null.
|
|
250
|
+
* @param trajectoryRequests - optional fallback request log.
|
|
251
|
+
* @returns the turn's metadata, null where unknown.
|
|
252
|
+
*/
|
|
253
|
+
function deriveTurnMeta(nodes, locations, turn, trajectoryRequests) {
|
|
254
|
+
if (turn === void 0) return EMPTY_META;
|
|
255
|
+
for (const key of locations.getTurn(turn)) {
|
|
256
|
+
const node = nodes.get(key);
|
|
257
|
+
if (node === void 0 || node.kind !== "assistant-step") continue;
|
|
258
|
+
const finalNode = (isRecord(node.data) ? node.data : void 0)?.finalNode;
|
|
259
|
+
if (!isRecord(finalNode)) continue;
|
|
260
|
+
const meta = metaFromRecord(finalNode);
|
|
261
|
+
if (meta.model !== null || meta.purpose !== null) return meta;
|
|
262
|
+
}
|
|
263
|
+
if (trajectoryRequests !== void 0) {
|
|
264
|
+
let latest;
|
|
265
|
+
for (const request of trajectoryRequests) if (request.turn === turn) latest = request;
|
|
266
|
+
if (latest !== void 0) return metaFromRecord(latest);
|
|
267
|
+
}
|
|
268
|
+
return EMPTY_META;
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
129
271
|
//#region src/client/rail-keyboard.ts
|
|
130
272
|
/**
|
|
131
273
|
* Pure roving-tabindex index math for the milestone rail.
|
|
@@ -242,17 +384,83 @@ window.__ModuleLoader__.load({
|
|
|
242
384
|
return `hsl(218, 88%, ${72 - (total <= 1 ? 0 : index / (total - 1)) * 27}%)`;
|
|
243
385
|
}
|
|
244
386
|
//#endregion
|
|
387
|
+
//#region src/client/turn-group-logic.ts
|
|
388
|
+
/**
|
|
389
|
+
* Partition consecutive marks by turn. Marks with the same numeric turn that
|
|
390
|
+
* appear one after another share a group; each mark with `turn === undefined`
|
|
391
|
+
* becomes its own singleton group with `turn: null`.
|
|
392
|
+
* @param marks - marks in rail order.
|
|
393
|
+
* @returns the groups, in original order, partitioning `marks` exactly.
|
|
394
|
+
*/
|
|
395
|
+
function buildTurnGroups(marks) {
|
|
396
|
+
const groups = [];
|
|
397
|
+
let current;
|
|
398
|
+
for (const mark of marks) {
|
|
399
|
+
if (mark.turn === void 0) {
|
|
400
|
+
current = void 0;
|
|
401
|
+
groups.push({
|
|
402
|
+
turn: null,
|
|
403
|
+
marks: [mark]
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (current !== void 0 && current.turn === mark.turn) current.marks.push(mark);
|
|
408
|
+
else {
|
|
409
|
+
current = {
|
|
410
|
+
turn: mark.turn,
|
|
411
|
+
marks: [mark]
|
|
412
|
+
};
|
|
413
|
+
groups.push(current);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return groups;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Flatten groups into render items, collapsing collapsed turns to their last
|
|
420
|
+
* mark and reporting where separators belong.
|
|
421
|
+
* @param groups - groups from {@link buildTurnGroups} (they partition the
|
|
422
|
+
* original marks array in order, so a running count yields original indices).
|
|
423
|
+
* @param collapsed - turns whose group should collapse to its LAST mark.
|
|
424
|
+
* @returns `items` (one RenderItem per visible dot, in group order) and
|
|
425
|
+
* `separatorsAt` (the index in `items` before which a separator should be
|
|
426
|
+
* inserted at each non-first group boundary; never includes 0).
|
|
427
|
+
*/
|
|
428
|
+
function buildRenderList(groups, collapsed) {
|
|
429
|
+
const items = [];
|
|
430
|
+
const separatorsAt = [];
|
|
431
|
+
let counter = 0;
|
|
432
|
+
for (const group of groups) {
|
|
433
|
+
const startIndex = items.length;
|
|
434
|
+
if (group.turn !== null && collapsed.has(group.turn) && group.marks.length > 1) {
|
|
435
|
+
const last = group.marks[group.marks.length - 1];
|
|
436
|
+
items.push({
|
|
437
|
+
mark: last,
|
|
438
|
+
displayIndex: counter + group.marks.length - 1
|
|
439
|
+
});
|
|
440
|
+
} else for (let i = 0; i < group.marks.length; i++) items.push({
|
|
441
|
+
mark: group.marks[i],
|
|
442
|
+
displayIndex: counter + i
|
|
443
|
+
});
|
|
444
|
+
counter += group.marks.length;
|
|
445
|
+
if (startIndex > 0) separatorsAt.push(startIndex);
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
items,
|
|
449
|
+
separatorsAt
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
245
453
|
//#region src/client/MilestoneRailSearch.tsx
|
|
246
454
|
/** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
|
|
247
455
|
const DOT_HIT$1 = 22;
|
|
248
456
|
/**
|
|
249
457
|
* @param props - the search state slice plus the rail's event handlers.
|
|
250
458
|
*/
|
|
251
|
-
function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear }) {
|
|
459
|
+
function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
|
|
252
460
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
253
461
|
type: "button",
|
|
254
462
|
"data-search-toggle": true,
|
|
255
|
-
"aria-label": "
|
|
463
|
+
"aria-label": t("search.label"),
|
|
256
464
|
"aria-pressed": panelOpen,
|
|
257
465
|
onClick: onToggle,
|
|
258
466
|
style: {
|
|
@@ -304,8 +512,8 @@ window.__ModuleLoader__.load({
|
|
|
304
512
|
},
|
|
305
513
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
306
514
|
"data-rail-search": true,
|
|
307
|
-
"aria-label": "
|
|
308
|
-
placeholder: "
|
|
515
|
+
"aria-label": t("search.label"),
|
|
516
|
+
placeholder: t("search.placeholder"),
|
|
309
517
|
value: query,
|
|
310
518
|
onChange: (e) => onQueryChange(e.target.value),
|
|
311
519
|
onKeyDown: onSearchKeyDown,
|
|
@@ -324,7 +532,7 @@ window.__ModuleLoader__.load({
|
|
|
324
532
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
325
533
|
type: "button",
|
|
326
534
|
"data-search-clear": true,
|
|
327
|
-
"aria-label": "
|
|
535
|
+
"aria-label": t("search.clear"),
|
|
328
536
|
onClick: onClear,
|
|
329
537
|
style: {
|
|
330
538
|
width: 22,
|
|
@@ -371,7 +579,10 @@ window.__ModuleLoader__.load({
|
|
|
371
579
|
/**
|
|
372
580
|
* @param props - the hovered mark + bookmark wiring (see {@link MilestoneRailTooltipProps}).
|
|
373
581
|
*/
|
|
374
|
-
function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onMouseEnter, onMouseLeave, panelRight }) {
|
|
582
|
+
function MilestoneRailTooltip({ hover, bookmarked, onToggleBookmark, onCopy, onFork, copied, forked, turnCollapsed, onToggleCollapse, onMouseEnter, onMouseLeave, panelRight, t }) {
|
|
583
|
+
const relativeTime = relativeTimeParts(hover.mark.time, Date.now());
|
|
584
|
+
const turn = hover.mark.turn;
|
|
585
|
+
const showCollapse = turn !== void 0 && hover.turnMarkCount !== null && hover.turnMarkCount > 1;
|
|
375
586
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
376
587
|
onMouseEnter,
|
|
377
588
|
onMouseLeave,
|
|
@@ -399,24 +610,22 @@ window.__ModuleLoader__.load({
|
|
|
399
610
|
style: {
|
|
400
611
|
display: "flex",
|
|
401
612
|
alignItems: "center",
|
|
613
|
+
flexWrap: "wrap",
|
|
402
614
|
gap: 8,
|
|
403
615
|
color: "#9aa4b8",
|
|
404
616
|
fontSize: 11,
|
|
405
617
|
marginBottom: 4
|
|
406
618
|
},
|
|
407
619
|
children: [
|
|
408
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
409
|
-
|
|
410
|
-
hover.
|
|
411
|
-
|
|
412
|
-
hover.total,
|
|
413
|
-
" 条"
|
|
414
|
-
] }),
|
|
620
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pos.of", {
|
|
621
|
+
n: hover.index + 1,
|
|
622
|
+
m: hover.total
|
|
623
|
+
}) }),
|
|
415
624
|
hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
|
|
416
625
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
417
626
|
type: "button",
|
|
418
627
|
"data-star": true,
|
|
419
|
-
"aria-label": "
|
|
628
|
+
"aria-label": t("bookmark.star"),
|
|
420
629
|
"aria-pressed": bookmarked,
|
|
421
630
|
"data-starred": bookmarked ? "true" : void 0,
|
|
422
631
|
onClick: (e) => {
|
|
@@ -448,12 +657,79 @@ window.__ModuleLoader__.load({
|
|
|
448
657
|
"aria-hidden": "true",
|
|
449
658
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
|
|
450
659
|
})
|
|
660
|
+
}),
|
|
661
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
662
|
+
type: "button",
|
|
663
|
+
"data-copy-message": true,
|
|
664
|
+
"data-copied": copied ? "true" : void 0,
|
|
665
|
+
onClick: (e) => {
|
|
666
|
+
e.stopPropagation();
|
|
667
|
+
onCopy(hover.mark);
|
|
668
|
+
},
|
|
669
|
+
style: {
|
|
670
|
+
flexShrink: 0,
|
|
671
|
+
display: "flex",
|
|
672
|
+
alignItems: "center",
|
|
673
|
+
justifyContent: "center",
|
|
674
|
+
background: "transparent",
|
|
675
|
+
border: "none",
|
|
676
|
+
padding: "2px 6px",
|
|
677
|
+
cursor: "pointer",
|
|
678
|
+
whiteSpace: "nowrap",
|
|
679
|
+
color: copied ? "#7ee2a8" : "#8b96ab"
|
|
680
|
+
},
|
|
681
|
+
children: t("copy.message")
|
|
682
|
+
}),
|
|
683
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
684
|
+
type: "button",
|
|
685
|
+
"data-fork-here": true,
|
|
686
|
+
"data-forked": forked ? "true" : void 0,
|
|
687
|
+
onClick: (e) => {
|
|
688
|
+
e.stopPropagation();
|
|
689
|
+
onFork(hover.mark);
|
|
690
|
+
},
|
|
691
|
+
style: {
|
|
692
|
+
flexShrink: 0,
|
|
693
|
+
display: "flex",
|
|
694
|
+
alignItems: "center",
|
|
695
|
+
justifyContent: "center",
|
|
696
|
+
background: "transparent",
|
|
697
|
+
border: "none",
|
|
698
|
+
padding: "2px 6px",
|
|
699
|
+
cursor: "pointer",
|
|
700
|
+
whiteSpace: "nowrap",
|
|
701
|
+
color: forked ? "#7ee2a8" : "#8b96ab"
|
|
702
|
+
},
|
|
703
|
+
children: t("fork.here")
|
|
704
|
+
}),
|
|
705
|
+
showCollapse && turn !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
706
|
+
type: "button",
|
|
707
|
+
"data-toggle-collapse": true,
|
|
708
|
+
"aria-pressed": turnCollapsed,
|
|
709
|
+
"data-collapsed": turnCollapsed ? "true" : void 0,
|
|
710
|
+
onClick: (e) => {
|
|
711
|
+
e.stopPropagation();
|
|
712
|
+
onToggleCollapse(turn);
|
|
713
|
+
},
|
|
714
|
+
style: {
|
|
715
|
+
flexShrink: 0,
|
|
716
|
+
display: "flex",
|
|
717
|
+
alignItems: "center",
|
|
718
|
+
justifyContent: "center",
|
|
719
|
+
background: "transparent",
|
|
720
|
+
border: "none",
|
|
721
|
+
padding: "2px 6px",
|
|
722
|
+
cursor: "pointer",
|
|
723
|
+
whiteSpace: "nowrap",
|
|
724
|
+
color: turnCollapsed ? "#7ee2a8" : "#8b96ab"
|
|
725
|
+
},
|
|
726
|
+
children: turnCollapsed ? t("expand.turn") : t("collapse.turn")
|
|
451
727
|
})
|
|
452
728
|
]
|
|
453
729
|
}),
|
|
454
730
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
455
731
|
style: { color: "#c7cede" },
|
|
456
|
-
children: hover.mark.preview !== "" ? hover.mark.preview : "
|
|
732
|
+
children: hover.mark.preview !== "" ? hover.mark.preview : t("no.text")
|
|
457
733
|
}),
|
|
458
734
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
459
735
|
style: {
|
|
@@ -465,24 +741,40 @@ window.__ModuleLoader__.load({
|
|
|
465
741
|
marginTop: 4
|
|
466
742
|
},
|
|
467
743
|
children: [
|
|
468
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children:
|
|
469
|
-
hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.
|
|
744
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(relativeTime.key, { n: relativeTime.n }) }),
|
|
745
|
+
hover.durationLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("duration.label", { name: hover.durationLabel }) }),
|
|
470
746
|
hover.reasonLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.reasonLabel }),
|
|
471
|
-
hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.
|
|
747
|
+
hover.ttftLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("ttft.label", { name: hover.ttftLabel }) }),
|
|
472
748
|
hover.tpsLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.tpsLabel })
|
|
473
749
|
]
|
|
750
|
+
}),
|
|
751
|
+
(hover.modelLabel !== null || hover.purposeLabel !== null || hover.tokensLabel !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
752
|
+
style: {
|
|
753
|
+
display: "flex",
|
|
754
|
+
flexWrap: "wrap",
|
|
755
|
+
gap: 8,
|
|
756
|
+
color: "#8b96ab",
|
|
757
|
+
fontSize: 11,
|
|
758
|
+
marginTop: 4
|
|
759
|
+
},
|
|
760
|
+
children: [
|
|
761
|
+
hover.modelLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
762
|
+
"data-model": hover.modelLabel,
|
|
763
|
+
children: hover.modelLabel
|
|
764
|
+
}),
|
|
765
|
+
hover.purposeLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
766
|
+
"data-purpose": hover.purposeLabel,
|
|
767
|
+
children: hover.purposeLabel
|
|
768
|
+
}),
|
|
769
|
+
hover.tokensLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
770
|
+
"data-tokens": hover.tokensLabel,
|
|
771
|
+
children: hover.tokensLabel
|
|
772
|
+
})
|
|
773
|
+
]
|
|
474
774
|
})
|
|
475
775
|
]
|
|
476
776
|
});
|
|
477
777
|
}
|
|
478
|
-
/** Relative wall-clock label for a Unix-epoch-ms timestamp. */
|
|
479
|
-
function formatRelativeTime(time) {
|
|
480
|
-
const diff = Date.now() - time;
|
|
481
|
-
if (diff < 6e4) return "刚刚";
|
|
482
|
-
if (diff < 36e5) return `${Math.floor(diff / 6e4)} 分钟前`;
|
|
483
|
-
if (diff < 864e5) return `${Math.floor(diff / 36e5)} 小时前`;
|
|
484
|
-
return `${Math.floor(diff / 864e5)} 天前`;
|
|
485
|
-
}
|
|
486
778
|
//#endregion
|
|
487
779
|
//#region src/client/useCurrentAnchor.ts
|
|
488
780
|
/**
|
|
@@ -624,18 +916,6 @@ window.__ModuleLoader__.load({
|
|
|
624
916
|
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
625
917
|
return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
|
|
626
918
|
}
|
|
627
|
-
/** Human label for a TurnEndReason kind. */
|
|
628
|
-
function reasonLabelOf(kind) {
|
|
629
|
-
switch (kind) {
|
|
630
|
-
case "completed": return "已完成";
|
|
631
|
-
case "aborted": return "已中止";
|
|
632
|
-
case "error": return "出错";
|
|
633
|
-
case "max-tokens": return "达到上限";
|
|
634
|
-
case "interrupted": return "已中断";
|
|
635
|
-
case "blocked": return "已阻塞";
|
|
636
|
-
default: return kind;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
919
|
/** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
|
|
640
920
|
function turnTailOf(turn) {
|
|
641
921
|
const data = turn.data;
|
|
@@ -644,13 +924,18 @@ window.__ModuleLoader__.load({
|
|
|
644
924
|
}
|
|
645
925
|
/**
|
|
646
926
|
* @param props - session standard kit (useSession, sessionId, useProjection),
|
|
647
|
-
* the injected loadOlder
|
|
648
|
-
* actions, injected by the framework from the declared store seat)
|
|
927
|
+
* the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
|
|
928
|
+
* actions, injected by the framework from the declared store seat), and the
|
|
929
|
+
* framework-synthesized `t` locale interpreter (registered via the entry's
|
|
930
|
+
* `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
|
|
931
|
+
* outside the slot machinery).
|
|
649
932
|
*/
|
|
650
|
-
function MilestoneRail({ useSession, loadOlder, useStore, actions }) {
|
|
933
|
+
function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, t = (key) => key }) {
|
|
651
934
|
const order = useSession((s) => s.chat.order);
|
|
652
935
|
const nodes = useSession((s) => s.chat.nodes);
|
|
936
|
+
const locations = useSession((s) => s.chat.locations);
|
|
653
937
|
const timeline = useSession((s) => s.chat.timeline);
|
|
938
|
+
const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
|
|
654
939
|
const hasMore = useSession((s) => s.hasMore);
|
|
655
940
|
const loadingOlder = useSession((s) => s.loadingOlder);
|
|
656
941
|
const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
|
|
@@ -696,6 +981,9 @@ window.__ModuleLoader__.load({
|
|
|
696
981
|
panelOpen: false
|
|
697
982
|
});
|
|
698
983
|
const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
|
|
984
|
+
const [copiedKey, setCopiedKey] = (0, react.useState)(null);
|
|
985
|
+
const [forkedKey, setForkedKey] = (0, react.useState)(null);
|
|
986
|
+
const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
|
|
699
987
|
const [focusIndex, setFocusIndex] = (0, react.useState)(0);
|
|
700
988
|
const listRef = (0, react.useRef)(null);
|
|
701
989
|
const currentKey = useCurrentAnchor(order);
|
|
@@ -710,6 +998,22 @@ window.__ModuleLoader__.load({
|
|
|
710
998
|
const { matches } = (0, react.useMemo)(() => filterMarks(displayMarks, search.query), [displayMarks, search.query]);
|
|
711
999
|
const hasQuery = search.query.trim() !== "";
|
|
712
1000
|
const activeMarkIndex = hasQuery && matches.length > 0 ? matches[Math.min(search.activePos, matches.length - 1)] : -1;
|
|
1001
|
+
const groups = (0, react.useMemo)(() => buildTurnGroups(displayMarks), [displayMarks]);
|
|
1002
|
+
const render = (0, react.useMemo)(() => buildRenderList(groups, collapsedTurns), [groups, collapsedTurns]);
|
|
1003
|
+
const separatorIndices = (0, react.useMemo)(() => new Set(render.separatorsAt), [render]);
|
|
1004
|
+
const collapsedSummaries = (0, react.useMemo)(() => {
|
|
1005
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
1006
|
+
for (const group of groups) if (group.turn !== null && group.marks.length > 1 && collapsedTurns.has(group.turn)) summaries.set(group.marks[group.marks.length - 1].key, group.marks.length);
|
|
1007
|
+
return summaries;
|
|
1008
|
+
}, [groups, collapsedTurns]);
|
|
1009
|
+
const turnMarkCounts = (0, react.useMemo)(() => {
|
|
1010
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1011
|
+
for (const mark of displayMarks) {
|
|
1012
|
+
if (mark.turn === void 0) continue;
|
|
1013
|
+
counts.set(mark.turn, (counts.get(mark.turn) ?? 0) + 1);
|
|
1014
|
+
}
|
|
1015
|
+
return counts;
|
|
1016
|
+
}, [displayMarks]);
|
|
713
1017
|
(0, react.useLayoutEffect)(() => {
|
|
714
1018
|
if (marks.length < MIN_MARKS) {
|
|
715
1019
|
setRailBox(null);
|
|
@@ -735,8 +1039,8 @@ window.__ModuleLoader__.load({
|
|
|
735
1039
|
};
|
|
736
1040
|
}, [marks.length]);
|
|
737
1041
|
(0, react.useLayoutEffect)(() => {
|
|
738
|
-
setFocusIndex((f) => clampIndex(f,
|
|
739
|
-
}, [
|
|
1042
|
+
setFocusIndex((f) => clampIndex(f, render.items.length));
|
|
1043
|
+
}, [render.items.length]);
|
|
740
1044
|
if (railBox === null || marks.length < MIN_MARKS) return null;
|
|
741
1045
|
const jump = (key) => {
|
|
742
1046
|
findRow(key)?.scrollIntoView({
|
|
@@ -786,16 +1090,17 @@ window.__ModuleLoader__.load({
|
|
|
786
1090
|
/** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
|
|
787
1091
|
const onListFocus = (e) => {
|
|
788
1092
|
if (e.target !== e.currentTarget) return;
|
|
789
|
-
focusDotAt(clampIndex(focusIndex,
|
|
1093
|
+
focusDotAt(clampIndex(focusIndex, render.items.length));
|
|
790
1094
|
};
|
|
791
1095
|
/**
|
|
792
1096
|
* Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
|
|
793
1097
|
* jump to first/last. Enter/Space are deliberately NOT handled — the dots
|
|
794
1098
|
* are real buttons, so native activation fires the jump click untouched
|
|
795
|
-
* (preventDefault here would swallow it).
|
|
1099
|
+
* (preventDefault here would swallow it). The rover counts RENDERED dots
|
|
1100
|
+
* (collapsed turns shrink the list).
|
|
796
1101
|
*/
|
|
797
1102
|
const onListKeyDown = (e) => {
|
|
798
|
-
const count =
|
|
1103
|
+
const count = render.items.length;
|
|
799
1104
|
let next = null;
|
|
800
1105
|
switch (e.key) {
|
|
801
1106
|
case "ArrowDown":
|
|
@@ -818,6 +1123,8 @@ window.__ModuleLoader__.load({
|
|
|
818
1123
|
focusDotAt(target);
|
|
819
1124
|
};
|
|
820
1125
|
const buildHover = (mark, index) => {
|
|
1126
|
+
if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
|
|
1127
|
+
if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
|
|
821
1128
|
const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
|
|
822
1129
|
let durationLabel = null;
|
|
823
1130
|
let reasonLabel = null;
|
|
@@ -827,7 +1134,7 @@ window.__ModuleLoader__.load({
|
|
|
827
1134
|
if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
|
|
828
1135
|
if (turn.end !== void 0) {
|
|
829
1136
|
const reason = turn.end.data.reason;
|
|
830
|
-
if (reason?.kind !== void 0) reasonLabel =
|
|
1137
|
+
if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
|
|
831
1138
|
}
|
|
832
1139
|
const tail = turnTailOf(turn);
|
|
833
1140
|
if (tail !== void 0) {
|
|
@@ -835,18 +1142,36 @@ window.__ModuleLoader__.load({
|
|
|
835
1142
|
if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
|
|
836
1143
|
}
|
|
837
1144
|
}
|
|
1145
|
+
const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
|
|
838
1146
|
return {
|
|
839
1147
|
mark,
|
|
840
1148
|
index,
|
|
841
1149
|
total: displayMarks.length,
|
|
842
|
-
turnLabel: mark.turn !== void 0 ?
|
|
1150
|
+
turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
|
|
843
1151
|
durationLabel,
|
|
844
1152
|
reasonLabel,
|
|
845
1153
|
ttftLabel,
|
|
846
|
-
tpsLabel
|
|
1154
|
+
tpsLabel,
|
|
1155
|
+
modelLabel: meta.model,
|
|
1156
|
+
purposeLabel: meta.purpose,
|
|
1157
|
+
tokensLabel: meta.inputTokens !== null && meta.outputTokens !== null ? `${meta.inputTokens} / ${meta.outputTokens} tok` : null,
|
|
1158
|
+
turnMarkCount: mark.turn !== void 0 ? turnMarkCounts.get(mark.turn) ?? 0 : null
|
|
847
1159
|
};
|
|
848
1160
|
};
|
|
849
1161
|
/**
|
|
1162
|
+
* C4: collapse/expand the hovered mark's turn in the rail. The set is
|
|
1163
|
+
* replaced immutably (a turn toggles out when already present); collapsing
|
|
1164
|
+
* keeps the turn's LAST mark visible via buildRenderList.
|
|
1165
|
+
*/
|
|
1166
|
+
const onToggleCollapse = (turn) => {
|
|
1167
|
+
setCollapsedTurns((prev) => {
|
|
1168
|
+
const next = new Set(prev);
|
|
1169
|
+
if (next.has(turn)) next.delete(turn);
|
|
1170
|
+
else next.add(turn);
|
|
1171
|
+
return next;
|
|
1172
|
+
});
|
|
1173
|
+
};
|
|
1174
|
+
/**
|
|
850
1175
|
* T10: flip a mark's bookmark in the persisted store. The store action is
|
|
851
1176
|
* the write path (the engine persists synchronously). The hover re-assert
|
|
852
1177
|
* forces a re-render so the star reflects the toggled state — production
|
|
@@ -858,6 +1183,20 @@ window.__ModuleLoader__.load({
|
|
|
858
1183
|
actions?.toggle(key);
|
|
859
1184
|
setHover((h) => h === null ? h : { ...h });
|
|
860
1185
|
};
|
|
1186
|
+
/**
|
|
1187
|
+
* C3: copy the hovered mark's FULL message text to the system clipboard.
|
|
1188
|
+
* The acknowledgement only shows when the write actually succeeded.
|
|
1189
|
+
*/
|
|
1190
|
+
const onCopy = async (mark) => {
|
|
1191
|
+
if (await copyText(mark.text)) setCopiedKey(mark.key);
|
|
1192
|
+
};
|
|
1193
|
+
/**
|
|
1194
|
+
* C3: fork the session at the hovered mark, anchoring the cut at its event
|
|
1195
|
+
* seq. The acknowledgement only shows once the fork resolved.
|
|
1196
|
+
*/
|
|
1197
|
+
const onFork = (mark) => {
|
|
1198
|
+
forkAt(mark.seq).then(() => setForkedKey(mark.key));
|
|
1199
|
+
};
|
|
861
1200
|
const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
|
|
862
1201
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
863
1202
|
style: {
|
|
@@ -871,15 +1210,15 @@ window.__ModuleLoader__.load({
|
|
|
871
1210
|
display: "flex",
|
|
872
1211
|
flexDirection: "column"
|
|
873
1212
|
},
|
|
874
|
-
"aria-label": "
|
|
1213
|
+
"aria-label": t("rail.label"),
|
|
875
1214
|
children: [
|
|
876
1215
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
|
|
877
1216
|
showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
878
1217
|
type: "button",
|
|
879
1218
|
"data-load-older": true,
|
|
880
1219
|
"data-loading-older": loadingOlder ? "true" : void 0,
|
|
881
|
-
title: "
|
|
882
|
-
"aria-label": "
|
|
1220
|
+
title: t("load.older"),
|
|
1221
|
+
"aria-label": t("load.older"),
|
|
883
1222
|
disabled: loadingOlder,
|
|
884
1223
|
onClick: () => {
|
|
885
1224
|
loadOlder();
|
|
@@ -905,7 +1244,7 @@ window.__ModuleLoader__.load({
|
|
|
905
1244
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
906
1245
|
type: "button",
|
|
907
1246
|
"data-bookmarks-toggle": true,
|
|
908
|
-
"aria-label": "
|
|
1247
|
+
"aria-label": t("bookmark.filter"),
|
|
909
1248
|
"aria-pressed": bookmarksOnly,
|
|
910
1249
|
"data-active": bookmarksOnly ? "true" : void 0,
|
|
911
1250
|
onClick: () => setBookmarksOnly((v) => !v),
|
|
@@ -947,13 +1286,14 @@ window.__ModuleLoader__.load({
|
|
|
947
1286
|
})),
|
|
948
1287
|
onQueryChange: updateQuery,
|
|
949
1288
|
onSearchKeyDown,
|
|
950
|
-
onClear: clearSearch
|
|
1289
|
+
onClear: clearSearch,
|
|
1290
|
+
t
|
|
951
1291
|
}),
|
|
952
1292
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
953
1293
|
ref: listRef,
|
|
954
1294
|
"data-rail-list": true,
|
|
955
1295
|
tabIndex: 0,
|
|
956
|
-
"aria-label": "
|
|
1296
|
+
"aria-label": t("rail.list"),
|
|
957
1297
|
onFocus: onListFocus,
|
|
958
1298
|
onKeyDown: onListKeyDown,
|
|
959
1299
|
style: {
|
|
@@ -967,25 +1307,38 @@ window.__ModuleLoader__.load({
|
|
|
967
1307
|
padding: "6px 0",
|
|
968
1308
|
scrollbarWidth: "none"
|
|
969
1309
|
},
|
|
970
|
-
children:
|
|
1310
|
+
children: render.items.map((item, i) => {
|
|
1311
|
+
const showSeparator = separatorIndices.has(i);
|
|
1312
|
+
const mark = displayMarks[item.displayIndex];
|
|
1313
|
+
const summaryCount = collapsedSummaries.get(mark.key);
|
|
971
1314
|
const bookmarked = isBookmarked(bookmarkedKeys, mark.key);
|
|
972
1315
|
const dotState = markState({
|
|
973
1316
|
key: mark.key,
|
|
974
1317
|
hasQuery,
|
|
975
|
-
isMatch: matches.includes(
|
|
976
|
-
isActive:
|
|
1318
|
+
isMatch: matches.includes(item.displayIndex),
|
|
1319
|
+
isActive: item.displayIndex === activeMarkIndex,
|
|
977
1320
|
isCurrent: !hasQuery && mark.key === currentKey
|
|
978
1321
|
});
|
|
979
1322
|
const isHovered = hover?.mark.key === mark.key;
|
|
980
1323
|
const boxShadow = isHovered ? "0 0 0 3px rgba(77, 124, 254, 0.35)" : dotState === "active" ? "0 0 0 3px rgba(255, 255, 255, 0.9)" : dotState === "current" ? "0 0 0 3px rgba(255, 255, 255, 0.75)" : "none";
|
|
981
1324
|
const badge = deriveBadge({
|
|
982
1325
|
nodeKinds: mark.turn === void 0 ? NO_KINDS : kindsByTurn.get(mark.turn) ?? NO_KINDS,
|
|
983
|
-
lastMark:
|
|
1326
|
+
lastMark: item.displayIndex === displayMarks.length - 1,
|
|
984
1327
|
running,
|
|
985
1328
|
awaitingInput
|
|
986
1329
|
});
|
|
987
1330
|
const ringStyle = badge === null ? null : badgeRingStyle(badge);
|
|
988
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
1331
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [showSeparator && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1332
|
+
"data-turn-separator": true,
|
|
1333
|
+
"data-turn": mark.turn === void 0 ? void 0 : mark.turn,
|
|
1334
|
+
style: {
|
|
1335
|
+
width: DOT_HIT - 8,
|
|
1336
|
+
height: 1,
|
|
1337
|
+
flexShrink: 0,
|
|
1338
|
+
background: "rgba(139, 150, 171, 0.35)",
|
|
1339
|
+
borderRadius: 1
|
|
1340
|
+
}
|
|
1341
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
989
1342
|
type: "button",
|
|
990
1343
|
style: {
|
|
991
1344
|
width: DOT_HIT,
|
|
@@ -1002,15 +1355,17 @@ window.__ModuleLoader__.load({
|
|
|
1002
1355
|
onMouseEnter: (e) => {
|
|
1003
1356
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
1004
1357
|
setHover({
|
|
1005
|
-
...buildHover(mark,
|
|
1358
|
+
...buildHover(mark, item.displayIndex),
|
|
1006
1359
|
top: rect.top + rect.height / 2
|
|
1007
1360
|
});
|
|
1008
1361
|
},
|
|
1009
1362
|
onClick: () => jump(mark.key),
|
|
1010
1363
|
"data-rail-dot": true,
|
|
1364
|
+
"data-collapsed-summary": summaryCount !== void 0 ? "true" : void 0,
|
|
1365
|
+
"data-collapsed-count": summaryCount,
|
|
1011
1366
|
tabIndex: focusIndex === i ? 0 : -1,
|
|
1012
1367
|
onFocus: () => setFocusIndex(i),
|
|
1013
|
-
"aria-label":
|
|
1368
|
+
"aria-label": t("jump.to", { n: item.displayIndex + 1 }),
|
|
1014
1369
|
"aria-current": dotState === "active" ? "true" : void 0,
|
|
1015
1370
|
"data-current": dotState === "current" ? "true" : void 0,
|
|
1016
1371
|
"data-dimmed": dotState === "dimmed" ? "true" : void 0,
|
|
@@ -1020,7 +1375,7 @@ window.__ModuleLoader__.load({
|
|
|
1020
1375
|
width: DOT_SIZE,
|
|
1021
1376
|
height: DOT_SIZE,
|
|
1022
1377
|
borderRadius: "50%",
|
|
1023
|
-
background: dotColor(
|
|
1378
|
+
background: dotColor(item.displayIndex, marks.length),
|
|
1024
1379
|
boxShadow,
|
|
1025
1380
|
transition: "transform 120ms ease, opacity 120ms ease",
|
|
1026
1381
|
transform: `scale(${isHovered ? 1.35 : dotState === "active" || dotState === "current" ? 1.25 : 1})`,
|
|
@@ -1040,7 +1395,7 @@ window.__ModuleLoader__.load({
|
|
|
1040
1395
|
}
|
|
1041
1396
|
})
|
|
1042
1397
|
})
|
|
1043
|
-
}, mark.key);
|
|
1398
|
+
})] }, mark.key);
|
|
1044
1399
|
})
|
|
1045
1400
|
}),
|
|
1046
1401
|
hover !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRailTooltip, {
|
|
@@ -1048,10 +1403,17 @@ window.__ModuleLoader__.load({
|
|
|
1048
1403
|
hover,
|
|
1049
1404
|
bookmarked: isBookmarked(bookmarkedKeys, hover.mark.key),
|
|
1050
1405
|
onToggleBookmark: () => onToggleBookmark(hover.mark.key),
|
|
1406
|
+
onCopy,
|
|
1407
|
+
onFork,
|
|
1408
|
+
copied: copiedKey === hover.mark.key,
|
|
1409
|
+
forked: forkedKey === hover.mark.key,
|
|
1410
|
+
turnCollapsed: hover.mark.turn !== void 0 && collapsedTurns.has(hover.mark.turn),
|
|
1411
|
+
onToggleCollapse,
|
|
1051
1412
|
onMouseEnter: () => setHover((h) => h),
|
|
1052
|
-
onMouseLeave: () => setHover(null)
|
|
1413
|
+
onMouseLeave: () => setHover(null),
|
|
1414
|
+
t
|
|
1053
1415
|
}),
|
|
1054
|
-
showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.
|
|
1416
|
+
showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1055
1417
|
"data-window-hint": true,
|
|
1056
1418
|
style: {
|
|
1057
1419
|
position: "absolute",
|
|
@@ -1065,11 +1427,7 @@ window.__ModuleLoader__.load({
|
|
|
1065
1427
|
pointerEvents: "none",
|
|
1066
1428
|
userSelect: "none"
|
|
1067
1429
|
},
|
|
1068
|
-
children:
|
|
1069
|
-
"已显示 ",
|
|
1070
|
-
marks.length,
|
|
1071
|
-
" 条 · 还有更早"
|
|
1072
|
-
]
|
|
1430
|
+
children: t("window.hint", { n: marks.length })
|
|
1073
1431
|
})
|
|
1074
1432
|
]
|
|
1075
1433
|
});
|
|
@@ -1124,10 +1482,138 @@ window.__ModuleLoader__.load({
|
|
|
1124
1482
|
await binding.session.loadOlder();
|
|
1125
1483
|
};
|
|
1126
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Wrap a session `fork` call into a safe action closure that anchors the cut
|
|
1487
|
+
* at an event seq and always bumps the inherited title.
|
|
1488
|
+
*
|
|
1489
|
+
* - Delegates to `sessions.fork({ sessionId, atSeq, increaseTitle: true })`;
|
|
1490
|
+
* the resolved child id is passed through.
|
|
1491
|
+
* - A rejection propagates unchanged so callers can surface the fork error.
|
|
1492
|
+
*
|
|
1493
|
+
* @param sessions - the injected sessions service (`ctx.sessions`).
|
|
1494
|
+
* @param sessionId - the session the rail is scoped to.
|
|
1495
|
+
* @returns an action that forks that session at a given event seq.
|
|
1496
|
+
*/
|
|
1497
|
+
function createForkAt(sessions, sessionId) {
|
|
1498
|
+
return (atSeq) => sessions.fork({
|
|
1499
|
+
sessionId,
|
|
1500
|
+
atSeq,
|
|
1501
|
+
increaseTitle: true
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
//#endregion
|
|
1505
|
+
//#region src/client/locales.ts
|
|
1506
|
+
/**
|
|
1507
|
+
* UI strings for the milestone rail, keyed flat (single-language-per-key,
|
|
1508
|
+
* no nesting) so the later i18n threading stays a mechanical
|
|
1509
|
+
* `value.replace('{name}', n)` substitution.
|
|
1510
|
+
*
|
|
1511
|
+
* `zh` is the source of truth and the key registry: it byte-matches the
|
|
1512
|
+
* current hardcoded output of MilestoneRail / MilestoneRailTooltip /
|
|
1513
|
+
* MilestoneRailSearch exactly (each `{n}`/`{m}`/`{name}` slot stands in for
|
|
1514
|
+
* the interpolated number or label), so swapping in these templates is
|
|
1515
|
+
* behavior-preserving. `en` is typed `Record<MilestoneKey, string>` so a
|
|
1516
|
+
* missing English translation is a compile error, not a runtime miss.
|
|
1517
|
+
*/
|
|
1518
|
+
const zh = {
|
|
1519
|
+
/** aria-label on each dot: `跳转到第 ${i + 1} 条消息`. */
|
|
1520
|
+
"jump.to": "跳转到第 {n} 条消息",
|
|
1521
|
+
/** Load-older coverage hint: `已显示 {marks.length} 条 · 还有更早`. */
|
|
1522
|
+
"window.hint": "已显示 {n} 条 · 还有更早",
|
|
1523
|
+
/** Hover turn badge: `第 ${mark.turn} 轮`. */
|
|
1524
|
+
"turn.label": "第 {n} 轮",
|
|
1525
|
+
/** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
|
|
1526
|
+
"pos.of": "第 {n} / {m} 条",
|
|
1527
|
+
/** Search input placeholder. */
|
|
1528
|
+
"search.placeholder": "搜索消息内容",
|
|
1529
|
+
/** aria-label on the search toggle button and the search input. */
|
|
1530
|
+
"search.label": "搜索消息",
|
|
1531
|
+
/** aria-label on the bookmarks-only filter toggle. */
|
|
1532
|
+
"bookmark.filter": "只看收藏",
|
|
1533
|
+
/** aria-label on the hover tooltip star toggle. */
|
|
1534
|
+
"bookmark.star": "收藏此消息",
|
|
1535
|
+
/** aria-label on the search clear button. */
|
|
1536
|
+
"search.clear": "清空搜索",
|
|
1537
|
+
/** title + aria-label on the load-older `···` button. */
|
|
1538
|
+
"load.older": "加载更早消息",
|
|
1539
|
+
/** aria-label on the rail root. */
|
|
1540
|
+
"rail.label": "会话里程碑",
|
|
1541
|
+
/** aria-label on the dot list. */
|
|
1542
|
+
"rail.list": "会话里程碑列表",
|
|
1543
|
+
/** Hover preview fallback for empty message text. */
|
|
1544
|
+
"no.text": "(无文本)",
|
|
1545
|
+
/** Relative time: `< 60s`. */
|
|
1546
|
+
"time.justNow": "刚刚",
|
|
1547
|
+
/** Relative time: `< 1h`. */
|
|
1548
|
+
"time.minutes": "{n} 分钟前",
|
|
1549
|
+
/** Relative time: `< 1d`. */
|
|
1550
|
+
"time.hours": "{n} 小时前",
|
|
1551
|
+
/** Relative time: `>= 1d`. */
|
|
1552
|
+
"time.days": "{n} 天前",
|
|
1553
|
+
/** Hover duration: `用时 {durationLabel}`. */
|
|
1554
|
+
"duration.label": "用时 {name}",
|
|
1555
|
+
/** Hover TTFT: `首字 {ttftLabel}`. */
|
|
1556
|
+
"ttft.label": "首字 {name}",
|
|
1557
|
+
/** TurnEndReason `completed`. */
|
|
1558
|
+
"reason.completed": "已完成",
|
|
1559
|
+
/** TurnEndReason `aborted`. */
|
|
1560
|
+
"reason.aborted": "已中止",
|
|
1561
|
+
/** TurnEndReason `error`. */
|
|
1562
|
+
"reason.error": "出错",
|
|
1563
|
+
/** TurnEndReason `max-tokens`. */
|
|
1564
|
+
"reason.maxTokens": "达到上限",
|
|
1565
|
+
/** TurnEndReason `interrupted`. */
|
|
1566
|
+
"reason.interrupted": "已中断",
|
|
1567
|
+
/** TurnEndReason `blocked`. */
|
|
1568
|
+
"reason.blocked": "已阻塞",
|
|
1569
|
+
/** Copy-message tooltip action. */
|
|
1570
|
+
"copy.message": "复制消息",
|
|
1571
|
+
/** Fork-from-here tooltip action. */
|
|
1572
|
+
"fork.here": "从此处 fork",
|
|
1573
|
+
/** Collapse-turn tooltip action. */
|
|
1574
|
+
"collapse.turn": "折叠此轮",
|
|
1575
|
+
/** Expand-turn tooltip action. */
|
|
1576
|
+
"expand.turn": "展开此轮"
|
|
1577
|
+
};
|
|
1578
|
+
const en = {
|
|
1579
|
+
"jump.to": "Jump to message {n}",
|
|
1580
|
+
"window.hint": "Showing {n} messages · more below",
|
|
1581
|
+
"turn.label": "Turn {n}",
|
|
1582
|
+
"pos.of": "Message {n} of {m}",
|
|
1583
|
+
"search.placeholder": "Search message content",
|
|
1584
|
+
"search.label": "Search messages",
|
|
1585
|
+
"bookmark.filter": "Bookmarks only",
|
|
1586
|
+
"bookmark.star": "Bookmark this message",
|
|
1587
|
+
"search.clear": "Clear search",
|
|
1588
|
+
"load.older": "Load older messages",
|
|
1589
|
+
"rail.label": "Session milestones",
|
|
1590
|
+
"rail.list": "Session milestone list",
|
|
1591
|
+
"no.text": "(no text)",
|
|
1592
|
+
"time.justNow": "Just now",
|
|
1593
|
+
"time.minutes": "{n} minutes ago",
|
|
1594
|
+
"time.hours": "{n} hours ago",
|
|
1595
|
+
"time.days": "{n} days ago",
|
|
1596
|
+
"duration.label": "Duration {name}",
|
|
1597
|
+
"ttft.label": "First token {name}",
|
|
1598
|
+
"reason.completed": "Completed",
|
|
1599
|
+
"reason.aborted": "Aborted",
|
|
1600
|
+
"reason.error": "Error",
|
|
1601
|
+
"reason.maxTokens": "Max tokens reached",
|
|
1602
|
+
"reason.interrupted": "Interrupted",
|
|
1603
|
+
"reason.blocked": "Blocked",
|
|
1604
|
+
"copy.message": "Copy message",
|
|
1605
|
+
"fork.here": "Fork from here",
|
|
1606
|
+
"collapse.turn": "Collapse turn",
|
|
1607
|
+
"expand.turn": "Expand turn"
|
|
1608
|
+
};
|
|
1127
1609
|
//#endregion
|
|
1128
1610
|
//#region src/client/index.ts
|
|
1129
1611
|
/** Required services (cordis fiber inject). */
|
|
1130
|
-
const inject = [
|
|
1612
|
+
const inject = [
|
|
1613
|
+
"slots",
|
|
1614
|
+
"sessions",
|
|
1615
|
+
"locale"
|
|
1616
|
+
];
|
|
1131
1617
|
/**
|
|
1132
1618
|
* Register the overlay and rail once their slot declarations are on the
|
|
1133
1619
|
* ledger. The overlay registers directly against the shipped shell.overlay
|
|
@@ -1136,6 +1622,10 @@ window.__ModuleLoader__.load({
|
|
|
1136
1622
|
* @param ctx - client root context.
|
|
1137
1623
|
*/
|
|
1138
1624
|
function apply(ctx) {
|
|
1625
|
+
ctx.effect(() => ctx.locale.register("dsh-milestone", {
|
|
1626
|
+
zh,
|
|
1627
|
+
en
|
|
1628
|
+
}), "dsh-milestone: dictionaries");
|
|
1139
1629
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
1140
1630
|
name: "shell.overlay",
|
|
1141
1631
|
id: "milestone",
|
|
@@ -1148,7 +1638,11 @@ window.__ModuleLoader__.load({
|
|
|
1148
1638
|
ctx.slots.inject("milestone.rail", () => ctx.slots.register({
|
|
1149
1639
|
name: "milestone.rail",
|
|
1150
1640
|
store: createBookmarksStore,
|
|
1151
|
-
|
|
1641
|
+
locale: "dsh-milestone",
|
|
1642
|
+
inject: (sessionId) => ({
|
|
1643
|
+
loadOlder: createLoadOlder(ctx.sessions, sessionId),
|
|
1644
|
+
forkAt: createForkAt(ctx.sessions, sessionId)
|
|
1645
|
+
})
|
|
1152
1646
|
}, MilestoneRail));
|
|
1153
1647
|
}
|
|
1154
1648
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-milestone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Git-style milestone timeline for DeepSeek Harness: hover for metadata, click to jump to any message. 会话里程碑导航条:圆点时间线,定位并跳转到每条提问。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -47,12 +47,14 @@
|
|
|
47
47
|
],
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
50
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
50
51
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
51
52
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
52
53
|
"react": "^18.2.0"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
56
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
56
58
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
57
59
|
"@deepseek-ai/dsh-client-ui-layout": "^0.1.0-rc.6",
|
|
58
60
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|