dsh-style-tweaks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +114 -0
  3. package/README.md +113 -0
  4. package/cordis.patch.yml +12 -0
  5. package/lib/client.js +2379 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/config.js +90 -0
  8. package/lib/config.js.map +1 -0
  9. package/lib/index.js +28 -0
  10. package/lib/index.js.map +1 -0
  11. package/lib/types/client/conversation-width.d.ts +81 -0
  12. package/lib/types/client/conversation-width.d.ts.map +1 -0
  13. package/lib/types/client/index.d.ts +104 -0
  14. package/lib/types/client/index.d.ts.map +1 -0
  15. package/lib/types/client/tweak-config.d.ts +55 -0
  16. package/lib/types/client/tweak-config.d.ts.map +1 -0
  17. package/lib/types/client/tweak-types.d.ts +32 -0
  18. package/lib/types/client/tweak-types.d.ts.map +1 -0
  19. package/lib/types/client/tweaks/code-block-flush-top.d.ts +91 -0
  20. package/lib/types/client/tweaks/code-block-flush-top.d.ts.map +1 -0
  21. package/lib/types/client/tweaks/locate-current-session.d.ts +62 -0
  22. package/lib/types/client/tweaks/locate-current-session.d.ts.map +1 -0
  23. package/lib/types/client/tweaks/project-running-indicator.d.ts +61 -0
  24. package/lib/types/client/tweaks/project-running-indicator.d.ts.map +1 -0
  25. package/lib/types/client/tweaks/registry.d.ts +22 -0
  26. package/lib/types/client/tweaks/registry.d.ts.map +1 -0
  27. package/lib/types/client/tweaks/settings-nav-scroll.d.ts +168 -0
  28. package/lib/types/client/tweaks/settings-nav-scroll.d.ts.map +1 -0
  29. package/lib/types/client/tweaks/stable-table.d.ts +3 -0
  30. package/lib/types/client/tweaks/stable-table.d.ts.map +1 -0
  31. package/lib/types/client/tweaks/stable-turn-rail.d.ts +53 -0
  32. package/lib/types/client/tweaks/stable-turn-rail.d.ts.map +1 -0
  33. package/lib/types/config.d.ts +149 -0
  34. package/lib/types/config.d.ts.map +1 -0
  35. package/lib/types/index.d.ts +19 -0
  36. package/lib/types/index.d.ts.map +1 -0
  37. package/lib/types/web.d.ts +55 -0
  38. package/lib/types/web.d.ts.map +1 -0
  39. package/lib/web.js +169 -0
  40. package/lib/web.js.map +1 -0
  41. package/package.json +111 -0
package/lib/client.js ADDED
@@ -0,0 +1,2379 @@
1
+ window.__ModuleLoader__.load({ id: "dsh-style-tweaks", factory: (require) => {
2
+ var __modules = Object.create(null); var __cache = Object.create(null);
3
+ __modules["./conversation-width.js"] = function(module, exports, require, __load_) {
4
+ "use strict";
5
+ /**
6
+ * dsh-style-tweaks — conversation-width override (browser half).
7
+ *
8
+ * When the user turns "plugin width control" on, this module:
9
+ *
10
+ * 1. Hides DSH's native 40 px hover drag handles. The handles live in a
11
+ * CSS-Modules file (`ConversationRoot.module.css`) whose class names are
12
+ * build-time-hashed, so a global stylesheet cannot target them by class —
13
+ * it MUST use the stable `data-width-handle` attribute selector.
14
+ *
15
+ * 2. Drives DSH's own shared width axis instead of forking it. The
16
+ * conversation root declares the single source of truth
17
+ * (`ConversationRoot.module.css`, `.root`):
18
+ *
19
+ * --dsh-chat-content-width: var(--dsh-chat-user-width, clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px));
20
+ * --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
21
+ *
22
+ * Every consumer — the transcript column (`data-chat-flow`), message
23
+ * bubbles, wide-table bleed, the back-to-bottom control, the composer
24
+ * card, and the dock/stats line — reads those two variables, so forcing
25
+ * `--dsh-chat-user-width` keeps the input card and the conversation area
26
+ * in lockstep by construction. The plugin never sets max-widths itself.
27
+ *
28
+ * 3. Reproduces DSH's narrow-viewport clamp in CSS. Natively
29
+ * (`ConversationRoot.tsx`, `resolveContentWidth`) a stored preference is
30
+ * re-clamped against the live column width — published as
31
+ * `--dsh-conversation-column-width` by a ResizeObserver — so opening the
32
+ * sidebar narrows the content instead of letting it overflow. This module
33
+ * declares the same clamp declaratively on the conversation root:
34
+ *
35
+ * --dsh-chat-user-width: min(<width>px, max(640px, calc(var(--dsh-conversation-column-width, 99999px) - 2 * <sideMargin>px)));
36
+ *
37
+ * i.e. the column never claims more than the dialog width, and never
38
+ * leaves less than `sideMargin` of whitespace on either side; 640px is
39
+ * DSH's own content floor (CONTENT_MIN). The var() substitution happens
40
+ * on the conversation root — the same element DSH writes the live column
41
+ * width on — so the clamp tracks sidebar toggles and window resizes with
42
+ * zero JS. The declaration is `!important` so it also beats DSH's inline
43
+ * re-publication of `--dsh-chat-user-width` on the same element.
44
+ *
45
+ * 4. Mirrors the chosen px into the shared localStorage slot
46
+ * (`dsh.conversation.contentWidth`) so toggling plugin-width OFF surfaces
47
+ * the user's last choice in the native drag handles — the switch is
48
+ * value-preserving in both directions.
49
+ *
50
+ * When the user turns the feature OFF, the installer disposes: the handle
51
+ * rule and the width-axis override are removed and the native handles take
52
+ * over from whatever px the user last picked.
53
+ *
54
+ * The conversation root cannot be targeted by a stable attribute of its own
55
+ * (it only carries `data-phase`), so the override anchors on the stable
56
+ * `data-conversation-scroll` descendant: `div:has([data-conversation-scroll])`
57
+ * matches exactly the ancestors of the scroll body, and the innermost
58
+ * declaration that matters — the conversation root — is where DSH publishes
59
+ * the live column width.
60
+ *
61
+ * ## Live-update contract
62
+ *
63
+ * `installConversationWidthStyles` returns a controller. `setWidth` mutates
64
+ * the same `<style>` element in place (no reinstall, no flicker) and is what
65
+ * the live settings subscription in `index.tsx` calls on every change. The
66
+ * `apply` effect MUST keep the same controller alive across settings
67
+ * changes — tearing it down and reinstalling on every tick would race with
68
+ * the user's viewport-driven sidebar drag and miss the very first frame.
69
+ * @module dsh-style-tweaks/client/conversation-width
70
+ */
71
+ Object.defineProperty(exports, "__esModule", { value: true });
72
+ exports.installConversationWidthStyles = installConversationWidthStyles;
73
+ const tweak_config_ts_1 = __load_("./tweak-config.js");
74
+ /** Stable attribute selector for the native WidthHandle strips (CSS-Modules-hashed class). */
75
+ const HANDLE_HIDE_RULE = '[data-width-handle]{display:none !important}';
76
+ /** DSH's own content floor (`ConversationRoot.tsx` CONTENT_MIN). */
77
+ const CONTENT_MIN_PX = 640;
78
+ /**
79
+ * CSS injected into <head> while plugin-width is on:
80
+ * - hide the native handles (hashed class → attribute selector)
81
+ * - clamp DSH's user-width preference to the plugin's dialog width, keeping
82
+ * at least `sideMargin` of whitespace per side as the live column narrows
83
+ * - mirror the raw px on :root (`--dsh-dialog-width-chat-width`) for any
84
+ * consumer that reads the legacy var from the document root.
85
+ */
86
+ function buildWidthCss(widthPx, sideMargin) {
87
+ return `
88
+ ${HANDLE_HIDE_RULE}
89
+ div:has([data-conversation-scroll]){--dsh-chat-user-width:min(${widthPx}px, max(${CONTENT_MIN_PX}px, calc(var(--dsh-conversation-column-width, 99999px) - ${sideMargin * 2}px))) !important}
90
+ :root{--dsh-dialog-width-chat-width:${widthPx}px}
91
+ `;
92
+ }
93
+ /**
94
+ * Install the width-override stylesheet. Idempotent: a second call with the
95
+ * same id returns the existing controller.
96
+ * @param widthPx - the plugin's chosen column width in px.
97
+ * @param sideMargin - whitespace in px to keep on each side of the column.
98
+ * @returns a controller exposing `setWidth` + `dispose`.
99
+ */
100
+ function installConversationWidthStyles(widthPx, sideMargin) {
101
+ const id = 'dsh-style-tweaks-conversation-width';
102
+ let style = document.querySelector(`style[data-plugin-css="${id}"]`);
103
+ if (style === null) {
104
+ style = document.createElement('style');
105
+ style.dataset.plugin = 'dsh-style-tweaks';
106
+ style.dataset.pluginCss = id;
107
+ document.head.appendChild(style);
108
+ }
109
+ // Persist to the shared storage slot so toggling plugin-width off surfaces
110
+ // the user's last choice in the native drag handles.
111
+ try {
112
+ localStorage.setItem(tweak_config_ts_1.CONVERSATION_WIDTH_STORAGE_KEY, `${widthPx}`);
113
+ }
114
+ catch { /* storage may be disabled; ignore */ }
115
+ // Track the latest applied width so dispose() can seed the conversation
116
+ // root with the value the user most recently picked, not the value at
117
+ // install time (setWidth mutates this; the constructor's widthPx would
118
+ // otherwise be stale across stepper clicks).
119
+ let currentWidth = widthPx;
120
+ const apply = (px, margin) => {
121
+ style.textContent = buildWidthCss(px, margin);
122
+ // Belt-and-suspenders: also set the var directly on :root in case any
123
+ // consumer reads the legacy var via JS rather than via CSS cascade.
124
+ document.documentElement.style.setProperty('--dsh-dialog-width-chat-width', `${px}px`);
125
+ };
126
+ apply(widthPx, sideMargin);
127
+ return {
128
+ setWidth: (px, margin) => {
129
+ currentWidth = px;
130
+ apply(px, margin);
131
+ try {
132
+ localStorage.setItem(tweak_config_ts_1.CONVERSATION_WIDTH_STORAGE_KEY, `${px}`);
133
+ }
134
+ catch { /* ignore */ }
135
+ },
136
+ dispose: () => {
137
+ // Before removing the !important clamp rule, seed --dsh-chat-user-width
138
+ // directly on the conversation root so the column keeps the user's
139
+ // chosen width instead of falling back to DSH's adaptive clamp
140
+ // (resolveContentWidth would otherwise shrink it to fit the live
141
+ // column, which is wrong when the user explicitly chose a wider value
142
+ // and just wants to hand control back to the native drag handles).
143
+ // The root is identified by [data-phase] (ConversationRoot.tsx) and is
144
+ // the same element DSH writes the var on in publishWidths().
145
+ const root = document.querySelector('[data-conversation-scroll]')?.closest('[data-phase]') ?? null;
146
+ if (root !== null) {
147
+ root.style.setProperty('--dsh-chat-user-width', `${currentWidth}px`);
148
+ }
149
+ style?.remove();
150
+ document.documentElement.style.removeProperty('--dsh-dialog-width-chat-width');
151
+ },
152
+ };
153
+ }
154
+ };
155
+ __modules["./index.js"] = function(module, exports, require, __load_) {
156
+ "use strict";
157
+ Object.defineProperty(exports, "__esModule", { value: true });
158
+ exports.inject = exports.SettingsClient = void 0;
159
+ exports.apply = apply;
160
+ const jsx_runtime_1 = require("react/jsx-runtime");
161
+ /**
162
+ * dsh-style-tweaks — browser half.
163
+ *
164
+ * Owns the runtime CSS that drives two feature areas under one Settings
165
+ * panel:
166
+ * 1. Column-width control (ported from dsh-dialog-width): px stepper,
167
+ * presets, plugin-vs-native toggle, side margin.
168
+ * 2. Opt-in CSS tweaks: stable-table layout on hover, with more added
169
+ * over time. Each tweak is a boolean field.
170
+ *
171
+ * Reads and writes the `style-tweaks` settings namespace via
172
+ * the same-origin route served by the server half.
173
+ */
174
+ const react_1 = require("react");
175
+ const react_dom_1 = require("react-dom");
176
+ const conversation_width_ts_1 = __load_("./conversation-width.js");
177
+ const tweak_config_ts_1 = __load_("./tweak-config.js");
178
+ const registry_ts_1 = __load_("./tweaks/registry.js");
179
+ const stable_table_ts_1 = __load_("./tweaks/stable-table.js");
180
+ const stable_turn_rail_ts_1 = __load_("./tweaks/stable-turn-rail.js");
181
+ const code_block_flush_top_ts_1 = __load_("./tweaks/code-block-flush-top.js");
182
+ const project_running_indicator_ts_1 = __load_("./tweaks/project-running-indicator.js");
183
+ const locate_current_session_ts_1 = __load_("./tweaks/locate-current-session.js");
184
+ const settings_nav_scroll_ts_1 = __load_("./tweaks/settings-nav-scroll.js");
185
+ const NS = 'style-tweaks';
186
+ const SETTINGS_ROUTE = '/_dsh/style-tweaks/settings';
187
+ /**
188
+ * Maps a tweak id to its mount function. Add new tweaks here. Pure-CSS
189
+ * injectors ignore the context; JS-level tweaks (DOM patching driven by app
190
+ * stores) receive it to read services like `sessions` / `workspaces`.
191
+ */
192
+ const TWEAK_INJECTORS = {
193
+ 'stable-table': () => (0, stable_table_ts_1.injectStableTableStyles)(),
194
+ 'stable-turn-rail': () => (0, stable_turn_rail_ts_1.injectStableTurnRailStyles)(),
195
+ 'code-block-flush-top': () => (0, code_block_flush_top_ts_1.injectCodeBlockFlushTopStyles)(),
196
+ 'project-running-indicator': project_running_indicator_ts_1.setupProjectRunningIndicator,
197
+ 'locate-current-session': locate_current_session_ts_1.setupLocateCurrentSession,
198
+ 'settings-nav-scroll': settings_nav_scroll_ts_1.setupSettingsNavScroll,
199
+ };
200
+ const en = {
201
+ nav: 'Style tweaks',
202
+ settingsTitle: 'Style tweaks',
203
+ settingsIntro: 'Opt-in style tweaks for DSH: precise conversation column-width control (with presets and side margin) plus a set of small fixes for the sidebar and the settings panel. Each toggle applies immediately and persists to your settings document.',
204
+ sectionLayout: 'Layout',
205
+ sectionTweaks: 'Tweaks',
206
+ dialogWidth: 'Dialog width',
207
+ dialogWidthHint: 'Number between 600 and 1600 px; 748 is DSH\'s default column width, larger values widen it.',
208
+ presetDefault: 'Default',
209
+ presetWide: 'Wide',
210
+ presetWideXl: 'Extra wide',
211
+ usePluginWidth: 'Plugin width control',
212
+ usePluginWidthHint: 'When ON, the width input / presets above drive the column and DSH\'s native drag handles are hidden. When OFF, DSH\'s native handles own the column; the width input mirrors their value.',
213
+ usePluginWidthOn: 'On',
214
+ usePluginWidthOff: 'Off',
215
+ sideMargin: 'Side margin',
216
+ sideMarginHint: 'Whitespace in px kept on each side of the conversation area. The column is clamped to the dialog width and narrows when the sidebar opens or the window shrinks, never hugging the edges. Minimum 32 px.',
217
+ defaultAction: 'Default',
218
+ applied: 'Applied',
219
+ unavailable: 'Settings unavailable.',
220
+ loading: 'Loading…',
221
+ readOnly: 'The active Settings provider is read-only.',
222
+ tweakOn: 'On',
223
+ tweakOff: 'Off',
224
+ 'tweak.stableTable.title': 'Stable table layout',
225
+ 'tweak.stableTable.description': 'Lock table layout on hover so surrounding content does not reflow ("text jumps when I hover a table").',
226
+ 'tweak.stableTurnRail.title': 'Stable turn-navigation rail',
227
+ 'tweak.stableTurnRail.description': 'Keep the turn-navigation rail at a stable position when scrolling up past the first message into the system prompt ("the rail jumps down by ~16 px when I scroll up after clicking the first turn").',
228
+ 'tweak.codeBlockFlushTop.title': 'Flush code-block top',
229
+ 'tweak.codeBlockFlushTop.description': 'Remove the 16 px gap above highlighted code blocks so the code sits flush with the preceding paragraph, list item, or heading.',
230
+ 'tweak.projectRunningIndicator.title': 'Project running indicator',
231
+ 'tweak.projectRunningIndicator.description': 'Show the conversation title\'s animated running dot on the right side of each project directory in the sidebar, so a running conversation stays visible even when the directory is collapsed.',
232
+ 'tweak.locateCurrentSession.title': 'Locate current session',
233
+ 'tweak.locateCurrentSession.description': 'Add a "locate" button next to the sidebar search box. Click it to expand the current session\'s workspace and scroll the session into view.',
234
+ 'tweak.settingsNavScroll.title': 'Scrollable settings nav',
235
+ 'tweak.settingsNavScroll.description': 'Let the settings dialog\'s left menu scroll when its entries outgrow the panel, instead of silently clipping the ones at the bottom.',
236
+ };
237
+ const zh = {
238
+ nav: '样式调整',
239
+ settingsTitle: '样式调整',
240
+ settingsIntro: 'DSH 界面的可选样式调整:对话列宽精确控制(含预设与两侧边距),以及侧边栏与设置面板的一组小幅修复。每个开关立即生效并持久化到设置文档。',
241
+ sectionLayout: '布局',
242
+ sectionTweaks: '调整项',
243
+ dialogWidth: '对话框宽度',
244
+ dialogWidthHint: '取值 600–1600 px;748 为 DSH 默认列宽,数字越大越宽。',
245
+ presetDefault: '默认',
246
+ presetWide: '稍宽',
247
+ presetWideXl: '更宽',
248
+ usePluginWidth: '插件宽度控制',
249
+ usePluginWidthHint: '开启时,上方宽度输入 / 预设驱动列宽,并隐藏 DSH 原生的拖拽手柄;关闭时,DSH 原生手柄接管列宽,宽度输入同步显示当前值。',
250
+ usePluginWidthOn: '开启',
251
+ usePluginWidthOff: '关闭',
252
+ sideMargin: '两侧边距',
253
+ sideMarginHint: '对话区域两侧保留的空白(px)。列宽被钳制为对话框宽度,侧边栏打开或窗口缩小时内容会收窄,不会贴住边缘。最低 32 px。',
254
+ defaultAction: '默认',
255
+ applied: '已应用',
256
+ unavailable: '设置暂不可用。',
257
+ loading: '加载中…',
258
+ readOnly: '当前设置提供方为只读。',
259
+ tweakOn: '开启',
260
+ tweakOff: '关闭',
261
+ 'tweak.stableTable.title': '表格布局稳定',
262
+ 'tweak.stableTable.description': '锁住表格 hover 时的布局,避免周围内容发生回流("鼠标移到表格上时下方文本会跳动")。',
263
+ 'tweak.stableTurnRail.title': '轮次导航栏稳定',
264
+ 'tweak.stableTurnRail.description': '向上滚动到第一条消息上方的系统提示词区域时,让右侧轮次导航栏保持在原位(不再下移约 16 像素)。',
265
+ 'tweak.codeBlockFlushTop.title': '代码块顶部贴齐',
266
+ 'tweak.codeBlockFlushTop.description': '去掉高亮代码块上方的 16 px 空白,让代码块紧贴在前面的段落、列表项或标题下方。',
267
+ 'tweak.projectRunningIndicator.title': '项目目录运行指示',
268
+ 'tweak.projectRunningIndicator.description': '在侧边栏项目目录右侧显示与对话标题一致的运行动画圆点,目录收起时也能一眼看出里面有对话正在进行。',
269
+ 'tweak.locateCurrentSession.title': '定位当前会话',
270
+ 'tweak.locateCurrentSession.description': '在侧边栏搜索框旁边添加一个"定位"按钮。点击后展开当前会话所属的工作区目录,并将该会话滚动到侧边栏视口内。',
271
+ 'tweak.settingsNavScroll.title': '设置菜单可滚动',
272
+ 'tweak.settingsNavScroll.description': '设置项较多时,让设置面板左侧菜单可以上下滚动,而不是把放不下的项直接裁掉。',
273
+ };
274
+ function resolveValue(value) {
275
+ return {
276
+ dialogWidth: (0, tweak_config_ts_1.resolveDialogWidth)(value?.dialogWidth),
277
+ usePluginWidth: value?.usePluginWidth ?? true,
278
+ sideMargin: (0, tweak_config_ts_1.resolveSideMargin)(value?.sideMargin),
279
+ stableTable: value?.stableTable ?? true,
280
+ stableTurnRail: value?.stableTurnRail ?? true,
281
+ codeBlockFlushTop: value?.codeBlockFlushTop ?? true,
282
+ projectRunningIndicator: value?.projectRunningIndicator ?? true,
283
+ locateCurrentSession: value?.locateCurrentSession ?? true,
284
+ settingsNavScroll: value?.settingsNavScroll ?? true,
285
+ };
286
+ }
287
+ const BASE_CSS = `
288
+ .cst-settings{display:grid;gap:8px;max-width:680px;padding:4px 2px 24px;color:var(--dsw-alias-label-primary)}
289
+ .cst-settings-header{display:flex;align-items:flex-start;gap:10px;padding:2px 2px 0}
290
+ .cst-logo{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;border:1px solid var(--dsw-alias-border-l1);background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-state-business-primary) 16%,transparent),transparent);font-size:15px;line-height:1}
291
+ .cst-settings-header h2{font-size:16px;letter-spacing:-.01em;margin:0 0 2px}
292
+ .cst-settings-header p{max-width:600px;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.45}
293
+ .cst-panel{display:grid;gap:0;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv1);overflow:hidden}
294
+ .cst-panel+.cst-panel{margin-top:10px}
295
+ .cst-section-label{font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);padding:9px 16px 4px}
296
+ .cst-field{display:grid;gap:6px;padding:7px 16px 10px}
297
+ .cst-field+.cst-field{border-top:1px solid var(--dsw-alias-border-l1)}
298
+ .cst-field-top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
299
+ .cst-field-top>span{font-size:13.5px;font-weight:600}
300
+ .cst-label{display:inline-flex;align-items:center;gap:6px}
301
+ .cst-hint{flex:none;display:inline-grid;place-items:center;width:15px;height:15px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:9.5px;font-weight:600;font-style:normal;line-height:1;cursor:help;user-select:none;transition:color .15s ease,border-color .15s ease}
302
+ .cst-hint:hover,.cst-hint:focus-visible{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
303
+ .cst-hint-pop{position:fixed;z-index:9999;width:max-content;max-width:300px;padding:8px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:11.5px;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.14);pointer-events:none}
304
+ .cst-controls{display:flex;align-items:center;gap:8px}
305
+ .cst-stepper{display:inline-flex;align-items:center;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
306
+ .cst-stepper button{width:28px;height:28px;border:none;background:transparent;color:inherit;font-size:15px;font-weight:500;line-height:1;cursor:pointer;display:grid;place-items:center;transition:background .15s ease}
307
+ .cst-stepper button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
308
+ .cst-stepper button:disabled{opacity:.35;cursor:default}
309
+ .cst-stepper input{box-sizing:border-box;width:60px;height:28px;border:none;border-left:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);background:transparent;color:inherit;font:inherit;font-size:13px;text-align:center;-moz-appearance:textfield}
310
+ .cst-stepper input::-webkit-outer-spin-button,.cst-stepper input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
311
+ .cst-stepper input:focus{outline:none}
312
+ .cst-seg{display:inline-flex;padding:3px;gap:3px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
313
+ .cst-seg button{border:none;border-radius:7px;padding:5px 12px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;cursor:pointer;transition:background .15s ease,color .15s ease}
314
+ .cst-seg button:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
315
+ .cst-seg button.cst-seg-active{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary);font-weight:600;box-shadow:none}
316
+ .cst-seg button.cst-seg-active:hover:not(:disabled){color:var(--dsw-alias-state-business-primary)}
317
+ .cst-seg button:disabled{opacity:.45;cursor:default}
318
+ .cst-presets{display:inline-flex;flex-wrap:wrap;margin-top:2px}
319
+ .cst-toast-wrap{display:flex;justify-content:flex-end;margin-top:8px;margin-right:18px}
320
+ .cst-toast{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:500;line-height:1;padding:6px 11px;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary);animation:cst-toast-in .22s cubic-bezier(.2,.7,.3,1)}
321
+ .cst-toast.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
322
+ .cst-toast-dot{flex:none;width:5px;height:5px;border-radius:50%;background:currentColor}
323
+ @keyframes cst-toast-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
324
+ .cst-loading{padding:16px;border-radius:12px;background:var(--dsw-alias-bg-layer-2);font-size:12px;color:var(--dsw-alias-label-secondary)}
325
+ .cst-alert{padding:10px 12px;border-radius:10px;font-size:12px;line-height:1.5}
326
+ .cst-alert.warning{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);color:var(--dsw-alias-state-warn-label)}
327
+ .cst-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
328
+ `;
329
+ function installBaseStyles() {
330
+ const id = 'dsh-style-tweaks-base';
331
+ let style = document.querySelector(`style[data-plugin-css="${id}"]`);
332
+ if (style === null) {
333
+ style = document.createElement('style');
334
+ style.dataset.plugin = 'dsh-style-tweaks';
335
+ style.dataset.pluginCss = id;
336
+ style.textContent = BASE_CSS;
337
+ document.head.appendChild(style);
338
+ }
339
+ return () => { style?.remove(); };
340
+ }
341
+ /**
342
+ * Settings requests retry briefly on 502/503: writing the profile patch
343
+ * hot-reloads the `web` node and restarts this plugin for a moment (it
344
+ * injects `web`), so a toggle click can land inside that window. The retry
345
+ * rides it out instead of surfacing "settings unavailable".
346
+ */
347
+ async function apiRequest(init) {
348
+ let lastError;
349
+ for (let attempt = 1; attempt <= 6; attempt++) {
350
+ try {
351
+ const response = await fetch(SETTINGS_ROUTE, { credentials: 'same-origin', ...init });
352
+ const body = await response.json();
353
+ if (response.ok && body.ok)
354
+ return body.value;
355
+ const failure = body;
356
+ const retryable = response.status === 502 || response.status === 503;
357
+ lastError = new Error(failure.error?.message ?? `Style tweaks request failed with HTTP ${response.status}`);
358
+ if (!retryable)
359
+ throw lastError;
360
+ }
361
+ catch (error) {
362
+ lastError = error;
363
+ }
364
+ await new Promise((resolve) => setTimeout(resolve, attempt * 250));
365
+ }
366
+ throw lastError ?? new Error('Style tweaks request failed');
367
+ }
368
+ /** Small external store shared by the Settings route and the CSS engine. */
369
+ class SettingsClient {
370
+ state = { status: 'loading', writable: false, value: undefined, revision: undefined };
371
+ listeners = new Set();
372
+ generation = 0;
373
+ subscribe = (listener) => {
374
+ this.listeners.add(listener);
375
+ return () => { this.listeners.delete(listener); };
376
+ };
377
+ getSnapshot = () => this.state;
378
+ publish(next) {
379
+ this.state = next;
380
+ for (const listener of this.listeners)
381
+ listener();
382
+ }
383
+ async load() {
384
+ const generation = ++this.generation;
385
+ if (this.state.status === 'loading')
386
+ this.publish({ ...this.state, status: 'loading' });
387
+ try {
388
+ const snapshot = await apiRequest();
389
+ if (generation !== this.generation)
390
+ return;
391
+ this.publish({
392
+ status: 'ready',
393
+ writable: snapshot.writable,
394
+ value: snapshot.value,
395
+ revision: snapshot.revision,
396
+ });
397
+ }
398
+ catch (error) {
399
+ if (generation !== this.generation)
400
+ return;
401
+ this.publish({ ...this.state, status: 'error', error: error instanceof Error ? error.message : String(error) });
402
+ }
403
+ }
404
+ async post(payload) {
405
+ const generation = ++this.generation;
406
+ const snapshot = await apiRequest({
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/json' },
409
+ body: JSON.stringify(payload),
410
+ });
411
+ if (generation !== this.generation)
412
+ return;
413
+ this.publish({
414
+ status: 'ready',
415
+ writable: snapshot.writable,
416
+ value: snapshot.value,
417
+ revision: snapshot.revision,
418
+ });
419
+ }
420
+ async set(field, value) {
421
+ await this.post({ action: 'set', field, value, expectedRevision: this.state.revision ?? 0 });
422
+ }
423
+ async unset(field) {
424
+ await this.post({ action: 'unset', field, expectedRevision: this.state.revision ?? 0 });
425
+ }
426
+ }
427
+ exports.SettingsClient = SettingsClient;
428
+ /** Required client services: slots (settings.section), locale, and the app stores the JS-level tweaks read. */
429
+ exports.inject = ['slots', 'locale', 'sessions', 'workspaces'];
430
+ /**
431
+ * Hover/focus hint: a small ⓘ next to the field label; the hint text renders
432
+ * in a fixed-position bubble portaled to <body> (so panel `overflow:hidden`
433
+ * can never clip it), measured in a layout effect to prefer the space above
434
+ * the anchor and flip below near the viewport top. No layout shift: hints
435
+ * never occupy flow height.
436
+ */
437
+ function Hint({ text }) {
438
+ const anchorRef = (0, react_1.useRef)(null);
439
+ const popRef = (0, react_1.useRef)(null);
440
+ const [open, setOpen] = (0, react_1.useState)(false);
441
+ const [pos, setPos] = (0, react_1.useState)({ top: -9999, left: -9999 });
442
+ (0, react_1.useLayoutEffect)(() => {
443
+ if (!open)
444
+ return;
445
+ const anchor = anchorRef.current?.getBoundingClientRect();
446
+ const pop = popRef.current;
447
+ if (anchor === undefined || pop === null)
448
+ return;
449
+ let left = Math.min(Math.max(8, anchor.left), window.innerWidth - pop.offsetWidth - 8);
450
+ let top = anchor.top - pop.offsetHeight - 8;
451
+ if (top < 8)
452
+ top = anchor.bottom + 8;
453
+ setPos({ top, left });
454
+ }, [open]);
455
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { ref: anchorRef, className: "cst-hint", role: "note", "aria-label": text, tabIndex: 0, onMouseEnter: () => { setOpen(true); }, onMouseLeave: () => { setOpen(false); }, onFocus: () => { setOpen(true); }, onBlur: () => { setOpen(false); }, children: "i" }), open && (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { ref: popRef, className: "cst-hint-pop", style: { top: pos.top, left: pos.left }, children: text }), document.body)] }));
456
+ }
457
+ function SettingsSection({ controller, t }) {
458
+ const state = (0, react_1.useSyncExternalStore)(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
459
+ const resolved = resolveValue(state.value);
460
+ const writable = state.writable;
461
+ const [status, setStatus] = (0, react_1.useState)(undefined);
462
+ (0, react_1.useEffect)(() => { if (state.status === 'loading' && state.value === undefined)
463
+ void controller.load(); }, [controller, state.status, state.value]);
464
+ (0, react_1.useEffect)(() => {
465
+ if (status === undefined)
466
+ return;
467
+ const timer = setTimeout(() => { setStatus(undefined); }, 1800);
468
+ return () => { clearTimeout(timer); };
469
+ }, [status]);
470
+ const [widthDraft, setWidthDraft] = (0, react_1.useState)(String(resolved.dialogWidth));
471
+ const [marginDraft, setMarginDraft] = (0, react_1.useState)(String(resolved.sideMargin));
472
+ (0, react_1.useEffect)(() => { setWidthDraft(String(resolved.dialogWidth)); }, [resolved.dialogWidth]);
473
+ (0, react_1.useEffect)(() => { setMarginDraft(String(resolved.sideMargin)); }, [resolved.sideMargin]);
474
+ const commitDialogWidth = (raw) => {
475
+ setWidthDraft(raw);
476
+ const parsed = Number(raw);
477
+ if (!Number.isFinite(parsed))
478
+ return;
479
+ const clamped = Math.min(tweak_config_ts_1.MAX_DIALOG_WIDTH, Math.max(tweak_config_ts_1.MIN_DIALOG_WIDTH, Math.round(parsed)));
480
+ setWidthDraft(String(clamped));
481
+ void controller.set('dialogWidth', clamped).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
482
+ };
483
+ const stepDialogWidth = (delta) => {
484
+ const next = Math.min(tweak_config_ts_1.MAX_DIALOG_WIDTH, Math.max(tweak_config_ts_1.MIN_DIALOG_WIDTH, resolved.dialogWidth + delta));
485
+ setWidthDraft(String(next));
486
+ void controller.set('dialogWidth', next).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
487
+ };
488
+ const applyWidthPreset = (width) => {
489
+ setWidthDraft(String(width));
490
+ void controller.set('dialogWidth', width).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
491
+ };
492
+ const setUsePluginWidth = (value) => {
493
+ void controller.set('usePluginWidth', value).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
494
+ };
495
+ const commitSideMargin = (raw) => {
496
+ setMarginDraft(raw);
497
+ const parsed = Number(raw);
498
+ if (!Number.isFinite(parsed))
499
+ return;
500
+ const clamped = Math.max(tweak_config_ts_1.MIN_SIDE_MARGIN, Math.round(parsed));
501
+ setMarginDraft(String(clamped));
502
+ void controller.set('sideMargin', clamped).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
503
+ };
504
+ const stepSideMargin = (delta) => {
505
+ const next = Math.max(tweak_config_ts_1.MIN_SIDE_MARGIN, resolved.sideMargin + delta);
506
+ setMarginDraft(String(next));
507
+ void controller.set('sideMargin', next).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
508
+ };
509
+ const setTweak = (tweak, value) => {
510
+ void controller.set(tweak.settingKey, value).then(() => { setStatus('applied'); }).catch(() => { setStatus('unavailable'); });
511
+ };
512
+ if (state.status === 'loading' && state.value === undefined) {
513
+ return (0, jsx_runtime_1.jsx)("div", { className: "cst-settings", children: (0, jsx_runtime_1.jsx)("div", { className: "cst-loading", children: t('loading') }) });
514
+ }
515
+ if (state.status === 'error') {
516
+ return (0, jsx_runtime_1.jsx)("div", { className: "cst-settings", children: (0, jsx_runtime_1.jsx)("div", { className: "cst-alert error", children: t('unavailable') }) });
517
+ }
518
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "cst-settings", children: [(0, jsx_runtime_1.jsxs)("header", { className: "cst-settings-header", children: [(0, jsx_runtime_1.jsx)("div", { className: "cst-logo", children: "\uD83C\uDFA8" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h2", { children: t('settingsTitle') }), (0, jsx_runtime_1.jsx)("p", { children: t('settingsIntro') })] })] }), !writable ? (0, jsx_runtime_1.jsx)("div", { className: "cst-alert warning", children: t('readOnly') }) : null, (0, jsx_runtime_1.jsxs)("section", { className: "cst-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "cst-section-label", children: t('sectionLayout') }), (0, jsx_runtime_1.jsx)("div", { className: "cst-field", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-field-top", children: [(0, jsx_runtime_1.jsxs)("span", { className: "cst-label", children: [t('usePluginWidth'), (0, jsx_runtime_1.jsx)(Hint, { text: t('usePluginWidthHint') })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-controls", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-seg", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: resolved.usePluginWidth ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { setUsePluginWidth(true); }, children: t('usePluginWidthOn') }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: !resolved.usePluginWidth ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { setUsePluginWidth(false); }, children: t('usePluginWidthOff') })] }) })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "cst-field", children: [(0, jsx_runtime_1.jsxs)("div", { className: "cst-field-top", children: [(0, jsx_runtime_1.jsxs)("span", { className: "cst-label", children: [t('dialogWidth'), (0, jsx_runtime_1.jsx)(Hint, { text: t('dialogWidthHint') })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-controls", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-stepper", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": "\u2212", disabled: !writable || resolved.dialogWidth <= tweak_config_ts_1.MIN_DIALOG_WIDTH, onClick: () => { stepDialogWidth(-20); }, children: "\u2212" }), (0, jsx_runtime_1.jsx)("input", { type: "number", min: tweak_config_ts_1.MIN_DIALOG_WIDTH, max: tweak_config_ts_1.MAX_DIALOG_WIDTH, step: 20, value: widthDraft, disabled: !writable, onChange: (event) => { setWidthDraft(event.target.value); }, onBlur: (event) => { commitDialogWidth(event.target.value); }, onKeyDown: (event) => { if (event.key === 'Enter')
519
+ commitDialogWidth(event.target.value); } }), (0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": "+", disabled: !writable || resolved.dialogWidth >= tweak_config_ts_1.MAX_DIALOG_WIDTH, onClick: () => { stepDialogWidth(20); }, children: "+" })] }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-presets", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-seg", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: resolved.dialogWidth === 880 ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { applyWidthPreset(880); }, children: [t('presetWide'), " \u00B7 880"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: resolved.dialogWidth === 1024 ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { applyWidthPreset(1024); }, children: [t('presetWideXl'), " \u00B7 1024"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: resolved.dialogWidth === 748 ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { applyWidthPreset(748); }, children: [t('presetDefault'), " \u00B7 748"] })] }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-field", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-field-top", children: [(0, jsx_runtime_1.jsxs)("span", { className: "cst-label", children: [t('sideMargin'), (0, jsx_runtime_1.jsx)(Hint, { text: t('sideMarginHint') })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-controls", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-stepper", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": "\u2212", disabled: !writable || resolved.sideMargin <= tweak_config_ts_1.MIN_SIDE_MARGIN, onClick: () => { stepSideMargin(-4); }, children: "\u2212" }), (0, jsx_runtime_1.jsx)("input", { type: "number", min: tweak_config_ts_1.MIN_SIDE_MARGIN, step: 4, value: marginDraft, disabled: !writable, onChange: (event) => { setMarginDraft(event.target.value); }, onBlur: (event) => { commitSideMargin(event.target.value); }, onKeyDown: (event) => { if (event.key === 'Enter')
520
+ commitSideMargin(event.target.value); } }), (0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": "+", disabled: !writable, onClick: () => { stepSideMargin(4); }, children: "+" })] }) })] }) })] }), (0, jsx_runtime_1.jsxs)("section", { className: "cst-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "cst-section-label", children: t('sectionTweaks') }), registry_ts_1.TWEAKS.map(tweak => {
521
+ const enabled = resolved[tweak.settingKey] ?? tweak.defaultEnabled;
522
+ return ((0, jsx_runtime_1.jsx)("div", { className: "cst-field", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-field-top", children: [(0, jsx_runtime_1.jsxs)("span", { className: "cst-label", children: [t(tweak.titleKey), (0, jsx_runtime_1.jsx)(Hint, { text: t(tweak.descriptionKey) })] }), (0, jsx_runtime_1.jsx)("div", { className: "cst-controls", children: (0, jsx_runtime_1.jsxs)("div", { className: "cst-seg", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: enabled ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { setTweak(tweak, true); }, children: t('tweakOn') }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: !enabled ? 'cst-seg-active' : '', disabled: !writable, onClick: () => { setTweak(tweak, false); }, children: t('tweakOff') })] }) })] }) }, tweak.id));
523
+ })] }), status === undefined ? null : ((0, jsx_runtime_1.jsx)("div", { className: "cst-toast-wrap", children: (0, jsx_runtime_1.jsxs)("div", { className: 'cst-toast' + (status === 'unavailable' ? ' error' : ''), role: "status", "aria-live": "polite", children: [(0, jsx_runtime_1.jsx)("span", { className: "cst-toast-dot", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: t(status) })] }) }))] }));
524
+ }
525
+ /**
526
+ * Sanity-check that every tweak in the registry has matching `titleKey` /
527
+ * `descriptionKey` strings in both `en` and `zh`. Catches "added a tweak
528
+ * but forgot the i18n strings" at apply time instead of as an untranslated
529
+ * label visible to users.
530
+ */
531
+ function assertTweakI18nComplete() {
532
+ const missing = (locale, table) => {
533
+ const out = [];
534
+ for (const tweak of registry_ts_1.TWEAKS) {
535
+ if (table[tweak.titleKey] === undefined)
536
+ out.push(`${locale}:${tweak.titleKey}`);
537
+ if (table[tweak.descriptionKey] === undefined)
538
+ out.push(`${locale}:${tweak.descriptionKey}`);
539
+ }
540
+ return out;
541
+ };
542
+ const problems = [...missing('en', en), ...missing('zh', zh)];
543
+ if (problems.length > 0) {
544
+ console.error('[dsh-style-tweaks] missing i18n keys:', problems);
545
+ }
546
+ }
547
+ function apply(ctx) {
548
+ assertTweakI18nComplete();
549
+ ctx.effect(installBaseStyles, 'dsh-style-tweaks: base styles');
550
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-style-tweaks: locale');
551
+ const t = ctx.locale.bind(NS);
552
+ const controller = new SettingsClient();
553
+ // Width-axis override: when plugin-width is on, install the handle-hiding
554
+ // + user-width-clamp CSS once and keep the same controller alive; on
555
+ // every settings change, mutate it in place via `setWidth` (no reinstall,
556
+ // no flicker — DSH's own `publishWidths` races the install on every
557
+ // ResizeObserver tick and would otherwise drop the first frame). When the
558
+ // user toggles plugin-width off, dispose the controller so DSH's native
559
+ // handles take back over.
560
+ ctx.effect(() => {
561
+ let widthController;
562
+ const sync = () => {
563
+ const value = controller.getSnapshot().value;
564
+ const usePlugin = value?.usePluginWidth ?? true;
565
+ const width = (0, tweak_config_ts_1.resolveDialogWidth)(value?.dialogWidth);
566
+ const sideMargin = (0, tweak_config_ts_1.resolveSideMargin)(value?.sideMargin);
567
+ if (usePlugin) {
568
+ if (widthController === undefined) {
569
+ widthController = (0, conversation_width_ts_1.installConversationWidthStyles)(width, sideMargin);
570
+ }
571
+ else {
572
+ widthController.setWidth(width, sideMargin);
573
+ }
574
+ }
575
+ else if (widthController !== undefined) {
576
+ widthController.dispose();
577
+ widthController = undefined;
578
+ }
579
+ };
580
+ sync();
581
+ void controller.load();
582
+ return controller.subscribe(sync);
583
+ }, 'dsh-style-tweaks: conversation width');
584
+ // Mount / unmount tweak styles live as settings change. The subscribe
585
+ // callback re-runs on every settings change, so all toggles apply live.
586
+ // (Without `controller.subscribe(sync)` returning, sync only runs once at
587
+ // boot — a regression that would let tweak toggles never take effect after
588
+ // the first paint.)
589
+ ctx.effect(() => {
590
+ const cleanups = [];
591
+ const sync = () => {
592
+ const value = controller.getSnapshot().value;
593
+ const resolved = resolveValue(value);
594
+ // Full remount: simplest + tweak count is small (< 10).
595
+ while (cleanups.length > 0)
596
+ cleanups.pop()();
597
+ for (const tweak of registry_ts_1.TWEAKS) {
598
+ const enabled = resolved[tweak.settingKey] ?? tweak.defaultEnabled;
599
+ if (!enabled)
600
+ continue;
601
+ const injector = TWEAK_INJECTORS[tweak.id];
602
+ if (injector === undefined)
603
+ continue;
604
+ cleanups.push(injector(ctx));
605
+ }
606
+ };
607
+ sync();
608
+ return controller.subscribe(sync);
609
+ }, 'dsh-style-tweaks: live tweak styles');
610
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
611
+ name: 'settings.section',
612
+ id: NS,
613
+ order: 90,
614
+ label: () => t('nav'),
615
+ inject: () => ({ controller, t }),
616
+ }, SettingsSection));
617
+ }
618
+ };
619
+ __modules["./tweak-config.js"] = function(module, exports, require, __load_) {
620
+ "use strict";
621
+ /**
622
+ * dsh-style-tweaks — client-side constants & resolvers.
623
+ *
624
+ * Mirror of the runtime values in `src/config.ts`. Duplicated because the
625
+ * client tsconfig has `rootDir: src/client` (and explicitly excludes parent
626
+ * dirs), so a runtime `import` from `../config` is a compile-time error.
627
+ * Keep these in lock-step with `src/config.ts` — the server's schemastery
628
+ * `Config` object is the source of truth for defaults / clamps.
629
+ *
630
+ * Types are still pulled from `src/config.ts` via `import type`, which the
631
+ * rootDir restriction allows (no runtime reference is emitted).
632
+ */
633
+ Object.defineProperty(exports, "__esModule", { value: true });
634
+ exports.DEFAULT_SETTINGS_NAV_SCROLL = exports.DEFAULT_LOCATE_CURRENT_SESSION = exports.DEFAULT_PROJECT_RUNNING_INDICATOR = exports.DEFAULT_CODE_BLOCK_FLUSH_TOP = exports.DEFAULT_STABLE_TURN_RAIL = exports.DEFAULT_STABLE_TABLE = exports.CONVERSATION_WIDTH_STORAGE_KEY = exports.MIN_SIDE_MARGIN = exports.DEFAULT_SIDE_MARGIN = exports.DEFAULT_USE_PLUGIN_WIDTH = exports.DEFAULT_DIALOG_WIDTH = exports.MAX_DIALOG_WIDTH = exports.MIN_DIALOG_WIDTH = void 0;
635
+ exports.resolveDialogWidth = resolveDialogWidth;
636
+ exports.resolveSideMargin = resolveSideMargin;
637
+ exports.resolveClientConfig = resolveClientConfig;
638
+ // ── Column-width constants (mirror src/config.ts) ───────────────────────
639
+ /** Dialog width min px. */
640
+ exports.MIN_DIALOG_WIDTH = 600;
641
+ /** Dialog width max px. */
642
+ exports.MAX_DIALOG_WIDTH = 1600;
643
+ /** Stock DSH column width. */
644
+ exports.DEFAULT_DIALOG_WIDTH = 748;
645
+ /** Plugin width control default (must match server; see config.ts). */
646
+ exports.DEFAULT_USE_PLUGIN_WIDTH = true;
647
+ /** Default side margin in px. */
648
+ exports.DEFAULT_SIDE_MARGIN = 50;
649
+ /** Minimum side margin in px. */
650
+ exports.MIN_SIDE_MARGIN = 32;
651
+ /** localStorage slot the native handle reads. */
652
+ exports.CONVERSATION_WIDTH_STORAGE_KEY = 'dsh.conversation.contentWidth';
653
+ // ── CSS tweak constants (mirror src/config.ts) ──────────────────────────
654
+ /** Default state of the stable-table tweak. */
655
+ exports.DEFAULT_STABLE_TABLE = true;
656
+ /** Default state of the stable-turn-rail tweak. */
657
+ exports.DEFAULT_STABLE_TURN_RAIL = true;
658
+ /** Default state of the code-block-flush-top tweak. */
659
+ exports.DEFAULT_CODE_BLOCK_FLUSH_TOP = true;
660
+ /** Default state of the project-running-indicator tweak. */
661
+ exports.DEFAULT_PROJECT_RUNNING_INDICATOR = true;
662
+ /** Default state of the locate-current-session tweak. */
663
+ exports.DEFAULT_LOCATE_CURRENT_SESSION = true;
664
+ /** Default state of the settings-nav-scroll tweak. */
665
+ exports.DEFAULT_SETTINGS_NAV_SCROLL = true;
666
+ /**
667
+ * Normalize a dialog width value (legacy strings included) to px.
668
+ * Must match `resolveDialogWidth` in src/config.ts.
669
+ */
670
+ function resolveDialogWidth(value) {
671
+ if (typeof value === 'number') {
672
+ return Math.min(exports.MAX_DIALOG_WIDTH, Math.max(exports.MIN_DIALOG_WIDTH, Math.round(value)));
673
+ }
674
+ return exports.DEFAULT_DIALOG_WIDTH;
675
+ }
676
+ /**
677
+ * Normalize a side-margin value (legacy strings included) to px.
678
+ * Must match `resolveSideMargin` in src/config.ts.
679
+ */
680
+ function resolveSideMargin(value) {
681
+ if (typeof value === 'number')
682
+ return Math.max(exports.MIN_SIDE_MARGIN, Math.round(value));
683
+ return exports.DEFAULT_SIDE_MARGIN;
684
+ }
685
+ /**
686
+ * Build a fully-defaulted ResolvedStyleTweaksConfig from any
687
+ * partial input. Mirror of `resolveConfig` in src/config.ts.
688
+ */
689
+ function resolveClientConfig(value) {
690
+ return {
691
+ dialogWidth: resolveDialogWidth(value?.dialogWidth),
692
+ usePluginWidth: value?.usePluginWidth ?? exports.DEFAULT_USE_PLUGIN_WIDTH,
693
+ sideMargin: resolveSideMargin(value?.sideMargin),
694
+ stableTable: value?.stableTable ?? exports.DEFAULT_STABLE_TABLE,
695
+ stableTurnRail: value?.stableTurnRail ?? exports.DEFAULT_STABLE_TURN_RAIL,
696
+ codeBlockFlushTop: value?.codeBlockFlushTop ?? exports.DEFAULT_CODE_BLOCK_FLUSH_TOP,
697
+ projectRunningIndicator: value?.projectRunningIndicator ?? exports.DEFAULT_PROJECT_RUNNING_INDICATOR,
698
+ locateCurrentSession: value?.locateCurrentSession ?? exports.DEFAULT_LOCATE_CURRENT_SESSION,
699
+ settingsNavScroll: value?.settingsNavScroll ?? exports.DEFAULT_SETTINGS_NAV_SCROLL,
700
+ };
701
+ }
702
+ };
703
+ __modules["./tweak-types.js"] = function(module, exports, require, __load_) {
704
+ "use strict";
705
+ /**
706
+ * dsh-style-tweaks — client-side type mirror.
707
+ *
708
+ * Mirror of the type definitions in `src/config.ts`. Duplicated because the
709
+ * client tsconfig has `rootDir: src/client`, so even `import type` from a
710
+ * parent directory is rejected by TS6059 ("file is not under rootDir").
711
+ * Keep these in lock-step with `src/config.ts`.
712
+ *
713
+ * The runtime values (constants, resolvers) live in `tweak-config.ts`.
714
+ */
715
+ Object.defineProperty(exports, "__esModule", { value: true });
716
+ };
717
+ __modules["./tweaks/code-block-flush-top.js"] = function(module, exports, require, __load_) {
718
+ "use strict";
719
+ /**
720
+ * DSH-style-tweaks — code-block-flush-top tweak.
721
+ *
722
+ * Removes the 16 px of top whitespace above and inside every highlighted
723
+ * code block so the highlighted box sits flush with the preceding
724
+ * paragraph, list item, or heading *and* the first line of code sits flush
725
+ * with the top edge of the box. The right, bottom, and left sides keep
726
+ * 16 px so a code block still breathes inside its parent column and the
727
+ * code itself still has lateral breathing room.
728
+ *
729
+ * ## Symptom
730
+ *
731
+ * DSH renders every markdown code fence through `CodeBlock`
732
+ * (`packages/client/ui-primitives/src/markdown/CodeBlock.tsx:60`), which
733
+ * wraps the highlighted `<pre>` in a banner + `<pre>` pair:
734
+ *
735
+ * <div className={clsx(css.block, 'md-code-block', className)}>
736
+ * <div className={css.bannerWrap}> ... language / copy button ... </div>
737
+ * <pre className="shiki css-variables"> ... highlighted code ... </pre>
738
+ * </div>
739
+ *
740
+ * Two 16 px gaps stack vertically to create the "blank space above the
741
+ * code" the user sees:
742
+ *
743
+ * 1. The wrapper `.block` carries `margin: 16px 0` (DSH's
744
+ * `CodeBlock.module.css:13`), so the highlighted code is offset 16 px
745
+ * from preceding content (paragraph, list item, heading, ...).
746
+ * 2. The inner `<pre>` carries `padding: 16px` (CodeBlock.module.css:73),
747
+ * so the first line of code sits 16 px below the banner / box edge.
748
+ *
749
+ * Chrome DevTools exposes these on two different elements — clicking on the
750
+ * `<pre class="shiki css-variables">` shows `padding: 16px`, clicking on
751
+ * the `<div class="_block_xxxxx md-code-block">` wrapper shows
752
+ * `margin: 16px`. Both need to go for the code to sit flush.
753
+ *
754
+ * ## Fix
755
+ *
756
+ * Two rules on the stable `.md-code-block` hook (the same hand-written class
757
+ * `stable-table.ts` uses for `.md-table-wide`):
758
+ *
759
+ * - `.md-code-block { margin-top: 0 !important }` collapses the outer
760
+ * gap above the box. Only `margin-top` is touched: writing a full
761
+ * `margin: 0 16px 16px 16px` would add 16 px of left/right margin to
762
+ * the wrapper, shrinking the visible code box by 32 px (a regression
763
+ * reported by the user as "the box shrinks by a ring"). The wrapper's
764
+ * horizontal sizing is owned by the conversation column — there is no
765
+ * left/right margin on `.block` to preserve.
766
+ * - `.md-code-block pre { padding: 0 16px 16px 16px !important }`
767
+ * collapses the inner gap above the code. The streaming arm of
768
+ * `CodeBlock.tsx:130` mounts `<pre>` as a direct child of the wrapper;
769
+ * the settled arm (`CodeBlock.tsx:166`) wraps it in an anonymous
770
+ * `<div>` for the `dangerouslySetInnerHTML` sink. A descendant selector
771
+ * matches both arms identically.
772
+ *
773
+ * ## Specificity
774
+ *
775
+ * DSH's rules in play:
776
+ *
777
+ * .block { margin: 16px 0 } (0,0,1,0)
778
+ * .block:not(:last-child) { margin-bottom: 11px } (0,0,2,0)
779
+ * .block :where(pre) { padding: 16px } (0,0,2,0)
780
+ *
781
+ * A plain `.md-code-block { ... !important }` is (0,0,1,0) and would lose
782
+ * to `.block:not(:last-child)` on the trailing-edge specificity tiebreak
783
+ * (the :not() pseudo-class adds one to specificity). Doubling the class —
784
+ * .md-code-block.md-code-block — reaches (0,0,2,0) and ties; !important
785
+ * then takes precedence on importance, leaving us in control of all four
786
+ * edges.
787
+ *
788
+ * The inner-pre selector uses .md-code-block.md-code-block pre, which is
789
+ * (0,0,2,1) — that strictly beats DSH's .block :where(pre) (0,0,2,0) on
790
+ * total specificity. The !important is belt-and-suspenders, but the
791
+ * specificity win alone is enough.
792
+ *
793
+ * ## Scope safety
794
+ *
795
+ * `.md-code-block` is set in exactly one place in DSH's source
796
+ * (`CodeBlock.tsx:170`); DSH reserves it for plugins/extensions so future
797
+ * code does not collide. The empty-fence branch (`renderCode` in
798
+ * `packages/client/ui-primitives/src/markdown/render.tsx:312-321`) renders a
799
+ * bare `<pre>` without `.md-code-block`, so empty fences are untouched —
800
+ * they have no banner/wrapper anyway.
801
+ *
802
+ * Streaming and settled arms of `CodeBlock` (`CodeBlock.tsx:130` streaming
803
+ * `<pre>`, `:166` settled `<div dangerouslySetInnerHTML>`) share the same
804
+ * wrapper class, so both arms are affected uniformly by the descendant
805
+ * selector.
806
+ */
807
+ Object.defineProperty(exports, "__esModule", { value: true });
808
+ exports.CODE_BLOCK_FLUSH_TOP_CSS_ID = void 0;
809
+ exports.injectCodeBlockFlushTopStyles = injectCodeBlockFlushTopStyles;
810
+ const CODE_BLOCK_FLUSH_TOP_CSS = `
811
+ /* Outer gap above the highlighted box: DSH's wrapper rule (.block { margin:
812
+ * 16px 0 }, CodeBlock.module.css:13) sets the wrapper's top/bottom margin
813
+ * via a single-class selector hashed by CSS Modules (specificity 0,0,1,0).
814
+ * DSH also tightens the trailing edge with .block:not(:last-child) {
815
+ * margin-bottom: 11px } — that one has specificity 0,0,2,0. We double-up
816
+ * .md-code-block (the stable hand-written hook reserved for plugins,
817
+ * CodeBlock.tsx:170) to reach 0,0,2,0 + !important so we beat the
818
+ * trailing-edge tightening on the importance tiebreak.
819
+ *
820
+ * Only margin-top is reset: writing margin: 0 16px 16px 16px !important
821
+ * would add 16 px of left/right margin to the wrapper, shrinking the
822
+ * visible code box by 32 px (the user reported the box "shrinks by a
823
+ * ring"). Setting just margin-top: 0 !important collapses the gap above
824
+ * the box while preserving DSH's natural horizontal margin (zero on the
825
+ * wrapper itself — its width comes from the conversation column). */
826
+ .md-code-block,
827
+ .md-code-block.md-code-block {
828
+ margin-top: 0 !important;
829
+ }
830
+
831
+ /* Inner gap above the highlighted code itself: DSH's .block :where(pre)
832
+ * rule sets padding: 16px on the pre element (CodeBlock.module.css:71-74).
833
+ * That padding puts the first line of code 16px below the banner / box
834
+ * edge, which reads as "extra blank space inside the box above the code".
835
+ * We collapse only the top edge so the code still breathes laterally.
836
+ *
837
+ * Specificity note: DSH's selector is .block :where(pre) which is
838
+ * (0,0,2,0) (one class + the :where() pseudo-class contributes nothing).
839
+ * A plain descendant selector .md-code-block pre is (0,0,1,1) and DSH
840
+ * still wins on total specificity. So we double-up the class:
841
+ * .md-code-block.md-code-block pre is (0,0,2,1) and beats DSH.
842
+ *
843
+ * The streaming arm of CodeBlock (line 130) mounts pre as a direct child
844
+ * of .md-code-block; the settled arm (line 166) wraps the pre in an extra
845
+ * anonymous div for the dangerouslySetInnerHTML sink. Descendant selection
846
+ * matches both arms identically. */
847
+ .md-code-block.md-code-block pre,
848
+ .md-code-block.md-code-block pre.shiki {
849
+ padding: 0 16px 16px 16px !important;
850
+ }
851
+ `;
852
+ exports.CODE_BLOCK_FLUSH_TOP_CSS_ID = 'cst-code-block-flush-top';
853
+ function injectCodeBlockFlushTopStyles() {
854
+ let style = document.querySelector(`style[data-tweak-css="${exports.CODE_BLOCK_FLUSH_TOP_CSS_ID}"]`);
855
+ if (style === null) {
856
+ style = document.createElement('style');
857
+ style.dataset.tweak = 'cst';
858
+ style.dataset.tweakCss = exports.CODE_BLOCK_FLUSH_TOP_CSS_ID;
859
+ style.textContent = CODE_BLOCK_FLUSH_TOP_CSS;
860
+ document.head.appendChild(style);
861
+ }
862
+ return () => { style?.remove(); };
863
+ }
864
+ };
865
+ __modules["./tweaks/locate-current-session.js"] = function(module, exports, require, __load_) {
866
+ "use strict";
867
+ /**
868
+ * dsh-style-tweaks — locate-current-session tweak.
869
+ *
870
+ * Adds a "locate current session" button to the left of the native search
871
+ * button in the sidebar's "工作区" (workspaces) section header. Clicking it:
872
+ *
873
+ * 1. Finds the currently-selected session row in the sidebar tree.
874
+ * 2. Locates its enclosing workspace group via the stable
875
+ * `[class*="groupSection"]` ancestor hook.
876
+ * 3. If the parent workspace row is collapsed (`aria-expanded="false"`),
877
+ * clicks it to expand.
878
+ * 4. Scrolls the session row to the center of the sidebar's visible area
879
+ * so the user can confirm "this is where I am".
880
+ *
881
+ * ## Why DOM patching
882
+ *
883
+ * DSH's `sidebar.workspaces` slot is a single owner (the workspace browser)
884
+ * and exposes no public sub-slot for the section header row. There is also
885
+ * no plugin-callable service that scrolls a sidebar row into view. The
886
+ * plugin therefore decorates the DOM from outside, exactly the way
887
+ * `project-running-indicator.ts` decorates project header rows.
888
+ *
889
+ * Stable anchors (CSS-Modules hashes rotate per build):
890
+ * • Native search button: localized `aria-label` resolved through the
891
+ * locale service (`workspace` ns, key `search.sessions.aria`), with a
892
+ * structural fallback on the `searchButton` class fragment.
893
+ * • Breadcrumb: same strategy (`conversation` ns, key
894
+ * `session.hierarchy`; fallback `crumbs` fragment).
895
+ * • Selected session row: `[role="treeitem"][aria-selected="true"]`
896
+ * (ARIA `aria-selected` is part of the WAI-ARIA tree pattern, DSH
897
+ * uses it to mark the active row.)
898
+ * • Workspace group container: closest element whose class attribute
899
+ * contains the substring `groupSection` (a semantic fragment DSH
900
+ * keeps even when its hashed prefix changes).
901
+ * • Workspace header row: `[role="treeitem"][aria-expanded]` inside the
902
+ * same group container.
903
+ *
904
+ * ## Lifecycle
905
+ *
906
+ * `setupLocateCurrentSession` returns a disposer that removes the button
907
+ * and disconnects the observer. The caller in `index.tsx` mounts/unmounts
908
+ * it on every settings change, exactly like the other JS-level tweaks —
909
+ * disabling the toggle in Settings causes the button to disappear with no
910
+ * page refresh.
911
+ *
912
+ * @module dsh-style-tweaks/client/tweaks/locate-current-session
913
+ */
914
+ Object.defineProperty(exports, "__esModule", { value: true });
915
+ exports.injectLocateCurrentSessionStyles = injectLocateCurrentSessionStyles;
916
+ exports.setupLocateCurrentSession = setupLocateCurrentSession;
917
+ /** Stable hook for the currently selected session row. */
918
+ const SELECTED_SESSION_SELECTOR = '[role="treeitem"][aria-selected="true"]';
919
+ /**
920
+ * Class fragment selector for the workspace group container. DSH marks
921
+ * each workspace's group block with a class whose name contains
922
+ * `groupSection`; the rest of the class name is a CSS-Modules hash.
923
+ * Attribute-substring matching is more durable than a fixed-class match.
924
+ */
925
+ const GROUP_SELECTOR = '[class*="groupSection"]';
926
+ /** Stable hook for a workspace header row inside the group. */
927
+ const PROJECT_ROW_SELECTOR = '[role="treeitem"][aria-expanded]';
928
+ let tWorkspace;
929
+ let tConversation;
930
+ /**
931
+ * The localized aria-label DSH paints on the sidebar search button
932
+ * (namespace `workspace`, key `search.sessions.aria`: zh `搜索会话`,
933
+ * en `Search sessions`).
934
+ */
935
+ function searchButtonLabel() {
936
+ return tWorkspace?.('search.sessions.aria');
937
+ }
938
+ /**
939
+ * The localized aria-label of the breadcrumb nav that carries the current
940
+ * session title (namespace `conversation`, key `session.hierarchy`).
941
+ */
942
+ function breadcrumbLabel() {
943
+ return tConversation?.('session.hierarchy');
944
+ }
945
+ /** Stable structural fallback for the sidebar search button. CSS-Modules
946
+ * class names are `<hash>_<semantic>`; the semantic suffix survives builds,
947
+ * so a substring match is build-durable where the full class is not. */
948
+ const SEARCH_BUTTON_FALLBACK_SELECTOR = 'button[class*="searchButton"]';
949
+ /** Stable structural fallback for the breadcrumb nav (`css.crumbs`). */
950
+ const BREADCRUMB_FALLBACK_SELECTOR = 'nav[class*="crumbs"] button[disabled]';
951
+ /**
952
+ * Inline SVG icon for the locate button.
953
+ *
954
+ * Inline SVG icon for the locate button.
955
+ *
956
+ * A stroke-based target / crosshair glyph drawn in a 16×16 viewBox to
957
+ * match DSH's native icon coordinate system. Stroke width is the
958
+ * user-tuned 1.1 — thin enough to feel precise, thick enough to read
959
+ * at the 16px display size. `currentColor` lets the button recolor on
960
+ * hover like the natives.
961
+ */
962
+ const PIN_ICON_SVG = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" fill="none"/><path d="M8 1v3M8 12v3M1 8h3M12 8h3" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" fill="none"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/></svg>';
963
+ /** CSS injected once per tweak-enable cycle; lives until the cycle disables. */
964
+ const LOCATE_CSS_ID = 'cst-locate-current-session';
965
+ const LOCATE_CSS = `
966
+ /* Injected button: visually consistent with the native search button.
967
+ * Key styles matched from DSH's native icon buttons:
968
+ * - corner-shape: superellipse(1) — creates the smooth circular hover
969
+ * background (border-radius alone is not enough)
970
+ * - display: flex — native buttons use flex, not grid
971
+ * - Spacing comes from the search slot's flex gap (see the slot rule
972
+ * below), matching the section header's native 4px icon gap */
973
+ button.cst-locate-btn {
974
+ flex: none;
975
+ display: flex;
976
+ place-items: center;
977
+ align-items: center;
978
+ justify-content: center;
979
+ width: 28px;
980
+ height: 28px;
981
+ padding: 0;
982
+ border: none;
983
+ border-radius: 50%;
984
+ corner-shape: superellipse(1);
985
+ background: transparent;
986
+ color: var(--dsw-alias-label-secondary, currentColor);
987
+ cursor: pointer;
988
+ position: relative;
989
+ z-index: 0;
990
+ transition: background-color .15s ease, color .15s ease;
991
+ }
992
+ button.cst-locate-btn > svg {
993
+ width: 16px;
994
+ height: 16px;
995
+ display: block;
996
+ }
997
+ button.cst-locate-btn:hover:not(:disabled) {
998
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06));
999
+ color: var(--dsw-alias-label-primary, currentColor);
1000
+ }
1001
+ button.cst-locate-btn:disabled {
1002
+ opacity: .35;
1003
+ cursor: default;
1004
+ }
1005
+
1006
+ /* Hover tooltip bubble, mirrored from DSH's own Tooltip primitive
1007
+ * (ui-primitives/src/Tooltip.module.css): fixed-position span, theme
1008
+ * tokens, 150ms fade-in. Kept as plain CSS so the injected button —
1009
+ * which lives outside React — renders the exact same visual. */
1010
+ .cst-locate-tooltip {
1011
+ position: fixed;
1012
+ z-index: 100;
1013
+ width: max-content;
1014
+ max-width: 50vw;
1015
+ padding: 3px 7px;
1016
+ border-radius: 8px;
1017
+ background: var(--dsw-alias-tooltip-bg, rgba(0, 0, 0, 0.85));
1018
+ color: var(--dsw-static-neutral-bluish-00, #fff);
1019
+ font-size: 13px;
1020
+ line-height: 20px;
1021
+ white-space: pre-line;
1022
+ overflow-wrap: break-word;
1023
+ pointer-events: none;
1024
+ animation: cst-tooltip-in 150ms var(--ds-ease-in-out, ease-in-out);
1025
+ }
1026
+ .cst-locate-tooltip[data-side='bottom'] {
1027
+ transform: translateX(-50%);
1028
+ }
1029
+ .cst-locate-tooltip[data-side='top'] {
1030
+ transform: translate(-50%, -100%);
1031
+ }
1032
+ @keyframes cst-tooltip-in {
1033
+ from { opacity: 0; }
1034
+ }
1035
+ @media (prefers-reduced-motion: reduce) {
1036
+ .cst-locate-tooltip {
1037
+ animation: none;
1038
+ }
1039
+ }
1040
+ /* The locate button is mounted as a left sibling of the search container
1041
+ * inside the search slot. Force the slot into a right-aligned flex row
1042
+ * with the same 4px gap the section header uses, so all four icons
1043
+ * (locate / search / view options / add workspace) are evenly spaced.
1044
+ * justify-content: flex-end keeps the group pinned to the right edge
1045
+ * exactly as DSH's native layout does.
1046
+ *
1047
+ * Anchors use semantic CSS-Modules fragments ("searchSlot" survives as a
1048
+ * suffix after the per-build hash prefix), never full hashed names. */
1049
+ div[class*="searchSlot"]:has(> button.cst-locate-btn) {
1050
+ display: flex;
1051
+ align-items: center;
1052
+ justify-content: flex-end;
1053
+ gap: 4px;
1054
+ }
1055
+ /* The search container directly follows our button in the slot. */
1056
+ button.cst-locate-btn + * {
1057
+ flex: none;
1058
+ }
1059
+
1060
+ `;
1061
+ /**
1062
+ * Install the locate-current-session stylesheet into <head>. Idempotent: a
1063
+ * second call returns no-op (the existing <style> is reused). The caller
1064
+ * keeps the disposer alive while the tweak is enabled and disposes it when
1065
+ * the user disables the tweak.
1066
+ */
1067
+ function injectLocateCurrentSessionStyles() {
1068
+ let style = document.querySelector(`style[data-tweak-css="${LOCATE_CSS_ID}"]`);
1069
+ if (style === null) {
1070
+ style = document.createElement('style');
1071
+ style.dataset.tweak = 'cst';
1072
+ style.dataset.tweakCss = LOCATE_CSS_ID;
1073
+ style.textContent = LOCATE_CSS;
1074
+ document.head.appendChild(style);
1075
+ }
1076
+ return () => { style?.remove(); };
1077
+ }
1078
+ /**
1079
+ * Read the current session's title from the top breadcrumb. DSH renders
1080
+ * the session lineage chip as a nav whose aria-label comes from the
1081
+ * locale service (`conversation` ns, key `session.hierarchy`); the current
1082
+ * session's button is the only `button[disabled]` inside it (DSH disables
1083
+ * the current crumb so it cannot be re-entered as a navigation target).
1084
+ * Returns the trimmed title text, or null when no session is open.
1085
+ */
1086
+ function readCurrentSessionTitle() {
1087
+ const label = breadcrumbLabel();
1088
+ const selector = label !== undefined
1089
+ ? `nav[aria-label="${label}"] button[disabled]`
1090
+ : BREADCRUMB_FALLBACK_SELECTOR;
1091
+ const btn = document.querySelector(selector);
1092
+ const text = btn?.textContent?.trim();
1093
+ return text !== undefined && text.length > 0 ? text : null;
1094
+ }
1095
+ /**
1096
+ * Find the sidebar treeitem that hosts the given session title. Walks every
1097
+ * `[class*="groupSection"]` in the sidebar and, for each one that is
1098
+ * currently expanded, looks for a session treeitem whose textContent starts
1099
+ * with `title` (titles are unique per workspace, so prefix match is enough
1100
+ * — sidebar rows also include trailing "1天" / "20小时" labels).
1101
+ *
1102
+ * Returns null when no match is found (e.g. the workspace is currently
1103
+ * collapsed, hiding its session rows). The caller falls back to the title
1104
+ * itself for the workspace lookup.
1105
+ */
1106
+ function findSessionRow(title) {
1107
+ for (const group of document.querySelectorAll(GROUP_SELECTOR)) {
1108
+ const projectRow = group.querySelector(PROJECT_ROW_SELECTOR);
1109
+ if (projectRow?.getAttribute('aria-expanded') !== 'true')
1110
+ continue;
1111
+ for (const session of group.querySelectorAll('[role="treeitem"]:not([aria-expanded])')) {
1112
+ if ((session.textContent ?? '').trimStart().startsWith(title)) {
1113
+ return session;
1114
+ }
1115
+ }
1116
+ }
1117
+ return null;
1118
+ }
1119
+ /**
1120
+ * Find the workspace projectRow (and its enclosing groupSection) that
1121
+ * contains the given session title. First tries an exact session-row match
1122
+ * in expanded groups; if none, matches by workspace title text using the
1123
+ * hint derived from the current session title (we use the current title
1124
+ * itself as a last-ditch hint — DSH sidebar group headers don't carry
1125
+ * their workspace id as a stable attribute, so workspace title matching is
1126
+ * the only DOM-resident anchor we have).
1127
+ */
1128
+ /**
1129
+ * Resolve the workspace title for the current session via the app stores.
1130
+ * `ctx.get('sessions').list.byId` maps id→{title}; `ctx.get('workspaces')
1131
+ * .list.items[*].sessionIds` lists which sessions live in each workspace.
1132
+ * Returns the workspace title (the same string DSH renders into the
1133
+ * sidebar project row), or null when either store is unavailable, the
1134
+ * session is not present in any workspace (ungrouped / archived), or the
1135
+ * breadcrumb title does not match any known session.
1136
+ */
1137
+ function resolveWorkspaceTitle(title, sessionList, workspaceList) {
1138
+ if (sessionList === undefined || workspaceList === undefined)
1139
+ return null;
1140
+ const sessions = sessionList.getSnapshot();
1141
+ let sessionId;
1142
+ for (const [id, summary] of Object.entries(sessions.byId)) {
1143
+ if (summary?.title === title) {
1144
+ sessionId = id;
1145
+ break;
1146
+ }
1147
+ }
1148
+ if (sessionId === undefined)
1149
+ return null;
1150
+ for (const ws of workspaceList.getSnapshot().items) {
1151
+ if (ws.sessionIds.includes(sessionId))
1152
+ return ws.title;
1153
+ }
1154
+ return null;
1155
+ }
1156
+ /**
1157
+ * Find the sidebar projectRow whose text equals `workspaceTitle`. DSH
1158
+ * renders each workspace header as a treeitem whose textContent is the
1159
+ * raw title (verified live: "dsh-dialog-width" while collapsed, same when
1160
+ * expanded). Returns the treeitem + its enclosing groupSection for click.
1161
+ */
1162
+ function findProjectRowByTitle(workspaceTitle) {
1163
+ for (const group of document.querySelectorAll(GROUP_SELECTOR)) {
1164
+ const projectRow = group.querySelector(PROJECT_ROW_SELECTOR);
1165
+ if (projectRow === null)
1166
+ continue;
1167
+ if ((projectRow.textContent ?? '').trim() === workspaceTitle) {
1168
+ return { group, projectRow };
1169
+ }
1170
+ }
1171
+ return null;
1172
+ }
1173
+ /**
1174
+ * Click a group's "expand remaining sessions" overflow button when DSH
1175
+ * caps the group's rendered session rows (default 5) and hides the rest
1176
+ * behind it. The button carries `aria-expanded` matching its state and a
1177
+ * semantic `sessionOverflowButton` class fragment that survives the
1178
+ * per-build hash prefix. No-op when the button is absent or already
1179
+ * expanded.
1180
+ */
1181
+ function expandOverflow(group) {
1182
+ const btn = group.querySelector('button[class*="sessionOverflowButton"][aria-expanded="false"]');
1183
+ btn?.click();
1184
+ }
1185
+ /**
1186
+ * Perform the locate action. Steps:
1187
+ * 1. Read the current session title from the breadcrumb (the only DOM
1188
+ * anchor that survives a sidebar rebuild).
1189
+ * 2. Fast path: if the sidebar already has the selected session row,
1190
+ * scroll to it. This covers the common "all open" case in O(1).
1191
+ * 3. Slow path: resolve the session → workspace via the app stores,
1192
+ * find the workspace's projectRow in the sidebar, and click it open
1193
+ * so DSH mounts the session row on the next render.
1194
+ * 4. After the click, the new treeitem carries `aria-selected="true"`
1195
+ * (DSH marks the current session on every tree paint). One rAF is
1196
+ * enough for the synchronous DOM update; we then scroll it into view.
1197
+ */
1198
+ function performLocate(sessionList, workspaceList) {
1199
+ const title = readCurrentSessionTitle();
1200
+ if (title === null)
1201
+ return;
1202
+ // Fast path: the selected session row is already mounted.
1203
+ let session = document.querySelector(SELECTED_SESSION_SELECTOR);
1204
+ if (session !== null && (session.textContent ?? '').trimStart().startsWith(title)) {
1205
+ session.scrollIntoView({ block: 'center', behavior: 'smooth' });
1206
+ return;
1207
+ }
1208
+ // Slow path: the workspace is collapsed (the session row is not in the
1209
+ // DOM). Look up the workspace title from the app stores.
1210
+ const workspaceTitle = resolveWorkspaceTitle(title, sessionList, workspaceList);
1211
+ if (workspaceTitle === null) {
1212
+ // No store or no mapping — bail. The breadcrumb still tells the user
1213
+ // which session is current.
1214
+ return;
1215
+ }
1216
+ const hit = findProjectRowByTitle(workspaceTitle);
1217
+ if (hit === null) {
1218
+ // The session belongs to a workspace that's not in the sidebar at all
1219
+ // (e.g. archived). Nothing to reveal.
1220
+ return;
1221
+ }
1222
+ const { projectRow, group } = hit;
1223
+ const collapsed = projectRow.getAttribute('aria-expanded') === 'false';
1224
+ // Scroll the selected row into view once it exists; when it still doesn't
1225
+ // (the group caps rendered rows and the current session hides behind the
1226
+ // "expand remaining N sessions" overflow), click the overflow open first
1227
+ // and re-query on the next frame.
1228
+ const revealAndScroll = () => {
1229
+ const next = document.querySelector(SELECTED_SESSION_SELECTOR);
1230
+ if (next !== null) {
1231
+ next.scrollIntoView({ block: 'center', behavior: 'smooth' });
1232
+ return;
1233
+ }
1234
+ expandOverflow(group);
1235
+ requestAnimationFrame(() => {
1236
+ document.querySelector(SELECTED_SESSION_SELECTOR)
1237
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
1238
+ });
1239
+ };
1240
+ if (collapsed) {
1241
+ // Click triggers DSH's expand handler, which mounts the session rows
1242
+ // and marks the current one with `aria-selected="true"`.
1243
+ projectRow.click();
1244
+ requestAnimationFrame(revealAndScroll);
1245
+ }
1246
+ else {
1247
+ // Workspace open but the selected marker is missing (DSH state edge
1248
+ // case, or the row hides behind the overflow button): re-query by
1249
+ // title within the expanded group.
1250
+ session = findSessionRow(title);
1251
+ if (session !== null) {
1252
+ session.scrollIntoView({ block: 'center', behavior: 'smooth' });
1253
+ }
1254
+ else {
1255
+ revealAndScroll();
1256
+ }
1257
+ }
1258
+ }
1259
+ /** Build the button element. Reused across mount calls. The click
1260
+ * handler is wired separately in `setupLocateCurrentSession` so it can
1261
+ * capture the live session/workspace store references at click time.
1262
+ *
1263
+ * We use the `title` attribute for the tooltip — this triggers the
1264
+ * browser-native tooltip which has the same black background as the
1265
+ * tooltips on DSH's other native icon buttons (search, view options,
1266
+ * add workspace). This keeps the tooltip style consistent without
1267
+ * custom CSS. */
1268
+ function buildButton() {
1269
+ const btn = document.createElement('button');
1270
+ btn.type = 'button';
1271
+ btn.className = 'cst-locate-btn';
1272
+ btn.dataset.cstLocateBtn = '';
1273
+ const { label } = buttonCopy();
1274
+ btn.setAttribute('aria-label', label);
1275
+ // No `title` while enabled — the custom bubble below plays the native
1276
+ // Tooltip's role instead; a title would double up with it.
1277
+ btn.innerHTML = PIN_ICON_SVG;
1278
+ attachTooltip(btn);
1279
+ return btn;
1280
+ }
1281
+ /** Is the active DSH locale a Chinese one? Read from `<html lang>`, which
1282
+ * the locale service keeps synced to the active locale (`zh-CN`, `en`, …).
1283
+ * Used only for our own button copy — DSH's own strings go through `t`. */
1284
+ function isZhLocale() {
1285
+ return (document.documentElement.lang ?? '').toLowerCase().startsWith('zh');
1286
+ }
1287
+ /** Localized copy for the injected button (enabled + disabled states). */
1288
+ function buttonCopy() {
1289
+ return isZhLocale()
1290
+ ? { label: '定位当前会话', disabled: '请先打开一个会话' }
1291
+ : { label: 'Locate current session', disabled: 'Open a session first' };
1292
+ }
1293
+ /**
1294
+ * Hover tooltip, replicating DSH's native `<Tooltip>` primitive for our
1295
+ * non-React button: 500ms hover delay, `position: fixed` bubble appended
1296
+ * to `<body>` (escapes ancestor overflow clipping without a portal),
1297
+ * bottom placement with the same viewport-fit moves — horizontal slide
1298
+ * back inside near the edges, vertical flip above when there is no room
1299
+ * below. The single live bubble lives in a module variable; the button is
1300
+ * remounted by DSH on view transitions, so a shared instance survives.
1301
+ */
1302
+ const TOOLTIP_DELAY_MS = 500;
1303
+ const EDGE_MARGIN = 12;
1304
+ let liveBubble = null;
1305
+ function hideTooltip() {
1306
+ liveBubble?.remove();
1307
+ liveBubble = null;
1308
+ }
1309
+ function showTooltip(anchor, text) {
1310
+ hideTooltip();
1311
+ const r = anchor.getBoundingClientRect();
1312
+ const bubble = document.createElement('span');
1313
+ bubble.className = 'cst-locate-tooltip';
1314
+ bubble.setAttribute('role', 'tooltip');
1315
+ bubble.textContent = text;
1316
+ bubble.dataset.side = 'bottom';
1317
+ bubble.style.left = `${r.left + r.width / 2}px`;
1318
+ bubble.style.top = `${r.bottom + 8}px`;
1319
+ document.body.appendChild(bubble);
1320
+ liveBubble = bubble;
1321
+ // Viewport fit, same moves as DSH's fit pass.
1322
+ const b = bubble.getBoundingClientRect();
1323
+ let dx = 0;
1324
+ if (b.right > window.innerWidth - EDGE_MARGIN)
1325
+ dx = window.innerWidth - EDGE_MARGIN - b.right;
1326
+ if (b.left + dx < EDGE_MARGIN)
1327
+ dx = EDGE_MARGIN - b.left;
1328
+ bubble.style.left = `${r.left + r.width / 2 + dx}px`;
1329
+ const fitsBelow = r.bottom + 8 + b.height <= window.innerHeight - EDGE_MARGIN;
1330
+ const fitsAbove = r.top - 8 - b.height >= EDGE_MARGIN;
1331
+ if (!fitsBelow && fitsAbove) {
1332
+ bubble.dataset.side = 'top';
1333
+ bubble.style.top = `${r.top - 8}px`;
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Wire the tooltip triggers on a mounted button, chaining the DSH
1338
+ * semantics: hover delayed, keyboard focus immediate, either leaving
1339
+ * hides. A disabled button fires no mouse events, so the disabled hint
1340
+ * rides the `title` attribute instead (browser-native, still works).
1341
+ */
1342
+ function attachTooltip(btn) {
1343
+ let timer = null;
1344
+ const cancel = () => {
1345
+ if (timer !== null) {
1346
+ clearTimeout(timer);
1347
+ timer = null;
1348
+ }
1349
+ };
1350
+ btn.addEventListener('mouseenter', () => {
1351
+ if (btn.disabled)
1352
+ return;
1353
+ cancel();
1354
+ timer = setTimeout(() => {
1355
+ timer = null;
1356
+ showTooltip(btn, buttonCopy().label);
1357
+ }, TOOLTIP_DELAY_MS);
1358
+ });
1359
+ btn.addEventListener('mouseleave', () => { cancel(); hideTooltip(); });
1360
+ btn.addEventListener('blur', () => { cancel(); hideTooltip(); });
1361
+ btn.addEventListener('focus', () => {
1362
+ if (btn.disabled)
1363
+ return;
1364
+ cancel();
1365
+ showTooltip(btn, buttonCopy().label);
1366
+ });
1367
+ }
1368
+ /**
1369
+ * Reflect current-session availability on the injected button.
1370
+ * - Has a session → enabled, aria-label stays "定位当前会话".
1371
+ * - No session → disabled, aria-label becomes "请先打开一个会话".
1372
+ *
1373
+ * We swap the aria-label (not `title`) when disabled because:
1374
+ * - DSH's native icon buttons don't use `title` — they rely on
1375
+ * aria-label only, and any tooltip layer DSH paints is keyed off
1376
+ * aria-label.
1377
+ * - Setting `title` would summon the browser-native tooltip, which
1378
+ * has a different visual style from the rest of the sidebar.
1379
+ *
1380
+ * The "has session" check uses the breadcrumb (`nav[aria-label="会话层级"]`
1381
+ * contains a `button[disabled]` with the session title) — NOT the
1382
+ * sidebar's `aria-selected` row. The breadcrumb survives sidebar rebuilds
1383
+ * AND survives the workspace being collapsed (the selected treeitem
1384
+ * itself is removed from the DOM in that case).
1385
+ */
1386
+ function syncButtonEnabledState(btn) {
1387
+ const hasSession = readCurrentSessionTitle() !== null;
1388
+ const { label, disabled } = buttonCopy();
1389
+ btn.disabled = !hasSession;
1390
+ if (hasSession) {
1391
+ btn.removeAttribute('data-cst-locate-disabled');
1392
+ btn.setAttribute('aria-label', label);
1393
+ // Enabled: the custom hover bubble carries the hint; a title would
1394
+ // summon the browser tooltip on top of it.
1395
+ btn.removeAttribute('title');
1396
+ }
1397
+ else {
1398
+ btn.dataset.cstLocateDisabled = '';
1399
+ btn.setAttribute('aria-label', disabled);
1400
+ // Disabled buttons fire no mouse events, so the custom bubble can't
1401
+ // trigger — fall back to the browser-native title tooltip.
1402
+ btn.setAttribute('title', disabled);
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Mount the injected button into the search slot, immediately to the left
1407
+ * of the native search container, or refresh the enabled state of an
1408
+ * already-mounted one. Returns the button element, or null if the search
1409
+ * button is absent (caller should retry on the next DOM mutation).
1410
+ *
1411
+ * Anchors resolve from the stable `button[aria-label="搜索会话"]` hook
1412
+ * (CSS-Modules hashes like `bhn1Oq_*` rotate per DSH build), then walk
1413
+ * up: search container → search slot. The button is inserted into the
1414
+ * slot as a left sibling of the container — NOT inside the container,
1415
+ * which is a 28px round cell with `overflow: hidden` that would both
1416
+ * clip the button and break the header's even 4px icon spacing. CSS
1417
+ * turns the slot into a right-aligned flex row with `gap: 4px`, so the
1418
+ * four header icons (locate / search / view options / add workspace)
1419
+ * share the native spacing.
1420
+ *
1421
+ * `refresh` is the cheap path called from the observer: re-sync the existing
1422
+ * button's enabled state on every tick. The microtask coalesces a burst
1423
+ * of mutations into one re-sync.
1424
+ */
1425
+ function mountButton(refresh = false) {
1426
+ // Anchor: the localized aria-label first (evaluated per call, so a live
1427
+ // locale switch re-resolves on the next observer tick); structural
1428
+ // fallback when the locale service is unavailable.
1429
+ const label = searchButtonLabel();
1430
+ const searchBtn = document.querySelector(label !== undefined
1431
+ ? `button[aria-label="${label}"]`
1432
+ : SEARCH_BUTTON_FALLBACK_SELECTOR);
1433
+ if (searchBtn === null)
1434
+ return null;
1435
+ const searchContainer = searchBtn.parentElement;
1436
+ const slot = searchContainer?.parentElement;
1437
+ if (searchContainer == null || slot == null)
1438
+ return null;
1439
+ // Idempotency: refresh an existing button in place instead of re-mounting.
1440
+ const existing = slot.querySelector('button.cst-locate-btn');
1441
+ if (existing !== null) {
1442
+ if (refresh)
1443
+ syncButtonEnabledState(existing);
1444
+ return existing;
1445
+ }
1446
+ const btn = buildButton();
1447
+ syncButtonEnabledState(btn);
1448
+ slot.insertBefore(btn, searchContainer);
1449
+ return btn;
1450
+ }
1451
+ /**
1452
+ * Setup the live tweak. Returns a disposer that removes the button and
1453
+ * the observer. Safe to call when the search button never appears — the
1454
+ * tweak then stays inert until the next observer tick finds one.
1455
+ */
1456
+ function setupLocateCurrentSession(ctx) {
1457
+ // Own stylesheet lives and dies with the tweak itself, so index.tsx can
1458
+ // register this setup like any plain injector.
1459
+ const removeStyles = injectLocateCurrentSessionStyles();
1460
+ // Bind the locale namespaces the anchors need. `bind` returns a t that
1461
+ // reads the active locale on every call, so language switches propagate
1462
+ // with no rebind — the next observer tick re-resolves the aria-label
1463
+ // anchors through the same functions. Failures fall back to structural
1464
+ // anchors, exactly like the store lookups below.
1465
+ try {
1466
+ const locale = ctx.locale;
1467
+ if (locale !== undefined && typeof locale.bind === 'function') {
1468
+ tWorkspace = locale.bind('workspace');
1469
+ tConversation = locale.bind('conversation');
1470
+ }
1471
+ }
1472
+ catch {
1473
+ tWorkspace = undefined;
1474
+ tConversation = undefined;
1475
+ }
1476
+ // Pull the app's own stores the same way project-running-indicator does,
1477
+ // so we can locate the current session's workspace even when its sidebar
1478
+ // group is collapsed (and therefore the session treeitem itself is not
1479
+ // in the DOM). The two store getters are typed via the project's own
1480
+ // `import type` so a host build without them still compiles.
1481
+ let sessionList;
1482
+ let workspaceList;
1483
+ try {
1484
+ sessionList = ctx.get('sessions')?.list;
1485
+ workspaceList = ctx.get('workspaces')?.list;
1486
+ if (sessionList === undefined || workspaceList === undefined
1487
+ || typeof sessionList.getSnapshot !== 'function'
1488
+ || typeof workspaceList.getSnapshot !== 'function') {
1489
+ throw new Error('unexpected store shape');
1490
+ }
1491
+ }
1492
+ catch {
1493
+ sessionList = undefined;
1494
+ workspaceList = undefined;
1495
+ }
1496
+ // Wire the click handler on every button we mount (including re-mounts
1497
+ // after a sidebar rebuild). The handler reads the live store snapshots
1498
+ // at click time, so session/workspace changes propagate without rebind.
1499
+ const onClick = (event) => {
1500
+ event.preventDefault();
1501
+ event.stopPropagation();
1502
+ performLocate(sessionList, workspaceList);
1503
+ };
1504
+ // Try once synchronously so the first paint already shows the button.
1505
+ const initial = mountButton();
1506
+ if (initial !== null)
1507
+ initial.addEventListener('click', onClick);
1508
+ // Watch for the search button to appear / be replaced (DSH rebuilds the
1509
+ // sidebar header on width toggle, workspace switch, and other view
1510
+ // transitions). MutationObserver coalesces a burst of changes into one
1511
+ // microtask. We also listen for attribute changes so the enabled state
1512
+ // tracks the current `aria-selected` row as the user switches sessions
1513
+ // without DSH rebuilding the tree.
1514
+ let scheduled = false;
1515
+ const schedule = () => {
1516
+ if (scheduled)
1517
+ return;
1518
+ scheduled = true;
1519
+ queueMicrotask(() => {
1520
+ scheduled = false;
1521
+ // refresh=true re-syncs the existing button's enabled state on every
1522
+ // tick; mountButton falls back to a no-op mount when the button is
1523
+ // already there. The microtask coalesces the burst into one re-sync.
1524
+ const btn = mountButton(true);
1525
+ if (btn !== null && !btn.dataset.cstLocateWired) {
1526
+ btn.addEventListener('click', onClick);
1527
+ btn.dataset.cstLocateWired = '1';
1528
+ }
1529
+ });
1530
+ };
1531
+ const observer = new MutationObserver(schedule);
1532
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-selected', 'aria-expanded'] });
1533
+ return () => {
1534
+ observer.disconnect();
1535
+ hideTooltip();
1536
+ removeStyles();
1537
+ for (const btn of document.querySelectorAll('button.cst-locate-btn')) {
1538
+ btn.removeEventListener('click', onClick);
1539
+ btn.remove();
1540
+ }
1541
+ };
1542
+ }
1543
+ };
1544
+ __modules["./tweaks/project-running-indicator.js"] = function(module, exports, require, __load_) {
1545
+ "use strict";
1546
+ /**
1547
+ * dsh-style-tweaks — project-running-indicator tweak.
1548
+ *
1549
+ * The first non-pure-CSS tweak: it renders the *app's own* `StateDot`
1550
+ * (the animated pixel-chase shown left of a running conversation's title,
1551
+ * `ui-primitives/src/StateDot.tsx`) on the right side of every project
1552
+ * directory header row in the sidebar, so a running conversation stays
1553
+ * visible even when its group is collapsed.
1554
+ *
1555
+ * ## Why DOM patching
1556
+ *
1557
+ * DSH's project header (`ProjectRowItem`, `ui-workspace/src/client/rows/
1558
+ * Rows.tsx:112`) exposes no plugin slot, and the `sidebar.workspaces` seat
1559
+ * is already occupied by the stock browser — so the header row can only be
1560
+ * decorated from outside, via a MutationObserver. Consistent with this
1561
+ * repo's selector philosophy, the engine anchors exclusively on hand-written
1562
+ * DOM facts that survive CSS-Modules hashing:
1563
+ *
1564
+ * - group header rows are the only `role="treeitem"` elements carrying
1565
+ * `aria-expanded` (session rows and flat-list rows never have it);
1566
+ * - the row's title text is the only `:scope > span > span` descendant
1567
+ * with non-empty text (folder/chevron slots hold bare SVGs, the action
1568
+ * buttons' labels live on `aria-label`, not text).
1569
+ *
1570
+ * ## Data source
1571
+ *
1572
+ * Running state is read from the app's own stores, not inferred from the
1573
+ * DOM (a collapsed group hides its child rows, so the DOM cannot answer):
1574
+ *
1575
+ * - `ctx.get('sessions').list` — snapshot store of `SessionSummary`s
1576
+ * (`byId[id].running`);
1577
+ * - `ctx.get('workspaces').list` — `WorkspaceSnapshot.items`, each
1578
+ * `WorkspaceView.sessionIds` mapping sessions to directories.
1579
+ *
1580
+ * Membership is computed from the workspace arrays (mirroring
1581
+ * `deriveGroups` in `ui-workspace/src/client/tree.ts`): archived and blank
1582
+ * sessions are skipped, sessions not in any workspace count toward the
1583
+ * "Ungrouped" bucket. A running subagent marks its top-level ancestor
1584
+ * session busy (same lineage walk as `indexSubagentDescendants`), which is
1585
+ * what the title's own dot shows.
1586
+ *
1587
+ * Header rows are matched to workspaces by title text; a header whose label
1588
+ * matches no workspace title is the Ungrouped bucket. This avoids depending
1589
+ * on DSH's locale copy for the bucket label (and tolerates renames for
1590
+ * free, since the title text *is* the match key).
1591
+ *
1592
+ * ## Rendering
1593
+ *
1594
+ * The dot is the real `StateDot` imported from
1595
+ * `@deepseek-ai/dsh-client-ui-primitives`. That package is in the client
1596
+ * shell's `PLATFORM_MODULES` baseline, so at runtime the import resolves to
1597
+ * the app's shared instance — its CSS module (and the global
1598
+ * `dsh-state-dot-chase` keyframes) and theme tokens are already loaded, and
1599
+ * the animation is pixel-identical to the one on session titles. The dot is
1600
+ * mounted with a `react-dom/client` root per decorated row and unmounted
1601
+ * whenever the group goes idle, the row leaves the DOM, or the tweak is
1602
+ * disabled.
1603
+ */
1604
+ Object.defineProperty(exports, "__esModule", { value: true });
1605
+ exports.setupProjectRunningIndicator = setupProjectRunningIndicator;
1606
+ const react_1 = require("react");
1607
+ const client_1 = require("react-dom/client");
1608
+ const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
1609
+ const INDICATOR_CSS = `
1610
+ span[data-cst-proj-indicator] {
1611
+ display: inline-flex;
1612
+ align-items: center;
1613
+ flex: none;
1614
+ margin-left: 2px;
1615
+ pointer-events: none;
1616
+ }
1617
+ `;
1618
+ const INDICATOR_CSS_ID = 'cst-project-running-indicator';
1619
+ /** Group header rows are the only treeitems with aria-expanded (Rows.tsx:143). */
1620
+ const HEADER_SELECTOR = '[role="treeitem"][aria-expanded]';
1621
+ function installIndicatorStyles() {
1622
+ let style = document.querySelector(`style[data-tweak-css="${INDICATOR_CSS_ID}"]`);
1623
+ if (style === null) {
1624
+ style = document.createElement('style');
1625
+ style.dataset.tweak = 'cst';
1626
+ style.dataset.tweakCss = INDICATOR_CSS_ID;
1627
+ style.textContent = INDICATOR_CSS;
1628
+ document.head.appendChild(style);
1629
+ }
1630
+ return () => { style?.remove(); };
1631
+ }
1632
+ /**
1633
+ * Derive which group labels must show a dot. Mirrors `deriveGroups`
1634
+ * membership rules: archived and blank sessions never render as rows, and
1635
+ * subagent rows belong to their top-level ancestor's group.
1636
+ */
1637
+ function computeGroupBusy(sessions, workspaces) {
1638
+ const list = sessions.getSnapshot();
1639
+ const snapshot = workspaces.getSnapshot();
1640
+ const archived = new Set(snapshot.archivedSessionIds);
1641
+ // Running subagents light up their top-level ancestor (Rows.tsx
1642
+ // sessionStatuses shows 'ongoing' for runningSubagentCount > 0).
1643
+ const busyAncestors = new Set();
1644
+ for (const summary of Object.values(list.byId)) {
1645
+ if (summary === undefined)
1646
+ continue;
1647
+ if (!summary.running || summary.origin !== 'subagent')
1648
+ continue;
1649
+ const seen = new Set();
1650
+ let current = summary;
1651
+ while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) {
1652
+ seen.add(current.id);
1653
+ current = list.byId[current.parentId];
1654
+ }
1655
+ if (current !== undefined && current.origin !== 'subagent')
1656
+ busyAncestors.add(current.id);
1657
+ }
1658
+ const isBusy = (id) => {
1659
+ const summary = list.byId[id];
1660
+ if (summary === undefined)
1661
+ return false;
1662
+ return summary.running || busyAncestors.has(id);
1663
+ };
1664
+ const busyTitles = new Set();
1665
+ const knownTitles = new Set();
1666
+ const grouped = new Set();
1667
+ for (const workspace of snapshot.items) {
1668
+ knownTitles.add(workspace.title);
1669
+ for (const id of workspace.sessionIds) {
1670
+ grouped.add(id);
1671
+ if (archived.has(id))
1672
+ continue;
1673
+ const summary = list.byId[id];
1674
+ // Blank sessions are hidden from the browser (tree.ts deriveGroups).
1675
+ if (summary === undefined || summary.blank)
1676
+ continue;
1677
+ if (isBusy(id))
1678
+ busyTitles.add(workspace.title);
1679
+ }
1680
+ }
1681
+ // Ungrouped bucket: host-list rows in no workspace (ids excludes the
1682
+ // breadcrumb-only subagent routes that byId additionally carries).
1683
+ let ungroupedBusy = false;
1684
+ for (const id of list.ids) {
1685
+ if (grouped.has(id) || archived.has(id))
1686
+ continue;
1687
+ const summary = list.byId[id];
1688
+ if (summary === undefined || summary.blank || summary.origin === 'subagent')
1689
+ continue;
1690
+ if (isBusy(id)) {
1691
+ ungroupedBusy = true;
1692
+ break;
1693
+ }
1694
+ }
1695
+ return { busyTitles, knownTitles, ungroupedBusy };
1696
+ }
1697
+ /** Title text of a header row: the only span-in-span with visible text. */
1698
+ function rowLabel(row) {
1699
+ for (const span of row.querySelectorAll(':scope > span > span')) {
1700
+ const label = span.textContent?.trim();
1701
+ if (label !== undefined && label.length > 0)
1702
+ return label;
1703
+ }
1704
+ return undefined;
1705
+ }
1706
+ /**
1707
+ * Mount/unmount the live tweak. Returns the disposer; safe to call when the
1708
+ * app stores are absent (unknown host build) — the tweak then stays inert.
1709
+ *
1710
+ * HMR-safe: the guard lives on `window` so it survives module reloads. A
1711
+ * hot-reload that calls this again before the previous cleanup runs will
1712
+ * tear down the stale instance first instead of stacking two
1713
+ * MutationObservers / two dots per row.
1714
+ */
1715
+ const GLOBAL_KEY = '__cst_project_running_indicator_cleanup__';
1716
+ function getGlobalCleanup() {
1717
+ return window[GLOBAL_KEY];
1718
+ }
1719
+ function setGlobalCleanup(fn) {
1720
+ ;
1721
+ window[GLOBAL_KEY] = fn;
1722
+ }
1723
+ function setupProjectRunningIndicator(ctx) {
1724
+ const previous = getGlobalCleanup();
1725
+ if (typeof previous === 'function') {
1726
+ previous();
1727
+ setGlobalCleanup(undefined);
1728
+ }
1729
+ const removeStyles = installIndicatorStyles();
1730
+ let sessionList;
1731
+ let workspaceList;
1732
+ try {
1733
+ // The services expose their state as snapshot stores: `sessions.list`
1734
+ // (SessionListState) and `workspaces.list` (WorkspaceSnapshot).
1735
+ sessionList = ctx.get('sessions').list;
1736
+ workspaceList = ctx.get('workspaces').list;
1737
+ if (typeof sessionList?.subscribe !== 'function' || typeof workspaceList?.subscribe !== 'function') {
1738
+ throw new Error('unexpected store shape');
1739
+ }
1740
+ }
1741
+ catch {
1742
+ // Unknown host build without the expected services: keep the CSS inert.
1743
+ return () => { removeStyles(); };
1744
+ }
1745
+ const indicators = new Map();
1746
+ const removeIndicator = (row) => {
1747
+ const indicator = indicators.get(row);
1748
+ if (indicator === undefined)
1749
+ return;
1750
+ indicators.delete(row);
1751
+ indicator.root.unmount();
1752
+ indicator.host.remove();
1753
+ // After this point the entry is gone from `indicators` and the host is
1754
+ // detached, so the React root and DOM node are both GC-eligible. Do not
1755
+ // call `indicator.root.render(...)` past here — it's a defunct handle.
1756
+ };
1757
+ const syncRow = (row, busy) => {
1758
+ const label = rowLabel(row);
1759
+ if (label === undefined)
1760
+ return;
1761
+ // The Ungrouped bucket is the header whose label matches no workspace
1762
+ // title — its label is DSH locale copy, so it is matched negatively.
1763
+ const active = busy.busyTitles.has(label)
1764
+ || (busy.ungroupedBusy && !busy.knownTitles.has(label));
1765
+ let indicator = indicators.get(row);
1766
+ if (!active) {
1767
+ // Group went idle (or the row lost its label): unmount the dot.
1768
+ if (indicator !== undefined)
1769
+ removeIndicator(row);
1770
+ return;
1771
+ }
1772
+ if (indicator === undefined) {
1773
+ const host = document.createElement('span');
1774
+ host.dataset.cstProjIndicator = '';
1775
+ row.insertBefore(host, row.lastElementChild);
1776
+ indicator = { host, root: (0, client_1.createRoot)(host), shown: false };
1777
+ indicators.set(row, indicator);
1778
+ }
1779
+ else if (!indicator.host.isConnected) {
1780
+ // Don't re-render on reattach — `shown` stays true and the root keeps
1781
+ // its existing vDOM, so the StateDot's animation continues uninterrupted
1782
+ // when the row leaves and re-enters the DOM.
1783
+ row.insertBefore(indicator.host, row.lastElementChild);
1784
+ }
1785
+ if (!indicator.shown) {
1786
+ indicator.shown = true;
1787
+ indicator.root.render((0, react_1.createElement)(dsh_client_ui_primitives_1.StateDot, { state: 'ongoing' }));
1788
+ }
1789
+ };
1790
+ const sync = () => {
1791
+ // Rows removed from the document: their entries must go (Map, not
1792
+ // WeakMap, because we need isConnected checks + explicit removal).
1793
+ for (const row of [...indicators.keys()]) {
1794
+ if (!row.isConnected)
1795
+ removeIndicator(row);
1796
+ }
1797
+ const busy = computeGroupBusy(sessionList, workspaceList);
1798
+ for (const row of document.querySelectorAll(HEADER_SELECTOR)) {
1799
+ syncRow(row, busy);
1800
+ }
1801
+ };
1802
+ // Coalesce a burst of DOM mutations into one scan per microtask: the
1803
+ // observer spans `document.body` and every streaming chunk lights it up
1804
+ // for a few ticks, but they all settle before the microtask queue drains.
1805
+ let syncScheduled = false;
1806
+ const scheduleSync = () => {
1807
+ if (syncScheduled)
1808
+ return;
1809
+ syncScheduled = true;
1810
+ queueMicrotask(() => {
1811
+ syncScheduled = false;
1812
+ sync();
1813
+ });
1814
+ };
1815
+ const observer = new MutationObserver(() => { scheduleSync(); });
1816
+ observer.observe(document.body, { childList: true, subtree: true });
1817
+ const unsubscribeSessions = sessionList.subscribe(() => { scheduleSync(); });
1818
+ const unsubscribeWorkspaces = workspaceList.subscribe(() => { scheduleSync(); });
1819
+ sync();
1820
+ const cleanup = () => {
1821
+ unsubscribeSessions();
1822
+ unsubscribeWorkspaces();
1823
+ observer.disconnect();
1824
+ for (const row of [...indicators.keys()])
1825
+ removeIndicator(row);
1826
+ removeStyles();
1827
+ setGlobalCleanup(undefined);
1828
+ };
1829
+ setGlobalCleanup(cleanup);
1830
+ return cleanup;
1831
+ }
1832
+ };
1833
+ __modules["./tweaks/registry.js"] = function(module, exports, require, __load_) {
1834
+ "use strict";
1835
+ /**
1836
+ * dsh-style-tweaks — tweak registry.
1837
+ *
1838
+ * Central catalogue of all CSS tweaks the plugin can inject. Each tweak is
1839
+ * described once here; the client iterates this list to (a) render Settings
1840
+ * panel toggles and (b) mount / unmount the corresponding styles live.
1841
+ */
1842
+ Object.defineProperty(exports, "__esModule", { value: true });
1843
+ exports.TWEAKS = void 0;
1844
+ /** All tweaks, in display order. Add new entries here. */
1845
+ exports.TWEAKS = [
1846
+ {
1847
+ id: 'stable-table',
1848
+ settingKey: 'stableTable',
1849
+ defaultEnabled: true,
1850
+ titleKey: 'tweak.stableTable.title',
1851
+ descriptionKey: 'tweak.stableTable.description',
1852
+ },
1853
+ {
1854
+ id: 'stable-turn-rail',
1855
+ settingKey: 'stableTurnRail',
1856
+ defaultEnabled: true,
1857
+ titleKey: 'tweak.stableTurnRail.title',
1858
+ descriptionKey: 'tweak.stableTurnRail.description',
1859
+ },
1860
+ {
1861
+ id: 'code-block-flush-top',
1862
+ settingKey: 'codeBlockFlushTop',
1863
+ defaultEnabled: true,
1864
+ titleKey: 'tweak.codeBlockFlushTop.title',
1865
+ descriptionKey: 'tweak.codeBlockFlushTop.description',
1866
+ },
1867
+ {
1868
+ id: 'project-running-indicator',
1869
+ settingKey: 'projectRunningIndicator',
1870
+ defaultEnabled: true,
1871
+ titleKey: 'tweak.projectRunningIndicator.title',
1872
+ descriptionKey: 'tweak.projectRunningIndicator.description',
1873
+ },
1874
+ {
1875
+ id: 'locate-current-session',
1876
+ settingKey: 'locateCurrentSession',
1877
+ defaultEnabled: true,
1878
+ titleKey: 'tweak.locateCurrentSession.title',
1879
+ descriptionKey: 'tweak.locateCurrentSession.description',
1880
+ },
1881
+ {
1882
+ id: 'settings-nav-scroll',
1883
+ settingKey: 'settingsNavScroll',
1884
+ defaultEnabled: true,
1885
+ titleKey: 'tweak.settingsNavScroll.title',
1886
+ descriptionKey: 'tweak.settingsNavScroll.description',
1887
+ },
1888
+ ];
1889
+ };
1890
+ __modules["./tweaks/settings-nav-scroll.js"] = function(module, exports, require, __load_) {
1891
+ "use strict";
1892
+ /**
1893
+ * dsh-style-tweaks — settings-nav-scroll tweak.
1894
+ *
1895
+ * Makes the settings dialog's LEFT nav column scrollable once its section
1896
+ * list outgrows the fixed-height panel, instead of silently clipping the
1897
+ * entries at the bottom. Without this, a user with several plugin-registered
1898
+ * sections cannot reach the last items at all — the panel hides them and
1899
+ * nothing scrolls. The scrollbar itself is an overlay-style thin strip:
1900
+ * parked in the rail's spare padding so it never squeezes the menu, and
1901
+ * visible only while the list is actually being scrolled (macOS-style),
1902
+ * driven by a scroll listener rather than `:hover`.
1903
+ *
1904
+ * ## Symptom
1905
+ *
1906
+ * The settings dialog is DSH's `SettingsRoot`
1907
+ * (`packages/client/ui-settings-general/src/client/SettingsRoot.tsx`):
1908
+ *
1909
+ * <div className={css.panel} role="dialog" aria-modal="true"> ← 800px, overflow: hidden
1910
+ * <nav className={css.nav}> ← 188px rail
1911
+ * <div className={css.navTitle}>…</div>
1912
+ * <div className={css.navList}> ← the section buttons
1913
+ * <button className={css.navCell}>…</button> × N
1914
+ * </div>
1915
+ * </nav>
1916
+ * <div className={css.content}> … </div>
1917
+ * </div>
1918
+ *
1919
+ * The panel has ONE height for every section (`SettingsRoot.module.css`:
1920
+ * `height: min(800px, calc(100vh - 48px)); overflow: hidden`) so it does not
1921
+ * resize under the pointer between nav clicks, and its own comment states
1922
+ * "whatever does not fit scrolls in `.options`" — the right content column
1923
+ * got `overflow-y: auto`, but the LEFT nav never received the same
1924
+ * treatment. `.navList` is a plain flex column (`gap: 4px`, default
1925
+ * `flex: 0 1 auto`, no `min-height: 0`, no overflow), and its `min-height:
1926
+ * auto` forbids shrinking below the content, so the list simply overflows
1927
+ * `.nav` and the `.panel`'s `overflow: hidden` clips whatever spills past
1928
+ * the bottom edge. With enough plugin sections (this plugin alone adds one),
1929
+ * the last nav entries become unreachable.
1930
+ *
1931
+ * ## Fix — part 1 (CSS): make the list the scroll container
1932
+ *
1933
+ * One rule turning the nav list into the scroll container of the nav rail's
1934
+ * leftover height:
1935
+ *
1936
+ * flex: 1 — the list owns the column height below the title
1937
+ * (previously implicit; nav is a flex column whose
1938
+ * only other child is the title row).
1939
+ * min-height: 0 — the load-bearing line: overrides the flex item's
1940
+ * `min-height: auto`, allowing the list to be shorter
1941
+ * than its content so overflow becomes possible.
1942
+ * overflow-y: auto — scroll only when entries exceed the height; a short
1943
+ * list looks exactly like stock DSH.
1944
+ * overscroll-behavior: contain — at the list's scroll end the chain stops
1945
+ * here instead of scrolling some page behind the modal.
1946
+ * padding-bottom: 12px — breathing room after the last entry when fully
1947
+ * scrolled (`.nav` has no bottom padding by design);
1948
+ * invisible when the list does not scroll.
1949
+ * margin-inline-end: -12px — parks the bar OUT of the content column,
1950
+ * 8px clear of the cells (see part 2).
1951
+ *
1952
+ * The title row stays fixed and the panel keeps its size — no layout shift
1953
+ * anywhere else.
1954
+ *
1955
+ * ## Fix — part 2 (CSS): the bar parks in the rail's padding gutter, clear of the cells
1956
+ *
1957
+ * A classic scrollbar consumes its width from the element's content box —
1958
+ * the first shipped version narrowed the cells 164 → 156px the moment the
1959
+ * bar appeared, and the bar sat there permanently while the list overflowed.
1960
+ * Native Chromium scrollbars have no CSS "overlay" mode, but the classic
1961
+ * bar can be parked out of the content column:
1962
+ *
1963
+ * margin-inline-end: -12px — the list's border box stretches the rail's
1964
+ * full 12px right padding into itself: 8px gap + 4px
1965
+ * bar strip. The thumb therefore sits 8px clear of the
1966
+ * entries (values user-tuned in the console as W=4 /
1967
+ * GAP=8, then solidified into the tweak) and floats
1968
+ * over the rail's padding area like an overlay bar,
1969
+ * flush with the gutter edge before the content column
1970
+ * boundary.
1971
+ *
1972
+ * [class*="_navCell"] { max-width: 164px } — the park widens the list's
1973
+ * content box whenever the bar is ABSENT (no overflow →
1974
+ * content 176px), which made the hover highlight paint
1975
+ * 12px wider than stock in that state. Capping the cells
1976
+ * at DSH's stock width (188 nav − 2×12 padding) pins
1977
+ * them to 164px in EVERY state — bar present, bar
1978
+ * absent, hovering, scrolling — so the rail's geometry
1979
+ * never differs from stock by a pixel. If DSH ever
1980
+ * widens the rail, the cap only over-narrows cosmetically;
1981
+ * if it narrows, stretch wins below the cap and nothing
1982
+ * changes.
1983
+ *
1984
+ * ::-webkit-scrollbar { width: 4px } — thinner than DSH's global 8px
1985
+ * skin; colour/radius of the visible thumb come from
1986
+ * DSH's global thumb rules (l2 tokens inherited from
1987
+ * the panel; the 4px radius auto-clamps to 2px on this
1988
+ * width), so only geometry lives here.
1989
+ *
1990
+ * ## Fix — part 3 (JS): show only while scrolling
1991
+ *
1992
+ * The idle bar must be invisible, and the first attempt used the classic
1993
+ * hover-reveal (`:not(:hover)::-webkit-scrollbar-thumb → transparent`).
1994
+ * Chromium does not reliably repaint custom scrollbar pseudo-elements when
1995
+ * only the HOST's `:hover` state changes — the thumb kept its last painted
1996
+ * style until an unrelated invalidation; in practice the bar appeared when
1997
+ * clicking a nav cell (React re-rendered the list → repaint) instead of
1998
+ * while scrolling, which is exactly backwards.
1999
+ *
2000
+ * So the show condition is driven by real activity: `scroll` events on the
2001
+ * list toggle a `cst-nav-scroll-show` class on the list itself, and ~800ms
2002
+ * after the last scroll event it is removed (macOS overlay-bar timing).
2003
+ * Class changes restyle the element itself, so the scrollbar repaints
2004
+ * reliably — no dependence on host pseudo-state. Wheel-scrolling requires
2005
+ * the pointer on the list, thumb drags fire scroll, and programmatic
2006
+ * scrolls (keyboard focus moving through the cells) count as activity too;
2007
+ * idle hovering shows nothing, matching "hide when not scrolling".
2008
+ *
2009
+ * The JS surface is deliberately small: a MutationObserver keeps the
2010
+ * scroll listener attached to whichever element currently matches the
2011
+ * selector (the dialog unmounts when closed and remounts when opened),
2012
+ * one passive `scroll` listener per attached list, one idle timer.
2013
+ *
2014
+ * ## Selector strategy
2015
+ *
2016
+ * DSH hashes CSS Modules class names with the `[hash]_[local]` pattern
2017
+ * (`packages/client/tsdown.client.ts`: `cssModules: { pattern:
2018
+ * '[hash]_[local]' }`), so the compiled class is `<hash>_navList` — the hash
2019
+ * is unpredictable across DSH versions, the local name is not. We match the
2020
+ * fragment with `[class*="_navList"]` and pin it structurally to the one
2021
+ * place that renders it: the modal panel's nav rail
2022
+ * (`div[role="dialog"][aria-modal="true"] > nav > …`). Both anchors are
2023
+ * redundant on their own; together they survive either DSH reshuffling the
2024
+ * dialog structure or renaming the local class.
2025
+ *
2026
+ * `navList` is defined in exactly one CSS module in DSH
2027
+ * (`ui-settings-general/src/client/SettingsRoot.module.css`), so the
2028
+ * fragment cannot collide with other dialogs (Modal primitive, lightbox,
2029
+ * onboarding steps, …) even before the structural pin.
2030
+ *
2031
+ * ## Specificity
2032
+ *
2033
+ * DSH's `.navList { display: flex; flex-direction: column; gap: 4px }` is
2034
+ * (0,0,1,0). The base rule is (0,3,2) — three attribute selectors plus two
2035
+ * element selectors — and DSH sets none of the base properties we touch.
2036
+ * The idle-thumb rule (0,3,2) beats DSH's unscoped global thumb skin
2037
+ * (0,0,1); the show rule adds a class (0,4,2) and its hover variant (0,5,2)
2038
+ * restores DSH's idle/hover token pair while shown. No `!important`
2039
+ * anywhere.
2040
+ *
2041
+ * ## Scrollbar skin
2042
+ *
2043
+ * The visible thumb reads DSH's themed scrollbar tokens: the panel rebinds
2044
+ * `--dsh-scrollbar-thumb{,-hover}` to the l2 elevation tokens (declared on
2045
+ * `.panel` so they inherit into any scrolling descendant); we reference the
2046
+ * same custom properties so the nav bar matches the right column's look,
2047
+ * only thinner.
2048
+ */
2049
+ Object.defineProperty(exports, "__esModule", { value: true });
2050
+ exports.SETTINGS_NAV_SCROLL_CSS_ID = void 0;
2051
+ exports.setupSettingsNavScroll = setupSettingsNavScroll;
2052
+ const NAV_LIST_SELECTOR = 'div[role="dialog"][aria-modal="true"] > nav > [class*="_navList"]';
2053
+ /** Class the scroll driver toggles to reveal the thumb. Lives on the list itself. */
2054
+ const SHOW_CLASS = 'cst-nav-scroll-show';
2055
+ /** Overlay-bar idle delay: fade out this long after the last scroll event. */
2056
+ const HIDE_DELAY_MS = 800;
2057
+ const SETTINGS_NAV_SCROLL_CSS = `
2058
+ /* Settings dialog left nav list: become the scroll container once the
2059
+ * section entries outgrow the fixed-height panel, with the bar parked in
2060
+ * the rail's right padding gutter — 8px gap + 4px strip = the full 12px
2061
+ * padding (see the module doc for the full derivation — panel
2062
+ * overflow:hidden clips the overflow today, and .options only gave the
2063
+ * RIGHT column a scroll treatment). */
2064
+ ${NAV_LIST_SELECTOR} {
2065
+ flex: 1;
2066
+ min-height: 0;
2067
+ overflow-y: auto;
2068
+ overscroll-behavior: contain;
2069
+ padding-bottom: 12px;
2070
+ margin-inline-end: -12px;
2071
+ }
2072
+
2073
+ /* Pin the cells to DSH's stock width (188 nav − 2×12 padding): the parked
2074
+ * margin widens the list's content box whenever the bar is absent, and
2075
+ * without the cap the hover highlight would paint 6px wider than stock in
2076
+ * that state. With the cap the cells stay stock-width in every state. */
2077
+ ${NAV_LIST_SELECTOR} > [class*="_navCell"] {
2078
+ max-width: 164px;
2079
+ }
2080
+
2081
+ /* Thin bar: 4px vs DSH's global 8px skin. Idle thumb is blanked; the
2082
+ * scroll driver below adds ${SHOW_CLASS} to reveal it with DSH's own l2
2083
+ * token colours while the list is actually scrolling. */
2084
+ ${NAV_LIST_SELECTOR}::-webkit-scrollbar {
2085
+ width: 4px;
2086
+ }
2087
+ ${NAV_LIST_SELECTOR}::-webkit-scrollbar-thumb {
2088
+ background: transparent;
2089
+ transition: background-color .15s ease;
2090
+ }
2091
+ ${NAV_LIST_SELECTOR}.${SHOW_CLASS}::-webkit-scrollbar-thumb {
2092
+ background: var(--dsh-scrollbar-thumb);
2093
+ }
2094
+ ${NAV_LIST_SELECTOR}.${SHOW_CLASS}::-webkit-scrollbar-thumb:hover {
2095
+ background: var(--dsh-scrollbar-thumb-hover);
2096
+ }
2097
+ `;
2098
+ exports.SETTINGS_NAV_SCROLL_CSS_ID = 'cst-settings-nav-scroll';
2099
+ /** Idempotent style install, same pattern as the pure-CSS tweaks — with one
2100
+ * upgrade: if HMR re-runs setup while the previous style element survived
2101
+ * (teardown order across a hot swap is not guaranteed), its text is from the
2102
+ * OLD bundle. Refresh the text unconditionally so the new rules always win. */
2103
+ function installNavScrollStyles() {
2104
+ let style = document.querySelector(`style[data-tweak-css="${exports.SETTINGS_NAV_SCROLL_CSS_ID}"]`);
2105
+ if (style === null) {
2106
+ style = document.createElement('style');
2107
+ style.dataset.tweak = 'cst';
2108
+ style.dataset.tweakCss = exports.SETTINGS_NAV_SCROLL_CSS_ID;
2109
+ document.head.appendChild(style);
2110
+ }
2111
+ if (style.textContent !== SETTINGS_NAV_SCROLL_CSS)
2112
+ style.textContent = SETTINGS_NAV_SCROLL_CSS;
2113
+ return () => { style?.remove(); };
2114
+ }
2115
+ /**
2116
+ * HMR duplicate-setup guard (same pattern as project-running-indicator): a
2117
+ * hot reload can run setup again before the previous effect's cleanup ran;
2118
+ * run the stale cleanup first so two drivers never fight over one list.
2119
+ */
2120
+ const GLOBAL_KEY = '__cst_settings_nav_scroll_cleanup__';
2121
+ function getGlobalCleanup() {
2122
+ return window[GLOBAL_KEY];
2123
+ }
2124
+ function setGlobalCleanup(fn) {
2125
+ ;
2126
+ window[GLOBAL_KEY] = fn;
2127
+ }
2128
+ /**
2129
+ * Mount the scroll driver: keep the scroll listener on whichever element
2130
+ * currently matches the nav-list selector, reveal the thumb while it
2131
+ * scrolls, fade it out after the idle delay.
2132
+ * @param _ctx - client context (unused; signature parity with JS tweaks).
2133
+ * @returns the disposer removing styles, listeners, and observers.
2134
+ */
2135
+ function setupSettingsNavScroll(_ctx) {
2136
+ const previous = getGlobalCleanup();
2137
+ if (typeof previous === 'function') {
2138
+ previous();
2139
+ setGlobalCleanup(undefined);
2140
+ }
2141
+ const removeStyles = installNavScrollStyles();
2142
+ let list;
2143
+ let onScroll;
2144
+ let hideTimer;
2145
+ const detach = () => {
2146
+ if (list === undefined)
2147
+ return;
2148
+ if (onScroll !== undefined)
2149
+ list.removeEventListener('scroll', onScroll);
2150
+ list.classList.remove(SHOW_CLASS);
2151
+ if (hideTimer !== undefined) {
2152
+ window.clearTimeout(hideTimer);
2153
+ hideTimer = undefined;
2154
+ }
2155
+ list = undefined;
2156
+ onScroll = undefined;
2157
+ };
2158
+ /** Fade the thumb out; the scroll listener stays attached for the next scroll. */
2159
+ const hide = () => {
2160
+ if (list === undefined)
2161
+ return;
2162
+ list.classList.remove(SHOW_CLASS);
2163
+ hideTimer = undefined;
2164
+ };
2165
+ const show = () => {
2166
+ if (list === undefined)
2167
+ return;
2168
+ list.classList.add(SHOW_CLASS);
2169
+ if (hideTimer !== undefined)
2170
+ window.clearTimeout(hideTimer);
2171
+ hideTimer = window.setTimeout(hide, HIDE_DELAY_MS);
2172
+ };
2173
+ const attach = (next) => {
2174
+ detach();
2175
+ list = next;
2176
+ onScroll = show;
2177
+ list.addEventListener('scroll', onScroll, { passive: true });
2178
+ };
2179
+ const scan = () => {
2180
+ const found = document.querySelector(NAV_LIST_SELECTOR);
2181
+ if (found === list)
2182
+ return;
2183
+ if (found === null) {
2184
+ detach();
2185
+ return;
2186
+ }
2187
+ attach(found);
2188
+ };
2189
+ let frame = 0;
2190
+ const scheduleScan = () => {
2191
+ if (frame !== 0)
2192
+ return;
2193
+ frame = window.requestAnimationFrame(() => {
2194
+ frame = 0;
2195
+ scan();
2196
+ });
2197
+ };
2198
+ const observer = new MutationObserver(scheduleScan);
2199
+ observer.observe(document.body, { childList: true, subtree: true });
2200
+ scan();
2201
+ const cleanup = () => {
2202
+ observer.disconnect();
2203
+ if (frame !== 0)
2204
+ window.cancelAnimationFrame(frame);
2205
+ detach();
2206
+ removeStyles();
2207
+ setGlobalCleanup(undefined);
2208
+ };
2209
+ setGlobalCleanup(cleanup);
2210
+ return cleanup;
2211
+ }
2212
+ };
2213
+ __modules["./tweaks/stable-table.js"] = function(module, exports, require, __load_) {
2214
+ "use strict";
2215
+ Object.defineProperty(exports, "__esModule", { value: true });
2216
+ exports.STABLE_TABLE_CSS_ID = void 0;
2217
+ exports.injectStableTableStyles = injectStableTableStyles;
2218
+ /**
2219
+ * dsh-style-tweaks — stable-table tweak.
2220
+ *
2221
+ * Locks markdown-table layout on hover so surrounding content does not
2222
+ * reflow ("text jumps when I hover a table").
2223
+ *
2224
+ * DSH web renders markdown tables inside a `.md-table-wide` scroll wrapper.
2225
+ * On hover, DSH's own stylesheet sets `padding-bottom: 0` on that wrapper
2226
+ * to expand the scroll viewport — but the resulting height delta (~8 px)
2227
+ * pushes every sibling below the table, which is the user-visible "jump".
2228
+ * DSH does not change the inner cells' geometry on hover.
2229
+ *
2230
+ * Diagnosed against DSH's own stylesheet (verified in a live browser):
2231
+ * ._tableScroll_*.md-table-wide:hover { padding-bottom: 0; }
2232
+ *
2233
+ * Fix: pin the wrapper's padding-bottom to its base value (8 px) across
2234
+ * base + hover + focus-visible states, so the table's outer height never
2235
+ * changes. As belt-and-braces, also suppress non-background transitions in
2236
+ * case future DSH versions add inner-cell hover effects.
2237
+ *
2238
+ * Selectors use stable hooks only — `.md-table-wide` is a hand-written
2239
+ * class DSH reserves for plugins/extensions; CSS Modules-generated names
2240
+ * like `_tableScroll_177e0_174` are intentionally avoided.
2241
+ */
2242
+ const STABLE_TABLE_CSS = `
2243
+ /* Pin the wrapper's padding-bottom to its base value across every state, so
2244
+ * the wrapper's height never changes on hover. 8 px is the DSH baseline
2245
+ * (verified in a live browser via getComputedStyle on .md-table-wide).
2246
+ *
2247
+ * Specificity note: DSH's own rule is something like
2248
+ * .[css-modules].md-table-wide:hover, ...:focus-visible { padding-bottom: 0 }
2249
+ * which has specificity (0, 0, 3, 0). A plain .md-table-wide:hover would only
2250
+ * be (0, 0, 2, 0), so even with !important DSH wins on the specificity
2251
+ * tiebreak. We double-up the .md-table-wide class to match (and exceed) DSH's
2252
+ * specificity. */
2253
+ .md-table-wide,
2254
+ .md-table-wide.md-table-wide:hover,
2255
+ .md-table-wide.md-table-wide:focus,
2256
+ .md-table-wide.md-table-wide:focus-visible,
2257
+ .md-table-wide.md-table-wide:focus-within {
2258
+ padding-bottom: 8px !important;
2259
+ }
2260
+
2261
+ /* Belt-and-braces: no animated transitions on geometry properties — even
2262
+ * if DSH sets transition: all 0.15s, the value oscillates within a zero
2263
+ * range so the user never sees motion. Background-color / color keep
2264
+ * transitioning for any highlight effect. */
2265
+ .md-table-wide table * {
2266
+ transition-property: background-color, color !important;
2267
+ }
2268
+ `;
2269
+ exports.STABLE_TABLE_CSS_ID = 'cst-stable-table';
2270
+ function injectStableTableStyles() {
2271
+ let style = document.querySelector(`style[data-tweak-css="${exports.STABLE_TABLE_CSS_ID}"]`);
2272
+ if (style === null) {
2273
+ style = document.createElement('style');
2274
+ style.dataset.tweak = 'cst';
2275
+ style.dataset.tweakCss = exports.STABLE_TABLE_CSS_ID;
2276
+ style.textContent = STABLE_TABLE_CSS;
2277
+ document.head.appendChild(style);
2278
+ }
2279
+ return () => { style?.remove(); };
2280
+ }
2281
+ };
2282
+ __modules["./tweaks/stable-turn-rail.js"] = function(module, exports, require, __load_) {
2283
+ "use strict";
2284
+ /**
2285
+ * dsh-style-tweaks — stable-turn-rail tweak.
2286
+ *
2287
+ * Fixes the visual jump of the turn-navigation rail when the reader scrolls
2288
+ * the chat upward into the system-prompt area at the very top of the
2289
+ * conversation column.
2290
+ *
2291
+ * ## Symptom
2292
+ *
2293
+ * The rail (right-side Turn navigator) sits inside the chat scroll container
2294
+ * as a `position: sticky; top: 0; height: 0` slot. Its parent has
2295
+ * `padding-top: 16px`. With `top: 0` sticky behaviour, the slot's natural
2296
+ * flow position is `padding-top` below the scrollport's visible top, so:
2297
+ *
2298
+ * • When the chat is scrolled far enough that the slot would naturally
2299
+ * be above the scrollport top (scrollTop > 16px), sticky activates and
2300
+ * pins the slot at scrollport top:0. Rail sits at the expected height.
2301
+ * • When the chat is scrolled all the way up (scrollTop ≤ 16px), the slot
2302
+ * falls back to its natural position — which is 16px below scrollport
2303
+ * top. The rail shifts DOWN by 16px.
2304
+ *
2305
+ * After clicking the first Turn marker, the chat lands such that the first
2306
+ * message sits near the reading line but the chat can still scroll up into
2307
+ * the system prompt above it. Scrolling that last bit causes the rail to
2308
+ * visibly drop by 16px.
2309
+ *
2310
+ * ## Fix
2311
+ *
2312
+ * Move the 16px of top padding from the scroll container down into the
2313
+ * chat column (`[data-chat-flow]`). Net effect:
2314
+ *
2315
+ * • Container's `padding-top` becomes 0, so the sticky slot's natural
2316
+ * position is at scrollport top:0 even when scrollTop = 0.
2317
+ * • Column picks up `padding-top: 16px`, so the first message still
2318
+ * starts 16px below the scrollport top — identical visual layout to
2319
+ * before, only the source of those 16px has moved.
2320
+ *
2321
+ * Selectors use stable DSH markers only:
2322
+ *
2323
+ * • `[data-chat-flow]` — the chat column (stable attribute, hardcoded in
2324
+ * ChatView.tsx).
2325
+ * • `:has(> * > nav[aria-label="Turn navigation"], > * > nav[aria-label="轮次导航"])`
2326
+ * — the chat scroll container, identified as the only element that has
2327
+ * the rail nav as a grandchild and the chat column as a direct child.
2328
+ * The two aria-label values cover DSH's shipped en / zh dictionaries
2329
+ * (TurnNavigator.tsx → `t('chat.turnNavigation.label')`).
2330
+ *
2331
+ * No CSS-module class names are referenced, so the fix stays correct across
2332
+ * DSH builds that re-hash module class names.
2333
+ */
2334
+ Object.defineProperty(exports, "__esModule", { value: true });
2335
+ exports.STABLE_TURN_RAIL_CSS_ID = void 0;
2336
+ exports.injectStableTurnRailStyles = injectStableTurnRailStyles;
2337
+ const STABLE_TURN_RAIL_CSS = `
2338
+ /* The chat scroll container: identifiable as the parent of the chat column
2339
+ that also holds the turn-rail slot as a grandchild. Removing its top
2340
+ padding keeps the sticky slot's natural position aligned with the
2341
+ scrollport's visible top, so the rail never "drops" near scrollTop = 0. */
2342
+ :has(> * > nav[aria-label="Turn navigation"], > * > nav[aria-label="轮次导航"]):has(> [data-chat-flow]) {
2343
+ padding-top: 0 !important;
2344
+ }
2345
+ /* Push the 16px that used to live on the container down into the chat
2346
+ column, so the first message still starts 16px below the scrollport top
2347
+ (identical to the unmodified layout). */
2348
+ [data-chat-flow] {
2349
+ padding-top: 16px !important;
2350
+ }
2351
+ `;
2352
+ exports.STABLE_TURN_RAIL_CSS_ID = 'cst-stable-turn-rail';
2353
+ function injectStableTurnRailStyles() {
2354
+ let style = document.querySelector(`style[data-tweak-css="${exports.STABLE_TURN_RAIL_CSS_ID}"]`);
2355
+ if (style === null) {
2356
+ style = document.createElement('style');
2357
+ style.dataset.tweak = 'cst';
2358
+ style.dataset.tweakCss = exports.STABLE_TURN_RAIL_CSS_ID;
2359
+ style.textContent = STABLE_TURN_RAIL_CSS;
2360
+ document.head.appendChild(style);
2361
+ }
2362
+ return () => { style?.remove(); };
2363
+ }
2364
+ };
2365
+ function __resolve(from, request) {
2366
+ if (!request.startsWith(".")) return request;
2367
+ var parts = from.slice(2).split("/"); parts.pop();
2368
+ for (var part of request.split("/")) { if (part === "." || part === "") continue; if (part === "..") parts.pop(); else parts.push(part); }
2369
+ return "./" + parts.join("/");
2370
+ }
2371
+ function __load(id) {
2372
+ if (__modules[id] === undefined) return require(id);
2373
+ if (__cache[id] !== undefined) return __cache[id].exports;
2374
+ var module = __cache[id] = { exports: {} };
2375
+ __modules[id](module, module.exports, require, function(request) { var resolved = __resolve(id, request); return __modules[resolved] === undefined ? require(request) : __load(resolved); });
2376
+ return module.exports;
2377
+ }
2378
+ return __load("./index.js"); } });
2379
+ //# sourceMappingURL=client.js.map