@morehao/dsh-skills-console 0.3.6 → 0.3.8

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 (3) hide show
  1. package/README.md +1 -1
  2. package/client.js +493 -342
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -37,7 +37,7 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
37
37
  - **两个入口**:默认在「设置 → 技能控制台」;页头 ⚙ 里可切到左侧栏独立面板,切换立即生效。
38
38
  - **一页两 tab**:已安装 / 订阅。页头右侧是「自动接入」(多选 Agent)与「添加技能 / 添加订阅」。
39
39
  - **每行五个动作**:开关(启停)、查看、更新、接入设置、删除;窄面板下隐藏「查看」,技能名本身仍是入口。状态标签覆盖最新 / 可更新 / 本地已改 / 上游已移除 / 软链断裂 / 无上游等。
40
- - **窄面板不跳版**:页头按内容自然换行——放得下时动作组在标题行右侧,放不下(约 630px 以下,如设置页)就整组换到下一行并靠右,插件名始终完整;列表按面板自身宽度在 860px 处切两列;页面预留滚动条槽位,滚动条出现/消失、切 tab、窗口微调都不会让整页翻面。
40
+ - **窄面板不跳版**:页头按内容自然换行——放得下时动作组在标题行右侧,放不下(约 630px 以下,如设置页)就整组换到下一行并靠右,插件名始终完整;列表按面板自身宽度在 860px 处切两列;滚动只发生在列表区(页头、工具栏、筛选条固定不动),滚动条出现/消失、切 tab、窗口微调都不会让整页翻面。
41
41
  - **Agent 多了不挤**:每行最多展示 3 个,其余折成 `+n`,悬浮或聚焦即可看全。
42
42
  - **找得到**:支持按名称 / 来源 / Agent 搜索,按「可更新 / 待检查 / 已停用」筛选,平铺或按 Agent 分组。
43
43
  - **待检查 vs 无上游**:「待检查」只表示*这次*没拿到上游结论(限流、网络、仓库 404 等);再次点「检查更新」时,如果上次没有结论、上游又只回了 304,会无视 ETag 完整重读一次树,不会把「待检查」永久粘住。没有可用订阅来源的技能标「无上游」——它们无法参与更新检查,不计入待检查。
package/client.js CHANGED
@@ -470,8 +470,45 @@ try {
470
470
  } catch {
471
471
  ReactDOM = null;
472
472
  }
473
- function portalize(node) {
474
- return ReactDOM?.createPortal && typeof document !== "undefined" && document.body ? ReactDOM.createPortal(node, document.body) : node;
473
+ function overlayHost() {
474
+ if (typeof document === "undefined") return null;
475
+ const root = document.querySelector?.(".dshsc-root");
476
+ const dialog = root?.closest?.('[role="dialog"]');
477
+ return dialog || document.querySelector?.("body") || null;
478
+ }
479
+ function portalize(node, target) {
480
+ const host = target || (typeof document !== "undefined" ? document.body : null);
481
+ return ReactDOM?.createPortal && host ? ReactDOM.createPortal(node, host) : node;
482
+ }
483
+ var overlayLayers = [];
484
+ var escapeGuarded = false;
485
+ function onOverlayEscape(event) {
486
+ if (event.key !== "Escape" || overlayLayers.length === 0) return;
487
+ event.preventDefault?.();
488
+ event.stopPropagation?.();
489
+ event.stopImmediatePropagation?.();
490
+ overlayLayers[overlayLayers.length - 1].close();
491
+ }
492
+ function useOverlayLayer(active, close) {
493
+ const closeRef = import_react.default.useRef(close);
494
+ closeRef.current = close;
495
+ import_react.default.useEffect(() => {
496
+ if (!active || typeof document === "undefined") return void 0;
497
+ const layer = { close: () => closeRef.current?.() };
498
+ overlayLayers.push(layer);
499
+ if (!escapeGuarded) {
500
+ document.addEventListener("keydown", onOverlayEscape, true);
501
+ escapeGuarded = true;
502
+ }
503
+ return () => {
504
+ const index = overlayLayers.indexOf(layer);
505
+ if (index >= 0) overlayLayers.splice(index, 1);
506
+ if (overlayLayers.length === 0 && escapeGuarded) {
507
+ document.removeEventListener("keydown", onOverlayEscape, true);
508
+ escapeGuarded = false;
509
+ }
510
+ };
511
+ }, [active]);
475
512
  }
476
513
 
477
514
  // client/icons.js
@@ -811,21 +848,52 @@ function FolderIcon({ size = 12 }) {
811
848
  }
812
849
 
813
850
  // client/section.js
814
- var import_react5 = __toESM(require("react"), 1);
851
+ var import_react6 = __toESM(require("react"), 1);
852
+
853
+ // client/scroll.js
854
+ function isScroller(node, tolerance = 4) {
855
+ if (!node || typeof getComputedStyle !== "function") return false;
856
+ const style = getComputedStyle(node);
857
+ return Boolean(style && /(auto|scroll)/.test(style.overflowY || "") && node.scrollHeight > node.clientHeight + tolerance);
858
+ }
859
+ function nearestScroller(node, tolerance = 4) {
860
+ let current = node?.parentElement || null;
861
+ while (current) {
862
+ if (isScroller(current, tolerance)) return current;
863
+ current = current.parentElement || null;
864
+ }
865
+ return typeof document !== "undefined" ? document.scrollingElement || null : null;
866
+ }
867
+ function scrollHost(node, tolerance = 4) {
868
+ if (isScroller(node, tolerance)) return node;
869
+ return node && nearestScroller(node, tolerance) || node;
870
+ }
815
871
 
816
872
  // client/styles.js
817
873
  var CSS = `
818
- /* 主面板形态:与 DSH 自带管理页同一边距/滚动方式。
819
- scrollbar-gutter: stable 让滚动条出现/消失都不改变内容宽度——否则列表一长出来,
820
- 内容就窄 5~15px,贴着断点的布局会整页跳一次。 */
821
- .dshsc-page { box-sizing: border-box; width: 100%; height: 100%; overflow: auto;
822
- scrollbar-gutter: stable;
874
+ /* 主面板形态:页面本身固定不滚,滚动条只出现在列表区(.dshsc-listscroll)。
875
+ 页头 / 工具栏 / 筛选条是 flex:none 的固定行,行区域 flex:1 + overflow:auto。
876
+ scrollbar-gutter 跟着滚动条一起挪到列表区:行区域变长/变短都不改变自身宽度,
877
+ 贴着 860 断点的布局不会因为滚动条出现整页翻面。 */
878
+ .dshsc-page { box-sizing: border-box; width: 100%; height: 100%; overflow: hidden;
823
879
  padding: calc(28px + var(--dsh-frame-top-clearance, 0px)) clamp(24px, 4vw, 48px) 48px; }
824
880
  .dshsc-page .dshsc-root { max-width: 980px; margin: 0 auto; }
825
881
  .dshsc-page .dshsc-title { font-size: 20px; font-weight: 500; line-height: 28px; }
826
882
  .dshsc-page .dshsc-sub { font-size: 13px; line-height: 20px; }
827
- .dshsc-root { display: flex; flex-direction: column; gap: 14px; color: var(--dsw-alias-label-primary);
883
+ /* 高度取满挂载面:侧栏 main 是 100%,设置页是宿主 .options 的内容高。
884
+ 宿主没给确定高度时(height:100% 解析为 auto)退化成外层滚动,不会把内容裁掉。 */
885
+ .dshsc-root { display: flex; flex-direction: column; gap: 14px; height: 100%; min-height: 0;
886
+ color: var(--dsw-alias-label-primary);
828
887
  container-type: inline-size; container-name: dshsc; }
888
+ /* 页头 / 工具栏 / 顶部提示条高度不参与压缩;tab 面板吃满剩余高度,
889
+ 面板里的筛选条与说明固定,只有行区域(.dshsc-listscroll)自己出现滚动条。 */
890
+ .dshsc-root > .dshsc-head, .dshsc-root > .dshsc-toolbar,
891
+ .dshsc-root > .dshsc-banner, .dshsc-root > .dshsc-meta { flex: none; }
892
+ .dshsc-tabpane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
893
+ .dshsc-tabpane > .dshsc-banner, .dshsc-tabpane > .dshsc-notice,
894
+ .dshsc-tabpane > .dshsc-listbar { flex: none; }
895
+ .dshsc-listscroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain;
896
+ scrollbar-gutter: stable; }
829
897
  /* 页头:左标题块、右业务动作。动作组不靠固定断点换行——标题块的 flex-basis 用内容宽度
830
898
  (auto),放得下就同排、放不下(约 630px 以下)动作组自然换到第二行并靠右。
831
899
  这样窄面板(设置页 ~565px)会回到「动作在下、标识在上」,身份胶囊永远是完整的,
@@ -1218,8 +1286,9 @@ var CSS = `
1218
1286
  color: var(--dsw-alias-label-secondary); line-height: 1.6; }
1219
1287
  .dshsc-reader-placeholder b { color: var(--dsw-alias-label-primary); font-size: 13px; }
1220
1288
 
1221
- /* 文件 tab:左树右预览;抽屉里上下排 */
1222
- .dshsc-reader-files { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
1289
+ /* 文件 tab:抽屉里上下排;整页(宽容器)左树右预览 */
1290
+ .dshsc-reader-files { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; }
1291
+ .dshsc-reader-page .dshsc-reader-files { grid-template-columns: minmax(200px, 240px) minmax(0, 1fr); }
1223
1292
  .dshsc-reader-tree { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: auto;
1224
1293
  max-height: 300px; background: var(--dsw-alias-bg-layer-1); }
1225
1294
  .dshsc-reader-preview { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden;
@@ -1313,8 +1382,11 @@ var CSS = `
1313
1382
  .dshsc-md-table th { background: var(--dsw-alias-bg-layer-2); font-weight: 600; }
1314
1383
  .dshsc-md-table tr:last-child td { border-bottom: 0; }
1315
1384
 
1316
- /* 整页(A1):返回条 + hero + 目录 + 720px 正文 */
1317
- .dshsc-reader-page { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
1385
+ /* 整页(A1):返回条 + hero + 目录 + 720px 正文。
1386
+ root 高度固定后,整页阅读器也自己撑满:顶栏 / hero 固定,正文列内部滚动,
1387
+ 返回按钮不会跟着长文滚走。 */
1388
+ .dshsc-reader-page { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
1389
+ .dshsc-reader-page > .dshsc-reader-topbar, .dshsc-reader-page > .dshsc-reader-hero { flex: none; }
1318
1390
  .dshsc-reader-topbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
1319
1391
  .dshsc-reader-topbar .dshsc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px;
1320
1392
  box-sizing: border-box; height: 28px; padding: 0 10px; font-size: 12px; line-height: 1; white-space: nowrap; }
@@ -1334,7 +1406,11 @@ var CSS = `
1334
1406
  background: var(--dsw-alias-bg-layer-1); }
1335
1407
  .dshsc-reader-hero .dshsc-reader-head { border-bottom: 0; }
1336
1408
  .dshsc-reader-hero .dshsc-reader-desc { -webkit-line-clamp: 3; max-width: 78ch; }
1337
- .dshsc-reader-layout { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 18px; align-items: start; }
1409
+ .dshsc-reader-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr);
1410
+ gap: 18px; align-items: stretch; }
1411
+ /* 只有目录真的在的时候才开第二轨:否则唯一的正文列会被自动摆进 180px 的目录轨,
1412
+ 文件 / 信息 tab 会窄成一条(树里的文件名被截断、预览挤到下面)。 */
1413
+ .dshsc-reader-layout.has-toc { grid-template-columns: 180px minmax(0, 1fr); }
1338
1414
  .dshsc-reader-toc { position: sticky; top: 0; display: flex; flex-direction: column; gap: 1px;
1339
1415
  max-height: 70vh; overflow: auto; padding-right: 4px; }
1340
1416
  .dshsc-reader-toc-title { font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase;
@@ -1348,7 +1424,8 @@ var CSS = `
1348
1424
  .dshsc-reader-tocitem.on { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
1349
1425
  color: var(--dsw-alias-brand-primary); }
1350
1426
  .dshsc-reader-tocitem:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1351
- .dshsc-reader-col { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--dsw-alias-border-l1);
1427
+ .dshsc-reader-col { display: flex; flex-direction: column; min-width: 0; min-height: 0;
1428
+ border: 1px solid var(--dsw-alias-border-l1);
1352
1429
  border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; }
1353
1430
  .dshsc-reader-col .dshsc-reader-tabs { border-top: 0; }
1354
1431
  .dshsc-reader-page .dshsc-reader-body { max-height: none; }
@@ -1359,11 +1436,20 @@ var CSS = `
1359
1436
  @container dshsc (max-width: 860px) {
1360
1437
  /* 行内「查看」先让位:技能名本身仍是入口 */
1361
1438
  .dshsc-row .dshsc-view { display: none; }
1362
- .dshsc-reader-layout { grid-template-columns: minmax(0, 1fr); }
1439
+ .dshsc-reader-layout.has-toc { grid-template-columns: minmax(0, 1fr); }
1440
+ .dshsc-reader-page .dshsc-reader-files { grid-template-columns: minmax(0, 1fr); }
1363
1441
  .dshsc-reader-toc { display: none; }
1364
1442
  .dshsc-reader-page .dshsc-reader-desc { -webkit-line-clamp: 2; }
1365
1443
  }
1366
1444
 
1445
+ /* 描述默认按行数截断;真被截断时下面给「展开 / 收起」,所以要压在上面所有 clamp 规则之后 */
1446
+ .dshsc-reader-descblock { display: flex; flex-direction: column; align-items: flex-start; }
1447
+ .dshsc-reader-desc.expanded { display: block; overflow: visible; -webkit-line-clamp: unset; }
1448
+ .dshsc-reader-more { margin-top: 3px; padding: 0; border: 0; border-radius: 4px; background: transparent;
1449
+ color: var(--dsw-alias-brand-primary); font: inherit; font-size: 11.5px; line-height: 1.6; cursor: pointer; }
1450
+ .dshsc-reader-more:hover { text-decoration: underline; }
1451
+ .dshsc-reader-more:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
1452
+
1367
1453
  .dshsc-toast { position: fixed; right: 18px; bottom: 18px; z-index: 70; font-size: 12px; padding: 10px 13px; border-radius: 9px;
1368
1454
  background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2);
1369
1455
  box-shadow: 0 8px 24px rgba(0,0,0,.22); max-width: 420px; }
@@ -2197,17 +2283,35 @@ function InstalledTab({
2197
2283
  ) : null;
2198
2284
  }
2199
2285
  function content() {
2200
- if (!data) return h("div", { className: "dshsc-empty" }, t("empty.loading"));
2286
+ if (!data) {
2287
+ return h(
2288
+ "div",
2289
+ { className: "dshsc-tabpane" },
2290
+ h(
2291
+ "div",
2292
+ { className: "dshsc-listscroll" },
2293
+ h("div", { className: "dshsc-empty" }, t("empty.loading"))
2294
+ )
2295
+ );
2296
+ }
2201
2297
  if (rows.length === 0) {
2202
2298
  return h(
2203
2299
  "div",
2204
- { className: "dshsc-empty" },
2205
- h("div", { className: "dshsc-name" }, t("empty.skills")),
2206
- h("div", { className: "dshsc-meta", style: { justifyContent: "center" } }, t("empty.skillsHint")),
2300
+ { className: "dshsc-tabpane" },
2207
2301
  h(
2208
2302
  "div",
2209
- { style: { marginTop: 10 } },
2210
- h("button", { className: "dshsc-btn primary", disabled: !writable, onClick: actions.onAddSkill }, t("action.addSkill"))
2303
+ { className: "dshsc-listscroll" },
2304
+ h(
2305
+ "div",
2306
+ { className: "dshsc-empty" },
2307
+ h("div", { className: "dshsc-name" }, t("empty.skills")),
2308
+ h("div", { className: "dshsc-meta", style: { justifyContent: "center" } }, t("empty.skillsHint")),
2309
+ h(
2310
+ "div",
2311
+ { style: { marginTop: 10 } },
2312
+ h("button", { className: "dshsc-btn primary", disabled: !writable, onClick: actions.onAddSkill }, t("action.addSkill"))
2313
+ )
2314
+ )
2211
2315
  )
2212
2316
  );
2213
2317
  }
@@ -2225,11 +2329,15 @@ function InstalledTab({
2225
2329
  ))) : h("div", { className: "dshsc-list" }, visibleRows.map(rowView));
2226
2330
  return h(
2227
2331
  "div",
2228
- { style: { display: "flex", flexDirection: "column", gap: 8 } },
2332
+ { className: "dshsc-tabpane" },
2229
2333
  visibleOnlyBanners(),
2230
2334
  builtinNotice(),
2231
2335
  listBar(),
2232
- visibleRows.length === 0 ? filteredEmpty() : body
2336
+ h(
2337
+ "div",
2338
+ { className: "dshsc-listscroll" },
2339
+ visibleRows.length === 0 ? filteredEmpty() : body
2340
+ )
2233
2341
  );
2234
2342
  }
2235
2343
  return content();
@@ -2281,90 +2389,105 @@ function SubscriptionsTab({
2281
2389
  if (subscriptions.length === 0) {
2282
2390
  return h(
2283
2391
  "div",
2284
- { className: "dshsc-empty" },
2285
- h("div", { className: "dshsc-name" }, t("empty.subs")),
2286
- h("div", { className: "dshsc-meta", style: { justifyContent: "center" } }, t("empty.subsHint")),
2392
+ { className: "dshsc-tabpane" },
2287
2393
  h(
2288
2394
  "div",
2289
- { style: { marginTop: 10 } },
2290
- h("button", { className: "dshsc-btn primary", disabled: !writable, onClick: onAdd }, t("action.addSubscription"))
2395
+ { className: "dshsc-listscroll" },
2396
+ h(
2397
+ "div",
2398
+ { className: "dshsc-empty" },
2399
+ h("div", { className: "dshsc-name" }, t("empty.subs")),
2400
+ h("div", { className: "dshsc-meta", style: { justifyContent: "center" } }, t("empty.subsHint")),
2401
+ h(
2402
+ "div",
2403
+ { style: { marginTop: 10 } },
2404
+ h("button", { className: "dshsc-btn primary", disabled: !writable, onClick: onAdd }, t("action.addSubscription"))
2405
+ )
2406
+ )
2291
2407
  )
2292
2408
  );
2293
2409
  }
2294
- return h("div", { className: "dshsc-list" }, subscriptions.map((subscription) => {
2295
- const entries = subscription.snapshot?.entries || [];
2296
- const installedNames = new Set(rows.filter((row) => row.subscriptionId === subscription.id).map((row) => row.name));
2297
- const open = Boolean(expanded[subscription.id]);
2298
- return h(
2410
+ return h(
2411
+ "div",
2412
+ { className: "dshsc-tabpane" },
2413
+ h(
2299
2414
  "div",
2300
- { className: "dshsc-sub-block", key: subscription.id },
2301
- h(
2302
- "div",
2303
- { className: "dshsc-sub-row" },
2304
- h("button", {
2305
- className: "dshsc-caret",
2306
- "aria-expanded": String(open),
2307
- "aria-label": `${subscription.source} ${open ? t("action.collapse") : t("action.expand")}`,
2308
- onClick: () => onToggle(subscription.id)
2309
- }, h(CaretIcon, { open })),
2310
- h("span", { className: "dshsc-name" }, subscription.source),
2311
- h("span", { className: "dshsc-tag" }, subscription.ref || t("subscription.defaultRef")),
2312
- h(
2313
- "span",
2314
- { className: "dshsc-substat" },
2315
- `${entries.length} ${t("skillCount")} · ${t("installed")} ${installedNames.size}`
2316
- ),
2317
- h(
2318
- "div",
2319
- { className: "dshsc-actions" },
2320
- h("button", {
2321
- className: "dshsc-btn sq",
2322
- disabled: busy !== null,
2323
- title: t("action.checkUpdates"),
2324
- "aria-label": `${t("action.checkUpdates")} ${subscription.source}`,
2325
- onClick: () => void onRefresh(subscription)
2326
- }, h(RefreshIcon, { size: 13 })),
2327
- h("button", { className: "dshsc-btn ghost danger", disabled: busy !== null, onClick: () => void onDelete(subscription) }, t("action.unsubscribe"))
2328
- )
2329
- ),
2330
- open ? h("div", { className: "dshsc-sub-list" }, entries.map((entry) => {
2331
- const installedAlready = installedNames.has(entry.name);
2415
+ { className: "dshsc-listscroll" },
2416
+ h("div", { className: "dshsc-list" }, subscriptions.map((subscription) => {
2417
+ const entries = subscription.snapshot?.entries || [];
2418
+ const installedNames = new Set(rows.filter((row) => row.subscriptionId === subscription.id).map((row) => row.name));
2419
+ const open = Boolean(expanded[subscription.id]);
2332
2420
  return h(
2333
2421
  "div",
2334
- { className: "dshsc-sub-entry", key: entry.name },
2335
- h("span", { className: "dshsc-name" }, entry.name),
2336
- h(
2337
- "span",
2338
- { className: `dshsc-state${installedAlready ? " ok" : ""}` },
2339
- installedAlready ? "✓ " : "",
2340
- installedAlready ? t("installed") : t("notInstalled")
2341
- ),
2422
+ { className: "dshsc-sub-block", key: subscription.id },
2342
2423
  h(
2343
2424
  "div",
2344
- { className: "dshsc-actions" },
2345
- installedAlready ? null : h("button", {
2346
- className: "dshsc-btn",
2347
- disabled: !writable || busy !== null,
2348
- onClick: () => void onInstall(subscription, entry)
2349
- }, t("action.install"))
2350
- )
2425
+ { className: "dshsc-sub-row" },
2426
+ h("button", {
2427
+ className: "dshsc-caret",
2428
+ "aria-expanded": String(open),
2429
+ "aria-label": `${subscription.source} ${open ? t("action.collapse") : t("action.expand")}`,
2430
+ onClick: () => onToggle(subscription.id)
2431
+ }, h(CaretIcon, { open })),
2432
+ h("span", { className: "dshsc-name" }, subscription.source),
2433
+ h("span", { className: "dshsc-tag" }, subscription.ref || t("subscription.defaultRef")),
2434
+ h(
2435
+ "span",
2436
+ { className: "dshsc-substat" },
2437
+ `${entries.length} ${t("skillCount")} · ${t("installed")} ${installedNames.size}`
2438
+ ),
2439
+ h(
2440
+ "div",
2441
+ { className: "dshsc-actions" },
2442
+ h("button", {
2443
+ className: "dshsc-btn sq",
2444
+ disabled: busy !== null,
2445
+ title: t("action.checkUpdates"),
2446
+ "aria-label": `${t("action.checkUpdates")} ${subscription.source}`,
2447
+ onClick: () => void onRefresh(subscription)
2448
+ }, h(RefreshIcon, { size: 13 })),
2449
+ h("button", { className: "dshsc-btn ghost danger", disabled: busy !== null, onClick: () => void onDelete(subscription) }, t("action.unsubscribe"))
2450
+ )
2451
+ ),
2452
+ open ? h("div", { className: "dshsc-sub-list" }, entries.map((entry) => {
2453
+ const installedAlready = installedNames.has(entry.name);
2454
+ return h(
2455
+ "div",
2456
+ { className: "dshsc-sub-entry", key: entry.name },
2457
+ h("span", { className: "dshsc-name" }, entry.name),
2458
+ h(
2459
+ "span",
2460
+ { className: `dshsc-state${installedAlready ? " ok" : ""}` },
2461
+ installedAlready ? "✓ " : "",
2462
+ installedAlready ? t("installed") : t("notInstalled")
2463
+ ),
2464
+ h(
2465
+ "div",
2466
+ { className: "dshsc-actions" },
2467
+ installedAlready ? null : h("button", {
2468
+ className: "dshsc-btn",
2469
+ disabled: !writable || busy !== null,
2470
+ onClick: () => void onInstall(subscription, entry)
2471
+ }, t("action.install"))
2472
+ )
2473
+ );
2474
+ })) : null
2351
2475
  );
2352
- })) : null
2353
- );
2354
- }));
2476
+ }))
2477
+ )
2478
+ );
2355
2479
  }
2356
2480
 
2357
2481
  // client/views/modals.js
2482
+ var import_react4 = __toESM(require("react"), 1);
2483
+
2484
+ // client/views/add-skill.js
2358
2485
  var import_react3 = __toESM(require("react"), 1);
2486
+
2487
+ // client/views/overlay.js
2359
2488
  function Overlay({ t, title, children, footer, onCancel }) {
2360
- import_react3.default.useEffect(() => {
2361
- const onKey = (event) => {
2362
- if (event.key === "Escape") onCancel?.();
2363
- };
2364
- window.addEventListener("keydown", onKey);
2365
- return () => window.removeEventListener("keydown", onKey);
2366
- }, [onCancel]);
2367
- return h(
2489
+ useOverlayLayer(true, onCancel);
2490
+ return portalize(h(
2368
2491
  "div",
2369
2492
  { className: "dshsc-overlay", onClick: (event) => {
2370
2493
  if (event.target === event.currentTarget) onCancel?.();
@@ -2384,10 +2507,154 @@ function Overlay({ t, title, children, footer, onCancel }) {
2384
2507
  children,
2385
2508
  h("div", { className: "dshsc-modal-foot" }, footer)
2386
2509
  )
2510
+ ), overlayHost());
2511
+ }
2512
+
2513
+ // client/views/add-skill.js
2514
+ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
2515
+ const [source, setSource] = import_react3.default.useState("");
2516
+ const [phase, setPhase] = import_react3.default.useState("idle");
2517
+ const [options, setOptions] = import_react3.default.useState([]);
2518
+ const [selected, setSelected] = import_react3.default.useState(() => /* @__PURE__ */ new Set());
2519
+ const [manual, setManual] = import_react3.default.useState("");
2520
+ const [skipped, setSkipped] = import_react3.default.useState(0);
2521
+ const [error, setError] = import_react3.default.useState(null);
2522
+ const reset = (value) => {
2523
+ setSource(value);
2524
+ setPhase("idle");
2525
+ setOptions([]);
2526
+ setSelected(/* @__PURE__ */ new Set());
2527
+ setSkipped(0);
2528
+ setError(null);
2529
+ };
2530
+ const probe = async () => {
2531
+ const value = source.trim();
2532
+ if (!value || phase === "probing") return;
2533
+ setPhase("probing");
2534
+ setError(null);
2535
+ try {
2536
+ const result = await onProbe(value);
2537
+ const found = Array.isArray(result?.skills) ? result.skills : [];
2538
+ setOptions(found);
2539
+ setSelected(new Set(found.map((item) => item.name)));
2540
+ setSkipped(Math.max(0, Number(result?.skipped) || 0));
2541
+ setPhase(found.length > 0 ? "picked" : "empty");
2542
+ } catch (err) {
2543
+ setError(err?.message || String(err));
2544
+ setPhase("failed");
2545
+ }
2546
+ };
2547
+ const toggle = (name) => setSelected((prev) => {
2548
+ const next = new Set(prev);
2549
+ if (next.has(name)) next.delete(name);
2550
+ else next.add(name);
2551
+ return next;
2552
+ });
2553
+ const names = manual.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
2554
+ const skippedText = skipped > 0 ? t("form.skipped").replace("{n}", String(skipped)) : null;
2555
+ const manualMode = phase === "empty" || phase === "failed";
2556
+ const skills = phase === "picked" ? [...selected] : names;
2557
+ const canSubmit = phase === "picked" ? selected.size > 0 : manualMode ? names.length > 0 : false;
2558
+ const submit = () => {
2559
+ if (!canSubmit) return;
2560
+ onInstall({ source: source.trim(), skills });
2561
+ };
2562
+ return h(
2563
+ Overlay,
2564
+ {
2565
+ t,
2566
+ title: t("action.addSkill"),
2567
+ onCancel,
2568
+ footer: [
2569
+ h("button", { key: "c", className: "dshsc-btn ghost", onClick: onCancel }, t("action.cancel")),
2570
+ h("button", {
2571
+ key: "s",
2572
+ className: "dshsc-btn primary",
2573
+ disabled: !canSubmit,
2574
+ onClick: submit
2575
+ }, t("action.save"))
2576
+ ]
2577
+ },
2578
+ h("p", { className: "dshsc-sub" }, t("form.pickHint")),
2579
+ h(
2580
+ "div",
2581
+ { className: "dshsc-field" },
2582
+ h("span", null, t("form.source")),
2583
+ h(
2584
+ "div",
2585
+ { className: "dshsc-inline" },
2586
+ h("input", {
2587
+ value: source,
2588
+ placeholder: t("form.sourceHint"),
2589
+ onChange: (event) => reset(event.target.value),
2590
+ onKeyDown: (event) => {
2591
+ if (event.key === "Enter") void probe();
2592
+ }
2593
+ }),
2594
+ h("button", {
2595
+ className: "dshsc-btn",
2596
+ disabled: !source.trim() || phase === "probing",
2597
+ onClick: () => void probe()
2598
+ }, phase === "probing" ? t("form.probing") : t("form.probe"))
2599
+ )
2600
+ ),
2601
+ phase === "picked" ? h(
2602
+ "div",
2603
+ { key: "picked" },
2604
+ h("div", { className: "dshsc-group" }, t("form.detected").replace("{n}", String(options.length))),
2605
+ h(
2606
+ "div",
2607
+ { style: { display: "flex", gap: 6, marginBottom: 6 } },
2608
+ h("button", {
2609
+ className: "dshsc-btn ghost",
2610
+ onClick: () => setSelected(new Set(options.map((item) => item.name)))
2611
+ }, t("form.selectAll")),
2612
+ h("button", {
2613
+ className: "dshsc-btn ghost",
2614
+ onClick: () => setSelected(/* @__PURE__ */ new Set())
2615
+ }, t("form.selectNone"))
2616
+ ),
2617
+ h("div", { className: "dshsc-panel" }, options.map((option) => h(
2618
+ "label",
2619
+ { className: "dshsc-check dshsc-skill", key: option.name },
2620
+ h("input", {
2621
+ type: "checkbox",
2622
+ checked: selected.has(option.name),
2623
+ onChange: () => toggle(option.name)
2624
+ }),
2625
+ h(
2626
+ "span",
2627
+ { className: "dshsc-skill-main" },
2628
+ h("span", { className: "dshsc-skill-name" }, option.name),
2629
+ option.description ? h("small", { className: "dshsc-skill-desc" }, option.description) : null,
2630
+ h("small", { className: "dshsc-skill-path" }, option.skillPath)
2631
+ )
2632
+ ))),
2633
+ skippedText ? h("p", { className: "dshsc-sub" }, skippedText) : null
2634
+ ) : null,
2635
+ phase === "probing" ? h("p", { className: "dshsc-sub" }, t("form.probingHint")) : null,
2636
+ manualMode ? h(
2637
+ "div",
2638
+ { key: "manual" },
2639
+ h("p", { className: "dshsc-sub" }, phase === "empty" ? skippedText || t("form.noSkills") : `${t("form.probeFailed")}${error ? ` · ${error}` : ""}`),
2640
+ h(
2641
+ "label",
2642
+ { className: "dshsc-field" },
2643
+ h("span", null, t("form.skills")),
2644
+ h("input", {
2645
+ value: manual,
2646
+ placeholder: "git-kit, handoff",
2647
+ onChange: (event) => setManual(event.target.value)
2648
+ })
2649
+ )
2650
+ ) : null,
2651
+ h("p", { className: "dshsc-sub" }, t("form.hint"))
2387
2652
  );
2388
2653
  }
2654
+
2655
+ // client/views/modals.js
2389
2656
  function FormModal({ t, title, hint, fields, onCancel, onSubmit }) {
2390
- const [form, setForm] = import_react3.default.useState(() => Object.fromEntries(fields.map((field) => [field.key, ""])));
2657
+ const [form, setForm] = import_react4.default.useState(() => Object.fromEntries(fields.map((field) => [field.key, ""])));
2391
2658
  const incomplete = fields.some(
2392
2659
  (field) => field.required !== false && !String(form[field.key] || "").trim()
2393
2660
  );
@@ -2435,14 +2702,14 @@ function LinksModal({ t, row, agents, workspaces, pickDirectory, onCancel, onSav
2435
2702
  const candidates = agents.filter(
2436
2703
  (agent) => agent.kind === "dsh" || agent.kind !== "project" && agent.exists
2437
2704
  );
2438
- const [targets, setTargets] = import_react3.default.useState(() => {
2705
+ const [targets, setTargets] = import_react4.default.useState(() => {
2439
2706
  const visible = new Set(candidates.map((agent) => agent.target));
2440
2707
  const initial = new Set(row.links.map((link) => link.target).filter((target) => visible.has(target)));
2441
2708
  initial.add("dsh-global");
2442
2709
  return initial;
2443
2710
  });
2444
- const [scopeProjects, setScopeProjects] = import_react3.default.useState(row.scopeProjects || []);
2445
- const [scopeMode, setScopeMode] = import_react3.default.useState((row.scopeProjects || []).length > 0 ? "projects" : "global");
2711
+ const [scopeProjects, setScopeProjects] = import_react4.default.useState(row.scopeProjects || []);
2712
+ const [scopeMode, setScopeMode] = import_react4.default.useState((row.scopeProjects || []).length > 0 ? "projects" : "global");
2446
2713
  const workspacePaths = new Set(workspaces.map((workspace) => workspace.path));
2447
2714
  const otherProjects = scopeProjects.filter((project) => !workspacePaths.has(project));
2448
2715
  const toggle = (target) => setTargets((prev) => {
@@ -2536,7 +2803,7 @@ function LinksModal({ t, row, agents, workspaces, pickDirectory, onCancel, onSav
2536
2803
  );
2537
2804
  }
2538
2805
  function AutoLinkModal({ t, settings, agents, dsh, onCancel, onSave }) {
2539
- const [targets, setTargets] = import_react3.default.useState(() => new Set(
2806
+ const [targets, setTargets] = import_react4.default.useState(() => new Set(
2540
2807
  Array.isArray(settings.autoLinkTargets) ? settings.autoLinkTargets : agents.map((agent) => agent.target)
2541
2808
  ));
2542
2809
  const toggle = (target) => setTargets((prev) => {
@@ -2592,146 +2859,6 @@ function AutoLinkModal({ t, settings, agents, dsh, onCancel, onSave }) {
2592
2859
  h("p", { className: "dshsc-sub" }, t("autoLink.hint"))
2593
2860
  );
2594
2861
  }
2595
- function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
2596
- const [source, setSource] = import_react3.default.useState("");
2597
- const [phase, setPhase] = import_react3.default.useState("idle");
2598
- const [options, setOptions] = import_react3.default.useState([]);
2599
- const [selected, setSelected] = import_react3.default.useState(() => /* @__PURE__ */ new Set());
2600
- const [manual, setManual] = import_react3.default.useState("");
2601
- const [skipped, setSkipped] = import_react3.default.useState(0);
2602
- const [error, setError] = import_react3.default.useState(null);
2603
- const reset = (value) => {
2604
- setSource(value);
2605
- setPhase("idle");
2606
- setOptions([]);
2607
- setSelected(/* @__PURE__ */ new Set());
2608
- setSkipped(0);
2609
- setError(null);
2610
- };
2611
- const probe = async () => {
2612
- const value = source.trim();
2613
- if (!value || phase === "probing") return;
2614
- setPhase("probing");
2615
- setError(null);
2616
- try {
2617
- const result = await onProbe(value);
2618
- const found = Array.isArray(result?.skills) ? result.skills : [];
2619
- setOptions(found);
2620
- setSelected(new Set(found.map((item) => item.name)));
2621
- setSkipped(Math.max(0, Number(result?.skipped) || 0));
2622
- setPhase(found.length > 0 ? "picked" : "empty");
2623
- } catch (err) {
2624
- setError(err?.message || String(err));
2625
- setPhase("failed");
2626
- }
2627
- };
2628
- const toggle = (name) => setSelected((prev) => {
2629
- const next = new Set(prev);
2630
- if (next.has(name)) next.delete(name);
2631
- else next.add(name);
2632
- return next;
2633
- });
2634
- const names = manual.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
2635
- const skippedText = skipped > 0 ? t("form.skipped").replace("{n}", String(skipped)) : null;
2636
- const manualMode = phase === "empty" || phase === "failed";
2637
- const skills = phase === "picked" ? [...selected] : names;
2638
- const canSubmit = phase === "picked" ? selected.size > 0 : manualMode ? names.length > 0 : false;
2639
- const submit = () => {
2640
- if (!canSubmit) return;
2641
- onInstall({ source: source.trim(), skills });
2642
- };
2643
- return h(
2644
- Overlay,
2645
- {
2646
- t,
2647
- title: t("action.addSkill"),
2648
- onCancel,
2649
- footer: [
2650
- h("button", { key: "c", className: "dshsc-btn ghost", onClick: onCancel }, t("action.cancel")),
2651
- h("button", {
2652
- key: "s",
2653
- className: "dshsc-btn primary",
2654
- disabled: !canSubmit,
2655
- onClick: submit
2656
- }, t("action.save"))
2657
- ]
2658
- },
2659
- h("p", { className: "dshsc-sub" }, t("form.pickHint")),
2660
- h(
2661
- "div",
2662
- { className: "dshsc-field" },
2663
- h("span", null, t("form.source")),
2664
- h(
2665
- "div",
2666
- { className: "dshsc-inline" },
2667
- h("input", {
2668
- value: source,
2669
- placeholder: t("form.sourceHint"),
2670
- onChange: (event) => reset(event.target.value),
2671
- onKeyDown: (event) => {
2672
- if (event.key === "Enter") void probe();
2673
- }
2674
- }),
2675
- h("button", {
2676
- className: "dshsc-btn",
2677
- disabled: !source.trim() || phase === "probing",
2678
- onClick: () => void probe()
2679
- }, phase === "probing" ? t("form.probing") : t("form.probe"))
2680
- )
2681
- ),
2682
- phase === "picked" ? h(
2683
- "div",
2684
- { key: "picked" },
2685
- h("div", { className: "dshsc-group" }, t("form.detected").replace("{n}", String(options.length))),
2686
- h(
2687
- "div",
2688
- { style: { display: "flex", gap: 6, marginBottom: 6 } },
2689
- h("button", {
2690
- className: "dshsc-btn ghost",
2691
- onClick: () => setSelected(new Set(options.map((item) => item.name)))
2692
- }, t("form.selectAll")),
2693
- h("button", {
2694
- className: "dshsc-btn ghost",
2695
- onClick: () => setSelected(/* @__PURE__ */ new Set())
2696
- }, t("form.selectNone"))
2697
- ),
2698
- h("div", { className: "dshsc-panel" }, options.map((option) => h(
2699
- "label",
2700
- { className: "dshsc-check dshsc-skill", key: option.name },
2701
- h("input", {
2702
- type: "checkbox",
2703
- checked: selected.has(option.name),
2704
- onChange: () => toggle(option.name)
2705
- }),
2706
- h(
2707
- "span",
2708
- { className: "dshsc-skill-main" },
2709
- h("span", { className: "dshsc-skill-name" }, option.name),
2710
- option.description ? h("small", { className: "dshsc-skill-desc" }, option.description) : null,
2711
- h("small", { className: "dshsc-skill-path" }, option.skillPath)
2712
- )
2713
- ))),
2714
- skippedText ? h("p", { className: "dshsc-sub" }, skippedText) : null
2715
- ) : null,
2716
- phase === "probing" ? h("p", { className: "dshsc-sub" }, t("form.probingHint")) : null,
2717
- manualMode ? h(
2718
- "div",
2719
- { key: "manual" },
2720
- h("p", { className: "dshsc-sub" }, phase === "empty" ? skippedText || t("form.noSkills") : `${t("form.probeFailed")}${error ? ` · ${error}` : ""}`),
2721
- h(
2722
- "label",
2723
- { className: "dshsc-field" },
2724
- h("span", null, t("form.skills")),
2725
- h("input", {
2726
- value: manual,
2727
- placeholder: "git-kit, handoff",
2728
- onChange: (event) => setManual(event.target.value)
2729
- })
2730
- )
2731
- ) : null,
2732
- h("p", { className: "dshsc-sub" }, t("form.hint"))
2733
- );
2734
- }
2735
2862
  function Modals({ t, modal, data, autoLinkAgents, autoLinkDsh, pickDirectory, actions, onClose, onSaveAutoLink }) {
2736
2863
  if (modal.type === "add-skill") {
2737
2864
  return h(AddSkillModal, {
@@ -2814,7 +2941,7 @@ ${conflicts.map((item) => `${item.label}: ${item.linkPath}`).join("\n")}`,
2814
2941
  }
2815
2942
 
2816
2943
  // client/views/skill-reader.js
2817
- var import_react4 = __toESM(require("react"), 1);
2944
+ var import_react5 = __toESM(require("react"), 1);
2818
2945
 
2819
2946
  // client/markdown.js
2820
2947
  function classifyHref(href) {
@@ -3097,7 +3224,7 @@ function resolveRelativeHref(currentDir, href) {
3097
3224
  return { relPath: out.join("/"), anchor: target.kind === "anchor" ? target.href : "" };
3098
3225
  }
3099
3226
 
3100
- // client/views/skill-reader.js
3227
+ // client/views/markdown-view.js
3101
3228
  var PARSE_CACHE = /* @__PURE__ */ new Map();
3102
3229
  function parseCached(text) {
3103
3230
  const key = String(text ?? "");
@@ -3110,48 +3237,6 @@ function parseCached(text) {
3110
3237
  }
3111
3238
  return parsed;
3112
3239
  }
3113
- function formatBytes(bytes) {
3114
- const value = Number(bytes) || 0;
3115
- if (value < 1024) return `${value} B`;
3116
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
3117
- return `${(value / (1024 * 1024)).toFixed(1)} MB`;
3118
- }
3119
- function formatWhen(iso) {
3120
- if (!iso) return null;
3121
- const date = new Date(iso);
3122
- if (Number.isNaN(date.getTime())) return null;
3123
- const pad = (value) => String(value).padStart(2, "0");
3124
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
3125
- }
3126
- var MARKDOWN = /\.(md|markdown)$/i;
3127
- function isMarkdownPath(relPath) {
3128
- return MARKDOWN.test(String(relPath || ""));
3129
- }
3130
- function nearestScroller(node) {
3131
- let current = node?.parentElement || null;
3132
- while (current) {
3133
- if (typeof getComputedStyle === "function") {
3134
- const style = getComputedStyle(current);
3135
- if (style && /(auto|scroll)/.test(style.overflowY || "") && current.scrollHeight > current.clientHeight + 4) {
3136
- return current;
3137
- }
3138
- }
3139
- current = current.parentElement || null;
3140
- }
3141
- return typeof document !== "undefined" ? document.scrollingElement || null : null;
3142
- }
3143
- function scrollHost(node) {
3144
- return node && nearestScroller(node) || node;
3145
- }
3146
- var STATUS_KEY = {
3147
- current: "status.current",
3148
- "update-available": "status.update",
3149
- "locally-modified": "status.local",
3150
- conflict: "status.conflict",
3151
- "upstream-deleted": "status.deleted",
3152
- "broken-link": "status.broken",
3153
- unmanaged: "status.unmanaged"
3154
- };
3155
3240
  function inlineNodes(nodes, { onOpenFile, onOpenAnchor, keyPrefix = "i" }) {
3156
3241
  if (!Array.isArray(nodes)) return null;
3157
3242
  return nodes.map((node, index) => {
@@ -3284,6 +3369,34 @@ function Markdown({ text, onOpenFile, onOpenAnchor }) {
3284
3369
  const { blocks } = parseCached(text);
3285
3370
  return h("div", { className: "dshsc-md" }, blockNodes(blocks, { onOpenFile, onOpenAnchor }));
3286
3371
  }
3372
+
3373
+ // client/views/skill-reader.js
3374
+ function formatBytes(bytes) {
3375
+ const value = Number(bytes) || 0;
3376
+ if (value < 1024) return `${value} B`;
3377
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
3378
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
3379
+ }
3380
+ function formatWhen(iso) {
3381
+ if (!iso) return null;
3382
+ const date = new Date(iso);
3383
+ if (Number.isNaN(date.getTime())) return null;
3384
+ const pad = (value) => String(value).padStart(2, "0");
3385
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
3386
+ }
3387
+ var MARKDOWN = /\.(md|markdown)$/i;
3388
+ function isMarkdownPath(relPath) {
3389
+ return MARKDOWN.test(String(relPath || ""));
3390
+ }
3391
+ var STATUS_KEY = {
3392
+ current: "status.current",
3393
+ "update-available": "status.update",
3394
+ "locally-modified": "status.local",
3395
+ conflict: "status.conflict",
3396
+ "upstream-deleted": "status.deleted",
3397
+ "broken-link": "status.broken",
3398
+ unmanaged: "status.unmanaged"
3399
+ };
3287
3400
  function TabButton({ id, label, count, active, onSelect }) {
3288
3401
  return h("button", {
3289
3402
  type: "button",
@@ -3324,14 +3437,18 @@ function SkillReader({
3324
3437
  onToast
3325
3438
  }) {
3326
3439
  const { state, file, loadFile, retry } = useSkillReader(row);
3327
- const [tab, setTab] = import_react4.default.useState(initialTab);
3328
- const [format, setFormat] = import_react4.default.useState("rendered");
3329
- const [docOverride, setDocOverride] = import_react4.default.useState(null);
3330
- const [fileFormat, setFileFormat] = import_react4.default.useState("rendered");
3331
- const [tocActive, setTocActive] = import_react4.default.useState(null);
3332
- const bodyRef = import_react4.default.useRef(null);
3333
- const dialogRef = import_react4.default.useRef(null);
3334
- const scrollRef = import_react4.default.useRef(0);
3440
+ const [tab, setTab] = import_react5.default.useState(initialTab);
3441
+ const [format, setFormat] = import_react5.default.useState("rendered");
3442
+ const [docOverride, setDocOverride] = import_react5.default.useState(null);
3443
+ const [fileFormat, setFileFormat] = import_react5.default.useState("rendered");
3444
+ const [tocActive, setTocActive] = import_react5.default.useState(null);
3445
+ const [descOpen, setDescOpen] = import_react5.default.useState(false);
3446
+ const [descClamped, setDescClamped] = import_react5.default.useState(false);
3447
+ const bodyRef = import_react5.default.useRef(null);
3448
+ const dialogRef = import_react5.default.useRef(null);
3449
+ const descRef = import_react5.default.useRef(null);
3450
+ const scrollRef = import_react5.default.useRef(0);
3451
+ useOverlayLayer(variant === "drawer" || variant === "page", onClose);
3335
3452
  const name = row?.name || "";
3336
3453
  const tree = state.tree;
3337
3454
  const files = tree?.files || [];
@@ -3339,30 +3456,54 @@ function SkillReader({
3339
3456
  const bodyText = docOverride ? docOverride.content : state.body?.content || "";
3340
3457
  const description = row?.description || state.body?.meta?.description || "";
3341
3458
  const headings = tab === "doc" && bodyText ? parseCached(bodyText).headings : [];
3342
- import_react4.default.useEffect(() => {
3459
+ import_react5.default.useEffect(() => {
3343
3460
  setDocOverride(null);
3344
3461
  scrollRef.current = 0;
3345
3462
  }, [name]);
3346
- import_react4.default.useEffect(() => {
3463
+ const descOwnerRef = import_react5.default.useRef(name);
3464
+ import_react5.default.useEffect(() => {
3465
+ if (descOwnerRef.current === name) return;
3466
+ descOwnerRef.current = name;
3467
+ setDescOpen(false);
3468
+ setDescClamped(false);
3469
+ }, [name]);
3470
+ import_react5.default.useLayoutEffect(() => {
3471
+ const node = descRef.current;
3472
+ if (!node) return void 0;
3473
+ const measure = () => {
3474
+ if (descOpen) return;
3475
+ const scroll = Number(node.scrollHeight) || 0;
3476
+ const client = Number(node.clientHeight) || 0;
3477
+ setDescClamped(scroll > client + 1);
3478
+ };
3479
+ measure();
3480
+ if (typeof window === "undefined" || !window.addEventListener) return void 0;
3481
+ window.addEventListener("resize", measure);
3482
+ let observer = null;
3483
+ if (typeof ResizeObserver === "function") {
3484
+ observer = new ResizeObserver(measure);
3485
+ observer.observe(node);
3486
+ }
3487
+ return () => {
3488
+ observer?.disconnect?.();
3489
+ window.removeEventListener("resize", measure);
3490
+ };
3491
+ }, [description, descOpen]);
3492
+ import_react5.default.useEffect(() => {
3347
3493
  if (!initialFile) return;
3348
3494
  setTab("files");
3349
3495
  void loadFile(initialFile);
3350
3496
  }, [initialFile, loadFile]);
3351
- import_react4.default.useEffect(() => {
3497
+ import_react5.default.useEffect(() => {
3352
3498
  const node = bodyRef.current;
3353
3499
  if (node && scrollRef.current) node.scrollTop = scrollRef.current;
3354
3500
  });
3355
- import_react4.default.useEffect(() => {
3501
+ import_react5.default.useEffect(() => {
3356
3502
  if (variant !== "drawer" || typeof document === "undefined") return void 0;
3357
3503
  const previous = document.activeElement;
3358
3504
  const node = dialogRef.current;
3359
3505
  node?.focus?.();
3360
3506
  const onKeyDown = (event) => {
3361
- if (event?.key === "Escape") {
3362
- event.stopPropagation?.();
3363
- onClose?.();
3364
- return;
3365
- }
3366
3507
  if (event?.key !== "Tab" || !node?.querySelectorAll) return;
3367
3508
  const focusables = Array.from(
3368
3509
  node.querySelectorAll('a[href],button:not([disabled]),[tabindex]:not([tabindex="-1"])')
@@ -3378,18 +3519,18 @@ function SkillReader({
3378
3519
  first.focus?.();
3379
3520
  }
3380
3521
  };
3381
- document.addEventListener("keydown", onKeyDown);
3522
+ document.addEventListener("keydown", onKeyDown, true);
3382
3523
  return () => {
3383
- document.removeEventListener("keydown", onKeyDown);
3524
+ document.removeEventListener("keydown", onKeyDown, true);
3384
3525
  previous?.focus?.();
3385
3526
  };
3386
3527
  }, [variant, onClose]);
3387
- import_react4.default.useEffect(() => {
3528
+ import_react5.default.useEffect(() => {
3388
3529
  if (variant !== "page" || typeof document === "undefined") return;
3389
3530
  const node = document.getElementById("dshsc-reader-name");
3390
3531
  node?.focus?.();
3391
3532
  }, [variant, name]);
3392
- import_react4.default.useEffect(() => {
3533
+ import_react5.default.useEffect(() => {
3393
3534
  if (variant !== "page" || typeof window === "undefined") return void 0;
3394
3535
  const node = bodyRef.current;
3395
3536
  if (!node?.querySelectorAll) return void 0;
@@ -3459,7 +3600,22 @@ function SkillReader({
3459
3600
  onClick: () => onClose?.()
3460
3601
  }, "×") : null
3461
3602
  ),
3462
- description ? h("p", { className: "dshsc-reader-desc" }, description) : null,
3603
+ description ? h(
3604
+ "div",
3605
+ { className: "dshsc-reader-descblock" },
3606
+ h("p", {
3607
+ className: `dshsc-reader-desc${descOpen ? " expanded" : ""}`,
3608
+ id: "dshsc-reader-desc",
3609
+ ref: descRef
3610
+ }, description),
3611
+ descClamped ? h("button", {
3612
+ type: "button",
3613
+ className: "dshsc-reader-more",
3614
+ "aria-expanded": String(descOpen),
3615
+ "aria-controls": "dshsc-reader-desc",
3616
+ onClick: () => setDescOpen((prev) => !prev)
3617
+ }, t(descOpen ? "action.collapse" : "action.expand")) : null
3618
+ ) : null,
3463
3619
  h(
3464
3620
  "div",
3465
3621
  { className: "dshsc-reader-meta" },
@@ -3752,6 +3908,7 @@ function SkillReader({
3752
3908
  }, t("action.update")) : null
3753
3909
  );
3754
3910
  if (variant === "page") {
3911
+ const tocShown = headings.length > 1;
3755
3912
  return h(
3756
3913
  "div",
3757
3914
  { className: "dshsc-reader-page" },
@@ -3777,8 +3934,8 @@ function SkillReader({
3777
3934
  h("div", { className: "dshsc-reader-hero" }, header),
3778
3935
  h(
3779
3936
  "div",
3780
- { className: "dshsc-reader-layout" },
3781
- headings.length > 1 ? h(
3937
+ { className: `dshsc-reader-layout${tocShown ? " has-toc" : ""}` },
3938
+ tocShown ? h(
3782
3939
  "nav",
3783
3940
  { className: "dshsc-reader-toc", "aria-label": t("viewer.toc") },
3784
3941
  h("div", { className: "dshsc-reader-toc-title" }, t("viewer.toc")),
@@ -3805,25 +3962,26 @@ function SkillReader({
3805
3962
  ref: dialogRef,
3806
3963
  tabIndex: -1
3807
3964
  }, header, tabsBar, body, actionsFooter)
3808
- ));
3965
+ ), overlayHost());
3809
3966
  }
3810
3967
 
3811
3968
  // client/section.js
3812
3969
  var UPDATABLE2 = /* @__PURE__ */ new Set(["update-available", "locally-modified", "conflict"]);
3813
3970
  function Section({ t, page = false, pickDirectory, onPlacementChange }) {
3814
3971
  const { data, error, busy, toast, setToast, setBusy, reload, run } = useSkillsData();
3815
- const [tab, setTab] = import_react5.default.useState("installed");
3816
- const [view, setView] = import_react5.default.useState("flat");
3817
- const [modal, setModal] = import_react5.default.useState(null);
3818
- const [expanded, setExpanded] = import_react5.default.useState({});
3819
- const [placementBusy, setPlacementBusy] = import_react5.default.useState(null);
3820
- const [placementOverride, setPlacementOverride] = import_react5.default.useState(null);
3821
- const [query, setQuery] = import_react5.default.useState("");
3822
- const [viewer, setViewer] = import_react5.default.useState(null);
3823
- const rootRef = import_react5.default.useRef(null);
3824
- const scrollPos = import_react5.default.useRef(null);
3825
- const [filter, setFilter] = import_react5.default.useState("all");
3826
- const [builtinOpen, setBuiltinOpen] = import_react5.default.useState(false);
3972
+ const [tab, setTab] = import_react6.default.useState("installed");
3973
+ const [view, setView] = import_react6.default.useState("flat");
3974
+ const [modal, setModal] = import_react6.default.useState(null);
3975
+ const [expanded, setExpanded] = import_react6.default.useState({});
3976
+ const [placementBusy, setPlacementBusy] = import_react6.default.useState(null);
3977
+ const [placementOverride, setPlacementOverride] = import_react6.default.useState(null);
3978
+ const [query, setQuery] = import_react6.default.useState("");
3979
+ const [viewer, setViewer] = import_react6.default.useState(null);
3980
+ const rootRef = import_react6.default.useRef(null);
3981
+ const scrollPos = import_react6.default.useRef(null);
3982
+ const restoreScroll = import_react6.default.useRef(null);
3983
+ const [filter, setFilter] = import_react6.default.useState("all");
3984
+ const [builtinOpen, setBuiltinOpen] = import_react6.default.useState(false);
3827
3985
  const { settingsOpen, settingsRef, settingsPlacement, closeSettings, toggleSettings } = useSettingsPopover();
3828
3986
  const { agentTip, agentTipPos, agentTipRef, openAgentTip, closeAgentTip } = useAgentTip();
3829
3987
  const rows = data?.installs || [];
@@ -3844,7 +4002,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
3844
4002
  const agentLabels = row.links.map((link) => link.target === "dsh-global" ? "DSH" : link.label);
3845
4003
  return [row.name, row.source || "", ...agentLabels].join(" ").toLowerCase().includes(queryText);
3846
4004
  });
3847
- import_react5.default.useEffect(() => {
4005
+ import_react6.default.useEffect(() => {
3848
4006
  if (!viewer || !data) return;
3849
4007
  if (rows.some((row) => row.name === viewer.name)) return;
3850
4008
  setViewer(null);
@@ -3856,43 +4014,36 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
3856
4014
  const autoLinkDsh = (data?.agents?.all || []).find((agent) => agent.target === "dsh-global") || null;
3857
4015
  const autoLinkLabel = [t("links.dshGlobal"), ...autoLinkAgents.map((agent) => agent.label)].join("、");
3858
4016
  const placement = (placementOverride ?? data?.settings?.placement) === "sidebar" ? "sidebar" : "settings";
3859
- function getScrollParent(node) {
3860
- let current = node?.parentElement || null;
3861
- while (current) {
3862
- if (typeof getComputedStyle === "function") {
3863
- const style = getComputedStyle(current);
3864
- if (style && /(auto|scroll)/.test(style.overflowY || "") && current.scrollHeight > current.clientHeight) {
3865
- return current;
3866
- }
3867
- }
3868
- current = current.parentElement || null;
3869
- }
3870
- return typeof document !== "undefined" ? document.scrollingElement || null : null;
4017
+ function listScroller() {
4018
+ const node = rootRef.current?.querySelector?.(".dshsc-listscroll");
4019
+ if (isScroller(node)) return node;
4020
+ return nearestScroller(rootRef.current);
3871
4021
  }
3872
4022
  function openViewer(row, mode = "drawer") {
3873
4023
  if (!row) return;
3874
4024
  if (mode === "page") {
3875
- scrollPos.current = getScrollParent(rootRef.current)?.scrollTop ?? 0;
4025
+ scrollPos.current = listScroller()?.scrollTop ?? 0;
3876
4026
  }
3877
4027
  setViewer({ name: row.name, mode });
3878
4028
  }
3879
4029
  function openPage() {
3880
4030
  setViewer((prev) => {
3881
4031
  if (!prev) return prev;
3882
- scrollPos.current = getScrollParent(rootRef.current)?.scrollTop ?? 0;
4032
+ scrollPos.current = listScroller()?.scrollTop ?? 0;
3883
4033
  return { ...prev, mode: "page" };
3884
4034
  });
3885
4035
  }
3886
4036
  function closeViewer() {
3887
- const restore = viewer?.mode === "page" ? scrollPos.current : null;
4037
+ if (viewer?.mode === "page") restoreScroll.current = scrollPos.current;
3888
4038
  setViewer(null);
3889
- if (restore === null || typeof window === "undefined") return;
3890
- const apply2 = () => {
3891
- const scroller = getScrollParent(rootRef.current);
3892
- if (scroller) scroller.scrollTop = restore;
3893
- };
3894
- if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(apply2);
3895
4039
  }
4040
+ import_react6.default.useLayoutEffect(() => {
4041
+ if (restoreScroll.current === null) return;
4042
+ const target = restoreScroll.current;
4043
+ restoreScroll.current = null;
4044
+ const scroller = listScroller();
4045
+ if (scroller) scroller.scrollTop = target;
4046
+ }, [viewer]);
3896
4047
  async function changePlacement(next) {
3897
4048
  if (next === placement || placementBusy) return;
3898
4049
  setPlacementBusy(next);
@@ -3953,7 +4104,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
3953
4104
  onToast: (next) => setToast(next)
3954
4105
  }) : null;
3955
4106
  const listNode = h(
3956
- import_react5.default.Fragment,
4107
+ import_react6.default.Fragment,
3957
4108
  null,
3958
4109
  h(Header, {
3959
4110
  t,
@@ -4021,7 +4172,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
4021
4172
  h("style", null, CSS),
4022
4173
  viewer?.mode === "page" ? null : listNode,
4023
4174
  readerNode,
4024
- toast ? h("div", { className: `dshsc-toast ${toast.kind}` }, toast.text) : null,
4175
+ toast ? portalize(h("div", { className: `dshsc-toast ${toast.kind}` }, toast.text), overlayHost()) : null,
4025
4176
  modal ? h(Modals, {
4026
4177
  t,
4027
4178
  modal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",
@@ -52,7 +52,8 @@
52
52
  "build:client": "node scripts/build-client.mjs",
53
53
  "dev:client": "node scripts/build-client.mjs --watch",
54
54
  "check:client": "node scripts/build-client.mjs --check",
55
- "check": "node scripts/check-syntax.mjs && node scripts/build-client.mjs --check && node scripts/check-naming.mjs && node scripts/check-pack.mjs && node scripts/smoke-release-notes.mjs",
55
+ "check:cycles": "node scripts/check-cycles.mjs",
56
+ "check": "node scripts/check-syntax.mjs && node scripts/check-cycles.mjs && node scripts/build-client.mjs --check && node scripts/check-naming.mjs && node scripts/check-pack.mjs && node scripts/smoke-release-notes.mjs",
56
57
  "check:syntax": "node scripts/check-syntax.mjs",
57
58
  "check:naming": "node scripts/check-naming.mjs",
58
59
  "check:pack": "node scripts/check-pack.mjs",