@klarkxy/dsh-git-commit 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -262,7 +262,14 @@ function useModelCatalog(load, options = {}) {
262
262
  refresh
263
263
  };
264
264
  }
265
- /** Also accepts a mapped state from a caller that owns broader async orchestration. */
265
+ /**
266
+ * Also accepts a mapped state from a caller that owns broader async orchestration.
267
+ *
268
+ * The dsh-ui-* classes carry the styling and resolve through the consuming
269
+ * plugin's own officialUiCss scope (every consumer mounts its root class, so
270
+ * e.g. `.dsh-model-hub .dsh-ui-loading` paints this); dsh-model-catalog-status
271
+ * stays as a structural hook for tests and layout, never a colour source.
272
+ */
266
273
  function ModelCatalogStatus({ state, locale }) {
267
274
  const zh = locale?.toLowerCase().startsWith("zh") === true;
268
275
  const copy = zh ? {
@@ -278,15 +285,17 @@ function ModelCatalogStatus({ state, locale }) {
278
285
  };
279
286
  if (!state.loading && !state.error && state.failures.length === 0) return null;
280
287
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
281
- className: "dsh-model-catalog-status",
288
+ className: "dsh-model-catalog-status dsh-ui-stack",
282
289
  lang: zh ? "zh" : "en",
283
290
  children: [
284
291
  state.loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
285
292
  role: "status",
293
+ className: "dsh-ui-loading",
286
294
  children: copy.loading
287
295
  }) : null,
288
296
  state.error ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
289
297
  role: "alert",
298
+ className: "dsh-ui-error dsh-ui-wrap",
290
299
  children: [
291
300
  copy.error,
292
301
  ": ",
@@ -295,20 +304,27 @@ function ModelCatalogStatus({ state, locale }) {
295
304
  }) : null,
296
305
  state.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
297
306
  role: "status",
298
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: copy.partial }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", { children: state.failures.map((failure, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
299
- failure.name || failure.id,
300
- ": ",
301
- failure.message
302
- ] }, `${failure.id}:${index}`)) })]
307
+ className: "dsh-ui-meta",
308
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: copy.partial }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
309
+ className: "dsh-ui-list",
310
+ children: state.failures.map((failure, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
311
+ failure.name || failure.id,
312
+ ": ",
313
+ failure.message
314
+ ] }, `${failure.id}:${index}`))
315
+ })]
303
316
  }) : null,
304
- state.error || state.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
305
- type: "button",
306
- size: "sm",
307
- disabled: state.loading,
308
- onClick: () => {
309
- state.refresh();
310
- },
311
- children: copy.retry
317
+ state.error || state.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
318
+ className: "dsh-ui-actions",
319
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
320
+ type: "button",
321
+ size: "sm",
322
+ disabled: state.loading,
323
+ onClick: () => {
324
+ state.refresh();
325
+ },
326
+ children: copy.retry
327
+ })
312
328
  }) : null
313
329
  ]
314
330
  });
@@ -328,7 +344,8 @@ function ModelCatalogStatus({ state, locale }) {
328
344
  * Sticky group headings, subsequence ranking, highlight walking and focus
329
345
  * hand-back all match the host. SYNC POINT: when the host's ModelSelect or
330
346
  * ModelSelect.module.css changes, this copy must be re-transcribed; styles
331
- * below may only reference --dsw-* theme tokens, never literal colors.
347
+ * below may only reference allowlisted theme tokens (the --dsw-* set plus the
348
+ * host's --ds-* motion tokens), never literal colors.
332
349
  *
333
350
  * It statically imports the primitives, so only browser clients should load
334
351
  * this entry; host code and node tests use the core entry (`./index.ts`).
@@ -836,7 +853,8 @@ function ModelMenu(props) {
836
853
  * sticky group headings (MenuGroup's own CSS), and the l1-stroke prominent
837
854
  * elevation at portal z-index 1100. The trigger mirrors the composer's 28px
838
855
  * model pill, chevron rotating open. MenuSurface paints fill and backdrop.
839
- * Only --dsw-* theme tokens may appear here — a literal color is a bug.
856
+ * Only allowlisted --dsw- and --ds- theme tokens may appear here — a literal
857
+ * color is a bug.
840
858
  */
841
859
  const modelMenuCss = `
842
860
  .dsh-model-menu-root { min-width: 0; position: relative; display: inline-flex; }
@@ -859,12 +877,14 @@ const modelMenuCss = `
859
877
  display: flex;
860
878
  }
861
879
  .dsh-model-menu-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
862
- .dsh-model-menu-trigger:focus-visible:not([data-selection-focus]) { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
880
+ .dsh-model-menu-trigger:focus-visible:not([data-selection-focus]) { box-shadow: 0 0 0 var(--dsw-focus-ring-width) var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
863
881
  .dsh-model-menu-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
864
882
  .dsh-model-menu-triggerLabel { text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; }
865
883
  .dsh-model-menu-triggerEffort { text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--dsw-alias-label-caption); flex-shrink: 1000; overflow: hidden; }
866
- .dsh-model-menu-chevron { color: var(--dsw-alias-label-caption); flex: none; transition: transform 0.12s; }
884
+ .dsh-model-menu-chevron { color: var(--dsw-alias-label-caption); flex: none; transition: transform var(--ds-transition-duration) var(--ds-ease-in-out); }
867
885
  .dsh-model-menu-chevronOpen { transform: rotate(180deg); }
886
+ /* Portal overlay: the host publishes no z-index token, so the card keeps the
887
+ * native menu layer's 1100 verbatim. */
868
888
  .dsh-model-menu-menu {
869
889
  z-index: 1100;
870
890
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
@@ -890,6 +910,8 @@ const modelMenuCss = `
890
910
  .dsh-model-menu-searchWithQuery { padding-right: 34px; }
891
911
  .dsh-model-menu-searchRow .dsh-model-menu-search input { padding: 0; font-size: 12px; line-height: normal; }
892
912
  .dsh-model-menu-searchRow .dsh-model-menu-search input::placeholder { color: var(--dsw-alias-label-caption); }
913
+ /* The clear button is a circle by geometry (border-radius: 50%), a shape the
914
+ * radius token scale does not describe. */
893
915
  .dsh-model-menu-searchClear {
894
916
  width: 24px;
895
917
  height: 24px;
@@ -1137,6 +1159,17 @@ ${scope} {
1137
1159
  /* --- Layout scaffolding. --- */
1138
1160
  & .dsh-ui-stack { display: flex; flex-direction: column; gap: 12px; }
1139
1161
  & .dsh-ui-stack-lg { display: flex; flex-direction: column; gap: 20px; }
1162
+ /* Plugin detail pages share one reading column. These are layout recipes;
1163
+ * controls keep their own primitive styles. Form spacing follows the host's
1164
+ * settings-form/fields.module.css and SettingsForm.module.css. */
1165
+ & .dsh-ui-page { display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: 760px; min-width: 0; }
1166
+ & .dsh-ui-form { display: flex; flex-direction: column; gap: 0; min-width: 0; }
1167
+ &.dsh-ui-form > .dsh-ui-field, & .dsh-ui-form > .dsh-ui-field { padding: 12px 0; }
1168
+ & .dsh-ui-form-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding-top: 16px; min-width: 0; }
1169
+ & .dsh-ui-fields-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 20px; min-width: 0; margin: 0; padding: 0; border: 0; }
1170
+ @media (max-width: 560px) {
1171
+ & .dsh-ui-fields-grid { grid-template-columns: minmax(0, 1fr); }
1172
+ }
1140
1173
  & .dsh-ui-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
1141
1174
  & .dsh-ui-row-wrap { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
1142
1175
  & .dsh-ui-spacer { flex: 1; min-width: 0; }
@@ -1164,6 +1197,9 @@ ${scope} {
1164
1197
  * official primitive, so this only owns the vertical rhythm. --- */
1165
1198
  & .dsh-ui-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
1166
1199
  & .dsh-ui-field + .dsh-ui-field { border-top: 0.5px solid var(--dsw-alias-border-l2); padding-top: 12px; }
1200
+ /* Grid fields share a row, so the stacked-field divider must not win later in
1201
+ * the cascade. Reset it here, after the field recipe. */
1202
+ & .dsh-ui-fields-grid > .dsh-ui-field { padding: 0; border: 0; }
1167
1203
  & .dsh-ui-label { display: flex; align-items: center; gap: 6px; font-size: 13px; line-height: 20px; font-weight: 500; color: var(--dsw-alias-label-primary); }
1168
1204
  & .dsh-ui-label-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
1169
1205
  & .dsh-ui-help { font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
@@ -1354,7 +1390,7 @@ ${scope} {
1354
1390
  & *, & *::before, & *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
1355
1391
  }
1356
1392
  }
1357
- `.replace(/^& (\.dsh-ui-[a-z0-9-]+)(?![+>~])/gm, (_match, className) => `&${className}, & ${className}`);
1393
+ `.replace(/^& (\.dsh-ui-[a-z0-9-]+)(?=\s*\{)/gm, (_match, className) => `&${className}, & ${className}`);
1358
1394
  }
1359
1395
  //#endregion
1360
1396
  //#region ../dsh-plugin-kit/src/contracts.ts
@@ -1370,6 +1406,37 @@ function modelSettingsChangesIdentity(packageName) {
1370
1406
  namespace: segment
1371
1407
  };
1372
1408
  }
1409
+ /**
1410
+ * Shared "Session Management" settings section id. The section is provided by
1411
+ * shared infrastructure (see `registerSessionSettingsItem` in `client-utils`),
1412
+ * not by any one business plugin: every participant registers only a row into
1413
+ * {@link SESSION_SETTINGS_ITEM_SLOT}, so any enabled subset yields the same page.
1414
+ */
1415
+ const SESSION_SETTINGS_SECTION_ID = "session-manager";
1416
+ /**
1417
+ * Child list slot declared by the shared Session Management page. Neutral
1418
+ * naming on purpose: participants never import each other, they only
1419
+ * contribute `{id, order}` rows into this slot while the page is alive.
1420
+ */
1421
+ const SESSION_SETTINGS_ITEM_SLOT = "dsh.session-settings.item";
1422
+ /**
1423
+ * Host-owned nav icon seat: a root-scoped list where each entry's `id` must
1424
+ * equal the `settings.section` id it decorates. Owner props are
1425
+ * `{size, active}`; icons stay `aria-hidden` so the section label remains the
1426
+ * accessible name. Contributors into a shared section may register the same
1427
+ * icon `id` with different priorities — the ledger keeps every entry while the
1428
+ * host renders one icon per section.
1429
+ */
1430
+ const SETTINGS_SECTION_ICON_SLOT = "settings.section.icon";
1431
+ /** Authenticated host-RPC channel for get/set of the shared default. */
1432
+ const AUXILIARY_MODEL_CHANNEL = "/dsh-auxiliary-model";
1433
+ /** Model-settings namespace under which the shared default is published. */
1434
+ const AUXILIARY_MODEL_NS = "@klarkxy/dsh-plugin-kit/auxiliary-model";
1435
+ /**
1436
+ * Typert identity of the shared change stream. Mounted on the root context
1437
+ * with ownerNs filtering, so frames only carry {@link AUXILIARY_MODEL_NS}.
1438
+ */
1439
+ const AUXILIARY_MODEL_CHANGES_IDENTITY = modelSettingsChangesIdentity("@klarkxy/dsh-plugin-kit");
1373
1440
  //#endregion
1374
1441
  //#region ../dsh-plugin-kit/src/model-settings-client.ts
1375
1442
  /** This stream carries invalidations only; ready is never a settings snapshot. */
@@ -1539,6 +1606,604 @@ function createModelSettingsSubscriber(ctx, packageName, identity = modelSetting
1539
1606
  } });
1540
1607
  }
1541
1608
  //#endregion
1609
+ //#region ../dsh-plugin-kit/src/client-utils.ts
1610
+ /** One `<svg>` with the shared icon chrome; children are plain elements. */
1611
+ function lineIcon(children) {
1612
+ return function SettingsSectionIcon(props) {
1613
+ return (0, react.createElement)("svg", {
1614
+ "aria-hidden": "true",
1615
+ width: props.size ?? 16,
1616
+ height: props.size ?? 16,
1617
+ viewBox: "0 0 16 16",
1618
+ fill: "none",
1619
+ stroke: "currentColor",
1620
+ strokeWidth: 1,
1621
+ strokeLinecap: "round",
1622
+ strokeLinejoin: "round"
1623
+ }, ...children.map(([tag, attrs], index) => (0, react.createElement)(tag, {
1624
+ key: index,
1625
+ ...attrs
1626
+ })));
1627
+ };
1628
+ }
1629
+ /** Chat bubble for the shared Session Management section. */
1630
+ const IconSessionSettings = lineIcon([["path", { d: "M8.5 13a5.5 5.5 0 0 0 5.5-5.5A5.5 5.5 0 0 0 8.5 2 5.5 5.5 0 0 0 3 7.5a5.48 5.48 0 0 0 .9 3L2.8 12.6a.5.5 0 0 0 .62.62l2.9-.98a5.48 5.48 0 0 0 2.18.76Z" }]]);
1631
+ /** Private Cordis rendezvous; every bundled copy of this helper resolves the same root service. */
1632
+ const SESSION_SETTINGS_PAGE_SERVICE = "dshSessionSettingsPage";
1633
+ /** The page component renders only its declared child list: items paint their own titles and styles. */
1634
+ function SessionSettingsPageView(props) {
1635
+ return props.renderSlot(SESSION_SETTINGS_ITEM_SLOT, {});
1636
+ }
1637
+ /**
1638
+ * Acquire the root-owned page service, creating and providing it on first use.
1639
+ * One counter covers participants, the section wait and the provider: the last
1640
+ * release removes the wait and then the provider synchronously (Cordis deletes
1641
+ * the impl before its async disposer awaits), so a later acquire sees a clean
1642
+ * get() miss and can re-establish the page.
1643
+ */
1644
+ function acquireSessionSettingsPage(root) {
1645
+ const existing = root.get(SESSION_SETTINGS_PAGE_SERVICE, false);
1646
+ if (existing) return existing;
1647
+ const slots = root.slots;
1648
+ let users = 0;
1649
+ let stopPage;
1650
+ let unprovide;
1651
+ const page = {
1652
+ retain() {
1653
+ users += 1;
1654
+ if (!stopPage) stopPage = slots.inject("settings.section", () => slots.register({
1655
+ name: "settings.section",
1656
+ id: SESSION_SETTINGS_SECTION_ID,
1657
+ order: 30,
1658
+ label: () => {
1659
+ const locale = root.get("locale", false);
1660
+ return String(locale?.getSnapshot?.().active ?? "").startsWith("zh") ? "会话管理" : "Session Management";
1661
+ },
1662
+ children: { [SESSION_SETTINGS_ITEM_SLOT]: {
1663
+ kind: "list",
1664
+ scope: "root"
1665
+ } }
1666
+ }, SessionSettingsPageView));
1667
+ },
1668
+ release() {
1669
+ if (users <= 0) return;
1670
+ users -= 1;
1671
+ if (users > 0) return;
1672
+ stopPage?.();
1673
+ stopPage = void 0;
1674
+ unprovide?.();
1675
+ }
1676
+ };
1677
+ unprovide = root.provide(SESSION_SETTINGS_PAGE_SERVICE, page);
1678
+ return page;
1679
+ }
1680
+ /**
1681
+ * Contribute one row to the shared Session Management settings page.
1682
+ *
1683
+ * `ctx` is the plugin's own context: the row's slot wait unloads with the
1684
+ * plugin, while the page itself is retained on the shared root context for as
1685
+ * long as any contributor lives. There is no fallback seat — when every
1686
+ * contributor is gone the section disappears with the page. Returns a disposer.
1687
+ * Shared root-owned infrastructure (the auxiliary-model card) calls this on
1688
+ * `ctx.root` so its row, icon and page share are owned by the root lifetime
1689
+ * and released by the returned disposer, not by any plugin fiber.
1690
+ */
1691
+ function registerSessionSettingsItem(ctx, item, component) {
1692
+ const slots = ctx.slots;
1693
+ const root = ctx.root;
1694
+ const page = acquireSessionSettingsPage(root);
1695
+ let stopItem;
1696
+ let stopIcon;
1697
+ try {
1698
+ page.retain();
1699
+ stopItem = slots.inject(SESSION_SETTINGS_ITEM_SLOT, () => slots.register({
1700
+ name: SESSION_SETTINGS_ITEM_SLOT,
1701
+ ...item
1702
+ }, component));
1703
+ stopIcon = slots.inject(SETTINGS_SECTION_ICON_SLOT, () => slots.register({
1704
+ name: SETTINGS_SECTION_ICON_SLOT,
1705
+ id: SESSION_SETTINGS_SECTION_ID,
1706
+ order: item.order,
1707
+ priority: item.order
1708
+ }, IconSessionSettings));
1709
+ } catch (error) {
1710
+ stopIcon?.();
1711
+ stopItem?.();
1712
+ page.release();
1713
+ throw error;
1714
+ }
1715
+ let disposed = false;
1716
+ return () => {
1717
+ if (disposed) return;
1718
+ disposed = true;
1719
+ stopIcon?.();
1720
+ stopItem?.();
1721
+ page.release();
1722
+ };
1723
+ }
1724
+ //#endregion
1725
+ //#region ../dsh-plugin-kit/src/auxiliary-model-client.tsx
1726
+ const AUXILIARY_MODEL_PACKAGE = "@klarkxy/dsh-plugin-kit";
1727
+ const AUXILIARY_MODEL_SEAT_ID = "auxiliary-model";
1728
+ /** Private Cordis rendezvous; every bundled copy resolves the same root service. */
1729
+ const AUXILIARY_MODEL_CARD_SERVICE = "dshAuxiliaryModelSettingsCard";
1730
+ /** Theme-token-only stylesheet: officialUiCss scope plus the shared ModelMenu recipe. */
1731
+ const auxiliaryModelCss = `${officialUiCss("ams-root")}${modelMenuCss}
1732
+ .ams-root .ams-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
1733
+ `;
1734
+ const auxiliaryCopy = {
1735
+ zh: {
1736
+ title: "默认辅助模型",
1737
+ help: "各辅助功能(如标题生成、摘要)没有单独指定模型时使用的模型;仅影响声明使用它的功能。",
1738
+ model: "辅助模型",
1739
+ modelDefault: "跟随会话或宿主默认",
1740
+ modelHint: "不选择模型时,各功能按自己的规则决定对话模型。",
1741
+ effortDefault: "跟随模型默认",
1742
+ loading: "正在读取设置…",
1743
+ failed: "无法读取模型设置。",
1744
+ stale: "设置已被其他窗口更新,请刷新后重试。",
1745
+ saveFailed: "保存失败,原设置未更改。请重试。",
1746
+ saved: "已保存。",
1747
+ retry: "重试",
1748
+ refresh: "刷新设置",
1749
+ syncUnavailable: "自动刷新不可用,请手动刷新设置。"
1750
+ },
1751
+ en: {
1752
+ title: "Default Auxiliary Model",
1753
+ help: "The model used by auxiliary features (such as title generation or summarizing) that do not name their own model; only features that opted in are affected.",
1754
+ model: "Auxiliary model",
1755
+ modelDefault: "Follow session or host default",
1756
+ modelHint: "With no model selected, each feature follows its own rules for the conversation model.",
1757
+ effortDefault: "Follow model default",
1758
+ loading: "Loading settings…",
1759
+ failed: "Could not load model settings.",
1760
+ stale: "Settings were changed elsewhere; refresh and retry.",
1761
+ saveFailed: "Save failed; the previous setting was not changed. Try again.",
1762
+ saved: "Saved.",
1763
+ retry: "Retry",
1764
+ refresh: "Refresh settings",
1765
+ syncUnavailable: "Automatic refresh unavailable; refresh settings manually."
1766
+ }
1767
+ };
1768
+ /** A failed RPC keeps its code so the card can translate known rejections. */
1769
+ var AuxiliaryRpcError = class extends Error {
1770
+ code;
1771
+ constructor(code, message) {
1772
+ super(message);
1773
+ this.code = code;
1774
+ this.name = "AuxiliaryRpcError";
1775
+ }
1776
+ };
1777
+ /** Host UI language, following live switches with the host's English fallback. */
1778
+ function useAuxiliaryLocale(client) {
1779
+ const subscribe = (0, react.useCallback)((listener) => client.locale?.subscribe(listener) ?? (() => {}), [client]);
1780
+ const snapshot = (0, react.useCallback)(() => client.locale?.getSnapshot().active ?? "en", [client]);
1781
+ return String((0, react.useSyncExternalStore)(subscribe, snapshot, snapshot)).startsWith("zh") ? "zh" : "en";
1782
+ }
1783
+ function isAbort(error) {
1784
+ return error instanceof Error && (error.name === "AbortError" || /abort|cancel/i.test(error.message));
1785
+ }
1786
+ function isStaleRejection(error) {
1787
+ if (!(error instanceof AuxiliaryRpcError)) return false;
1788
+ return error.code === "stale" || error.code === "auxiliary-model/conflict" || /changed|revision|reload|stale|conflict/i.test(error.message);
1789
+ }
1790
+ async function callAuxiliaryRpc(client, endpoint, payload = {}) {
1791
+ const result = await client.connection.rpc.call(AUXILIARY_MODEL_CHANNEL, endpoint, payload);
1792
+ if (!result || result.ok !== true) {
1793
+ const error = result && "error" in result ? result.error : void 0;
1794
+ throw new AuxiliaryRpcError(error?.code ?? "unknown", error?.message ?? `RPC ${endpoint} failed`);
1795
+ }
1796
+ return result.value;
1797
+ }
1798
+ /**
1799
+ * The shared card. All state comes from the shared host service over the
1800
+ * plugin-kit RPC channel; the card only renders the stored row.
1801
+ */
1802
+ function AuxiliaryModelCard({ client, subscribe }) {
1803
+ const language = useAuxiliaryLocale(client);
1804
+ const text = auxiliaryCopy[language];
1805
+ const [settings, setSettings] = (0, react.useState)();
1806
+ const catalogState = useModelCatalog((0, react.useCallback)(() => readModelCatalog(client), [client]), { subscribe: (0, react.useCallback)((change) => client.connection.generation?.subscribe(change), [client]) });
1807
+ const { refresh: refreshCatalog } = catalogState;
1808
+ const choices = parseModelMenuChoices(catalogState.catalog, settings?.model ?? void 0);
1809
+ const [menuOpen, setMenuOpen] = (0, react.useState)(false);
1810
+ const [error, setError] = (0, react.useState)("");
1811
+ const [note, setNote] = (0, react.useState)(false);
1812
+ const [busy, setBusy] = (0, react.useState)(false);
1813
+ const [syncError, setSyncError] = (0, react.useState)("");
1814
+ const operation = (0, react.useRef)({
1815
+ epoch: 0,
1816
+ active: false,
1817
+ reading: false,
1818
+ writing: false,
1819
+ pending: false
1820
+ });
1821
+ const refreshRef = (0, react.useRef)(() => {});
1822
+ const refresh = () => {
1823
+ const state = operation.current;
1824
+ if (!state.active) return;
1825
+ if (state.reading || state.writing) {
1826
+ state.pending = true;
1827
+ return;
1828
+ }
1829
+ state.pending = false;
1830
+ state.reading = true;
1831
+ setBusy(true);
1832
+ const epoch = state.epoch;
1833
+ callAuxiliaryRpc(client, "defaults.get").then((next) => {
1834
+ if (state.active && state.epoch === epoch && !state.pending) {
1835
+ setSettings(next);
1836
+ setError("");
1837
+ }
1838
+ }).catch((reason) => {
1839
+ if (state.active && state.epoch === epoch && !state.pending && !isAbort(reason)) setError("failed");
1840
+ }).finally(() => {
1841
+ if (!state.active || state.epoch !== epoch) return;
1842
+ state.reading = false;
1843
+ setBusy(false);
1844
+ if (state.pending) refreshRef.current();
1845
+ });
1846
+ };
1847
+ refreshRef.current = refresh;
1848
+ (0, react.useEffect)(() => {
1849
+ const state = operation.current;
1850
+ state.epoch++;
1851
+ state.active = true;
1852
+ state.reading = false;
1853
+ state.writing = false;
1854
+ state.pending = false;
1855
+ setBusy(false);
1856
+ setSettings(void 0);
1857
+ setError("");
1858
+ setNote(false);
1859
+ refreshRef.current();
1860
+ return () => {
1861
+ state.active = false;
1862
+ state.epoch++;
1863
+ };
1864
+ }, [client]);
1865
+ (0, react.useEffect)(() => {
1866
+ setSyncError("");
1867
+ let live = true;
1868
+ let stop;
1869
+ try {
1870
+ stop = subscribe?.(() => {
1871
+ if (live) {
1872
+ setSyncError("");
1873
+ refreshCatalog();
1874
+ refreshRef.current();
1875
+ }
1876
+ }, (message) => {
1877
+ if (live) setSyncError(message);
1878
+ });
1879
+ } catch {
1880
+ if (live) setSyncError("Model settings sync service unavailable");
1881
+ }
1882
+ return () => {
1883
+ live = false;
1884
+ try {
1885
+ stop?.();
1886
+ } catch {}
1887
+ };
1888
+ }, [subscribe, refreshCatalog]);
1889
+ const save = (0, react.useCallback)((model) => {
1890
+ const state = operation.current;
1891
+ if (!settings || !state.active || state.writing || state.reading) return;
1892
+ state.writing = true;
1893
+ const epoch = state.epoch;
1894
+ setBusy(true);
1895
+ setNote(false);
1896
+ setError("");
1897
+ callAuxiliaryRpc(client, "defaults.set", {
1898
+ model: model ?? {
1899
+ provider: "",
1900
+ model: ""
1901
+ },
1902
+ expectedRevision: settings.revision
1903
+ }).then((next) => {
1904
+ if (state.active && state.epoch === epoch) {
1905
+ setSettings(next);
1906
+ setNote(true);
1907
+ refreshCatalog();
1908
+ }
1909
+ }).catch((reason) => {
1910
+ if (state.active && state.epoch === epoch) setError(isStaleRejection(reason) ? "stale" : "saveFailed");
1911
+ }).finally(() => {
1912
+ if (!state.active || state.epoch !== epoch) return;
1913
+ state.writing = false;
1914
+ setBusy(false);
1915
+ if (state.pending) refreshRef.current();
1916
+ });
1917
+ }, [
1918
+ client,
1919
+ settings,
1920
+ refreshCatalog
1921
+ ]);
1922
+ const locked = busy;
1923
+ if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1924
+ className: "ams-root dsh-ui-card",
1925
+ "data-testid": "auxiliary-model-settings",
1926
+ children: [
1927
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1928
+ className: "dsh-ui-heading",
1929
+ children: text.title
1930
+ }),
1931
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1932
+ className: "dsh-ui-help",
1933
+ children: text.help
1934
+ }),
1935
+ error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1936
+ role: "alert",
1937
+ className: "dsh-ui-error",
1938
+ children: text[error]
1939
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1940
+ role: "status",
1941
+ className: "dsh-ui-hint",
1942
+ children: text.loading
1943
+ }),
1944
+ syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1945
+ role: "status",
1946
+ className: "dsh-ui-hint",
1947
+ children: text.syncUnavailable
1948
+ }),
1949
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1950
+ className: "dsh-ui-actions",
1951
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1952
+ variant: "ghost",
1953
+ size: "sm",
1954
+ disabled: locked,
1955
+ onClick: () => refreshRef.current(),
1956
+ children: text.retry
1957
+ })
1958
+ })
1959
+ ]
1960
+ });
1961
+ const current = settings.model ?? {
1962
+ provider: "",
1963
+ model: ""
1964
+ };
1965
+ const follow = !current.provider || !current.model;
1966
+ const selected = follow ? void 0 : choices.find((item) => item.provider === current.provider && item.model === current.model);
1967
+ const efforts = follow ? [] : modelMenuEffortOptions(selected, current.reasoningEffort);
1968
+ const currentLabel = follow ? text.modelDefault : selected?.label ?? `${current.provider} / ${current.model}`;
1969
+ const selectedEffort = efforts.find((item) => item.id === (current.reasoningEffort || ""));
1970
+ const effortName = selectedEffort ? modelMenuEffortLabel(selectedEffort, language) : void 0;
1971
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1972
+ className: "ams-root dsh-ui-card",
1973
+ "data-testid": "auxiliary-model-settings",
1974
+ children: [
1975
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1976
+ className: "dsh-ui-heading",
1977
+ children: text.title
1978
+ }),
1979
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1980
+ className: "dsh-ui-help",
1981
+ children: text.help
1982
+ }),
1983
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1984
+ className: "dsh-ui-field",
1985
+ children: [
1986
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1987
+ className: "dsh-ui-label",
1988
+ children: text.model
1989
+ }),
1990
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelCatalogStatus, {
1991
+ state: catalogState,
1992
+ locale: language
1993
+ }),
1994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelMenu, {
1995
+ locale: language,
1996
+ open: menuOpen && !locked,
1997
+ onOpenChange: (next) => setMenuOpen(next && !locked),
1998
+ listClassName: "ams-root ams-menu-portal",
1999
+ anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2000
+ type: "button",
2001
+ className: "dsh-model-menu-trigger",
2002
+ disabled: locked,
2003
+ "aria-label": `${text.model}: ${currentLabel}`,
2004
+ children: [
2005
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2006
+ className: "dsh-model-menu-triggerLabel",
2007
+ children: currentLabel
2008
+ }),
2009
+ !follow && efforts.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2010
+ className: "dsh-model-menu-triggerEffort",
2011
+ children: effortName ?? text.effortDefault
2012
+ }) : null,
2013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: `dsh-model-menu-chevron${menuOpen && !locked ? " dsh-model-menu-chevronOpen" : ""}` })
2014
+ ]
2015
+ }),
2016
+ choices,
2017
+ selected: follow ? void 0 : {
2018
+ provider: current.provider,
2019
+ model: current.model
2020
+ },
2021
+ onPick: (picked) => save({
2022
+ provider: picked.provider,
2023
+ model: picked.model
2024
+ }),
2025
+ leading: [{
2026
+ id: "default",
2027
+ label: text.modelDefault,
2028
+ selected: follow
2029
+ }],
2030
+ onPickLeading: () => save(null),
2031
+ efforts,
2032
+ selectedEffort: current.reasoningEffort || "",
2033
+ onPickEffort: (effort) => save(follow ? null : {
2034
+ provider: current.provider,
2035
+ model: current.model,
2036
+ ...effort ? { reasoningEffort: effort } : {}
2037
+ }),
2038
+ modelLabel: text.model,
2039
+ modelValue: currentLabel,
2040
+ defaultEffortLabel: text.effortDefault
2041
+ }),
2042
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2043
+ className: "dsh-ui-hint",
2044
+ children: text.modelHint
2045
+ })
2046
+ ]
2047
+ }),
2048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2049
+ className: "ams-row",
2050
+ children: [
2051
+ syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2052
+ role: "status",
2053
+ className: "dsh-ui-hint",
2054
+ children: text.syncUnavailable
2055
+ }),
2056
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2057
+ role: "alert",
2058
+ className: "dsh-ui-error",
2059
+ children: text[error]
2060
+ }),
2061
+ note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2062
+ role: "status",
2063
+ className: "dsh-ui-notice",
2064
+ children: text.saved
2065
+ }),
2066
+ error || syncError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2067
+ variant: "ghost",
2068
+ size: "sm",
2069
+ disabled: locked,
2070
+ onClick: () => refreshRef.current(),
2071
+ children: text.refresh
2072
+ }) : null
2073
+ ]
2074
+ })
2075
+ ]
2076
+ });
2077
+ }
2078
+ const AUXILIARY_MODEL_WATCH_COORDINATOR = "dshAuxiliaryModelWatchCoordinator";
2079
+ function acquireWatchCoordinator(root) {
2080
+ const existing = root.get(AUXILIARY_MODEL_WATCH_COORDINATOR, false);
2081
+ if (existing) return existing;
2082
+ let unprovide;
2083
+ const coordinator = {
2084
+ tail: Promise.resolve(),
2085
+ active: 0,
2086
+ mount() {
2087
+ coordinator.active += 1;
2088
+ const previousTail = coordinator.tail;
2089
+ const lease = {
2090
+ closed: false,
2091
+ done() {
2092
+ if (lease.closed) return;
2093
+ lease.closed = true;
2094
+ coordinator.active -= 1;
2095
+ const owned = lease.fiber;
2096
+ lease.fiber = void 0;
2097
+ coordinator.tail = coordinator.tail.then(() => Promise.resolve(owned?.dispose()).catch(() => {}));
2098
+ coordinator.tail.then(() => {
2099
+ if (coordinator.active > 0) return;
2100
+ try {
2101
+ if (root.get(AUXILIARY_MODEL_WATCH_COORDINATOR, false) === coordinator) Promise.resolve(unprovide?.()).catch(() => {});
2102
+ } catch {}
2103
+ });
2104
+ },
2105
+ fiber: void 0
2106
+ };
2107
+ previousTail.then(() => {
2108
+ if (lease.closed) return;
2109
+ try {
2110
+ lease.fiber = root.inject(["remote"], (injected) => {
2111
+ const scope = injected;
2112
+ const mounting = scope.remote.$mount(modelSettingsChangesRemote(AUXILIARY_MODEL_PACKAGE, AUXILIARY_MODEL_CHANGES_IDENTITY));
2113
+ mounting.catch(() => {});
2114
+ scope.effect(() => async () => {
2115
+ await (await mounting.catch(() => void 0))?.();
2116
+ });
2117
+ });
2118
+ } catch {}
2119
+ });
2120
+ return lease;
2121
+ }
2122
+ };
2123
+ unprovide = root.provide(AUXILIARY_MODEL_WATCH_COORDINATOR, coordinator);
2124
+ return coordinator;
2125
+ }
2126
+ /**
2127
+ * Acquire the root-owned card service, creating it on first use. One counter
2128
+ * covers participants, the seat wait and the change-stream mount: the last
2129
+ * release removes the wait, then the watch contribution, then the provider
2130
+ * itself synchronously (Cordis deletes the impl before its async disposer
2131
+ * awaits), so a later acquire sees a clean get() miss and can re-establish.
2132
+ */
2133
+ function acquireAuxiliaryModelCard(root, ctx) {
2134
+ const existing = root.get(AUXILIARY_MODEL_CARD_SERVICE, false);
2135
+ if (existing) return existing;
2136
+ let users = 0;
2137
+ let styleElement;
2138
+ let stopItem;
2139
+ let unprovide;
2140
+ const watchLease = acquireWatchCoordinator(root).mount();
2141
+ const subscriber = createModelSettingsSubscriber(ctx.root, AUXILIARY_MODEL_PACKAGE, AUXILIARY_MODEL_CHANGES_IDENTITY, (ns) => ns === AUXILIARY_MODEL_NS);
2142
+ const render = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AuxiliaryModelCard, {
2143
+ client: root,
2144
+ subscribe: subscriber.subscribe
2145
+ });
2146
+ const service = {
2147
+ retain() {
2148
+ users += 1;
2149
+ if (styleElement === void 0 && typeof document !== "undefined") {
2150
+ styleElement = document.createElement("style");
2151
+ styleElement.setAttribute("data-plugin", "dsh-auxiliary-model-shared");
2152
+ styleElement.textContent = auxiliaryModelCss;
2153
+ document.head.appendChild(styleElement);
2154
+ }
2155
+ if (!stopItem) stopItem = registerSessionSettingsItem(ctx.root, {
2156
+ id: AUXILIARY_MODEL_SEAT_ID,
2157
+ order: 55
2158
+ }, render);
2159
+ },
2160
+ release() {
2161
+ if (users <= 0) return;
2162
+ users -= 1;
2163
+ if (users > 0) return;
2164
+ stopItem?.();
2165
+ stopItem = void 0;
2166
+ styleElement?.remove();
2167
+ styleElement = void 0;
2168
+ watchLease.done();
2169
+ try {
2170
+ Promise.resolve(unprovide?.()).catch(() => {});
2171
+ } catch {}
2172
+ unprovide = void 0;
2173
+ }
2174
+ };
2175
+ unprovide = root.provide(AUXILIARY_MODEL_CARD_SERVICE, service);
2176
+ return service;
2177
+ }
2178
+ /**
2179
+ * Show the shared "default auxiliary model" card while the calling plugin is
2180
+ * enabled.
2181
+ *
2182
+ * `ctx` is the plugin's own client context: the card itself is retained on the
2183
+ * shared root context for as long as any participant lives, so an arbitrary
2184
+ * subset of enabled plugins yields the same single card. The mount's slot wait
2185
+ * and the change-stream contribution are owned by the root service — the first
2186
+ * participant's unload cannot take the card down. Returns a disposer; it may
2187
+ * be invoked by both ctx.effect and an explicit caller and releases at most
2188
+ * once, so a double call cannot unbalance the shared refcount.
2189
+ */
2190
+ function registerAuxiliaryModelSettings(ctx) {
2191
+ const root = ctx.root;
2192
+ const service = acquireAuxiliaryModelCard(root, ctx);
2193
+ try {
2194
+ service.retain();
2195
+ } catch (error) {
2196
+ service.release();
2197
+ throw error;
2198
+ }
2199
+ let disposed = false;
2200
+ return () => {
2201
+ if (disposed) return;
2202
+ disposed = true;
2203
+ service.release();
2204
+ };
2205
+ }
2206
+ //#endregion
1542
2207
  //#region src/contracts.ts
1543
2208
  const PLUGIN_NAME = "@klarkxy/dsh-git-commit";
1544
2209
  const RPC_CHANNEL = "/dsh-git-commit";
@@ -1559,12 +2224,11 @@ const zh = {
1559
2224
  commitAction: "分次提交全部工作区修改",
1560
2225
  files: "个文件",
1561
2226
  model: "模型",
1562
- modelDefault: "默认模型",
1563
- modelCustom: "插件设置",
1564
2227
  filesCount: "({n} 个文件)",
1565
2228
  confirmPrompt: "将把 {n} 个文件的全部修改分次提交到当前分支。",
1566
2229
  confirm: "确认提交",
1567
2230
  cancel: "取消",
2231
+ commitBlocked: "正在保存模型设置或上次保存未生效,恢复后才可提交",
1568
2232
  loading: "正在读取仓库状态…",
1569
2233
  running: "正在整理修改并分次提交…",
1570
2234
  done: "已创建提交",
@@ -1590,7 +2254,7 @@ const zh = {
1590
2254
  workspaceChanged: "工作区或分支已变更,请刷新后重试提交",
1591
2255
  settingsUnavailable: "无法读取提交设置,请重新加载插件后重试",
1592
2256
  invalidRequest: "提交请求无效,请刷新后重试",
1593
- planUnavailable: "模型没有给出可用的提交方案,已取消本次提交,工作区没有被改动。换一个模型重试,或在插件设置里开启兜底提交。",
2257
+ planUnavailable: "模型没有给出可用的提交方案,已取消本次提交,工作区没有被改动。换一个模型重试,或开启下方「模型不可用时仍然提交」。",
1594
2258
  close: "关闭"
1595
2259
  };
1596
2260
  const en = {
@@ -1599,12 +2263,11 @@ const en = {
1599
2263
  commitAction: "Commit all workspace changes",
1600
2264
  files: "files",
1601
2265
  model: "Model",
1602
- modelDefault: "default model",
1603
- modelCustom: "Plugin settings",
1604
2266
  filesCount: " ({n} files)",
1605
2267
  confirmPrompt: "All changes in {n} files will be committed to the current branch in one or more commits.",
1606
2268
  confirm: "Commit now",
1607
2269
  cancel: "Cancel",
2270
+ commitBlocked: "A settings write is pending or unrecovered; commits unlock once it settles",
1608
2271
  loading: "Reading repository status…",
1609
2272
  running: "Organizing changes into commits…",
1610
2273
  done: "Commits created",
@@ -1630,7 +2293,7 @@ const en = {
1630
2293
  workspaceChanged: "The workspace or branch changed; refresh and retry the commit",
1631
2294
  settingsUnavailable: "Could not read commit settings; reload the plugin and retry",
1632
2295
  invalidRequest: "Invalid commit request; refresh and retry",
1633
- planUnavailable: "The model returned no usable commit plan, so the run was cancelled and your workspace is untouched. Try another model, or turn on the fallback in plugin settings.",
2296
+ planUnavailable: "The model returned no usable commit plan, so the run was cancelled and your workspace is untouched. Try another model, or enable “Commit even without a model plan” below.",
1634
2297
  close: "Close"
1635
2298
  };
1636
2299
  /** A failed RPC keeps its code so the panel can explain a rejection in its own words. */
@@ -1734,7 +2397,7 @@ function GitBranchIcon() {
1734
2397
  ]
1735
2398
  });
1736
2399
  }
1737
- function GitCommitAction({ sessionId, useSession, call, t }) {
2400
+ function GitCommitAction({ sessionId, useSession, call, t, client, subscribeSettings }) {
1738
2401
  const [open, setOpen] = (0, react.useState)(false);
1739
2402
  const [status, setStatus] = (0, react.useState)();
1740
2403
  const [loading, setLoading] = (0, react.useState)(false);
@@ -1742,6 +2405,29 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1742
2405
  const [result, setResult] = (0, react.useState)(null);
1743
2406
  const [error, setError] = (0, react.useState)(null);
1744
2407
  const [confirming, setConfirming] = (0, react.useState)(false);
2408
+ const pendingWrites = (0, react.useRef)(0);
2409
+ const writeFailed = (0, react.useRef)(false);
2410
+ const settingsDirty = (0, react.useRef)(false);
2411
+ const [settingsPending, setSettingsPending] = (0, react.useState)(false);
2412
+ const [settingsSeen, setSettingsSeen] = (0, react.useState)(false);
2413
+ const syncLock = (0, react.useCallback)(() => {
2414
+ const locked = pendingWrites.current > 0 || writeFailed.current;
2415
+ settingsDirty.current = locked;
2416
+ setSettingsPending(locked);
2417
+ }, []);
2418
+ const markWrite = (0, react.useCallback)((active) => {
2419
+ pendingWrites.current = Math.max(0, pendingWrites.current + (active ? 1 : -1));
2420
+ syncLock();
2421
+ }, [syncLock]);
2422
+ const markWriteFailed = (0, react.useCallback)(() => {
2423
+ writeFailed.current = true;
2424
+ syncLock();
2425
+ }, [syncLock]);
2426
+ const markSeen = (0, react.useCallback)(() => {
2427
+ writeFailed.current = false;
2428
+ setSettingsSeen(true);
2429
+ syncLock();
2430
+ }, [syncLock]);
1745
2431
  const rootRef = (0, react.useRef)(null);
1746
2432
  const triggerRef = (0, react.useRef)(null);
1747
2433
  const panelRef = (0, react.useRef)(null);
@@ -1754,19 +2440,26 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1754
2440
  margin: 16
1755
2441
  });
1756
2442
  const sessionRunning = useSession((snapshot) => snapshot.running === true);
2443
+ const panelPortalRef = (0, react.useMemo)(() => ({ get current() {
2444
+ const panel = panelRef.current;
2445
+ if (!panel) return null;
2446
+ return { contains: (node) => panel.contains(node) || [...document.querySelectorAll(".gcm-menu-portal")].some((menu) => menu.contains(node)) };
2447
+ } }), []);
1757
2448
  const changeOpen = (0, react.useCallback)((next) => {
2449
+ if (!next && pendingWrites.current > 0) return;
1758
2450
  setOpen(next);
1759
2451
  setConfirming(false);
1760
2452
  if (next) {
1761
2453
  setResult(null);
1762
2454
  setError(null);
2455
+ setSettingsSeen(false);
1763
2456
  }
1764
2457
  }, []);
1765
2458
  const positioned = Boolean(position);
1766
2459
  (0, react.useEffect)(() => {
1767
2460
  if (open && positioned) panelRef.current?.focus();
1768
2461
  }, [open, positioned]);
1769
- (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, changeOpen, panelRef);
2462
+ (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, changeOpen, panelPortalRef);
1770
2463
  (0, react.useEffect)(() => {
1771
2464
  generationRef.current += 1;
1772
2465
  setOpen(false);
@@ -1774,6 +2467,11 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1774
2467
  setResult(null);
1775
2468
  setError(null);
1776
2469
  setRunning(false);
2470
+ pendingWrites.current = 0;
2471
+ writeFailed.current = false;
2472
+ settingsDirty.current = false;
2473
+ setSettingsPending(false);
2474
+ setSettingsSeen(false);
1777
2475
  }, [sessionId]);
1778
2476
  const loadStatus = (0, react.useCallback)(async () => {
1779
2477
  const generation = generationRef.current;
@@ -1808,6 +2506,10 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1808
2506
  }, [open, changeOpen]);
1809
2507
  const runCommit = (0, react.useCallback)(async () => {
1810
2508
  const generation = generationRef.current;
2509
+ if (settingsDirty.current || client && !settingsSeen) {
2510
+ setConfirming(false);
2511
+ return;
2512
+ }
1811
2513
  setConfirming(false);
1812
2514
  setRunning(true);
1813
2515
  setResult(null);
@@ -1829,10 +2531,15 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1829
2531
  }
1830
2532
  }
1831
2533
  }
1832
- }, [call, sessionId]);
2534
+ }, [
2535
+ call,
2536
+ sessionId,
2537
+ client,
2538
+ settingsSeen
2539
+ ]);
1833
2540
  const available = status?.available === true;
1834
2541
  const workspaceCount = status?.workspace.files ?? 0;
1835
- const canCommit = available && !running && !sessionRunning && workspaceCount > 0;
2542
+ const canCommit = available && !running && !sessionRunning && workspaceCount > 0 && !settingsPending && (!client || settingsSeen);
1836
2543
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1837
2544
  ref: rootRef,
1838
2545
  className: "gcm-root",
@@ -1902,33 +2609,21 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1902
2609
  role: "alert",
1903
2610
  children: errorText(t, error)
1904
2611
  }),
2612
+ client ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GitCommitSettingsPanel, {
2613
+ client,
2614
+ subscribeSettings,
2615
+ disabled: running,
2616
+ onSaving: markWrite,
2617
+ onWriteFailed: markWriteFailed,
2618
+ onReady: markSeen,
2619
+ onSaved: () => void loadStatus()
2620
+ }) : null,
1905
2621
  status && !available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1906
2622
  className: "dsh-ui-meta",
1907
2623
  role: "status",
1908
2624
  children: reasonText(t, status)
1909
2625
  }),
1910
2626
  available && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1911
- status?.model ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1912
- className: "gcm-model",
1913
- children: [
1914
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1915
- className: "dsh-ui-hint",
1916
- children: t("model")
1917
- }),
1918
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1919
- className: "dsh-ui-truncate",
1920
- children: [
1921
- status.model.provider,
1922
- "/",
1923
- status.model.model
1924
- ]
1925
- }),
1926
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
1927
- tone: status.model.source === "page" ? "info" : "neutral",
1928
- children: status.model.source === "page" ? t("modelCustom") : t("modelDefault")
1929
- })
1930
- ]
1931
- }) : null,
1932
2627
  confirming && canCommit ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1933
2628
  className: "dsh-ui-stack",
1934
2629
  role: "group",
@@ -1964,6 +2659,11 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1964
2659
  sessionRunning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1965
2660
  className: "dsh-ui-hint",
1966
2661
  children: t("sessionRunning")
2662
+ }),
2663
+ settingsPending && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2664
+ className: "dsh-ui-hint",
2665
+ role: "status",
2666
+ children: t("commitBlocked")
1967
2667
  })
1968
2668
  ] }),
1969
2669
  running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -2039,11 +2739,9 @@ const clientCss = `${officialUiCss("gcm-root")}${modelMenuCss}
2039
2739
  * of at the trigger. Naming both classes puts this rule at the same weight,
2040
2740
  * later in the sheet, so it wins. */
2041
2741
  .gcm-root.gcm-panel { position: fixed; width: min(360px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 32px)); overflow: auto; }
2042
- .gcm-model { display: flex; align-items: center; gap: 6px; min-width: 0; margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); }
2043
- .gcm-commits { margin: 0; padding-inline-start: 18px; display: grid; gap: 2px; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
2044
- .gcm-commits code { font-size: 12px; background: var(--dsw-alias-bg-layer-2); padding: 0 4px; border-radius: var(--dsw-radius-sm); }
2045
- .gcm-settings { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
2046
- .gcm-settings-status { display: flex; flex-direction: column; gap: 4px; }
2742
+ .gcm-root .gcm-commits { margin: 0; padding-inline-start: 18px; display: grid; gap: 2px; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
2743
+ .gcm-root .gcm-commits code { font-size: 12px; background: var(--dsw-alias-bg-layer-2); padding: 0 4px; border-radius: var(--dsw-radius-sm); }
2744
+ .gcm-root .gcm-settings-status { display: flex; flex-direction: column; gap: 4px; }
2047
2745
  `;
2048
2746
  /** Host UI language, following live switches with the host's English fallback. */
2049
2747
  function useHostLocale(client) {
@@ -2052,31 +2750,31 @@ function useHostLocale(client) {
2052
2750
  return (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot);
2053
2751
  }
2054
2752
  const settingsZh = {
2055
- pageTitle: "Git 提交",
2056
2753
  model: "提交规划模型",
2057
- modelDefault: "默认模型",
2058
- modelHint: "留空则使用当前会话模型,再回落到宿主默认对话模型。",
2754
+ modelDefault: "默认辅助模型",
2755
+ modelHint: "留空使用全局默认辅助模型;未设置时跟随当前会话模型,再回落到宿主默认对话模型。",
2059
2756
  fallback: "模型不可用时仍然提交",
2060
2757
  fallbackHint: "默认关闭:模型规划失败会取消本次提交,工作区不变。开启后全部改动会合并成一个提交,由插件代写提交信息。",
2061
2758
  loading: "正在读取设置…",
2062
2759
  saved: "已保存。",
2063
2760
  stale: "设置已更新,请刷新后重试。",
2064
2761
  failed: "无法读取提交设置。",
2762
+ saveFailed: "设置未能保存,已显示的选择未生效。请重试。",
2065
2763
  retry: "重试",
2066
2764
  refresh: "刷新设置",
2067
2765
  syncUnavailable: "自动刷新不可用,请手动刷新设置。"
2068
2766
  };
2069
2767
  const settingsEn = {
2070
- pageTitle: "Git Commit",
2071
2768
  model: "Commit planning model",
2072
- modelDefault: "Default model",
2073
- modelHint: "Leave empty to use the current session model, then the host default chat model.",
2769
+ modelDefault: "Default auxiliary model",
2770
+ modelHint: "Leave empty to use the global default auxiliary model; when none is set, the current conversation model, then the host default chat model.",
2074
2771
  fallback: "Commit even without a model plan",
2075
2772
  fallbackHint: "Off by default: a failed plan cancels the run and leaves the workspace untouched. When on, every change lands in one commit with a plugin-written message.",
2076
2773
  loading: "Loading settings…",
2077
2774
  saved: "Saved.",
2078
2775
  stale: "Settings changed; refresh and retry.",
2079
2776
  failed: "Could not load commit settings.",
2777
+ saveFailed: "The change could not be saved, so the shown selection did not take effect. Try again.",
2080
2778
  retry: "Retry",
2081
2779
  refresh: "Refresh settings",
2082
2780
  syncUnavailable: "Automatic refresh unavailable; refresh settings manually."
@@ -2084,7 +2782,8 @@ const settingsEn = {
2084
2782
  function settingsCopy(locale) {
2085
2783
  return String(locale).startsWith("zh") ? settingsZh : settingsEn;
2086
2784
  }
2087
- /** Plugin-page settings row: pick the model this plugin's planning call uses. */
2785
+ /** Model/fallback block embedded in the commit popover; picks persist as the
2786
+ * plugin's saved defaults and sync through the model-settings change channel. */
2088
2787
  function GitCommitSettingsPanel(props) {
2089
2788
  const hostLocale = useHostLocale(props.client);
2090
2789
  const text = settingsCopy(props.locale ?? hostLocale);
@@ -2122,6 +2821,7 @@ function GitCommitSettingsPanel(props) {
2122
2821
  if (state.active && state.epoch === epoch && !state.pending) {
2123
2822
  setSettings(next);
2124
2823
  setError("");
2824
+ props.onReady?.();
2125
2825
  }
2126
2826
  }).catch(() => {
2127
2827
  if (state.active && state.epoch === epoch && !state.pending) setError("failed");
@@ -2171,14 +2871,16 @@ function GitCommitSettingsPanel(props) {
2171
2871
  props.client,
2172
2872
  refreshCatalog
2173
2873
  ]);
2874
+ const controlsLocked = props.disabled === true;
2174
2875
  const save = (0, react.useCallback)((patch) => {
2175
2876
  const state = operation.current;
2176
- if (!settings || !state.active || state.writing || state.reading) return;
2877
+ if (!settings || !state.active || state.writing || state.reading || props.disabled) return;
2177
2878
  state.writing = true;
2178
2879
  const epoch = state.epoch;
2179
2880
  setBusy(true);
2180
2881
  setNote(false);
2181
2882
  setError("");
2883
+ props.onSaving?.(true);
2182
2884
  rpc(call, "settings.update", {
2183
2885
  settings: {
2184
2886
  model: patch.model ?? settings.model,
@@ -2189,10 +2891,16 @@ function GitCommitSettingsPanel(props) {
2189
2891
  if (state.active && state.epoch === epoch) {
2190
2892
  setSettings(next);
2191
2893
  setNote(true);
2894
+ props.onReady?.();
2895
+ props.onSaved?.(next);
2896
+ }
2897
+ }).catch((reason) => {
2898
+ if (state.active && state.epoch === epoch) {
2899
+ props.onWriteFailed?.();
2900
+ setError(reason instanceof RpcError && reason.code === "stale" ? "stale" : "saveFailed");
2192
2901
  }
2193
- }).catch(() => {
2194
- if (state.active && state.epoch === epoch) setError("stale");
2195
2902
  }).finally(() => {
2903
+ props.onSaving?.(false);
2196
2904
  if (!state.active || state.epoch !== epoch) return;
2197
2905
  state.writing = false;
2198
2906
  setBusy(false);
@@ -2201,11 +2909,12 @@ function GitCommitSettingsPanel(props) {
2201
2909
  }, [
2202
2910
  call,
2203
2911
  settings,
2204
- text.saved,
2205
- text.stale
2912
+ text.stale,
2913
+ props.disabled
2206
2914
  ]);
2915
+ const locked = busy || controlsLocked;
2207
2916
  if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2208
- className: "gcm-root gcm-settings",
2917
+ className: "gcm-root gcm-settings dsh-ui-stack",
2209
2918
  children: [
2210
2919
  error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2211
2920
  role: "alert",
@@ -2224,7 +2933,7 @@ function GitCommitSettingsPanel(props) {
2224
2933
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2225
2934
  variant: "ghost",
2226
2935
  size: "sm",
2227
- disabled: busy,
2936
+ disabled: locked,
2228
2937
  onClick: () => refreshRef.current(),
2229
2938
  children: text.retry
2230
2939
  })
@@ -2238,36 +2947,9 @@ function GitCommitSettingsPanel(props) {
2238
2947
  const selectedEffort = efforts.find((item) => item.id === (current.reasoningEffort || ""));
2239
2948
  const effortName = selectedEffort ? modelMenuEffortLabel(selectedEffort, language) : void 0;
2240
2949
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2241
- className: "gcm-root gcm-settings",
2950
+ className: "gcm-root gcm-settings dsh-ui-stack",
2242
2951
  "data-testid": "git-commit-settings",
2243
2952
  children: [
2244
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2245
- className: "gcm-settings-status",
2246
- children: [
2247
- syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2248
- role: "status",
2249
- className: "dsh-ui-hint",
2250
- children: text.syncUnavailable
2251
- }),
2252
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2253
- variant: "ghost",
2254
- size: "sm",
2255
- disabled: busy,
2256
- onClick: () => refreshRef.current(),
2257
- children: text.refresh
2258
- }),
2259
- error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2260
- role: "alert",
2261
- className: "dsh-ui-error",
2262
- children: text[error]
2263
- }),
2264
- note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2265
- role: "status",
2266
- className: "dsh-ui-notice",
2267
- children: text.saved
2268
- })
2269
- ]
2270
- }),
2271
2953
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2272
2954
  className: "dsh-ui-field",
2273
2955
  children: [
@@ -2281,13 +2963,13 @@ function GitCommitSettingsPanel(props) {
2281
2963
  }),
2282
2964
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelMenu, {
2283
2965
  locale: language,
2284
- open: menuOpen && !busy,
2285
- onOpenChange: (next) => setMenuOpen(next && !busy),
2286
- listClassName: "gcm-root",
2966
+ open: menuOpen && !locked,
2967
+ onOpenChange: (next) => setMenuOpen(next && !locked),
2968
+ listClassName: "gcm-root gcm-menu-portal",
2287
2969
  anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2288
2970
  type: "button",
2289
2971
  className: "dsh-model-menu-trigger",
2290
- disabled: busy,
2972
+ disabled: locked,
2291
2973
  "aria-label": `${text.model}: ${currentLabel}`,
2292
2974
  children: [
2293
2975
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2298,7 +2980,7 @@ function GitCommitSettingsPanel(props) {
2298
2980
  className: "dsh-model-menu-triggerEffort",
2299
2981
  children: effortName ?? text.modelDefault
2300
2982
  }) : null,
2301
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: `dsh-model-menu-chevron${menuOpen && !busy ? " dsh-model-menu-chevronOpen" : ""}` })
2983
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: `dsh-model-menu-chevron${menuOpen && !locked ? " dsh-model-menu-chevronOpen" : ""}` })
2302
2984
  ]
2303
2985
  }),
2304
2986
  choices,
@@ -2339,6 +3021,33 @@ function GitCommitSettingsPanel(props) {
2339
3021
  className: "dsh-ui-meta",
2340
3022
  children: text.modelHint
2341
3023
  }),
3024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3025
+ className: "gcm-settings-status",
3026
+ children: [
3027
+ syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3028
+ role: "status",
3029
+ className: "dsh-ui-hint",
3030
+ children: text.syncUnavailable
3031
+ }),
3032
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3033
+ role: "alert",
3034
+ className: "dsh-ui-error",
3035
+ children: text[error]
3036
+ }),
3037
+ note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3038
+ role: "status",
3039
+ className: "dsh-ui-notice",
3040
+ children: text.saved
3041
+ }),
3042
+ error || syncError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3043
+ variant: "ghost",
3044
+ size: "sm",
3045
+ disabled: locked,
3046
+ onClick: () => refreshRef.current(),
3047
+ children: text.refresh
3048
+ }) : null
3049
+ ]
3050
+ }),
2342
3051
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("article", {
2343
3052
  className: "gcm-root dsh-ui-card",
2344
3053
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
@@ -2355,7 +3064,7 @@ function GitCommitSettingsPanel(props) {
2355
3064
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Switch, {
2356
3065
  checked: settings.allowFallback,
2357
3066
  label: text.fallback,
2358
- disabled: busy,
3067
+ disabled: locked,
2359
3068
  onChange: (next) => save({ allowFallback: next })
2360
3069
  })]
2361
3070
  })
@@ -2369,6 +3078,7 @@ function apply(ctx) {
2369
3078
  const settingsSubscriber = createModelSettingsSubscriber(ctx, PLUGIN_NAME);
2370
3079
  const subscribeSettings = (change, error) => settingsSubscriber.subscribe(change, error);
2371
3080
  ctx.provide(GIT_COMMIT_CLIENT_SERVICE, { active: true });
3081
+ ctx.effect(() => registerAuxiliaryModelSettings(ctx), "git-commit.auxiliary-model");
2372
3082
  ctx.effect(() => client.locale.register(NS, {
2373
3083
  zh: { ...zh },
2374
3084
  en: { ...en }
@@ -2381,19 +3091,16 @@ function apply(ctx) {
2381
3091
  document.head.appendChild(style);
2382
3092
  return () => style.remove();
2383
3093
  }, "git-commit.styles");
2384
- ctx.effect(() => client.slots.inject("plugins.bundle.config", () => client.slots.register({
2385
- name: "plugins.bundle.config",
2386
- key: PLUGIN_NAME
2387
- }, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GitCommitSettingsPanel, {
2388
- client,
2389
- subscribeSettings
2390
- }))), "git-commit.settings");
2391
3094
  ctx.effect(() => client.slots.inject("conversation.session.header.actions", () => client.slots.register({
2392
3095
  name: "conversation.session.header.actions",
2393
3096
  id: "git-commit",
2394
3097
  order: 10,
2395
3098
  locale: NS,
2396
- inject: () => ({ call: (endpoint, payload) => client.connection.rpc.call(RPC_CHANNEL, endpoint, payload) })
3099
+ inject: () => ({
3100
+ call: (endpoint, payload) => client.connection.rpc.call(RPC_CHANNEL, endpoint, payload),
3101
+ client,
3102
+ subscribeSettings
3103
+ })
2397
3104
  }, GitCommitAction)), "git-commit.slot");
2398
3105
  }
2399
3106
  //#endregion