@stofone/dsh-agent-roster 0.5.1 → 0.6.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/client.js CHANGED
@@ -33,9 +33,11 @@ window.__ModuleLoader__.load({
33
33
  const ROSTERS_ROUTE = "/plugins/dsh-agent-roster/rosters";
34
34
  const INSTALL_ROUTE = "/plugins/dsh-agent-roster/install";
35
35
  const COLLECTION_ROUTE = "/plugins/dsh-agent-roster/collection";
36
+ const APPLY_ROUTE = "/plugins/dsh-agent-roster/apply";
36
37
 
37
38
  const EFFORTS = ["low", "medium", "high", "default"];
38
39
  const LANGUAGES = ["auto", "zh", "en"];
40
+ const REGIONS = ["auto", "global", "china"];
39
41
  const FORMATS = ["yaml", "json"];
40
42
  const STYLE_ID = "dsh-agent-roster-style";
41
43
 
@@ -156,10 +158,12 @@ window.__ModuleLoader__.load({
156
158
 
157
159
  function useLibrary() {
158
160
  const [state, setState] = useState({ status: "loading", data: null, error: false });
159
- const load = useCallback(async () => {
161
+ const load = useCallback(async (options) => {
162
+ // 只有档案源那处会传 { refresh: true }:绕过 registry 的 TTL,强制回源。
163
+ const forced = options !== null && typeof options === "object" && options.refresh === true;
160
164
  setState((current) => ({ ...current, status: current.data === null ? "loading" : "refreshing" }));
161
165
  try {
162
- const result = await readJson(LIBRARY_ROUTE);
166
+ const result = await readJson(forced ? `${LIBRARY_ROUTE}?refresh=1` : LIBRARY_ROUTE);
163
167
  if (!result.ok || result.body === null) throw new Error(String(result.status));
164
168
  setState({ status: "ready", data: result.body, error: false });
165
169
  } catch {
@@ -169,7 +173,7 @@ window.__ModuleLoader__.load({
169
173
  useEffect(() => {
170
174
  load();
171
175
  }, [load]);
172
- return { ...state, reload: load };
176
+ return { ...state, reload: () => load(), refresh: () => load({ refresh: true }) };
173
177
  }
174
178
 
175
179
  /* ------------------------------------------------------------------ */
@@ -503,7 +507,152 @@ window.__ModuleLoader__.load({
503
507
  );
504
508
  }
505
509
 
506
- function RosterCard({ roster, deployment, onEdit, onDuplicate, onDelete, onCopy }) {
510
+ /**
511
+ * 队长卡:显示档案里声明的队长 + 与部署实际值的核对结果 + “应用”入口。
512
+ *
513
+ * 应用只做两件事:切会话模型、往会话里排一段建团队提示词。它不会、也不能
514
+ * 直接改部署配置——provider/model 不一致时,界面如实说“要改部署”,而不是假装成功。
515
+ */
516
+ function CaptainPanel({ roster, report, sessions, onToast }) {
517
+ useTranslation();
518
+ const captain = roster.captain ?? {};
519
+ const [mode, setMode] = useState("existing");
520
+ const [sessionId, setSessionId] = useState("");
521
+ const [cwd, setCwd] = useState("");
522
+ const [busy, setBusy] = useState(false);
523
+ const [result, setResult] = useState(null);
524
+
525
+ useEffect(() => {
526
+ if (sessionId === "" && sessions.length > 0) setSessionId(sessions[0].sessionId);
527
+ }, [sessions, sessionId]);
528
+
529
+ const checks = report?.checks ?? [];
530
+ const canApply = mode === "new" || sessionId !== "";
531
+
532
+ async function apply() {
533
+ if (!canApply || busy) return;
534
+ setBusy(true);
535
+ setResult(null);
536
+ try {
537
+ const payload = { rosterId: roster.id, mode };
538
+ if (mode === "existing") payload.sessionId = sessionId;
539
+ else if (cwd.trim() !== "") payload.cwd = cwd.trim();
540
+ const response = await readJson(APPLY_ROUTE, {
541
+ method: "POST",
542
+ headers: { "content-type": "application/json" },
543
+ body: JSON.stringify(payload),
544
+ });
545
+ if (!response.ok || response.body?.ok !== true) {
546
+ setResult({ ok: false, code: response.body?.code ?? response.body?.error?.code ?? String(response.status) });
547
+ onToast(t("captain.applyFailed"));
548
+ return;
549
+ }
550
+ setResult({ ok: true, sessionId: response.body.sessionId });
551
+ onToast(t("captain.applied"));
552
+ } catch {
553
+ setResult({ ok: false, code: "network" });
554
+ onToast(t("misc.failed"));
555
+ } finally {
556
+ setBusy(false);
557
+ }
558
+ }
559
+
560
+ return h(
561
+ "section",
562
+ { className: "rstr-captain" },
563
+ h("div", { className: "rstr-section-title rstr-mt" }, t("captain.title")),
564
+ h("p", { className: "rstr-hint" }, t("captain.hint")),
565
+ h(
566
+ "div",
567
+ { className: "rstr-meta-grid" },
568
+ h("span", { className: "rstr-label" }, t("captain.declared")),
569
+ h("span", { className: "rstr-mono" }, captain.provider || "?") ,
570
+ h("span", { className: "rstr-mono" }, captain.model || "?"),
571
+ h("span", { className: "rstr-mono" }, captain.reasoningEffort || t("captain.effortDefault")),
572
+ h(
573
+ "span",
574
+ { className: "rstr-muted" },
575
+ String(captain.personaPrefix ?? "").trim() === "" ? t("captain.noPersona") : t("captain.hasPersona"),
576
+ ),
577
+ ),
578
+ checks.length === 0
579
+ ? h("p", { className: "rstr-hint" }, t("captain.noChecks"))
580
+ : h(
581
+ "ul",
582
+ { className: "rstr-checks" },
583
+ checks.map((check) =>
584
+ h(
585
+ "li",
586
+ { key: check.field, className: "rstr-check" },
587
+ h("span", null, t(`captain.check.${check.field}`)),
588
+ h(
589
+ "span",
590
+ { className: check.status === "match" ? "rstr-ok" : check.status === "differ" ? "rstr-bad" : "rstr-muted" },
591
+ t(`captain.status.${check.status}`, { declared: check.declared || "?", actual: check.actual || "?" }),
592
+ ),
593
+ ),
594
+ ),
595
+ ),
596
+ h(
597
+ "div",
598
+ { className: "rstr-apply" },
599
+ h("div", { className: "rstr-label" }, t("captain.applyTo")),
600
+ h(
601
+ "div",
602
+ { className: "rstr-segmented" },
603
+ h(
604
+ "button",
605
+ { type: "button", className: mode === "existing" ? "rstr-seg rstr-seg-on" : "rstr-seg", onClick: () => setMode("existing") },
606
+ t("captain.modeExisting"),
607
+ ),
608
+ h(
609
+ "button",
610
+ { type: "button", className: mode === "new" ? "rstr-seg rstr-seg-on" : "rstr-seg", onClick: () => setMode("new") },
611
+ t("captain.modeNew"),
612
+ ),
613
+ ),
614
+ mode === "existing"
615
+ ? h(
616
+ "select",
617
+ {
618
+ className: "rstr-select",
619
+ value: sessionId,
620
+ onChange: (event) => setSessionId(event.target.value),
621
+ },
622
+ sessions.length === 0
623
+ ? h("option", { value: "" }, t("captain.noSessions"))
624
+ : sessions.map((session) =>
625
+ h(
626
+ "option",
627
+ { key: session.sessionId, value: session.sessionId },
628
+ `${session.sessionId.slice(0, 12)} · ${session.cwd || "?"}${
629
+ session.model === null ? "" : ` · ${session.model.model}`
630
+ }`,
631
+ ),
632
+ ),
633
+ )
634
+ : h("input", {
635
+ className: "rstr-input",
636
+ placeholder: t("captain.cwdPlaceholder"),
637
+ value: cwd,
638
+ onChange: (event) => setCwd(event.target.value),
639
+ }),
640
+ h(
641
+ "button",
642
+ { type: "button", className: "rstr-btn rstr-btn-primary", disabled: !canApply || busy, onClick: apply },
643
+ busy ? t("captain.applying") : t("captain.apply"),
644
+ ),
645
+ ),
646
+ result !== null && result.ok === false
647
+ ? h("p", { className: "rstr-bad rstr-small" }, t("captain.applyFailedCode", { code: result.code }))
648
+ : null,
649
+ result !== null && result.ok === true
650
+ ? h("p", { className: "rstr-ok rstr-small" }, t("captain.appliedTo", { session: result.sessionId }))
651
+ : null,
652
+ );
653
+ }
654
+
655
+ function RosterCard({ roster, deployment, captainReport, sessions, onToast, onEdit, onDuplicate, onDelete, onCopy }) {
507
656
  const [open, setOpen] = useState(false);
508
657
  const errors = (roster.diagnostics ?? []).filter((item) => item?.severity === "error").length;
509
658
  const deployed = deployment !== undefined && deployment.deployed > 0;
@@ -573,6 +722,9 @@ window.__ModuleLoader__.load({
573
722
  roster.members.length === 0
574
723
  ? h("p", { className: "rstr-muted" }, t("roster.noMembers"))
575
724
  : roster.members.map((member) => h(MemberBlock, { key: member.name, member })),
725
+ roster.captain === null || roster.captain === undefined
726
+ ? null
727
+ : h(CaptainPanel, { roster, report: captainReport, sessions: sessions ?? [], onToast }),
576
728
  h(DiagnosticList, { items: roster.diagnostics }),
577
729
  )
578
730
  : null,
@@ -1064,11 +1216,16 @@ window.__ModuleLoader__.load({
1064
1216
  /* 主面板 */
1065
1217
  /* ------------------------------------------------------------------ */
1066
1218
 
1219
+ // 面板本身拿不到 ctx:这里由 apply() 把宿主服务放进来,供设置弹窗使用。
1220
+ const HOST = { form: null, openPanel: null };
1221
+
1067
1222
  function Panel() {
1068
1223
  useTranslation();
1069
1224
  const library = useLibrary();
1070
1225
  const [tab, setTab] = useState("rosters");
1071
1226
  const [query, setQuery] = useState("");
1227
+ const [selectedTags, setSelectedTags] = useState(() => new Set());
1228
+ const [settingsOpen, setSettingsOpen] = useState(false);
1072
1229
  const [editor, setEditor] = useState(null);
1073
1230
  const [importing, setImporting] = useState(false);
1074
1231
  const [removing, setRemoving] = useState(null);
@@ -1082,17 +1239,47 @@ window.__ModuleLoader__.load({
1082
1239
 
1083
1240
  const snapshot = library.data;
1084
1241
  const rosters = useMemo(() => {
1085
- const all = snapshot?.workspaces?.flatMap((workspace) =>
1242
+ return snapshot?.workspaces?.flatMap((workspace) =>
1086
1243
  (workspace.rosters ?? []).map((roster) => ({ ...roster, rootDir: workspace.rootDir })),
1087
1244
  ) ?? [];
1245
+ }, [snapshot]);
1246
+
1247
+ // 标签池:整个库出现过的标签 + 各标签档案数,按出现次数降序(同数按字母)。
1248
+ const tagPool = useMemo(() => {
1249
+ const counts = new Map();
1250
+ for (const roster of rosters) {
1251
+ for (const tag of roster.tags ?? []) counts.set(tag, (counts.get(tag) ?? 0) + 1);
1252
+ }
1253
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1254
+ }, [rosters]);
1255
+
1256
+ const filtered = useMemo(() => {
1088
1257
  const needle = query.trim().toLowerCase();
1089
- if (needle === "") return all;
1090
- return all.filter((roster) =>
1091
- [roster.id, roster.title, roster.description, ...(roster.tags ?? []), ...(roster.members ?? []).map((member) => member.name)]
1258
+ return rosters.filter((roster) => {
1259
+ if (selectedTags.size > 0) {
1260
+ const tags = roster.tags ?? [];
1261
+ // 多选是“且”:勾了 revision 和 testing,就只看同时带这两个标签的档案。
1262
+ for (const tag of selectedTags) if (!tags.includes(tag)) return false;
1263
+ }
1264
+ if (needle === "") return true;
1265
+ return [roster.id, roster.title, roster.description, ...(roster.tags ?? []), ...(roster.members ?? []).map((member) => member.name)]
1092
1266
  .filter((value) => typeof value === "string")
1093
- .some((value) => value.toLowerCase().includes(needle)),
1094
- );
1095
- }, [snapshot, query]);
1267
+ .some((value) => value.toLowerCase().includes(needle));
1268
+ });
1269
+ }, [rosters, query, selectedTags]);
1270
+
1271
+ function toggleTag(tag) {
1272
+ setSelectedTags((current) => {
1273
+ const next = new Set(current);
1274
+ if (next.has(tag)) next.delete(tag);
1275
+ else next.add(tag);
1276
+ return next;
1277
+ });
1278
+ }
1279
+
1280
+ function clearTags() {
1281
+ setSelectedTags(() => new Set());
1282
+ }
1096
1283
 
1097
1284
  const teams = snapshot?.teams?.teams ?? [];
1098
1285
  const hints = snapshot?.hints ?? [];
@@ -1153,9 +1340,18 @@ window.__ModuleLoader__.load({
1153
1340
  h(
1154
1341
  "div",
1155
1342
  { className: "rstr-head-actions" },
1156
- h("button", { type: "button", className: "rstr-btn", onClick: library.reload, disabled: library.status === "refreshing" }, library.status === "refreshing" ? t("panel.refreshing") : t("panel.refresh")),
1157
- h("button", { type: "button", className: "rstr-btn", onClick: () => setImporting(true), disabled: targets.length === 0 }, t("panel.import")),
1158
- h("button", { type: "button", className: "rstr-btn rstr-btn-primary", onClick: () => setEditor({ roster: null, rootDir: defaultRoot }), disabled: targets.length === 0 }, t("panel.create")),
1343
+ // 头部只留一个入口:导入/新建属于档案页,刷新索引属于档案源,各自回到自己的页签。
1344
+ h(
1345
+ "button",
1346
+ {
1347
+ type: "button",
1348
+ className: "rstr-btn rstr-settings-btn",
1349
+ onClick: () => setSettingsOpen(true),
1350
+ title: t("panel.settings"),
1351
+ },
1352
+ h("span", { className: "rstr-settings-glyph", "aria-hidden": "true" }, "\u2699"),
1353
+ t("panel.settings"),
1354
+ ),
1159
1355
  ),
1160
1356
  );
1161
1357
 
@@ -1216,31 +1412,93 @@ window.__ModuleLoader__.load({
1216
1412
  } else {
1217
1413
  content = h(
1218
1414
  "div",
1219
- null,
1220
- h("input", {
1221
- className: "rstr-input rstr-search",
1222
- value: query,
1223
- placeholder: t("panel.searchPlaceholder"),
1224
- "aria-label": t("panel.search"),
1225
- onChange: (event) => setQuery(event.target.value),
1226
- }),
1227
- rosters.length === 0
1228
- ? h("p", { className: "rstr-muted" }, t("panel.noMatch"))
1229
- : h(
1415
+ { className: "rstr-rosters" },
1416
+ h("aside", { className: "rstr-tagpool", "aria-label": t("panel.tagPool") }, [
1417
+ h("span", { className: "rstr-tagpool-title" }, t("panel.tagPool")),
1418
+ tagPool.length === 0
1419
+ ? h("p", { className: "rstr-muted rstr-small" }, t("panel.tagPoolEmpty"))
1420
+ : h(
1421
+ "div",
1422
+ { className: "rstr-tagpool-list" },
1423
+ tagPool.map(([tag, count]) =>
1424
+ h(
1425
+ "button",
1426
+ {
1427
+ key: tag,
1428
+ type: "button",
1429
+ className: `rstr-tag${selectedTags.has(tag) ? " rstr-tag-on" : ""}`,
1430
+ "aria-pressed": selectedTags.has(tag),
1431
+ onClick: () => toggleTag(tag),
1432
+ },
1433
+ h("span", { className: "rstr-tag-name" }, tag),
1434
+ h("span", { className: "rstr-tag-count" }, String(count)),
1435
+ ),
1436
+ ),
1437
+ ),
1438
+ selectedTags.size === 0
1439
+ ? null
1440
+ : h(
1441
+ "button",
1442
+ { type: "button", className: "rstr-btn rstr-btn-sm rstr-tag-clear", onClick: clearTags },
1443
+ t("panel.tagClear"),
1444
+ ),
1445
+ ]),
1446
+ h(
1447
+ "div",
1448
+ { className: "rstr-rosters-main" },
1449
+ h(
1450
+ "div",
1451
+ { className: "rstr-toolbar" },
1452
+ h("input", {
1453
+ className: "rstr-input rstr-search",
1454
+ value: query,
1455
+ placeholder: t("panel.searchPlaceholder"),
1456
+ "aria-label": t("panel.search"),
1457
+ onChange: (event) => setQuery(event.target.value),
1458
+ }),
1459
+ h(
1230
1460
  "div",
1231
- { className: "rstr-cards" },
1232
- rosters.map((roster) =>
1461
+ { className: "rstr-toolbar-actions" },
1462
+ h(
1463
+ "button",
1464
+ { type: "button", className: "rstr-btn", onClick: () => setImporting(true), disabled: targets.length === 0 },
1465
+ t("panel.import"),
1466
+ ),
1467
+ h(
1468
+ "button",
1469
+ {
1470
+ type: "button",
1471
+ className: "rstr-btn rstr-btn-primary",
1472
+ onClick: () => setEditor({ roster: null, rootDir: defaultRoot }),
1473
+ disabled: targets.length === 0,
1474
+ },
1475
+ t("panel.create"),
1476
+ ),
1477
+ ),
1478
+ ),
1479
+ filtered.length === 0
1480
+ ? h("p", { className: "rstr-muted" }, t("panel.noMatch"))
1481
+ : h(
1482
+ "div",
1483
+ { className: "rstr-cards" },
1484
+ filtered.map((roster) =>
1233
1485
  h(RosterCard, {
1234
1486
  key: `${roster.rootDir}::${roster.id}`,
1235
1487
  roster,
1236
1488
  deployment: snapshot?.deployments?.[roster.id],
1489
+ captainReport: (snapshot?.captain?.reports ?? []).find(
1490
+ (item) => item.rosterId === roster.id && item.rootDir === roster.rootDir,
1491
+ ),
1492
+ sessions: snapshot?.sessions?.sessions ?? [],
1493
+ onToast: setToast,
1237
1494
  onEdit: () => setEditor({ roster, rootDir: roster.rootDir }),
1238
1495
  onDuplicate: () => setEditor({ roster, rootDir: roster.rootDir, duplicate: true }),
1239
1496
  onDelete: () => setRemoving(roster),
1240
1497
  onCopy: copyPayload,
1241
1498
  }),
1242
1499
  ),
1243
- ),
1500
+ ),
1501
+ ),
1244
1502
  );
1245
1503
  }
1246
1504
  }
@@ -1257,6 +1515,8 @@ window.__ModuleLoader__.load({
1257
1515
  remote: snapshot?.remote ?? null,
1258
1516
  onToast: setToast,
1259
1517
  onPulled: () => library.reload(),
1518
+ onRefresh: () => library.refresh(),
1519
+ refreshing: library.status === "refreshing",
1260
1520
  });
1261
1521
  }
1262
1522
 
@@ -1290,6 +1550,22 @@ window.__ModuleLoader__.load({
1290
1550
  : null,
1291
1551
  ),
1292
1552
  toast !== null ? h("div", { className: "rstr-toast", role: "status" }, toast) : null,
1553
+ settingsOpen
1554
+ ? h(
1555
+ Modal,
1556
+ { title: t("panel.settings"), onClose: () => setSettingsOpen(false), width: "560px" },
1557
+ h(SettingsSection, {
1558
+ form: HOST.form,
1559
+ library,
1560
+ onSaved: () => library.reload(),
1561
+ inModal: true,
1562
+ onOpenPanel: () => {
1563
+ setSettingsOpen(false);
1564
+ HOST.openPanel?.();
1565
+ },
1566
+ }),
1567
+ )
1568
+ : null,
1293
1569
  editor !== null
1294
1570
  ? h(Editor, {
1295
1571
  target: { roster: editor.roster, rootDir: editor.rootDir, duplicate: editor.duplicate === true },
@@ -1332,7 +1608,7 @@ window.__ModuleLoader__.load({
1332
1608
  * 拉取是**显式动作**:后端按内容比对,相同的跳过、不同的默认不覆盖(会把冲突数报回来)。
1333
1609
  * 前端不做这份判断——它只把结果说清楚,避免"点一下按钮,本地改动就没了"。
1334
1610
  */
1335
- function SourcesTab({ remote, onToast, onPulled }) {
1611
+ function SourcesTab({ remote, onToast, onPulled, onRefresh, refreshing }) {
1336
1612
  useTranslation();
1337
1613
  const [pulling, setPulling] = useState("");
1338
1614
  const [openId, setOpenId] = useState("");
@@ -1387,10 +1663,33 @@ window.__ModuleLoader__.load({
1387
1663
  }
1388
1664
  }
1389
1665
 
1666
+ // 刷新是档案源自己的动作:绕过 TTL 重新回源,而不是重读本地快照。
1667
+ const head = h(
1668
+ "div",
1669
+ { className: "rstr-sources-head" },
1670
+ h(
1671
+ "p",
1672
+ { className: "rstr-muted rstr-small" },
1673
+ indexes.length > 0
1674
+ ? indexes
1675
+ .map((index) =>
1676
+ `${index.label} · ${index.stale ? t("source.stale") : t("source.fetchedAt", { when: index.fetchedAt ?? "-" })}`,
1677
+ )
1678
+ .join(" ")
1679
+ : t("source.refreshHint"),
1680
+ ),
1681
+ h(
1682
+ "button",
1683
+ { type: "button", className: "rstr-btn", onClick: onRefresh, disabled: refreshing === true },
1684
+ refreshing === true ? t("panel.refreshing") : t("source.refresh"),
1685
+ ),
1686
+ );
1687
+
1390
1688
  if (collections.length === 0) {
1391
1689
  return h(
1392
1690
  "div",
1393
- null,
1691
+ { className: "rstr-sources" },
1692
+ head,
1394
1693
  h(EmptyState, { title: t("source.empty"), body: t("source.emptyHint") }),
1395
1694
  problems.length > 0 ? h(DiagnosticList, { items: problems }) : null,
1396
1695
  );
@@ -1399,17 +1698,7 @@ window.__ModuleLoader__.load({
1399
1698
  return h(
1400
1699
  "div",
1401
1700
  { className: "rstr-sources" },
1402
- indexes.length > 0
1403
- ? h(
1404
- "p",
1405
- { className: "rstr-muted rstr-small" },
1406
- indexes
1407
- .map((index) =>
1408
- `${index.label} · ${index.stale ? t("source.stale") : t("source.fetchedAt", { when: index.fetchedAt ?? "-" })}`,
1409
- )
1410
- .join(" "),
1411
- )
1412
- : null,
1701
+ head,
1413
1702
  h(
1414
1703
  "div",
1415
1704
  { className: "rstr-cards" },
@@ -1553,14 +1842,20 @@ window.__ModuleLoader__.load({
1553
1842
  * 语言之外的配置项只读展示——档案目录与状态目录属于"部署事实",
1554
1843
  * 让它们出现在两个地方反而会让人分不清哪个生效。
1555
1844
  */
1556
- function SettingsSection({ form, library, onOpenPanel }) {
1845
+ function SettingsSection({ form, library, onOpenPanel, onSaved, inModal }) {
1557
1846
  useTranslation();
1558
- const [language, setLanguage] = useState(() => readFormLanguage(form));
1847
+ const [language, setLanguage] = useState(() => readLanguage(form));
1848
+ const [region, setRegion] = useState(() => readFormValue(form, "region", "auto"));
1849
+ const [dirs, setDirs] = useState(() => (library?.data?.config?.rosterDirs ?? []).join(", "));
1850
+ const [saved, setSaved] = useState("");
1559
1851
  const [version, setVersion] = useState("");
1560
1852
 
1561
1853
  useEffect(() => {
1562
1854
  if (form?.subscribe === undefined) return undefined;
1563
- const off = form.subscribe(() => setLanguage(readFormLanguage(form)));
1855
+ const off = form.subscribe(() => {
1856
+ setLanguage(readLanguage(form));
1857
+ setRegion(readFormValue(form, "region", "auto"));
1858
+ });
1564
1859
  return () => {
1565
1860
  if (typeof off === "function") off();
1566
1861
  };
@@ -1568,12 +1863,42 @@ window.__ModuleLoader__.load({
1568
1863
 
1569
1864
  useEffect(() => {
1570
1865
  setVersion(String(library?.data?.version ?? ""));
1571
- }, [library?.data?.version]);
1866
+ setDirs((library?.data?.config?.rosterDirs ?? []).join(", "));
1867
+ }, [library?.data?.version, library?.data?.config?.rosterDirs]);
1868
+
1869
+ useEffect(() => {
1870
+ if (saved === "") return undefined;
1871
+ const timer = setTimeout(() => setSaved(""), 2000);
1872
+ return () => clearTimeout(timer);
1873
+ }, [saved]);
1572
1874
 
1573
- function change(next) {
1875
+ function write(key, value) {
1876
+ try {
1877
+ form?.set?.(key, value);
1878
+ setSaved(t("settings.saved"));
1879
+ } catch {
1880
+ setSaved(t("settings.saveFailed"));
1881
+ }
1882
+ }
1883
+
1884
+ function changeLanguage(next) {
1574
1885
  setLanguage(next);
1575
- // auto 用空串表示"跟随":配置项的默认值就是 auto,写回字符串即可。
1576
- form?.set?.("language", next);
1886
+ write("language", next);
1887
+ }
1888
+
1889
+ function changeRegion(next) {
1890
+ setRegion(next);
1891
+ write("region", next);
1892
+ }
1893
+
1894
+ function saveDirs() {
1895
+ // 逗号/分号/空白都当分隔符:用户从别处粘路径时不该因为分隔符被卡住。
1896
+ const list = dirs
1897
+ .split(/[,;\n]/)
1898
+ .map((item) => item.trim())
1899
+ .filter((item) => item !== "");
1900
+ write("rosterDirs", list);
1901
+ onSaved?.();
1577
1902
  }
1578
1903
 
1579
1904
  return h(
@@ -1596,7 +1921,7 @@ window.__ModuleLoader__.load({
1596
1921
  type: "button",
1597
1922
  className: `rstr-seg${language === value ? " rstr-seg-on" : ""}`,
1598
1923
  "aria-pressed": language === value,
1599
- onClick: () => change(value),
1924
+ onClick: () => changeLanguage(value),
1600
1925
  },
1601
1926
  value === "auto" ? t("settings.languageAuto") : value === "zh" ? t("settings.languageZh") : t("settings.languageEn"),
1602
1927
  ),
@@ -1606,14 +1931,42 @@ window.__ModuleLoader__.load({
1606
1931
  h(
1607
1932
  "div",
1608
1933
  { className: "rstr-settings-row" },
1934
+ h("span", { className: "rstr-label" }, t("settings.region")),
1935
+ h(
1936
+ "div",
1937
+ { className: "rstr-segmented" },
1938
+ REGIONS.map((value) =>
1939
+ h(
1940
+ "button",
1941
+ {
1942
+ key: value,
1943
+ type: "button",
1944
+ className: `rstr-seg${region === value ? " rstr-seg-on" : ""}`,
1945
+ "aria-pressed": region === value,
1946
+ onClick: () => changeRegion(value),
1947
+ },
1948
+ value === "auto" ? t("settings.regionAuto") : value === "global" ? t("settings.regionGlobal") : t("settings.regionChina"),
1949
+ ),
1950
+ ),
1951
+ ),
1952
+ ),
1953
+ h(
1954
+ "div",
1955
+ { className: "rstr-settings-field" },
1609
1956
  h("span", { className: "rstr-label" }, t("settings.rosterDirs")),
1610
1957
  h(
1611
- "code",
1612
- { className: "rstr-code rstr-truncate", title: (library?.data?.config?.rosterDirs ?? []).join(", ") },
1613
- (library?.data?.config?.rosterDirs ?? []).length > 0
1614
- ? library.data.config.rosterDirs.join(", ")
1615
- : `${t("settings.globalLibrary")} ${library?.data?.config?.defaultRoot ?? ""}`,
1958
+ "div",
1959
+ { className: "rstr-settings-inline" },
1960
+ h("input", {
1961
+ className: "rstr-input",
1962
+ value: dirs,
1963
+ placeholder: library?.data?.config?.defaultRoot ?? "~/.dsh/agent-roster",
1964
+ "aria-label": t("settings.rosterDirs"),
1965
+ onChange: (event) => setDirs(event.target.value),
1966
+ }),
1967
+ h("button", { type: "button", className: "rstr-btn rstr-btn-sm", onClick: saveDirs }, t("settings.save")),
1616
1968
  ),
1969
+ h("span", { className: "rstr-muted rstr-small" }, t("settings.rosterDirsHint")),
1617
1970
  ),
1618
1971
  h(
1619
1972
  "div",
@@ -1636,11 +1989,28 @@ window.__ModuleLoader__.load({
1636
1989
  { className: "rstr-settings-row" },
1637
1990
  h("span", { className: "rstr-label" }, t("settings.version")),
1638
1991
  h("code", { className: "rstr-code" }, version),
1639
- h("button", { type: "button", className: "rstr-btn rstr-btn-sm", onClick: onOpenPanel }, t("settings.openPanel")),
1992
+ inModal === true ? null : h("button", { type: "button", className: "rstr-btn rstr-btn-sm", onClick: onOpenPanel }, t("settings.openPanel")),
1640
1993
  ),
1994
+ saved === "" ? null : h("p", { className: "rstr-muted rstr-small" }, saved),
1641
1995
  );
1642
1996
  }
1643
1997
 
1998
+ /* 通用读表单值:volatile 字段在 getSnapshot().value 里,取不到就用兜底。 */
1999
+ function readFormValue(form, key, fallback) {
2000
+ try {
2001
+ const value = form?.getSnapshot?.()?.value;
2002
+ if (value !== null && typeof value === "object" && typeof value[key] === "string") return value[key];
2003
+ } catch {
2004
+ /* 表单不可用时按兜底值。 */
2005
+ }
2006
+ return fallback;
2007
+ }
2008
+
2009
+ function readLanguage(form) {
2010
+ const value = readFormValue(form, "language", "auto");
2011
+ return LANGUAGES.includes(value) ? value : "auto";
2012
+ }
2013
+
1644
2014
  function readFormLanguage(form) {
1645
2015
  let value = "auto";
1646
2016
  try {
@@ -1668,7 +2038,7 @@ window.__ModuleLoader__.load({
1668
2038
  * 文本色一律显式给出(不用 inherit),避免继承到与背景同色的文本。
1669
2039
  */
1670
2040
  const CSS = `
1671
- .rstr-page { display: flex; flex-direction: column; gap: 16px; padding: 20px 24px 32px; max-width: 1120px; margin: 0 auto; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.55; }
2041
+ .rstr-page { display: flex; flex-direction: column; gap: 16px; padding: 20px 24px 32px; width: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.55; }
1672
2042
  .rstr-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
1673
2043
  .rstr-h1 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; color: var(--dsw-alias-label-primary); }
1674
2044
  .rstr-muted { color: var(--dsw-alias-label-secondary); margin: 0; }
@@ -1678,6 +2048,13 @@ window.__ModuleLoader__.load({
1678
2048
  .rstr-code-strong { font-size: 13px; color: var(--dsw-alias-label-primary); font-weight: 600; }
1679
2049
  .rstr-truncate { display: inline-block; max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
1680
2050
  .rstr-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
2051
+ .rstr-settings-btn { display: inline-flex; align-items: center; gap: 6px; }
2052
+ .rstr-settings-glyph { font-size: 14px; line-height: 1; }
2053
+ .rstr-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
2054
+ .rstr-toolbar-actions { display: flex; gap: 8px; margin-left: auto; }
2055
+ .rstr-sources-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
2056
+ .rstr-settings { display: flex; flex-direction: column; gap: 10px; }
2057
+ .rstr-settings-title { margin: 0; font-size: 14px; font-weight: 600; }
1681
2058
 
1682
2059
  .rstr-btn { border: 1px solid var(--dsw-alias-border-l3); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 6px 12px; font-size: 13px; font-family: inherit; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
1683
2060
  .rstr-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
@@ -1708,7 +2085,19 @@ window.__ModuleLoader__.load({
1708
2085
  .rstr-tab-on { color: var(--dsw-alias-label-primary); border-bottom-color: var(--dsw-alias-state-business-primary); font-weight: 600; }
1709
2086
  .rstr-tab-count { font-size: 11px; padding: 0 6px; border-radius: 999px; background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-secondary); }
1710
2087
 
1711
- .rstr-cards { display: grid; gap: 12px; }
2088
+ .rstr-rosters { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 16px; align-items: start; }
2089
+ @media (max-width: 860px) { .rstr-rosters { grid-template-columns: minmax(0, 1fr); } }
2090
+ .rstr-tagpool { display: flex; flex-direction: column; gap: 8px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-2); position: sticky; top: 12px; }
2091
+ .rstr-tagpool-title { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
2092
+ .rstr-tagpool-list { display: flex; flex-direction: column; gap: 4px; }
2093
+ .rstr-tag { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 1px solid transparent; background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 4px 8px; font-size: 12px; font-family: inherit; cursor: pointer; text-align: left; }
2094
+ .rstr-tag:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
2095
+ .rstr-tag:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; }
2096
+ .rstr-tag-on { background: var(--dsw-alias-button-ghost-active-fill); border-color: var(--dsw-alias-border-l3); color: var(--dsw-alias-label-primary); font-weight: 600; }
2097
+ .rstr-tag-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2098
+ .rstr-tag-count { font-size: 11px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
2099
+ .rstr-tag-clear { align-self: flex-start; }
2100
+ .rstr-cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); align-items: start; }
1712
2101
  .rstr-card { border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 14px 16px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 10px; }
1713
2102
  .rstr-card-bad { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent); }
1714
2103
  .rstr-card-id { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -1768,6 +2157,13 @@ window.__ModuleLoader__.load({
1768
2157
 
1769
2158
  .rstr-editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
1770
2159
  .rstr-editor-toolbar .rstr-input { width: 110px; }
2160
+ .rstr-captain { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--dsw-alias-border-l3); margin-top: 12px; padding-top: 10px; }
2161
+ .rstr-meta-grid { display: grid; grid-template-columns: auto repeat(4, minmax(0, auto)); gap: 6px 12px; align-items: baseline; font-size: 12px; }
2162
+ .rstr-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
2163
+ .rstr-check { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); padding-bottom: 3px; }
2164
+ .rstr-apply { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2165
+ .rstr-select { background: var(--dsw-alias-bg-l2); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; padding: 5px 8px; font-size: 12px; max-width: 320px; }
2166
+ .rstr-bad { color: var(--dsw-alias-state-error-primary); }
1771
2167
  .rstr-segmented { display: inline-flex; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; overflow: hidden; }
1772
2168
  .rstr-seg { border: none; background: transparent; padding: 5px 12px; font-size: 12px; cursor: pointer; color: var(--dsw-alias-label-secondary); }
1773
2169
  .rstr-seg:hover { background: var(--dsw-alias-interactive-bg-hover); }
@@ -1786,10 +2182,11 @@ window.__ModuleLoader__.load({
1786
2182
 
1787
2183
  .rstr-foot { display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; margin-top: 4px; color: var(--dsw-alias-label-secondary); }
1788
2184
 
1789
- .rstr-settings { display: flex; flex-direction: column; gap: 10px; }
1790
- .rstr-settings-title { margin: 0; font-size: 14px; font-weight: 600; }
1791
2185
  .rstr-settings-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
1792
2186
  .rstr-settings-row .rstr-label { min-width: 120px; }
2187
+ .rstr-settings-field { display: flex; flex-direction: column; gap: 6px; }
2188
+ .rstr-settings-inline { display: flex; align-items: center; gap: 8px; }
2189
+ .rstr-settings-inline .rstr-input { flex: 1 1 auto; min-width: 0; }
1793
2190
  `;
1794
2191
 
1795
2192
  function ensureStyle() {
@@ -1889,7 +2286,8 @@ window.__ModuleLoader__.load({
1889
2286
  "agent-roster: language sync",
1890
2287
  );
1891
2288
 
1892
- const openPanel = () => {
2289
+ HOST.form = form;
2290
+ HOST.openPanel = () => {
1893
2291
  try {
1894
2292
  layout?.selectPanel?.(PANEL_ID);
1895
2293
  } catch {
@@ -1938,7 +2336,7 @@ window.__ModuleLoader__.load({
1938
2336
  { name: "settings.section", id: PANEL_ID, order: 55, locale: NS, label: () => t("settings.nav") },
1939
2337
  function AgentRosterSettings() {
1940
2338
  const library = useLibrary();
1941
- return h(SettingsSection, { form, library, onOpenPanel: openPanel });
2339
+ return h(SettingsSection, { form, library, onOpenPanel: HOST.openPanel, inModal: false });
1942
2340
  },
1943
2341
  ),
1944
2342
  );