dsh-plugin-effort-declare 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CONTRIBUTING.en.md +5 -1
  2. package/CONTRIBUTING.md +5 -1
  3. package/INSTALL.en.md +1 -1
  4. package/INSTALL.md +1 -1
  5. package/README.en.md +8 -7
  6. package/README.md +8 -7
  7. package/lib/client.js +389 -65
  8. package/lib/client.js.map +1 -1
  9. package/lib/types/client/EffortDeclareSection.d.ts +12 -2
  10. package/lib/types/client/EffortDeclareSection.d.ts.map +1 -1
  11. package/lib/types/client/build-info.d.ts +2 -0
  12. package/lib/types/client/build-info.d.ts.map +1 -0
  13. package/lib/types/client/index.d.ts.map +1 -1
  14. package/lib/types/client/load-drafts.d.ts +36 -6
  15. package/lib/types/client/load-drafts.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +1 -1
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/core/attribution.d.ts +13 -0
  19. package/lib/types/core/attribution.d.ts.map +1 -0
  20. package/lib/types/core/catalog.d.ts +5 -3
  21. package/lib/types/core/catalog.d.ts.map +1 -1
  22. package/lib/types/core/drafts.d.ts +34 -2
  23. package/lib/types/core/drafts.d.ts.map +1 -1
  24. package/lib/types/core/efforts.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/README.en.md +1 -1
  27. package/src/README.md +1 -1
  28. package/src/client/EffortDeclareSection.tsx +158 -46
  29. package/src/client/README.en.md +5 -3
  30. package/src/client/README.md +5 -3
  31. package/src/client/build-info.ts +12 -0
  32. package/src/client/effort-declare.module.css +8 -0
  33. package/src/client/globals.d.ts +5 -0
  34. package/src/client/index.ts +14 -9
  35. package/src/client/load-drafts.ts +124 -8
  36. package/src/client/locales.ts +3 -0
  37. package/src/core/README.en.md +4 -3
  38. package/src/core/README.md +4 -3
  39. package/src/core/attribution.ts +24 -0
  40. package/src/core/catalog.ts +5 -3
  41. package/src/core/drafts.ts +151 -4
  42. package/src/core/efforts.ts +4 -1
package/lib/client.js CHANGED
@@ -12,9 +12,11 @@ window.__ModuleLoader__.load({
12
12
  *
13
13
  * Levels match `@deepseek-ai/dsh-llm-pi-ai` catalog.ts `THINKING_LEVELS`.
14
14
  * Formats match `SUPPORTED_THINKING_FORMATS` in the same file (rc.8).
15
- * Tests pin these lists against llm-pi-ai. The settings page never offers
16
- * the handwritten thinkingFormat list as writable choices — only the live
17
- * schema union, plus a stored value that the union omitted.
15
+ * Tests pin these lists against a checked-in schema fixture
16
+ * (`tests/fixtures/pi-ai-thinking-format-union.ts`) and the local level
17
+ * whitelist. The settings page never offers the handwritten thinkingFormat
18
+ * list as writable choices — only the live schema union, plus a stored
19
+ * value that the union omitted.
18
20
  */
19
21
  /** Selectable reasoning levels, in pi-ai escalation order. */
20
22
  const THINKING_LEVELS = [
@@ -156,6 +158,14 @@ window.__ModuleLoader__.load({
156
158
  * Route-card draft merge: user-layer slices, namespace revision, dirty preserve.
157
159
  * No React — settings UI and tests share these helpers.
158
160
  */
161
+ /** JSON-stable equality matching pathOps (key order included). */
162
+ function sliceEqual(left, right) {
163
+ return JSON.stringify(left) === JSON.stringify(right);
164
+ }
165
+ /** Whether two settings slices differ. */
166
+ function sliceChanged(before, after) {
167
+ return !sliceEqual(before, after);
168
+ }
159
169
  /** Build a draft from the stored user subtree (never from effective `value`). */
160
170
  function routeDraftFromUserProfile(args) {
161
171
  const { provider, displayName, settingsPath, revision, userProfile } = args;
@@ -210,9 +220,103 @@ window.__ModuleLoader__.load({
210
220
  });
211
221
  });
212
222
  }
223
+ function modelRowId(row) {
224
+ return String(row.id);
225
+ }
226
+ function indexById(rows) {
227
+ const map = /* @__PURE__ */ new Map();
228
+ for (const row of rows) {
229
+ const id = modelRowId(row);
230
+ if (!map.has(id)) map.set(id, row);
231
+ }
232
+ return map;
233
+ }
234
+ function effortsPresence(row) {
235
+ if (row === void 0 || !Object.hasOwn(row, "reasoningEfforts")) return {
236
+ present: false,
237
+ value: void 0
238
+ };
239
+ return {
240
+ present: true,
241
+ value: row.reasoningEfforts
242
+ };
243
+ }
244
+ function effortsEqual(left, right) {
245
+ if (left.present !== right.present) return false;
246
+ if (!left.present) return true;
247
+ return sliceEqual(left.value, right.value);
248
+ }
249
+ function overlayLocalEfforts(incomingRow, prevRow) {
250
+ const next = structuredClone(incomingRow);
251
+ if (Object.hasOwn(prevRow, "reasoningEfforts")) next.reasoningEfforts = structuredClone(prevRow.reasoningEfforts);
252
+ else delete next.reasoningEfforts;
253
+ return next;
254
+ }
255
+ function objectKeyChanged(left, right, key) {
256
+ const leftHas = Object.hasOwn(left, key);
257
+ if (leftHas !== Object.hasOwn(right, key)) return true;
258
+ if (!leftHas) return false;
259
+ return sliceChanged(left[key], right[key]);
260
+ }
261
+ /**
262
+ * Membership follows the latest user-layer models list (Models page add/delete).
263
+ * Local unsaved `reasoningEfforts` (including a cleared key) overlay by id.
264
+ */
265
+ function mergeModelsById(args) {
266
+ const prevById = indexById(args.prevModels);
267
+ const prevOrigById = indexById(args.prevOriginal);
268
+ const incomingOrigById = indexById(args.incomingOriginal);
269
+ const incomingIds = new Set(args.incomingModels.map(modelRowId));
270
+ let conflicted = false;
271
+ const models = args.incomingModels.map((incomingRow) => {
272
+ const id = modelRowId(incomingRow);
273
+ const prevRow = prevById.get(id);
274
+ if (prevRow === void 0) return structuredClone(incomingRow);
275
+ const prevOrig = prevOrigById.get(id);
276
+ if (!!effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) return structuredClone(incomingRow);
277
+ const incomingOrig = incomingOrigById.get(id);
278
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrig))) conflicted = true;
279
+ return overlayLocalEfforts(incomingRow, prevRow);
280
+ });
281
+ for (const [id, prevRow] of prevById) {
282
+ if (incomingIds.has(id)) continue;
283
+ const prevOrig = prevOrigById.get(id);
284
+ if (effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) continue;
285
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrigById.get(id)))) conflicted = true;
286
+ }
287
+ return {
288
+ models,
289
+ conflicted
290
+ };
291
+ }
213
292
  /**
214
- * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
215
- * revision follow the incoming snapshot so a later save is against the new user layer.
293
+ * Three-way compat merge: locally changed keys stay local; everything else
294
+ * follows incoming. Conflict only when a locally dirty key also moved in originals.
295
+ */
296
+ function mergeCompat(args) {
297
+ if (!sliceChanged(args.prev, args.prevOriginal)) return {
298
+ compat: cloneObject(args.incoming),
299
+ conflicted: false
300
+ };
301
+ const compat = cloneObject(args.incoming);
302
+ let conflicted = false;
303
+ const keys = /* @__PURE__ */ new Set([...Object.keys(args.prev), ...Object.keys(args.prevOriginal)]);
304
+ for (const key of keys) {
305
+ if (!objectKeyChanged(args.prev, args.prevOriginal, key)) continue;
306
+ if (objectKeyChanged(args.prevOriginal, args.incomingOriginal, key)) conflicted = true;
307
+ if (Object.hasOwn(args.prev, key)) compat[key] = structuredClone(args.prev[key]);
308
+ else delete compat[key];
309
+ }
310
+ return {
311
+ compat,
312
+ conflicted
313
+ };
314
+ }
315
+ /**
316
+ * Apply a freshly loaded table. Membership and metadata follow incoming;
317
+ * unsaved reasoningEfforts / dirty compat keys overlay by id. Conflict only
318
+ * when a locally dirty field also changed in originals (revision-only bumps
319
+ * and sibling-card saves do not warn).
216
320
  */
217
321
  function mergeLoadedDrafts(current, incoming, options) {
218
322
  const currentByProvider = new Map(current.map((draft) => [draft.provider, draft]));
@@ -221,13 +325,27 @@ window.__ModuleLoader__.load({
221
325
  drafts: incoming.map((next) => {
222
326
  const prev = currentByProvider.get(next.provider);
223
327
  if (prev === void 0 || !options.preserveDirty || !draftDirty(prev)) return next;
224
- conflicted.push(next.provider);
328
+ const modelsMerge = mergeModelsById({
329
+ prevModels: prev.models,
330
+ prevOriginal: prev.originalModels,
331
+ incomingModels: next.models,
332
+ incomingOriginal: next.originalModels
333
+ });
334
+ const compatMerge = mergeCompat({
335
+ prev: prev.compat,
336
+ prevOriginal: prev.originalCompat,
337
+ incoming: next.compat,
338
+ incomingOriginal: next.originalCompat
339
+ });
340
+ if (modelsMerge.conflicted || compatMerge.conflicted) conflicted.push(next.provider);
225
341
  return {
226
- ...prev,
342
+ provider: next.provider,
227
343
  displayName: next.displayName,
228
344
  settingsPath: next.settingsPath,
229
345
  revision: next.revision,
346
+ models: modelsMerge.models,
230
347
  originalModels: cloneModels(next.originalModels),
348
+ compat: compatMerge.compat,
231
349
  originalCompat: cloneObject(next.originalCompat),
232
350
  compatPresent: next.compatPresent
233
351
  };
@@ -271,7 +389,10 @@ window.__ModuleLoader__.load({
271
389
  const next = { ...efforts };
272
390
  delete next.off;
273
391
  if (mode === "empty") next.off = null;
274
- else if (mode === "value") next.off = value.trim().length > 0 ? value : "none";
392
+ else if (mode === "value") {
393
+ const trimmed = value.trim();
394
+ next.off = trimmed.length > 0 ? trimmed : "none";
395
+ }
275
396
  return next;
276
397
  }
277
398
  /** Whether a thinking level (other than Off) is currently declared. */
@@ -410,20 +531,104 @@ window.__ModuleLoader__.load({
410
531
  return validateReasoningEfforts(row.reasoningEfforts);
411
532
  }
412
533
  //#endregion
534
+ //#region src/core/attribution.ts
535
+ /**
536
+ * Plugin footer attribution. Version and end-year are frozen into the client
537
+ * bundle at pack time; this module only formats the line.
538
+ */
539
+ /** First publication year (LICENSE). Not the user's wall clock. */
540
+ const COPYRIGHT_FROM = 2026;
541
+ const COPYRIGHT_HOLDER = "Stardust";
542
+ /**
543
+ * `0.1.2 © 2026 Stardust` or `0.1.2 © 2026–2027 Stardust`.
544
+ * Throws if version is empty or `to < from` — a bad stamp must not render.
545
+ */
546
+ function formatAttribution(version, from, to) {
547
+ if (version.trim() === "") throw new Error("plugin version must be a non-empty string");
548
+ if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) throw new Error(`invalid copyright range: ${String(from)}\u2013${String(to)}`);
549
+ return `${version} \u00a9 ${to === from ? String(from) : `${String(from)}\u2013${String(to)}`} ${COPYRIGHT_HOLDER}`;
550
+ }
551
+ //#endregion
552
+ //#region src/client/build-info.ts
553
+ /**
554
+ * Footer line frozen into the client bundle. Do not read the clock or
555
+ * package.json at DSH startup — host apply is empty and the settings page
556
+ * runs in the browser.
557
+ */
558
+ const PLUGIN_FOOTER_TEXT = formatAttribution("0.1.3", COPYRIGHT_FROM, 2026);
559
+ //#endregion
413
560
  //#region src/client/load-drafts.ts
414
561
  function schemaDefaultString(node) {
415
562
  if (!isPlainObject(node) || !isPlainObject(node.meta)) return void 0;
416
563
  return typeof node.meta.default === "string" ? node.meta.default : void 0;
417
564
  }
565
+ /** Namespace revision on a describe snapshot, if that row exists. */
566
+ function namespaceRevision(snapshot, ns) {
567
+ return snapshot.view?.namespaces.find((view) => view.ns === ns)?.revision;
568
+ }
418
569
  /**
419
- * First paint: `ensure()` (reads only from idle). Never treat ensure as refresh.
420
- * Callers that must not apply a stale settlement compare generation themselves.
421
- *
422
- * `formats` is the live schema union only. Empty means the dropdown has no
423
- * writable choices (stored values stay visible via `thinkingFormatChoices`).
570
+ * True when `incoming` is the Host echo of a mutate this page already folded,
571
+ * or an older revision the snapshot has already passed. `echoed` is undefined
572
+ * until the first successful write.
424
573
  */
425
- async function loadDrafts(api, describe, schema) {
426
- await describe.ensure();
574
+ function isOwnDocumentEcho(echoed, incoming) {
575
+ return echoed !== void 0 && incoming <= echoed;
576
+ }
577
+ /**
578
+ * After a preserve-dirty reload: conflicted cards get a conflict notice;
579
+ * live cards drop leftover conflict/error; saved notices stay; gone cards drop.
580
+ */
581
+ function foldReloadNotices(current, args) {
582
+ const live = new Set(args.liveProviders);
583
+ const conflicted = new Set(args.conflicted);
584
+ const next = {};
585
+ for (const [provider, notice] of Object.entries(current)) {
586
+ if (!live.has(provider)) continue;
587
+ if (conflicted.has(provider)) continue;
588
+ if (notice.kind === "conflict" || notice.kind === "error") continue;
589
+ next[provider] = notice;
590
+ }
591
+ for (const provider of args.conflicted) next[provider] = args.conflictNotice;
592
+ return next;
593
+ }
594
+ function waitUntil(describe, predicate, signal) {
595
+ if (signal?.aborted) return Promise.resolve(false);
596
+ if (predicate()) return Promise.resolve(true);
597
+ return new Promise((resolve) => {
598
+ let settled = false;
599
+ const finish = (ok) => {
600
+ if (settled) return;
601
+ settled = true;
602
+ stop();
603
+ signal?.removeEventListener("abort", onAbort);
604
+ resolve(ok);
605
+ };
606
+ const onAbort = () => {
607
+ finish(false);
608
+ };
609
+ const stop = describe.subscribe(() => {
610
+ if (predicate()) finish(true);
611
+ });
612
+ signal?.addEventListener("abort", onAbort);
613
+ if (predicate()) finish(true);
614
+ else if (signal?.aborted) finish(false);
615
+ });
616
+ }
617
+ /** Resolve when the mirror's namespace revision is at least `revision`, or abort. */
618
+ async function waitForNamespaceRevision(describe, ns, revision, signal) {
619
+ return await waitUntil(describe, () => {
620
+ const current = namespaceRevision(describe.getSnapshot(), ns);
621
+ return current !== void 0 && current >= revision;
622
+ }, signal) ? "matched" : "aborted";
623
+ }
624
+ /** Resolve when the namespace revision differs from `previous`, or abort. */
625
+ async function waitForNamespaceRevisionChange(describe, ns, previous, signal) {
626
+ return await waitUntil(describe, () => {
627
+ const current = namespaceRevision(describe.getSnapshot(), ns);
628
+ return current !== void 0 && current !== previous;
629
+ }, signal) ? "changed" : "aborted";
630
+ }
631
+ async function assembleDrafts(api, describe, schema) {
427
632
  const mirrored = describe.getSnapshot();
428
633
  if (mirrored.view === void 0) return {
429
634
  writable: false,
@@ -474,6 +679,14 @@ window.__ModuleLoader__.load({
474
679
  drafts
475
680
  };
476
681
  }
682
+ /**
683
+ * `ensure`: first paint / idle recovery (official ensure only reads from idle).
684
+ * `snapshot`: refresh after the mirror revision already moved — do not ensure.
685
+ */
686
+ async function loadDrafts(api, describe, schema, mode = "ensure") {
687
+ if (mode === "ensure") await describe.ensure();
688
+ return assembleDrafts(api, describe, schema);
689
+ }
477
690
  //#endregion
478
691
  //#region src/client/schema-ops.ts
479
692
  /** Wrap a live settingsSchema service as plain callbacks. */
@@ -506,7 +719,7 @@ window.__ModuleLoader__.load({
506
719
  }
507
720
  //#endregion
508
721
  //#region \0dsh-css:C:\Users\zimo\AppData\Roaming\io.github.hairyf.deepseek-harness-desktop\data\dsh\临时目录\dsh-plugin-effort-declare\src\client\effort-declare.module.css.mjs
509
- const cssText = ".hYEBUa_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.hYEBUa_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}.hYEBUa_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:14px;line-height:22px}.hYEBUa_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}.hYEBUa_savedNotice{color:var(--dsw-alias-state-success-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_rows{flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none;display:flex}.hYEBUa_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.hYEBUa_rowHead{align-items:baseline;gap:8px;display:flex}.hYEBUa_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.hYEBUa_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px}.hYEBUa_compatSummary{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.hYEBUa_presetRow,.hYEBUa_actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}.hYEBUa_primaryButton,.hYEBUa_secondaryButton{box-sizing:border-box;height:36px;font:inherit;cursor:pointer;border-radius:18px;justify-content:center;align-items:center;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.hYEBUa_primaryButton{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border:none}.hYEBUa_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.hYEBUa_secondaryButton{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0}.hYEBUa_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}.hYEBUa_primaryButton:disabled,.hYEBUa_secondaryButton:disabled,.hYEBUa_linkButton:disabled,.hYEBUa_input:disabled{opacity:.4;cursor:default}.hYEBUa_primaryButton:focus-visible,.hYEBUa_secondaryButton:focus-visible,.hYEBUa_linkButton:focus-visible,.hYEBUa_input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.hYEBUa_linkButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_linkButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.hYEBUa_modelEntry{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:8px;padding:10px 0;display:flex}.hYEBUa_modelHead{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.hYEBUa_modelId{font-size:13px;font-weight:500;line-height:20px}.hYEBUa_modelName{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.hYEBUa_levels{flex-wrap:wrap;gap:10px 14px;display:flex}.hYEBUa_level{align-items:center;gap:6px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_wireRow{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_input,.hYEBUa_selectInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);height:32px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:0 10px;font-size:13px}.hYEBUa_wireInput{width:7em}.hYEBUa_offGroup{flex-direction:column;gap:6px;display:flex}.hYEBUa_advanced{border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}.hYEBUa_advanced summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.hYEBUa_advancedBody{flex-direction:column;gap:10px;padding-top:10px;display:flex}.hYEBUa_check{align-items:flex-start;gap:8px;font-size:12px;line-height:18px;display:flex}";
722
+ const cssText = ".hYEBUa_section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}.hYEBUa_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:500;line-height:24px}.hYEBUa_intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:14px;line-height:22px}.hYEBUa_notice{color:var(--dsw-alias-state-warn-label);margin:0;font-size:12px;line-height:18px}.hYEBUa_savedNotice{color:var(--dsw-alias-state-success-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}.hYEBUa_rows{flex-direction:column;gap:8px;margin:12px 0 0;padding:0;list-style:none;display:flex}.hYEBUa_rowCard{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.hYEBUa_rowHead{align-items:baseline;gap:8px;display:flex}.hYEBUa_rowName{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}.hYEBUa_rowTag{border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px}.hYEBUa_compatSummary{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:18px}.hYEBUa_presetRow,.hYEBUa_actions{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px}.hYEBUa_primaryButton,.hYEBUa_secondaryButton{box-sizing:border-box;height:36px;font:inherit;cursor:pointer;border-radius:18px;justify-content:center;align-items:center;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.hYEBUa_primaryButton{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border:none}.hYEBUa_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.hYEBUa_secondaryButton{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0}.hYEBUa_secondaryButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}.hYEBUa_primaryButton:disabled,.hYEBUa_secondaryButton:disabled,.hYEBUa_linkButton:disabled,.hYEBUa_input:disabled{opacity:.4;cursor:default}.hYEBUa_primaryButton:focus-visible,.hYEBUa_secondaryButton:focus-visible,.hYEBUa_linkButton:focus-visible,.hYEBUa_input:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}.hYEBUa_linkButton{box-sizing:border-box;height:28px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:14px;align-items:center;padding:0 10px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_linkButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.hYEBUa_modelEntry{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:8px;padding:10px 0;display:flex}.hYEBUa_modelHead{flex-wrap:wrap;align-items:baseline;gap:8px;display:flex}.hYEBUa_modelId{font-size:13px;font-weight:500;line-height:20px}.hYEBUa_modelName{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.hYEBUa_levels{flex-wrap:wrap;gap:10px 14px;display:flex}.hYEBUa_level{align-items:center;gap:6px;font-size:12px;line-height:18px;display:inline-flex}.hYEBUa_wireRow{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.hYEBUa_input,.hYEBUa_selectInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);height:32px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:0 10px;font-size:13px}.hYEBUa_wireInput{width:7em}.hYEBUa_offGroup{flex-direction:column;gap:6px;display:flex}.hYEBUa_advanced{border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}.hYEBUa_advanced summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.hYEBUa_advancedBody{flex-direction:column;gap:10px;padding-top:10px;display:flex}.hYEBUa_check{align-items:flex-start;gap:8px;font-size:12px;line-height:18px;display:flex}.hYEBUa_footer{color:var(--dsw-alias-label-tertiary);opacity:.7;margin:16px 0 0;font-size:11px;line-height:16px}";
510
723
  const cssTagId = "dsh-plugin-effort-declare/effort-declare.module.css";
511
724
  var effort_declare_module_css_default = {
512
725
  "actions": "hYEBUa_actions",
@@ -516,6 +729,7 @@ window.__ModuleLoader__.load({
516
729
  "compatSummary": "hYEBUa_compatSummary",
517
730
  "error": "hYEBUa_error",
518
731
  "fieldLabel": "hYEBUa_fieldLabel",
732
+ "footer": "hYEBUa_footer",
519
733
  "input": "hYEBUa_input",
520
734
  "intro": "hYEBUa_intro",
521
735
  "level": "hYEBUa_level",
@@ -670,10 +884,10 @@ window.__ModuleLoader__.load({
670
884
  });
671
885
  }
672
886
  function RouteCard(props) {
673
- const { draft, writable, busy, reloading, t, onChange } = props;
887
+ const { draft, writable, busy, saveLocked, t, onChange } = props;
674
888
  const noModels = draft.models.length === 0;
675
889
  const editDisabled = !writable || busy || noModels;
676
- const saveLocked = editDisabled || reloading;
890
+ const saveDisabled = saveLocked || noModels;
677
891
  const formats = thinkingFormatChoices(props.formats, draft.compat.thinkingFormat);
678
892
  const summary = compatSummary(draft.compat);
679
893
  const sameWire = draft.compat.supportsReasoningEffort === false;
@@ -857,7 +1071,7 @@ window.__ModuleLoader__.load({
857
1071
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
858
1072
  type: "button",
859
1073
  className: effort_declare_module_css_default.primaryButton,
860
- disabled: saveLocked || !dirty || clientError !== void 0,
1074
+ disabled: saveDisabled || !dirty || clientError !== void 0,
861
1075
  onClick: () => {
862
1076
  props.onSave(draft);
863
1077
  },
@@ -881,40 +1095,92 @@ window.__ModuleLoader__.load({
881
1095
  const [notices, setNotices] = (0, react.useState)({});
882
1096
  const generationRef = (0, react.useRef)(0);
883
1097
  const draftsRef = (0, react.useRef)(drafts);
1098
+ const echoedRevisionRef = (0, react.useRef)(void 0);
1099
+ const pendingRevisionRef = (0, react.useRef)(void 0);
1100
+ const busyRouteRef = (0, react.useRef)(null);
1101
+ const abortRef = (0, react.useRef)(null);
884
1102
  draftsRef.current = drafts;
885
- const reload = (0, react.useCallback)((preserveDirty) => {
1103
+ const applyDrafts = (next) => {
1104
+ const resolved = typeof next === "function" ? next(draftsRef.current) : next;
1105
+ draftsRef.current = resolved;
1106
+ setDrafts(resolved);
1107
+ };
1108
+ const snapshotMode = () => describe === void 0 || describe.getSnapshot().status === "idle" ? "ensure" : "snapshot";
1109
+ const beginGeneration = () => {
1110
+ abortRef.current?.abort();
1111
+ const abort = new AbortController();
1112
+ abortRef.current = abort;
1113
+ return {
1114
+ generation: nextGeneration(generationRef),
1115
+ signal: abort.signal
1116
+ };
1117
+ };
1118
+ const failGeneration = (generation, failure) => {
1119
+ if (!generationIsCurrent(generationRef, generation)) return;
1120
+ setStatus("error");
1121
+ setError(failure instanceof Error ? failure.message : t("loadError"));
1122
+ };
1123
+ const settleReload = (generation, preserveDirty, result) => {
1124
+ if (!generationIsCurrent(generationRef, generation)) return;
1125
+ setWritable(result.writable);
1126
+ setFormats(result.formats);
1127
+ if (result.error !== void 0) {
1128
+ setStatus("error");
1129
+ setError(result.error);
1130
+ return;
1131
+ }
1132
+ const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty });
1133
+ applyDrafts(merged.drafts);
1134
+ setNotices((current) => foldReloadNotices(current, {
1135
+ conflicted: merged.conflicted,
1136
+ conflictNotice: {
1137
+ kind: "conflict",
1138
+ text: t("dirtyConflict")
1139
+ },
1140
+ liveProviders: merged.drafts.map((draft) => draft.provider)
1141
+ }));
1142
+ setStatus("ready");
1143
+ };
1144
+ const loadSnapshotThenSettle = async (generation, preserveDirty) => {
1145
+ if (api === void 0 || describe === void 0 || schema === void 0) return;
1146
+ if (!generationIsCurrent(generationRef, generation)) return;
1147
+ try {
1148
+ const result = await loadDrafts(api, describe, schema, "snapshot");
1149
+ settleReload(generation, preserveDirty, result);
1150
+ } catch (failure) {
1151
+ failGeneration(generation, failure);
1152
+ }
1153
+ };
1154
+ const reload = (0, react.useCallback)((preserveDirty, mode = "ensure") => {
886
1155
  if (api === void 0 || describe === void 0 || schema === void 0) {
887
1156
  setStatus("error");
888
1157
  setError(t("loadError"));
889
1158
  return;
890
1159
  }
891
- const generation = nextGeneration(generationRef);
892
- setStatus("loading");
1160
+ const { generation } = beginGeneration();
1161
+ if (draftsRef.current.length === 0) setStatus("loading");
893
1162
  setError("");
894
- loadDrafts(api, describe, schema).then((result) => {
895
- if (!generationIsCurrent(generationRef, generation)) return;
896
- setWritable(result.writable);
897
- setFormats(result.formats);
898
- if (result.error !== void 0) {
899
- setStatus("error");
900
- setError(result.error);
901
- return;
902
- }
903
- const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty });
904
- setDrafts(merged.drafts);
905
- if (merged.conflicted.length > 0) setNotices((current) => {
906
- const next = { ...current };
907
- for (const provider of merged.conflicted) next[provider] = {
908
- kind: "conflict",
909
- text: t("dirtyConflict")
910
- };
911
- return next;
912
- });
913
- setStatus("ready");
1163
+ loadDrafts(api, describe, schema, mode).then((result) => {
1164
+ settleReload(generation, preserveDirty, result);
914
1165
  }, (failure) => {
915
- if (!generationIsCurrent(generationRef, generation)) return;
916
- setStatus("error");
917
- setError(failure instanceof Error ? failure.message : t("loadError"));
1166
+ failGeneration(generation, failure);
1167
+ });
1168
+ }, [
1169
+ api,
1170
+ describe,
1171
+ schema,
1172
+ t
1173
+ ]);
1174
+ const refreshAtRevision = (0, react.useCallback)((revision, preserveDirty) => {
1175
+ if (api === void 0 || describe === void 0 || schema === void 0) return;
1176
+ const { generation, signal } = beginGeneration();
1177
+ if (draftsRef.current.length === 0) setStatus("loading");
1178
+ setError("");
1179
+ waitForNamespaceRevision(describe, LLM_PI_AI_NS, revision, signal).then((outcome) => {
1180
+ if (outcome === "aborted" || !generationIsCurrent(generationRef, generation)) return;
1181
+ return loadSnapshotThenSettle(generation, preserveDirty);
1182
+ }, (failure) => {
1183
+ failGeneration(generation, failure);
918
1184
  });
919
1185
  }, [
920
1186
  api,
@@ -922,15 +1188,46 @@ window.__ModuleLoader__.load({
922
1188
  schema,
923
1189
  t
924
1190
  ]);
1191
+ const flushPendingSettings = (refresh) => {
1192
+ const pending = pendingRevisionRef.current;
1193
+ pendingRevisionRef.current = void 0;
1194
+ if (pending === void 0) return;
1195
+ if (isOwnDocumentEcho(echoedRevisionRef.current, pending)) return;
1196
+ refresh(pending, true);
1197
+ };
925
1198
  (0, react.useEffect)(() => {
926
- reload(false);
1199
+ reload(false, "ensure");
927
1200
  }, [reload]);
1201
+ (0, react.useEffect)(() => () => {
1202
+ abortRef.current?.abort();
1203
+ nextGeneration(generationRef);
1204
+ }, []);
928
1205
  (0, react.useEffect)(() => {
929
1206
  if (props.subscribeInvalidate === void 0) return void 0;
930
- return props.subscribeInvalidate((source) => {
931
- if (source === "settings" || source === "directory") reload(true);
1207
+ return props.subscribeInvalidate((event) => {
1208
+ if (event.source === "writable") {
1209
+ const view = describe?.getSnapshot().view;
1210
+ if (view !== void 0) setWritable(view.writable);
1211
+ return;
1212
+ }
1213
+ if (event.source === "settings") {
1214
+ if (busyRouteRef.current !== null) {
1215
+ pendingRevisionRef.current = event.revision;
1216
+ return;
1217
+ }
1218
+ if (isOwnDocumentEcho(echoedRevisionRef.current, event.revision)) return;
1219
+ refreshAtRevision(event.revision, true);
1220
+ return;
1221
+ }
1222
+ if (event.source === "directory") reload(true, snapshotMode());
1223
+ if (event.source === "reset") reload(true, "ensure");
932
1224
  });
933
- }, [props.subscribeInvalidate, reload]);
1225
+ }, [
1226
+ describe,
1227
+ props.subscribeInvalidate,
1228
+ refreshAtRevision,
1229
+ reload
1230
+ ]);
934
1231
  const patchNotice = (provider, notice) => {
935
1232
  setNotices((current) => {
936
1233
  const copy = { ...current };
@@ -941,7 +1238,13 @@ window.__ModuleLoader__.load({
941
1238
  };
942
1239
  const save = async (draft) => {
943
1240
  if (api === void 0 || describe === void 0 || schema === void 0) return;
944
- if (status === "loading" || busyRoute !== null) return;
1241
+ if (status === "loading" || busyRouteRef.current !== null) {
1242
+ patchNotice(draft.provider, {
1243
+ kind: "error",
1244
+ text: t("saveBusy")
1245
+ });
1246
+ return;
1247
+ }
945
1248
  const blocking = draft.models.map((row) => errorText(modelEffortError(row), t)).find((text) => text !== void 0);
946
1249
  if (blocking !== void 0) {
947
1250
  patchNotice(draft.provider, {
@@ -950,6 +1253,7 @@ window.__ModuleLoader__.load({
950
1253
  });
951
1254
  return;
952
1255
  }
1256
+ busyRouteRef.current = draft.provider;
953
1257
  setBusyRoute(draft.provider);
954
1258
  patchNotice(draft.provider, void 0);
955
1259
  try {
@@ -961,7 +1265,7 @@ window.__ModuleLoader__.load({
961
1265
  afterCompat: draft.compat
962
1266
  });
963
1267
  if (ops.length === 0) {
964
- setDrafts((current) => current.map((row) => row.provider === draft.provider ? alignDraft(row) : row));
1268
+ applyDrafts((current) => current.map((row) => row.provider === draft.provider ? alignDraft(row) : row));
965
1269
  return;
966
1270
  }
967
1271
  const willWriteCompat = ops.some((op) => op.path.length > draft.settingsPath.length && op.path[draft.settingsPath.length] === "compat");
@@ -995,12 +1299,21 @@ window.__ModuleLoader__.load({
995
1299
  kind: conflict ? "conflict" : "error",
996
1300
  text: conflict ? t("conflict") : response.result.error.message
997
1301
  });
998
- if (conflict) reload(true);
1302
+ if (conflict) {
1303
+ const { generation, signal } = beginGeneration();
1304
+ waitForNamespaceRevisionChange(describe, LLM_PI_AI_NS, draft.revision, signal).then((outcome) => {
1305
+ if (outcome === "aborted" || !generationIsCurrent(generationRef, generation)) return;
1306
+ return loadSnapshotThenSettle(generation, true);
1307
+ }, (failure) => {
1308
+ failGeneration(generation, failure);
1309
+ });
1310
+ }
999
1311
  return;
1000
1312
  }
1001
1313
  const view = response.result.value;
1314
+ echoedRevisionRef.current = view.revision;
1002
1315
  describe.acceptView(view);
1003
- setDrafts((current) => applySaveSuccess(current, draft.provider, {
1316
+ applyDrafts(applySaveSuccess(draftsRef.current, draft.provider, {
1004
1317
  user: view.user ?? {},
1005
1318
  revision: view.revision
1006
1319
  }));
@@ -1014,7 +1327,9 @@ window.__ModuleLoader__.load({
1014
1327
  text: failure instanceof Error ? failure.message : t("loadError")
1015
1328
  });
1016
1329
  } finally {
1330
+ busyRouteRef.current = null;
1017
1331
  setBusyRoute(null);
1332
+ flushPendingSettings(refreshAtRevision);
1018
1333
  }
1019
1334
  };
1020
1335
  const showLoading = status === "loading" && drafts.length === 0;
@@ -1049,7 +1364,7 @@ window.__ModuleLoader__.load({
1049
1364
  type: "button",
1050
1365
  className: effort_declare_module_css_default.secondaryButton,
1051
1366
  onClick: () => {
1052
- reload(true);
1367
+ reload(true, snapshotMode());
1053
1368
  },
1054
1369
  children: t("reload")
1055
1370
  }) : null,
@@ -1067,26 +1382,30 @@ window.__ModuleLoader__.load({
1067
1382
  formats: formats.length > 0 ? formats : [],
1068
1383
  writable,
1069
1384
  busy: busyRoute === draft.provider,
1070
- reloading: status === "loading",
1385
+ saveLocked: !writable || busyRoute !== null || status === "loading",
1071
1386
  notice: notices[draft.provider],
1072
1387
  t,
1073
1388
  onChange: (next) => {
1074
1389
  patchNotice(next.provider, void 0);
1075
- setDrafts((current) => current.map((row) => row.provider === next.provider ? next : row));
1390
+ applyDrafts((current) => current.map((row) => row.provider === next.provider ? next : row));
1076
1391
  },
1077
1392
  onSave: (next) => {
1078
1393
  save(next);
1079
1394
  },
1080
1395
  onCancel: (next) => {
1081
1396
  patchNotice(next.provider, void 0);
1082
- setDrafts((current) => current.map((row) => row.provider === next.provider ? {
1397
+ applyDrafts((current) => current.map((row) => row.provider === next.provider ? {
1083
1398
  ...row,
1084
1399
  models: cloneModels(row.originalModels),
1085
1400
  compat: cloneObject(row.originalCompat)
1086
1401
  } : row));
1087
1402
  }
1088
1403
  }, draft.provider))
1089
- }) : null
1404
+ }) : null,
1405
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1406
+ className: effort_declare_module_css_default.footer,
1407
+ children: PLUGIN_FOOTER_TEXT
1408
+ })
1090
1409
  ]
1091
1410
  });
1092
1411
  }
@@ -1104,6 +1423,7 @@ window.__ModuleLoader__.load({
1104
1423
  readOnly: "当前设置为只读,无法保存。",
1105
1424
  save: "保存",
1106
1425
  saving: "保存中…",
1426
+ saveBusy: "另有路由正在保存,请稍候。",
1107
1427
  cancel: "取消",
1108
1428
  saved: "已保存。对话选择器会按新的能力声明显示 Effort 行。",
1109
1429
  conflict: "设置已被其他地方改过,请重新加载后再保存。",
@@ -1146,6 +1466,7 @@ window.__ModuleLoader__.load({
1146
1466
  readOnly: "Settings are read-only; saving is disabled.",
1147
1467
  save: "Save",
1148
1468
  saving: "Saving…",
1469
+ saveBusy: "Another route is saving. Wait, then save this card.",
1149
1470
  cancel: "Cancel",
1150
1471
  saved: "Saved. The composer Effort row follows this capability declaration.",
1151
1472
  conflict: "Settings changed elsewhere. Reload, then save again.",
@@ -1222,22 +1543,25 @@ window.__ModuleLoader__.load({
1222
1543
  const describe = ctx.settingsScope.describe();
1223
1544
  const invalidation = /* @__PURE__ */ new Set();
1224
1545
  ctx.effect(() => {
1225
- const emit = (source) => {
1226
- for (const listener of invalidation) listener(source);
1546
+ const emit = (event) => {
1547
+ for (const listener of invalidation) listener(event);
1227
1548
  };
1228
1549
  const disposers = [
1229
1550
  describe.subscribe(() => {
1230
- emit("settings");
1551
+ emit({ source: "writable" });
1231
1552
  }),
1232
- ctx.remote.$on("settings/document-updated", (ns) => {
1553
+ ctx.remote.$on("settings/document-updated", (ns, revision) => {
1233
1554
  if (ns !== "llm-pi-ai") return;
1234
- emit("settings");
1555
+ emit({
1556
+ source: "settings",
1557
+ revision
1558
+ });
1235
1559
  }),
1236
1560
  ctx.remote.$on("llm/adapters-updated", () => {
1237
- emit("directory");
1561
+ emit({ source: "directory" });
1238
1562
  }),
1239
1563
  ctx.on("connection/reset", () => {
1240
- emit("directory");
1564
+ emit({ source: "reset" });
1241
1565
  })
1242
1566
  ];
1243
1567
  return () => {