@klarkxy/dsh-current-title 0.2.0 → 0.2.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.
package/lib/client.js CHANGED
@@ -418,7 +418,7 @@ ${scope} {
418
418
  border: 0.5px solid var(--dsw-alias-border-l3);
419
419
  border-radius: var(--dsw-radius-md);
420
420
  background: var(--dsw-alias-bg-layer-1);
421
- font-family: var(--ds-font-family-code, ui-monospace, 'Cascadia Mono', Consolas, monospace);
421
+ font-family: var(--ds-font-family-code);
422
422
  font-size: 12px;
423
423
  line-height: 20px;
424
424
  color: var(--dsw-alias-label-secondary);
@@ -494,6 +494,162 @@ ${scope} {
494
494
  `.replace(/^& (\.dsh-ui-[a-z0-9-]+)(?![+>~])/gm, (_match, className) => `&${className}, & ${className}`);
495
495
  }
496
496
  //#endregion
497
+ //#region ../dsh-plugin-kit/src/contracts.ts
498
+ /**
499
+ * Remote contribution for model-settings change notifications.
500
+ * Provided by `@klarkxy/dsh-model-hub` (`ModelSettingsChangesController`);
501
+ * subscribers degrade silently when that package is not installed.
502
+ * The stream carries invalidations only — `ready` is never a settings snapshot.
503
+ */
504
+ const MODEL_SETTINGS_CHANGES_REMOTE = {
505
+ package: "@klarkxy/dsh-model-hub",
506
+ descriptors: [{
507
+ id: "modelSettingsChanges/watch",
508
+ service: "modelSettingsChangesController",
509
+ namespace: "modelSettingsChanges",
510
+ method: "watch",
511
+ mode: "stream",
512
+ invocation: { kind: "direct" },
513
+ parameters: [],
514
+ cancellation: { parameter: "signal" }
515
+ }]
516
+ };
517
+ //#endregion
518
+ //#region ../dsh-plugin-kit/src/model-settings-client.ts
519
+ /** This stream carries invalidations only; ready is never a settings snapshot. */
520
+ function parseModelSettingsChangeFrame(value) {
521
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid model settings change frame");
522
+ const frame = value;
523
+ if (frame.kind === "ready" && Object.keys(frame).length === 1) return { kind: "ready" };
524
+ if (frame.kind === "changed" && Object.keys(frame).length === 3 && typeof frame.ns === "string" && frame.ns.length > 0 && typeof frame.revision === "number" && Number.isSafeInteger(frame.revision) && frame.revision >= 0) return {
525
+ kind: "changed",
526
+ ns: frame.ns,
527
+ revision: frame.revision
528
+ };
529
+ throw new Error("Invalid model settings change frame");
530
+ }
531
+ MODEL_SETTINGS_CHANGES_REMOTE.package, MODEL_SETTINGS_CHANGES_REMOTE.descriptors.map((descriptor) => ({
532
+ ...descriptor,
533
+ result: {
534
+ mode: "strict",
535
+ typeSymbol: "ModelSettings.ChangeFrame",
536
+ create: () => ({ parse: parseModelSettingsChangeFrame })
537
+ }
538
+ }));
539
+ /** Native RemoteStream owns carrier reconnects; no timers or browser transport.
540
+ * A factory shares one logical stream while at least one subscriber remains. */
541
+ function createModelSettingsSubscriber(ctx, namespace) {
542
+ const listeners = /* @__PURE__ */ new Set();
543
+ let injection;
544
+ let current;
545
+ let lifecycle = 0;
546
+ let failureMessage;
547
+ const report = (message) => {
548
+ failureMessage = message;
549
+ for (const listener of [...listeners]) try {
550
+ listener.error?.(message);
551
+ } catch {}
552
+ };
553
+ const invalidate = () => {
554
+ for (const listener of [...listeners]) {
555
+ if (!listeners.has(listener)) continue;
556
+ try {
557
+ listener.change();
558
+ } catch (error) {
559
+ try {
560
+ listener.error?.(error instanceof Error ? error.message : String(error));
561
+ } catch {}
562
+ }
563
+ }
564
+ };
565
+ const start = () => {
566
+ const life = ++lifecycle;
567
+ injection = ctx.inject(["remote", "remote.modelSettingsChanges"], (injected) => {
568
+ if (life !== lifecycle || !listeners.size) return;
569
+ const scope = injected;
570
+ const stream = scope.remote.$stream({
571
+ name: "Model settings sync",
572
+ open: (signal) => scope.remote.modelSettingsChanges.watch(signal),
573
+ ended: (accepted) => /* @__PURE__ */ new Error(`Model settings sync stream ended ${accepted ? "after" : "before"} ready; refresh or reopen the page`)
574
+ });
575
+ const session = {
576
+ stream,
577
+ active: true
578
+ };
579
+ current = session;
580
+ scope.effect(() => () => {
581
+ session.active = false;
582
+ if (current === session) {
583
+ current = void 0;
584
+ if (life === lifecycle && listeners.size) report("Model settings sync service unavailable");
585
+ }
586
+ return stream.dispose();
587
+ });
588
+ (async () => {
589
+ let generation;
590
+ let ready = false;
591
+ try {
592
+ for await (const item of stream) {
593
+ if (!session.active || life !== lifecycle || !listeners.size) break;
594
+ if (item.signal.aborted) continue;
595
+ if (generation !== item.generation) {
596
+ generation = item.generation;
597
+ ready = false;
598
+ }
599
+ const frame = parseModelSettingsChangeFrame(item.value);
600
+ if (frame.kind === "ready") {
601
+ ready = true;
602
+ failureMessage = void 0;
603
+ item.accept();
604
+ invalidate();
605
+ } else {
606
+ if (!ready) throw new Error("Model settings sync change arrived before ready");
607
+ if (namespace === "*" || frame.ns === namespace) invalidate();
608
+ }
609
+ }
610
+ } catch (error) {
611
+ if (session.active && life === lifecycle) report(error instanceof Error ? error.message : String(error));
612
+ } finally {
613
+ if (current === session) current = void 0;
614
+ session.active = false;
615
+ await stream.dispose().catch(() => {});
616
+ }
617
+ })();
618
+ });
619
+ queueMicrotask(() => {
620
+ if (life === lifecycle && listeners.size && !current) report("Model settings sync service unavailable");
621
+ });
622
+ };
623
+ return Object.freeze({ subscribe(onChange, onError) {
624
+ const listener = {
625
+ change: onChange,
626
+ error: onError
627
+ };
628
+ listeners.add(listener);
629
+ if (!injection) start();
630
+ else if (failureMessage) queueMicrotask(() => {
631
+ if (listeners.has(listener) && failureMessage) try {
632
+ listener.error?.(failureMessage);
633
+ } catch {}
634
+ });
635
+ return () => {
636
+ if (!listeners.delete(listener) || listeners.size) return;
637
+ lifecycle++;
638
+ if (current) {
639
+ current.active = false;
640
+ current.stream.dispose().catch(() => {});
641
+ }
642
+ current = void 0;
643
+ failureMessage = void 0;
644
+ const previous = injection;
645
+ injection = void 0;
646
+ try {
647
+ Promise.resolve(previous?.dispose()).catch(() => {});
648
+ } catch {}
649
+ };
650
+ } });
651
+ }
652
+ //#endregion
497
653
  //#region src/contracts.ts
498
654
  const RPC_CHANNEL = "/dsh-current-title";
499
655
  const TITLE_CLIENT_SERVICE = "dshCurrentTitleClient";
@@ -517,7 +673,11 @@ const titleCopy = {
517
673
  cadenceFirst: "仅首条消息",
518
674
  saved: "已保存。",
519
675
  failed: "无法读取标题设置。",
520
- inactive: "标题服务未接管,新消息暂时不会自动更新。"
676
+ inactive: "标题服务未接管,新消息暂时不会自动更新。",
677
+ retry: "重试",
678
+ externalChange: "设置已在别处修改。提示词草稿已保留,请重新加载后确认。",
679
+ syncUnavailable: "自动刷新不可用,请手动重新加载设置。",
680
+ reload: "重新加载(丢弃草稿)"
521
681
  },
522
682
  en: {
523
683
  label: "Auto Title",
@@ -536,9 +696,33 @@ const titleCopy = {
536
696
  cadenceFirst: "First message only",
537
697
  saved: "Saved.",
538
698
  failed: "Could not read title settings.",
539
- inactive: "Title generation is not active, so new messages will not rename this session."
699
+ inactive: "Title generation is not active, so new messages will not rename this session.",
700
+ retry: "Retry",
701
+ externalChange: "Settings changed elsewhere. Prompt draft preserved; reload and review.",
702
+ syncUnavailable: "Automatic refresh unavailable; reload settings manually.",
703
+ reload: "Reload (discard draft)"
540
704
  }
541
705
  };
706
+ /** Only plugin-owned defaults are translated; model output and remote diagnostics stay intact. */
707
+ const titleMessages = [
708
+ ["标题设置无效。", "Invalid title settings."],
709
+ ["设置已更新,请刷新后重试。", "Settings changed. Refresh and try again."],
710
+ ["无法保存标题设置。", "Could not save title settings."],
711
+ ["缺少会话。", "A session is required."],
712
+ ["找不到该会话。", "Session not found."],
713
+ ["标题服务未就绪。", "Title service is not ready."],
714
+ ["已取消。", "Cancelled."],
715
+ ["标题生成失败。", "Title generation failed."],
716
+ ["未知操作。", "Unknown operation."],
717
+ ["缺少所需的宿主服务。", "required host services are missing"],
718
+ ["原生标题服务位置已被占用。", "native title provider slot is occupied"],
719
+ ["其他标题服务已接管;先前的服务未恢复。", "another title provider owns the slot; previous owner was not restored"],
720
+ [titleCopy.zh.saved, titleCopy.en.saved],
721
+ [titleCopy.zh.failed, titleCopy.en.failed]
722
+ ];
723
+ function titleMessage(message, locale) {
724
+ return titleMessages.find((pair) => pair.includes(message))?.[locale === "zh" ? 0 : 1] ?? message;
725
+ }
542
726
  /** `undefined` draft = not edited; an empty string is a real edit (use built-in instruction). */
543
727
  function promptFieldValue(draft, saved) {
544
728
  return draft ?? saved;
@@ -705,12 +889,21 @@ function TitleSettings(props) {
705
889
  const seat = useNativeSeat(props.client);
706
890
  const sessionId = seat.sessionId;
707
891
  const text = titleCopy[seat.locale];
892
+ const messages = titleCopy.en;
708
893
  const [status, setStatus] = (0, react.useState)();
709
894
  const [busy, setBusy] = (0, react.useState)(false);
710
895
  const [note, setNote] = (0, react.useState)("");
711
896
  const [error, setError] = (0, react.useState)("");
712
897
  const [prompt, setPrompt] = (0, react.useState)(void 0);
713
898
  const [choices, setChoices] = (0, react.useState)([]);
899
+ const [externalChange, setExternalChange] = (0, react.useState)(false);
900
+ const [syncError, setSyncError] = (0, react.useState)("");
901
+ const [loadingSettings, setLoadingSettings] = (0, react.useState)(true);
902
+ const dirty = (0, react.useRef)(false);
903
+ const external = (0, react.useRef)(false);
904
+ const reading = (0, react.useRef)(false);
905
+ const pending = (0, react.useRef)(false);
906
+ const syncRef = (0, react.useRef)(() => {});
714
907
  const workRef = (0, react.useRef)(void 0);
715
908
  if (!workRef.current) workRef.current = createTitleClientWork();
716
909
  const work = workRef.current;
@@ -719,6 +912,11 @@ function TitleSettings(props) {
719
912
  const readGate = readGateRef.current;
720
913
  const busyRef = (0, react.useRef)(busy);
721
914
  busyRef.current = busy;
915
+ const changeBusy = (value) => {
916
+ busyRef.current = value;
917
+ setBusy(value);
918
+ if (!value && pending.current) syncRef.current();
919
+ };
722
920
  const call = (endpoint, payload = {}) => props.client.connection.rpc.call(RPC_CHANNEL, endpoint, payload);
723
921
  const readCall = (endpoint, payload) => readGate.run(() => call(endpoint, payload));
724
922
  function commitMarker(next) {
@@ -731,6 +929,8 @@ function TitleSettings(props) {
731
929
  (0, react.useEffect)(() => props.client.connection.generation?.subscribe(() => {
732
930
  readGate.reset();
733
931
  work.beginLoad();
932
+ reading.current = false;
933
+ setLoadingSettings(false);
734
934
  busyRef.current = false;
735
935
  setBusy(false);
736
936
  }), [
@@ -760,6 +960,9 @@ function TitleSettings(props) {
760
960
  ]);
761
961
  (0, react.useEffect)(() => {
762
962
  const generation = work.beginLoad();
963
+ reading.current = true;
964
+ pending.current = false;
965
+ setLoadingSettings(true);
763
966
  readGate.reset();
764
967
  const isCurrent = () => work.isLoad(generation);
765
968
  setBusy(false);
@@ -769,10 +972,22 @@ function TitleSettings(props) {
769
972
  call: readCall,
770
973
  sessionId: sessionId || void 0,
771
974
  isCurrent,
772
- failedMessage: text.failed,
773
- onStatus: setStatus,
774
- onError: setError,
975
+ failedMessage: messages.failed,
976
+ onStatus: (next) => {
977
+ if (!pending.current) setStatus((current) => current && external.current ? {
978
+ ...next,
979
+ settings: current.settings
980
+ } : next);
981
+ },
982
+ onError: (message) => {
983
+ if (!pending.current) setError(message);
984
+ },
775
985
  onMarker: commitMarker
986
+ }).finally(() => {
987
+ if (!work.isLoad(generation)) return;
988
+ reading.current = false;
989
+ setLoadingSettings(false);
990
+ if (pending.current) syncRef.current();
776
991
  });
777
992
  return () => {
778
993
  if (work.isLoad(generation)) work.beginLoad();
@@ -781,49 +996,95 @@ function TitleSettings(props) {
781
996
  props.client,
782
997
  props.marker,
783
998
  sessionId,
784
- text.failed,
785
999
  work
786
1000
  ]);
787
1001
  const refreshStatus = (0, react.useCallback)(() => {
788
1002
  if (!readGate.canRead() || shouldSkipTitleRefresh({ busy: busyRef.current }) || work.disposed) return;
1003
+ if (reading.current) return;
1004
+ reading.current = true;
1005
+ setLoadingSettings(true);
789
1006
  readGate.reset();
790
1007
  const token = work.beginRequest();
791
1008
  runTitleStatusLoad({
792
1009
  call: readCall,
793
1010
  sessionId: sessionId || void 0,
794
1011
  isCurrent: () => work.isRequest(token) && !shouldSkipTitleRefresh({ busy: busyRef.current }),
795
- failedMessage: text.failed,
1012
+ failedMessage: messages.failed,
796
1013
  onStatus: (next) => {
797
- if (shouldSkipTitleRefresh({ busy: busyRef.current })) return;
798
- setStatus(next);
1014
+ if (pending.current || shouldSkipTitleRefresh({ busy: busyRef.current })) return;
1015
+ setStatus((current) => current && external.current ? {
1016
+ ...next,
1017
+ settings: current.settings
1018
+ } : next);
799
1019
  setError("");
800
1020
  },
801
- onError: setError,
1021
+ onError: (message) => {
1022
+ if (!pending.current) setError(message);
1023
+ },
802
1024
  onMarker: (next) => {
803
1025
  if (!next || shouldSkipTitleRefresh({ busy: busyRef.current })) return;
804
1026
  commitMarker(next);
805
1027
  }
1028
+ }).finally(() => {
1029
+ if (!work.isRequest(token)) return;
1030
+ reading.current = false;
1031
+ setLoadingSettings(false);
1032
+ if (pending.current) syncRef.current();
806
1033
  });
807
1034
  }, [
808
1035
  props.client,
809
1036
  props.marker,
810
1037
  sessionId,
811
- text.failed,
812
1038
  work
813
1039
  ]);
814
1040
  useFeatureRefresh(props.client, sessionId, refreshStatus, status?.session?.generating === true && !busy, Boolean(sessionId));
1041
+ syncRef.current = () => {
1042
+ if (work.disposed) return;
1043
+ if (reading.current || busyRef.current) {
1044
+ pending.current = true;
1045
+ return;
1046
+ }
1047
+ pending.current = false;
1048
+ if (dirty.current) {
1049
+ external.current = true;
1050
+ setExternalChange(true);
1051
+ return;
1052
+ }
1053
+ readGate.reset();
1054
+ refreshStatus();
1055
+ };
1056
+ (0, react.useEffect)(() => {
1057
+ setSyncError("");
1058
+ let live = true;
1059
+ const stop = props.subscribeSettings?.(() => {
1060
+ if (!live) return;
1061
+ setSyncError("");
1062
+ if (dirty.current && !busyRef.current) {
1063
+ external.current = true;
1064
+ setExternalChange(true);
1065
+ }
1066
+ syncRef.current();
1067
+ }, (message) => {
1068
+ if (live) setSyncError(message);
1069
+ });
1070
+ return () => {
1071
+ live = false;
1072
+ stop?.();
1073
+ };
1074
+ }, [props.subscribeSettings, props.client]);
815
1075
  const savedPrompt = status?.settings.prompt ?? "";
816
1076
  const savedModel = status?.settings.model ?? {
817
1077
  provider: "",
818
1078
  model: ""
819
1079
  };
1080
+ const selectableChoices = choices.some((choice) => choice.provider === savedModel.provider && choice.model === savedModel.model) ? choices : [...choices, ...catalogChoices({}, savedModel)];
820
1081
  const modelKey = savedModel.provider && savedModel.model ? `${savedModel.provider}\u001f${savedModel.model}` : "";
821
1082
  const title = status?.session?.title;
822
1083
  const pinned = status?.session?.pinned === true;
823
1084
  const weOwn = status?.support.weOwn === true;
824
- const canRegenerate = Boolean(sessionId) && weOwn && !busy;
1085
+ const canRegenerate = Boolean(sessionId) && weOwn && !busy && !loadingSettings;
825
1086
  function saveSettings(patch) {
826
- if (!status) return;
1087
+ if (!status || busyRef.current || reading.current || external.current) return;
827
1088
  readGate.reset();
828
1089
  const token = work.beginRequest();
829
1090
  setError("");
@@ -833,9 +1094,9 @@ function TitleSettings(props) {
833
1094
  ...patch,
834
1095
  expectedRevision: status.settings.revision,
835
1096
  isCurrent: () => work.isRequest(token),
836
- savedMessage: text.saved,
837
- failedMessage: text.failed,
838
- onBusy: setBusy,
1097
+ savedMessage: messages.saved,
1098
+ failedMessage: messages.failed,
1099
+ onBusy: changeBusy,
839
1100
  onSettings: (settings) => setStatus((current) => current ? {
840
1101
  ...current,
841
1102
  settings
@@ -846,6 +1107,7 @@ function TitleSettings(props) {
846
1107
  }
847
1108
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
848
1109
  className: "current-title-settings ct-root dsh-ui-panel",
1110
+ lang: seat.locale,
849
1111
  "data-testid": "current-title-settings",
850
1112
  children: [
851
1113
  !status && !error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -870,12 +1132,12 @@ function TitleSettings(props) {
870
1132
  status && !weOwn ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
871
1133
  role: "status",
872
1134
  className: "dsh-ui-hint",
873
- children: [text.inactive, status.support.limitation ? ` ${status.support.limitation}` : ""]
1135
+ children: [text.inactive, status.support.limitation ? ` ${titleMessage(status.support.limitation, seat.locale)}` : ""]
874
1136
  }) : null,
875
1137
  error ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
876
1138
  role: "alert",
877
1139
  className: "dsh-ui-error dsh-ui-wrap",
878
- children: error
1140
+ children: titleMessage(error, seat.locale)
879
1141
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
880
1142
  variant: "outline",
881
1143
  size: "sm",
@@ -884,16 +1146,39 @@ function TitleSettings(props) {
884
1146
  readGate.reset();
885
1147
  refreshStatus();
886
1148
  },
887
- children: seat.locale === "zh" ? "重试" : "Retry"
1149
+ children: text.retry
888
1150
  })] }) : null,
889
1151
  note ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
890
1152
  role: "status",
891
1153
  className: "dsh-ui-notice",
892
- children: note
1154
+ children: titleMessage(note, seat.locale)
1155
+ }) : null,
1156
+ externalChange ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1157
+ role: "alert",
1158
+ className: "dsh-ui-error",
1159
+ children: text.externalChange
1160
+ }) : null,
1161
+ syncError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1162
+ role: "status",
1163
+ className: "dsh-ui-hint",
1164
+ children: text.syncUnavailable
893
1165
  }) : null,
894
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1166
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
895
1167
  className: "dsh-ui-actions",
896
- children: sessionId ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1168
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1169
+ variant: "ghost",
1170
+ size: "sm",
1171
+ disabled: busy || loadingSettings,
1172
+ onClick: () => {
1173
+ setPrompt(void 0);
1174
+ dirty.current = false;
1175
+ external.current = false;
1176
+ setExternalChange(false);
1177
+ readGate.reset();
1178
+ syncRef.current();
1179
+ },
1180
+ children: text.reload
1181
+ }), sessionId ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
897
1182
  variant: "outline",
898
1183
  size: "md",
899
1184
  disabled: !canRegenerate,
@@ -907,15 +1192,15 @@ function TitleSettings(props) {
907
1192
  call,
908
1193
  sessionId,
909
1194
  isCurrent: () => work.isRequest(token),
910
- failedMessage: text.failed,
911
- onBusy: setBusy,
1195
+ failedMessage: messages.failed,
1196
+ onBusy: changeBusy,
912
1197
  onStatus: setStatus,
913
1198
  onError: setError,
914
1199
  onMarker: commitMarker
915
1200
  });
916
1201
  },
917
1202
  children: text.regenerate
918
- }) : null
1203
+ }) : null]
919
1204
  }),
920
1205
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
921
1206
  className: "dsh-ui-field",
@@ -929,15 +1214,17 @@ function TitleSettings(props) {
929
1214
  value: status ? promptFieldValue(prompt, savedPrompt) : "",
930
1215
  maxLength: 4e3,
931
1216
  rows: 5,
932
- disabled: busy || !status,
1217
+ disabled: busy || loadingSettings || !status,
933
1218
  onChange: (event) => {
1219
+ dirty.current = true;
934
1220
  setPrompt(event.target.value);
935
1221
  setNote("");
936
1222
  },
937
1223
  onBlur: () => {
938
- if (!status) return;
1224
+ if (!status || busyRef.current || reading.current || external.current) return;
939
1225
  const next = promptToSave(prompt, savedPrompt);
940
1226
  if (next === void 0) {
1227
+ dirty.current = false;
941
1228
  setPrompt(void 0);
942
1229
  return;
943
1230
  }
@@ -949,14 +1236,15 @@ function TitleSettings(props) {
949
1236
  prompt: next,
950
1237
  expectedRevision: status.settings.revision,
951
1238
  isCurrent: () => work.isRequest(token),
952
- savedMessage: text.saved,
953
- failedMessage: text.failed,
954
- onBusy: setBusy,
1239
+ savedMessage: messages.saved,
1240
+ failedMessage: messages.failed,
1241
+ onBusy: changeBusy,
955
1242
  onSettings: (settings) => {
956
1243
  setStatus((current) => current ? {
957
1244
  ...current,
958
1245
  settings
959
1246
  } : current);
1247
+ dirty.current = false;
960
1248
  setPrompt(void 0);
961
1249
  },
962
1250
  onNote: setNote,
@@ -978,10 +1266,10 @@ function TitleSettings(props) {
978
1266
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
979
1267
  className: "dsh-ui-select",
980
1268
  value: modelKey,
981
- disabled: busy || !status,
1269
+ disabled: busy || loadingSettings || !status,
982
1270
  onChange: (event) => {
983
1271
  const value = event.target.value;
984
- const choice = choices.find((item) => `${item.provider}\u001f${item.model}` === value);
1272
+ const choice = selectableChoices.find((item) => `${item.provider}\u001f${item.model}` === value);
985
1273
  saveSettings({ model: choice ? {
986
1274
  provider: choice.provider,
987
1275
  model: choice.model
@@ -993,7 +1281,7 @@ function TitleSettings(props) {
993
1281
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
994
1282
  value: "",
995
1283
  children: text.modelDefault
996
- }), choices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1284
+ }), selectableChoices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
997
1285
  value: `${choice.provider}\u001f${choice.model}`,
998
1286
  children: choice.label
999
1287
  }, `${choice.provider}\u001f${choice.model}`))]
@@ -1007,7 +1295,7 @@ function TitleSettings(props) {
1007
1295
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1008
1296
  className: "dsh-ui-select",
1009
1297
  value: status?.settings.cadence ?? "all-prompts",
1010
- disabled: busy || !status,
1298
+ disabled: busy || loadingSettings || !status,
1011
1299
  onChange: (event) => {
1012
1300
  saveSettings({ cadence: event.target.value === "first-prompt" ? "first-prompt" : "all-prompts" });
1013
1301
  },
@@ -1045,6 +1333,8 @@ const styles = `${officialUiCss("ct-root")}
1045
1333
  `;
1046
1334
  function apply(ctx) {
1047
1335
  const client = ctx;
1336
+ const settingsSubscriber = createModelSettingsSubscriber(ctx, "@klarkxy/dsh-current-title");
1337
+ const subscribeSettings = (change, error) => settingsSubscriber.subscribe(change, error);
1048
1338
  const marker = createTitleClientMarker();
1049
1339
  ctx.provide(TITLE_CLIENT_SERVICE, marker);
1050
1340
  ctx.effect(() => {
@@ -1060,7 +1350,8 @@ function apply(ctx) {
1060
1350
  key: "@klarkxy/dsh-current-title"
1061
1351
  }, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TitleSettings, {
1062
1352
  client,
1063
- marker
1353
+ marker,
1354
+ subscribeSettings
1064
1355
  }))), "current-title.settings");
1065
1356
  ctx.effect(() => {
1066
1357
  let live = true;
@@ -1,3 +1,4 @@
1
+ import { ModelRoute, defaultModelRoute, normalizeModelRoute } from "@klarkxy/dsh-model-route";
1
2
  import { RpcResult } from "@klarkxy/dsh-plugin-kit/contracts";
2
3
  //#region src/contracts.d.ts
3
4
  declare const PLUGIN_NAME = "@klarkxy/dsh-current-title";
@@ -36,12 +37,12 @@ interface TitleSnapshot {
36
37
  readonly eventSeq: number;
37
38
  readonly updatedAt: number;
38
39
  }
39
- /** Empty provider and model means the shared weak role. Both must be set together. */
40
- interface TitleModelRoute {
41
- provider: string;
42
- model: string;
43
- reasoningEffort?: string;
44
- }
40
+ /**
41
+ * Empty provider and model means the shared weak role. Both must be set together.
42
+ * No title-only fields: the stored value is the shared ModelRoute (250/80 caps
43
+ * live in @klarkxy/dsh-model-route). The alias keeps the title-specific name.
44
+ */
45
+ type TitleModelRoute = ModelRoute;
45
46
  interface TitleSettings {
46
47
  revision: number;
47
48
  locale: TitleLocaleMode;
@@ -79,11 +80,9 @@ interface GenerateConfig {
79
80
  readonly timeoutMs: number;
80
81
  readonly maxTitleBytes: number;
81
82
  }
82
- declare function defaultModelRoute(): TitleModelRoute;
83
83
  declare function defaultSettings(locale?: TitleLocaleMode): TitleSettings;
84
84
  declare function normalizeCadence(value: unknown): TitleCadence;
85
85
  declare function normalizePrompt(value: unknown): string;
86
- declare function normalizeModelRoute(value: unknown): TitleModelRoute;
87
86
  declare function defaultGenerateConfig(locale?: TitleLocaleMode): GenerateConfig;
88
87
  declare function isTitleLocaleMode(value: unknown): value is TitleLocaleMode;
89
88
  //#endregion
package/lib/contracts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { defaultModelRoute, normalizeModelRoute } from "@klarkxy/dsh-model-route";
1
2
  //#region src/contracts.ts
2
3
  const PLUGIN_NAME = "@klarkxy/dsh-current-title";
3
4
  const FEATURE_ID = "current-title";
@@ -17,12 +18,6 @@ const DEFAULT_MAX_INPUT_BYTES = 4096;
17
18
  const DEFAULT_MAX_OUTPUT_TOKENS = 256;
18
19
  const DEFAULT_TIMEOUT_MS = 6e4;
19
20
  const DEFAULT_MAX_TITLE_BYTES = 80;
20
- function defaultModelRoute() {
21
- return {
22
- provider: "",
23
- model: ""
24
- };
25
- }
26
21
  function defaultSettings(locale = "auto") {
27
22
  return {
28
23
  revision: 0,
@@ -39,22 +34,6 @@ function normalizePrompt(value) {
39
34
  if (typeof value !== "string") return "";
40
35
  return value.replace(/\u0000/gu, "").trim().slice(0, MAX_PROMPT_CHARS);
41
36
  }
42
- function normalizeModelRoute(value) {
43
- if (!value || typeof value !== "object" || Array.isArray(value)) return defaultModelRoute();
44
- const route = value;
45
- const provider = typeof route.provider === "string" ? route.provider.trim() : "";
46
- const model = typeof route.model === "string" ? route.model.trim() : "";
47
- const effort = typeof route.reasoningEffort === "string" ? route.reasoningEffort.trim() : "";
48
- if (!provider || !model || provider.length > 250 || model.length > 250) return defaultModelRoute();
49
- return effort && effort.length <= 80 ? {
50
- provider,
51
- model,
52
- reasoningEffort: effort
53
- } : {
54
- provider,
55
- model
56
- };
57
- }
58
37
  function defaultGenerateConfig(locale = "auto") {
59
38
  return {
60
39
  maxRecentMessages: 8,