dsh-sessions-manager 3.2.1 → 3.3.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.en.md CHANGED
@@ -14,6 +14,13 @@
14
14
 
15
15
  A persistent DSH plugin (host + browser halves). Starting with v3.0.0, it covers both the **settings panel** and the **main sidebar**, so frequent session actions don't require opening Settings.
16
16
 
17
+ ## Changelog
18
+
19
+ - **v3.3.0**: new **starred sessions** — an always-visible star button on each row and a dedicated "Starred" view, backed by a plugin-owned schema v3 index (reusing the recycle bin's version-migration / serialized-mutation / atomic-write pattern); new **single-session Markdown export** (human-readable transcript, sectioned by turn, with user / assistant / tool-call summaries); the detail panel gains an **export section** whose "Download raw log (ZIP)" action reuses DSH's official `session.export` endpoint (subsessions + attachments included, auto-hidden when the backend lacks raw-artifact support) instead of duplicating it.
20
+ - **v3.2.2**: engineering hardening, no behavior change. Sidebar decision logic extracted to `src/client/logic.js` (dot semantics / drop validation / fiber row detection) with 13 new regression tests; CI now smoke-tests each runtime version against real cordis and tracks runtime drift.
21
+ - **v3.2.1**: zstd session-log safety fix (frame-boundary scan mismatching zstd magic inside compressed data).
22
+ - **v3.2.0**: sidebar cross-workspace drag & drop + full session management workbench.
23
+
17
24
  ## What's new in v3.2
18
25
 
19
26
  - **Cross-workspace sidebar drag-and-drop**: drop a session on a workspace heading to move it. It coexists with DSH's native same-group sorting and provides target highlighting, same-workspace protection, failure feedback, and a keyboard-accessible menu fallback.
@@ -29,12 +36,14 @@ A persistent DSH plugin (host + browser halves). Starting with v3.0.0, it covers
29
36
 
30
37
  ### Settings panel: Session Manager
31
38
 
32
- - **Unified panel**: four top-level views — **All / Active / Archived / Recycle bin** — with search across title, session ID, and workspace, plus workspace filters and creation-time/title sorting.
39
+ - **Unified panel**: five top-level views — **All / Active / Archived / Starred / Recycle bin** — with search across title, session ID, and workspace, plus workspace filters and creation-time/title sorting.
40
+ - **Starred sessions**: an always-visible star button on each session row toggles starring optimistically with rollback on failure; the Starred view is orthogonal to DSH's active/archived states and they stack. The star index is plugin-owned (schema v3) and never touches DSH logs; stars are cleaned up automatically when a session is permanently purged.
33
41
  - **Cold-title synchronization**: the sidebar is corrected from the latest log-backed `session/title`, so renamed cold sessions no longer need to be opened before showing their current name.
34
42
  - **Sidebar workspace drag-and-drop**: drag a session onto a workspace heading to move it, with a highlighted drop target, same-workspace protection, failure feedback, and the More → Move session menu retained for keyboard access.
35
43
  - **Archive / Restore**: archive hides a session from the sidebar; restore unarchives it and puts it back into its original workspace group.
36
44
  - **Move to a workspace**: pick an **existing workspace**, or enter a **new directory path** (auto-created); the new-path mode also lets you open the **native OS directory picker** with a **「浏览…」** button. The session's working directory and log are migrated together, and even an open session can be moved safely.
37
45
  - **Session details**: expand any session to see **disk usage**, **turns / steps / user·assistant messages / tool calls / image attachments** stats, **tool-usage breakdown**, **search·fetch records**, the **write/edit file list** (already filtered for paths that no longer exist on disk), and **lineage** (parent session / child sessions / subagents).
46
+ - **Export**: the detail panel offers two actions — "**Download raw log (ZIP)**" goes straight to DSH's official `session.export` endpoint (subsessions + attachments included, hidden automatically when the persistence backend lacks raw-artifact support); "**Export Markdown**" renders the session into a human-readable transcript (front matter + per-turn sections + user / assistant / tool-call summaries, with no streaming-delta duplication).
38
47
  - **Batch multi-select**: select-all / archive selected / restore selected / delete selected (single confirmation for batch delete).
39
48
 
40
49
  ### Main sidebar: session ⋯ menu augmentation
@@ -138,6 +147,8 @@ lib/client.js pre-built client (ModuleLoader CJS handshake)
138
147
  | POST | `/archived-sessions/move` | Move a session to a target workspace `{ sessionId, targetPath }` |
139
148
  | POST | `/archived-sessions/details` | Session details (disk / stats / tools / fetch / files / lineage) `{ sessionId }` |
140
149
  | POST | `/archived-sessions/sidebar-state` | Authoritative sidebar titles, recycle-bin IDs, and permanent-deletion tombstones |
150
+ | POST | `/archived-sessions/star/set` | Star / unstar sessions `{ sessionId | sessionIds, starred }` |
151
+ | GET | `/archived-sessions/export-md?sessionId=` | Single-session Markdown export (human-readable transcript) |
141
152
 
142
153
  > Deleting a session moves it to the recycle bin by default; only "permanently delete" inside the recycle bin physically removes the log. Permanently deleted sessions are hidden forever on the client side so they don't reappear in the sidebar or "ungrouped" due to DSH runtime caching.
143
154
 
package/README.md CHANGED
@@ -14,6 +14,13 @@
14
14
 
15
15
  一个 DSH 持久化插件(host + browser 双半)。v3.0.0 起同时覆盖「设置面板」与「主页侧边栏」两个入口,无需打开设置即可完成高频会话操作。
16
16
 
17
+ ## 更新日志
18
+
19
+ - **v3.3.0**:新增**收藏(星标)**——会话行内常驻星标按钮与独立「已收藏」视图,索引采用 schema v3(沿用回收站的版本迁移 / 串行变更 / 原子写入模式);新增**单会话 Markdown 导出**(人类可读对话记录,按轮分节,含用户 / 助手 / 工具调用摘要);详情面板新增**导出区**,「下载原始日志 (ZIP)」直接复用 DSH 官方 `session.export` 端点(含子会话与附件,后端不支持时自动降级隐藏),不重复造轮子。
20
+ - **v3.2.2**:工程加固版,无功能变化。侧栏判定纯逻辑抽取到 `src/client/logic.js`(状态点语义 / 拖拽校验 / fiber 行识别),新增 13 个回归测试;CI 新增 runtime 版本冒烟(真实 cordis DI 契约校验 + 版本漂移追踪)。
21
+ - **v3.2.1**:zstd 会话日志安全修复(帧边界定位误匹配压缩数据内的 magic)。
22
+ - **v3.2.0**:侧栏跨工作区拖拽 + 完整会话管理工作台。
23
+
17
24
  ## v3.2 亮点
18
25
 
19
26
  - **侧栏跨工作区拖拽**:把会话拖到目标工作区标题即可完成迁移;与 DSH 原生同组排序兼容,提供目标高亮、同工作区拦截、失败反馈,并保留键盘可达的菜单入口。
@@ -29,12 +36,14 @@
29
36
 
30
37
  ### 设置面板:会话管理
31
38
 
32
- - **统一面板**:顶部提供「全部 / 活动 / 已归档 / 回收站」四个视图,支持按标题、会话 ID、工作区搜索,按工作区筛选,并按创建时间或标题排序。
39
+ - **统一面板**:顶部提供「全部 / 活动 / 已归档 / 已收藏 / 回收站」五个视图,支持按标题、会话 ID、工作区搜索,按工作区筛选,并按创建时间或标题排序。
40
+ - **收藏(星标)**:会话行左侧常驻星标按钮,单击即收藏 / 取消(乐观更新、失败回滚);「已收藏」视图与 DSH 的活动 / 归档状态正交、可叠加;收藏索引为插件自有 schema v3,不触碰 DSH 日志,会话被彻底删除时自动清理。
33
41
  - **冷态标题同步**:侧栏使用日志中最新的 `session/title` 修正冷启动缓存,改名后的会话无需先打开即可显示新名称。
34
42
  - **侧栏跨工作区拖拽**:直接把会话拖到目标工作区标题即可切换工作区;目标高亮、同工作区拦截、失败反馈,并保留“更多 → 移动会话”作为键盘操作入口。
35
43
  - **归档 / 恢复**:归档把会话从侧栏隐藏;恢复取消归档并放回原工作区分组。
36
44
  - **移动到工作区**:任选**已有工作区**或**新建目录路径**(自动创建),新建目录支持点击 **「浏览…」** 调用系统目录选择窗口。会话的工作目录与日志一起迁移;即使会话处于打开状态也可安全移动。
37
45
  - **会话详情**:展开单条会话查看**磁盘占用**、**轮次 / 步骤 / 用户·助手消息 / 工具调用 / 图片附件**统计、**工具使用分布**、**搜索·抓取记录**、**write/edit 写过的文件列表**(已过滤磁盘上已不存在的路径),以及**血统**(父会话 / 子会话 / 子代理)。
46
+ - **导出**:详情面板底部提供两个入口——「**下载原始日志 (ZIP)**」直接走 DSH 官方 `session.export` 端点(含子会话与附件,持久化后端不支持时自动隐藏);「**导出 Markdown**」由本插件把会话渲染为人类可读对话记录(front matter + 按轮分节 + 用户 / 助手 / 工具调用摘要,流式增量不重复)。
38
47
  - **批量多选**:全选 / 批量归档 / 恢复所选 / 删除所选(批量删除一次二次确认)。
39
48
 
40
49
  ### 主页侧边栏:会话 ⋯ 菜单增强
@@ -138,6 +147,8 @@ lib/client.js 预构建 client(ModuleLoader CJS handshake)
138
147
  | POST | `/archived-sessions/move` | 把会话移动到目标工作区 `{ sessionId, targetPath }` |
139
148
  | POST | `/archived-sessions/details` | 会话详情(磁盘/统计/工具/fetch/文件/血统)`{ sessionId }` |
140
149
  | POST | `/archived-sessions/sidebar-state` | 返回侧栏权威标题、回收站 ID 与永久删除墓碑 |
150
+ | POST | `/archived-sessions/star/set` | 收藏 / 取消收藏 `{ sessionId | sessionIds, starred }` |
151
+ | GET | `/archived-sessions/export-md?sessionId=` | 单会话 Markdown 导出(人类可读对话记录) |
141
152
 
142
153
  > 删除会话默认进入回收站,只有回收站内的「彻底删除」才会物理移除日志。被彻底删除的会话由前端永久隐藏,避免 DSH 运行时缓存使其重新出现在侧栏或「未分组」中。
143
154
 
package/lib/client.js CHANGED
@@ -35,6 +35,51 @@ __export(index_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
37
  var import_react = __toESM(require("react"), 1);
38
+
39
+ // src/client/logic.js
40
+ function dotStateFor({ manualUnread = false, dataState = "", isActive = false } = {}) {
41
+ if (manualUnread) return "manual";
42
+ switch (dataState) {
43
+ case "running":
44
+ return "running";
45
+ case "warning":
46
+ return "feedback";
47
+ case "error":
48
+ return "error";
49
+ case "done":
50
+ return isActive ? null : "done";
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+ function canDropOnWorkspace(item, target) {
56
+ if (!item || !target) return false;
57
+ if (item.workspacePath && target.path === item.workspacePath) return false;
58
+ return true;
59
+ }
60
+ function sessionForNodes(nodes, knownSessions) {
61
+ for (const node of nodes || []) {
62
+ const id = node && node.id != null ? String(node.id) : "";
63
+ if (knownSessions.has(id)) return knownSessions.get(id);
64
+ if (id && node.workspaceId == null && (node.title != null || node.updatedAt != null || node.blank != null)) {
65
+ return { sessionId: id, title: node.title || "", workspacePath: null };
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ function workspaceForNodes(nodes) {
71
+ for (const group of nodes || []) {
72
+ if (group && group.workspaceId != null && typeof group.cwd === "string" && group.cwd) {
73
+ return { workspaceId: String(group.workspaceId), path: group.cwd, title: group.label || group.cwd };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ function starredOf(items) {
79
+ return (items || []).filter((item) => item && item.starred);
80
+ }
81
+
82
+ // src/client/index.jsx
38
83
  var import_jsx_runtime = require("react/jsx-runtime");
39
84
  var inject = ["slots"];
40
85
  var PANEL_PREFS_KEY = "dsm-panel-prefs-v1";
@@ -80,6 +125,11 @@ var CSS = `
80
125
  .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
81
126
  .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
82
127
  .archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
128
+ .archv-star{appearance:none;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;border-radius:7px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:color .15s ease,background-color .15s ease}
129
+ .archv-star:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
130
+ .archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}
131
+ .archv-star-on,.archv-star-on:hover{color:var(--dsw-alias-state-business-primary)}
132
+ .archv-star-on svg{fill:currentColor}
83
133
  .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
84
134
  .archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
85
135
  .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
@@ -125,6 +175,9 @@ var CSS = `
125
175
  .dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
126
176
  .dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
127
177
  .dtl-sec{margin-top:12px}
178
+ .dtl-export{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
179
+ .dtl-export .archv-btn{text-decoration:none}
180
+ .dtl-note{margin-top:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}
128
181
  .dtl-sec-t{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px}
129
182
  .dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
130
183
  .dtl-tag{display:inline-flex;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:2px 8px}
@@ -226,7 +279,7 @@ function SessionPanel({ workspacesSvc }) {
226
279
  const [sessions, setSessions] = (0, import_react.useState)(null);
227
280
  const [workspaces, setWorkspaces] = (0, import_react.useState)([]);
228
281
  const initialPrefs = (0, import_react.useRef)(loadPanelPrefs()).current;
229
- const [filter, setFilter] = (0, import_react.useState)(() => ["all", "active", "archived", "trash"].includes(initialPrefs.filter) ? initialPrefs.filter : "all");
282
+ const [filter, setFilter] = (0, import_react.useState)(() => ["all", "active", "archived", "starred", "trash"].includes(initialPrefs.filter) ? initialPrefs.filter : "all");
230
283
  const [query, setQuery] = (0, import_react.useState)("");
231
284
  const [workspaceFilter, setWorkspaceFilter] = (0, import_react.useState)(() => initialPrefs.workspaceFilter || "all");
232
285
  const [sortBy, setSortBy] = (0, import_react.useState)(() => ["newest", "oldest", "title"].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : "newest");
@@ -249,6 +302,9 @@ function SessionPanel({ workspacesSvc }) {
249
302
  const [details, setDetails] = (0, import_react.useState)({});
250
303
  const [openDetails, setOpenDetails] = (0, import_react.useState)(null);
251
304
  const [detailsLoading, setDetailsLoading] = (0, import_react.useState)(null);
305
+ const [mdBusy, setMdBusy] = (0, import_react.useState)(null);
306
+ const [zipOk, setZipOk] = (0, import_react.useState)(true);
307
+ const zipChecked = (0, import_react.useRef)(false);
252
308
  const [openMenu, setOpenMenu] = (0, import_react.useState)(null);
253
309
  const timer = (0, import_react.useRef)(null);
254
310
  const menuRef = (0, import_react.useRef)(null);
@@ -287,6 +343,43 @@ function SessionPanel({ workspacesSvc }) {
287
343
  } catch (e) {
288
344
  }
289
345
  }, [filter, workspaceFilter, sortBy]);
346
+ const toggleStar = async (it) => {
347
+ const next = !it.starred;
348
+ setSessions((s) => s && s.map((x) => x.sessionId === it.sessionId ? { ...x, starred: next } : x));
349
+ try {
350
+ await postJSON("/archived-sessions/star/set", { sessionId: it.sessionId, starred: next });
351
+ } catch (e) {
352
+ setSessions((s) => s && s.map((x) => x.sessionId === it.sessionId ? { ...x, starred: !next } : x));
353
+ showToast("\u6536\u85CF\u5931\u8D25\uFF1A" + String(e && e.message || e));
354
+ }
355
+ };
356
+ const exportMarkdown = async (it) => {
357
+ if (mdBusy) return;
358
+ setMdBusy(it.sessionId);
359
+ try {
360
+ const res = await fetch("/archived-sessions/export-md?sessionId=" + encodeURIComponent(it.sessionId));
361
+ if (!res.ok) throw new Error("HTTP " + res.status);
362
+ const blob = await res.blob();
363
+ const url = URL.createObjectURL(blob);
364
+ const a = document.createElement("a");
365
+ a.href = url;
366
+ a.download = "dsh-session-" + it.sessionId + ".md";
367
+ document.body.appendChild(a);
368
+ a.click();
369
+ a.remove();
370
+ URL.revokeObjectURL(url);
371
+ showToast("\u5DF2\u5BFC\u51FA Markdown");
372
+ } catch (e) {
373
+ showToast("\u5BFC\u51FA\u5931\u8D25\uFF1A" + String(e && e.message || e));
374
+ } finally {
375
+ setMdBusy(null);
376
+ }
377
+ };
378
+ (0, import_react.useEffect)(() => {
379
+ if (openDetails === null || zipChecked.current) return;
380
+ zipChecked.current = true;
381
+ fetch("/api/session.export?sessionId=probe&includeDescendants=false", { method: "HEAD" }).then((res) => setZipOk(res.status !== 501)).catch(() => setZipOk(true));
382
+ }, [openDetails]);
290
383
  (0, import_react.useEffect)(() => {
291
384
  if (openMenu === null) return;
292
385
  const onDown = (e) => {
@@ -324,9 +417,10 @@ function SessionPanel({ workspacesSvc }) {
324
417
  };
325
418
  const archivedList = sessions ? sessions.filter((x) => x.archived) : [];
326
419
  const activeList = sessions ? sessions.filter((x) => !x.archived) : [];
420
+ const starredList = starredOf(sessions);
327
421
  const list = (0, import_react.useMemo)(() => {
328
422
  if (filter === "trash") return [];
329
- const base = filter === "archived" ? archivedList : filter === "active" ? activeList : sessions || [];
423
+ const base = filter === "archived" ? archivedList : filter === "active" ? activeList : filter === "starred" ? starredList : sessions || [];
330
424
  const needle = query.trim().toLocaleLowerCase();
331
425
  const filtered = base.filter((item) => {
332
426
  if (workspaceFilter !== "all" && (item.workspacePath || "") !== workspaceFilter) return false;
@@ -634,6 +728,15 @@ function SessionPanel({ workspacesSvc }) {
634
728
  archivedList.length,
635
729
  ")"
636
730
  ] }),
731
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", role: "tab", "aria-selected": filter === "starred", className: "sess-fbtn" + (filter === "starred" ? " sess-fbtn-on" : ""), onClick: () => {
732
+ setFilter("starred");
733
+ clearSel();
734
+ setConfirmBatch(false);
735
+ }, children: [
736
+ "\u5DF2\u6536\u85CF (",
737
+ starredList.length,
738
+ ")"
739
+ ] }),
637
740
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", role: "tab", "aria-selected": filter === "trash", className: "sess-fbtn" + (filter === "trash" ? " sess-fbtn-on" : ""), onClick: () => {
638
741
  setFilter("trash");
639
742
  clearSel();
@@ -670,11 +773,11 @@ function SessionPanel({ workspacesSvc }) {
670
773
  "\u663E\u793A ",
671
774
  list.length,
672
775
  " \u4E2A\u4F1A\u8BDD",
673
- query || workspaceFilter !== "all" ? `\uFF0C\u5171 ${filter === "archived" ? archivedList.length : filter === "active" ? activeList.length : sessions.length} \u4E2A` : ""
776
+ query || workspaceFilter !== "all" ? `\uFF0C\u5171 ${filter === "archived" ? archivedList.length : filter === "active" ? activeList.length : filter === "starred" ? starredList.length : sessions.length} \u4E2A` : ""
674
777
  ] })
675
778
  ] }),
676
779
  filter !== "trash" && list.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sess-batch", children: [
677
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "sess-btntext", children: selIds.length ? `\u5DF2\u9009 ${selIds.length} \u9879` : filter === "archived" ? `\u5171 ${archivedList.length} \u4E2A\u5F52\u6863\u4F1A\u8BDD` : `\u5171 ${sessions.length} \u4E2A\u4F1A\u8BDD\uFF08\u6D3B\u52A8 ${activeList.length} / \u5DF2\u5F52\u6863 ${archivedList.length}\uFF09` }),
780
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "sess-btntext", children: selIds.length ? `\u5DF2\u9009 ${selIds.length} \u9879` : filter === "archived" ? `\u5171 ${archivedList.length} \u4E2A\u5F52\u6863\u4F1A\u8BDD` : filter === "starred" ? `\u5171 ${starredList.length} \u4E2A\u6536\u85CF\u4F1A\u8BDD` : `\u5171 ${sessions.length} \u4E2A\u4F1A\u8BDD\uFF08\u6D3B\u52A8 ${activeList.length} / \u5DF2\u5F52\u6863 ${archivedList.length}\uFF09` }),
678
781
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "archv-btn", disabled: list.length === 0, onClick: selectAll, children: "\u5168\u9009" }),
679
782
  selIds.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
680
783
  filter === "archived" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -685,7 +788,7 @@ function SessionPanel({ workspacesSvc }) {
685
788
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "archv-btn", onClick: clearSel, children: "\u53D6\u6D88\u9009\u62E9" })
686
789
  ] })
687
790
  ] }),
688
- filter !== "trash" && list.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "archv-empty", children: query || workspaceFilter !== "all" ? "\u6CA1\u6709\u5339\u914D\u7684\u4F1A\u8BDD\u3002\u8BF7\u8C03\u6574\u641C\u7D22\u8BCD\u6216\u5DE5\u4F5C\u533A\u7B5B\u9009\u3002" : filter === "archived" ? "\u76EE\u524D\u6CA1\u6709\u5F52\u6863\u4F1A\u8BDD\u3002\u5728\u201C\u5168\u90E8\u201D\u91CC\u9009\u4E2D\u4F1A\u8BDD\u70B9\u201C\u5F52\u6863\u201D\u5373\u53EF\u6536\u7EB3\u8FDB\u6765\u3002" : filter === "active" ? "\u76EE\u524D\u6CA1\u6709\u6D3B\u52A8\u4F1A\u8BDD\u3002" : "\u6682\u65E0\u53EF\u7BA1\u7406\u7684\u4F1A\u8BDD\u3002" }) : filter !== "trash" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "archv-list", role: "list", children: list.map((it) => {
791
+ filter !== "trash" && list.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "archv-empty", children: query || workspaceFilter !== "all" ? "\u6CA1\u6709\u5339\u914D\u7684\u4F1A\u8BDD\u3002\u8BF7\u8C03\u6574\u641C\u7D22\u8BCD\u6216\u5DE5\u4F5C\u533A\u7B5B\u9009\u3002" : filter === "archived" ? "\u76EE\u524D\u6CA1\u6709\u5F52\u6863\u4F1A\u8BDD\u3002\u5728\u201C\u5168\u90E8\u201D\u91CC\u9009\u4E2D\u4F1A\u8BDD\u70B9\u201C\u5F52\u6863\u201D\u5373\u53EF\u6536\u7EB3\u8FDB\u6765\u3002" : filter === "active" ? "\u76EE\u524D\u6CA1\u6709\u6D3B\u52A8\u4F1A\u8BDD\u3002" : filter === "starred" ? "\u8FD8\u6CA1\u6709\u6536\u85CF\u7684\u4F1A\u8BDD\u3002\u70B9\u51FB\u4F1A\u8BDD\u5DE6\u4FA7\u7684\u661F\u6807\u5373\u53EF\u6536\u85CF\u3002" : "\u6682\u65E0\u53EF\u7BA1\u7406\u7684\u4F1A\u8BDD\u3002" }) : filter !== "trash" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "archv-list", role: "list", children: list.map((it) => {
689
792
  const date = fmtDate(it.createdAt);
690
793
  const expanded = openMove === it.sessionId;
691
794
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "archv-card" + (expanded ? " archv-card-exp" : ""), role: "listitem", children: [
@@ -700,6 +803,21 @@ function SessionPanel({ workspacesSvc }) {
700
803
  "aria-label": "\u9009\u62E9 " + (it.title || it.sessionId)
701
804
  }
702
805
  ),
806
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
807
+ "button",
808
+ {
809
+ type: "button",
810
+ className: "archv-star" + (it.starred ? " archv-star-on" : ""),
811
+ "aria-pressed": !!it.starred,
812
+ "aria-label": (it.starred ? "\u53D6\u6D88\u6536\u85CF " : "\u6536\u85CF ") + (it.title || it.sessionId),
813
+ title: it.starred ? "\u53D6\u6D88\u6536\u85CF" : "\u6536\u85CF",
814
+ onClick: (e) => {
815
+ e.stopPropagation();
816
+ toggleStar(it);
817
+ },
818
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", width: "15", height: "15", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" }) })
819
+ }
820
+ ),
703
821
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "archv-body", children: [
704
822
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "archv-main", children: [
705
823
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "archv-name", title: it.title || "", children: it.title || "(\u65E0\u6807\u9898)" }),
@@ -869,6 +987,24 @@ function SessionPanel({ workspacesSvc }) {
869
987
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: d.lineage.subagents.join(", ") })
870
988
  ] })
871
989
  ] })
990
+ ] }),
991
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dtl-sec", children: [
992
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dtl-sec-t", children: "\u5BFC\u51FA" }),
993
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dtl-export", children: [
994
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
995
+ "a",
996
+ {
997
+ className: "archv-btn",
998
+ href: `/api/session.export?sessionId=${encodeURIComponent(it.sessionId)}&includeDescendants=true`,
999
+ onClick: (e) => e.stopPropagation(),
1000
+ style: zipOk ? void 0 : { pointerEvents: "none", opacity: 0.45 },
1001
+ title: zipOk ? "\u542B\u5B50\u4F1A\u8BDD\u4E0E\u9644\u4EF6\uFF0C\u7531 DSH \u63D0\u4F9B" : "\u5F53\u524D\u6301\u4E45\u5316\u540E\u7AEF\u4E0D\u652F\u6301\u539F\u59CB\u65E5\u5FD7\u5BFC\u51FA",
1002
+ children: "\u4E0B\u8F7D\u539F\u59CB\u65E5\u5FD7 (ZIP)"
1003
+ }
1004
+ ),
1005
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "archv-btn", disabled: mdBusy === it.sessionId, onClick: () => exportMarkdown(it), children: mdBusy === it.sessionId ? "\u751F\u6210\u4E2D\u2026" : "\u5BFC\u51FA Markdown" })
1006
+ ] }),
1007
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dtl-note", children: "ZIP \u542B\u5B50\u4F1A\u8BDD\u4E0E\u9644\u4EF6\uFF0C\u7531 DSH \u63D0\u4F9B \xB7 Markdown \u4E3A\u672C\u63D2\u4EF6\u751F\u6210\u7684\u53EF\u8BFB\u5BF9\u8BDD\u8BB0\u5F55" })
872
1008
  ] })
873
1009
  ] });
874
1010
  })()
@@ -1377,15 +1513,8 @@ function installSidebarStatusDots() {
1377
1513
  const sd = row.querySelector("[data-state]");
1378
1514
  if (sd) sd.style.display = "none";
1379
1515
  let dot = row.querySelector("[" + DOT + "]");
1380
- let color = null;
1381
- if (manualUnread.has(id)) color = COLOR.manual;
1382
- else if (sd) {
1383
- const st = sd.getAttribute("data-state");
1384
- if (st === "running") color = COLOR.running;
1385
- else if (st === "warning") color = COLOR.feedback;
1386
- else if (st === "error") color = COLOR.error;
1387
- else if (st === "done") color = activeId === id ? null : COLOR.done;
1388
- }
1516
+ const state = dotStateFor({ manualUnread: manualUnread.has(id), dataState: sd ? sd.getAttribute("data-state") : "", isActive: activeId === id });
1517
+ const color = state ? COLOR[state] : null;
1389
1518
  if (!color) {
1390
1519
  if (dot) dot.remove();
1391
1520
  return;
@@ -1449,24 +1578,10 @@ function installSidebarWorkspaceDrag() {
1449
1578
  }
1450
1579
  return out;
1451
1580
  };
1452
- const sessionForRow = (row) => {
1453
- for (const node of fiberNodes(row)) {
1454
- const id = node && node.id != null ? String(node.id) : "";
1455
- if (sessions.has(id)) return sessions.get(id);
1456
- if (id && node.workspaceId == null && (node.title != null || node.updatedAt != null || node.blank != null)) {
1457
- return { sessionId: id, title: node.title || "", workspacePath: null };
1458
- }
1459
- }
1460
- return null;
1461
- };
1581
+ const sessionForRow = (row) => sessionForNodes(fiberNodes(row), sessions);
1462
1582
  const workspaceForRow = (row) => {
1463
1583
  if (sessionForRow(row)) return null;
1464
- for (const group of fiberNodes(row)) {
1465
- if (group.workspaceId != null && typeof group.cwd === "string" && group.cwd) {
1466
- return { workspaceId: String(group.workspaceId), path: group.cwd, title: group.label || group.cwd };
1467
- }
1468
- }
1469
- return null;
1584
+ return workspaceForNodes(fiberNodes(row));
1470
1585
  };
1471
1586
  const eventRow = (event) => {
1472
1587
  for (const item of event.composedPath ? event.composedPath() : []) {
@@ -1514,7 +1629,7 @@ function installSidebarWorkspaceDrag() {
1514
1629
  const row = eventRow(event);
1515
1630
  const target = row && workspaceForRow(row);
1516
1631
  if (!dragging || !target || moving) return;
1517
- if (dragging.workspacePath && target.path === dragging.workspacePath) return;
1632
+ if (!canDropOnWorkspace(dragging, target)) return;
1518
1633
  event.preventDefault();
1519
1634
  event.stopPropagation();
1520
1635
  if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
@@ -1527,7 +1642,7 @@ function installSidebarWorkspaceDrag() {
1527
1642
  const row = eventRow(event);
1528
1643
  const item = dragging;
1529
1644
  const target = row && workspaceForRow(row);
1530
- if (!item || !target || moving || item.workspacePath && target.path === item.workspacePath) return;
1645
+ if (!item || !target || moving || !canDropOnWorkspace(item, target)) return;
1531
1646
  event.preventDefault();
1532
1647
  event.stopPropagation();
1533
1648
  moving = true;