dsh-model-groups 0.0.0-stage → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const ARTIFACTS = ['pi-ai.patch.json', 'settings-models.patch.json', 'llm-typert.host.patch.json', 'llm-typert.remote-client.patch.json', 'api-remotes.patch.json'];
7
+ const pluginRoot = fileURLToPath(new URL('../', import.meta.url));
8
+ export const sha256 = source => crypto.createHash('sha256').update(source).digest('hex');
9
+ const inside = (root, target) => target.startsWith(root + path.sep);
10
+
11
+ export function transformSource(source, artifact, reverse = false) {
12
+ let result = source;
13
+ const replacements = reverse ? [...artifact.replacements].reverse() : artifact.replacements;
14
+ for (const replacement of replacements) {
15
+ const before = reverse ? replacement.after : replacement.before;
16
+ const after = reverse ? replacement.before : replacement.after;
17
+ if (typeof before !== 'string' || before.length === 0 || typeof after !== 'string') throw new Error('Invalid literal replacement');
18
+ if (!result.includes(before)) throw new Error('Patch source did not match; no files written');
19
+ result = result.split(before).join(after);
20
+ }
21
+ const expected = reverse ? artifact.baseSha256 : artifact.resultSha256;
22
+ if (sha256(result) !== expected) throw new Error('Patch result hash mismatch; no files written');
23
+ return result;
24
+ }
25
+
26
+ /** Validate ALL files before any modification. Check mode is read-only. */
27
+ export function planPatches(dshRoot, mode = 'check', artifactDirectory = path.join(pluginRoot, 'native')) {
28
+ if (!['check', 'apply', 'revert'].includes(mode)) throw new Error('Unknown patch mode');
29
+ const root = fs.realpathSync(dshRoot);
30
+ const inner = path.join(root, 'node_modules/@deepseek-ai/dsh/package.json');
31
+ const dsh = JSON.parse(fs.readFileSync(fs.existsSync(inner) ? inner : path.join(root, 'package.json'), 'utf8'));
32
+ if (dsh.name !== '@deepseek-ai/dsh' || dsh.version !== '0.2.2-rc.1') throw new Error('This patch supports only @deepseek-ai/dsh 0.2.2-rc.1');
33
+ const seen = new Set();
34
+ return ARTIFACTS.map(name => {
35
+ const artifact = JSON.parse(fs.readFileSync(path.join(artifactDirectory, name), 'utf8'));
36
+ if (artifact.version !== '0.2.2-rc.1' || !/^@deepseek-ai\/dsh-[a-z-]+$/.test(artifact.packageName)) throw new Error('Unsupported patch package/version');
37
+ if (!Array.isArray(artifact.replacements) || artifact.replacements.length === 0 || !/^[0-9a-f]{64}$/.test(artifact.baseSha256) || !/^[0-9a-f]{64}$/.test(artifact.resultSha256)) throw new Error('Invalid patch manifest');
38
+ const prefix = `node_modules/${artifact.packageName}/`;
39
+ if (typeof artifact.target !== 'string' || !artifact.target.startsWith(prefix) || artifact.target.includes('..') || artifact.target.includes('\\')) throw new Error('Unsafe patch target');
40
+ const file = path.resolve(root, artifact.target);
41
+ if (!inside(root, file) || fs.realpathSync(file) !== file || !fs.statSync(file).isFile() || seen.has(file)) throw new Error('Unexpected target path or duplicate patch');
42
+ seen.add(file);
43
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', artifact.packageName, 'package.json'), 'utf8'));
44
+ if (pkg.name !== artifact.packageName || pkg.version !== artifact.version) throw new Error('Installed native package version mismatch');
45
+ const source = fs.readFileSync(file, 'utf8');
46
+ const hash = sha256(source);
47
+ const state = hash === artifact.baseSha256 ? 'pristine' : hash === artifact.resultSha256 ? 'patched' : 'modified';
48
+ if (state === 'modified') throw new Error(`Refusing unrecognized content: ${artifact.target}`);
49
+ // A no-op still verifies both directions to detect a broken manifest.
50
+ const reverse = state === 'patched';
51
+ const transformed = transformSource(source, artifact, reverse);
52
+ const result = (mode === 'apply' && state === 'pristine') || (mode === 'revert' && state === 'patched') ? transformed : source;
53
+ return { name, artifact, file, source, result, state, mode: fs.statSync(file).mode & 0o777 };
54
+ });
55
+ }
56
+
57
+ function stageReplacement(file, content, mode) {
58
+ // Only replace an observed regular target, with a same-directory atomic rename.
59
+ if (!path.isAbsolute(file) || fs.realpathSync(file) !== file || !fs.lstatSync(file).isFile()) throw new Error('Unexpected native target before write');
60
+ const temporary = `${file}.dsh-multikey-${crypto.randomUUID()}.tmp`;
61
+ const fd = fs.openSync(temporary, 'wx', mode);
62
+ try {
63
+ fs.writeFileSync(fd, content, 'utf8');
64
+ fs.fsyncSync(fd);
65
+ } catch (error) {
66
+ fs.closeSync(fd);
67
+ discardStaged(temporary, file);
68
+ throw error;
69
+ }
70
+ fs.closeSync(fd);
71
+ return temporary;
72
+ }
73
+ function discardStaged(temporary, file) {
74
+ if (!path.isAbsolute(temporary) || !temporary.startsWith(`${file}.dsh-multikey-`) || path.dirname(temporary) !== path.dirname(file) || !temporary.endsWith('.tmp')) throw new Error('Unsafe staged native path');
75
+ if (fs.existsSync(temporary)) {
76
+ if (fs.realpathSync(temporary) !== temporary || !fs.lstatSync(temporary).isFile()) throw new Error('Unexpected staged native file');
77
+ fs.unlinkSync(temporary);
78
+ }
79
+ }
80
+ export function applyPlan(plans, backupDirectory = path.join(pluginRoot, 'native', 'backups')) {
81
+ const changed = plans.filter(plan => plan.source !== plan.result);
82
+ // Re-check all inputs just before writing. Never overwrite concurrent edits.
83
+ for (const plan of plans) if (fs.readFileSync(plan.file, 'utf8') !== plan.source) throw new Error('Concurrent native edit; no files written');
84
+ if (changed.length === 0) return 0;
85
+ fs.mkdirSync(backupDirectory, { recursive: true });
86
+ for (const plan of changed) {
87
+ const backup = path.join(backupDirectory, `${plan.artifact.packageName.slice('@deepseek-ai/'.length)}-${path.basename(plan.file)}-${sha256(plan.source)}.js`);
88
+ if (fs.existsSync(backup)) {
89
+ if (sha256(fs.readFileSync(backup)) !== sha256(plan.source)) throw new Error('Backup hash mismatch; no files written');
90
+ } else fs.writeFileSync(backup, plan.source, { mode: 0o600, flag: 'wx' });
91
+ }
92
+ const staged = [];
93
+ const written = [];
94
+ try {
95
+ // Finish every write before replacing any target; a disk error cannot truncate it.
96
+ for (const plan of changed) staged.push({ plan, temporary: stageReplacement(plan.file, plan.result, plan.mode) });
97
+ for (const { plan, temporary } of staged) {
98
+ if (fs.realpathSync(plan.file) !== plan.file || fs.readFileSync(plan.file, 'utf8') !== plan.source) throw new Error('Concurrent native edit');
99
+ fs.renameSync(temporary, plan.file);
100
+ written.push(plan);
101
+ }
102
+ } catch (error) {
103
+ for (const plan of written.reverse()) {
104
+ if (fs.realpathSync(plan.file) === plan.file && fs.readFileSync(plan.file, 'utf8') === plan.result) {
105
+ const temporary = stageReplacement(plan.file, plan.source, plan.mode);
106
+ try { fs.renameSync(temporary, plan.file); } finally { discardStaged(temporary, plan.file); }
107
+ }
108
+ }
109
+ throw error;
110
+ } finally {
111
+ for (const { plan, temporary } of staged) discardStaged(temporary, plan.file);
112
+ }
113
+ return changed.length;
114
+ }
115
+
116
+ function main(args) {
117
+ if (args.includes('--help')) {
118
+ console.log('Usage: node scripts/native-patches.mjs --dsh-root <installed-DSH-root> [--check|--apply|--revert]\nDefault: read-only --check. Stop DSH before apply/revert, then restart the same service and refresh its existing page. Never revert while saved sessions still select @dsh-key/ aliases.');
119
+ return;
120
+ }
121
+ const modes = args.filter(arg => ['--check', '--apply', '--revert'].includes(arg));
122
+ if (modes.length > 1) throw new Error('Choose one mode');
123
+ const i = args.indexOf('--dsh-root');
124
+ if (i === -1 || !args[i + 1]) throw new Error('Supply an explicit --dsh-root; see --help');
125
+ const allowed = new Set(['--check', '--apply', '--revert', '--dsh-root', args[i + 1]]);
126
+ if (args.some(arg => !allowed.has(arg))) throw new Error('Unknown argument');
127
+ const mode = (modes[0] ?? '--check').slice(2);
128
+ const plans = planPatches(args[i + 1], mode);
129
+ for (const plan of plans) console.log(`${plan.artifact.target}: ${plan.state}`);
130
+ if (mode !== 'check') console.log(`${applyPlan(plans)} native files changed. Restart the existing DSH service and refresh its page; the live process is not reloaded by this script.`);
131
+ }
132
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
133
+ try { main(process.argv.slice(2)); }
134
+ catch (error) { console.error(error.message); process.exitCode = 1; }
135
+ }
package/src/client.js ADDED
@@ -0,0 +1,33 @@
1
+ const groupPreferences = createGroupPreferences();
2
+ const EMPTY_PROVIDERS_SNAPSHOT = Object.freeze({ status: 'unavailable', value: {} });
3
+
4
+ const inject = ['slots', 'modelDirectories', 'sessions', 'locale', 'remote', 'remote.session', 'remote.llm', 'configForms'];
5
+ export function apply(ctx) {
6
+ installModelSelectStyles();
7
+ installModelGroupsStyles();
8
+ const providersForm = ctx.configForms?.get('llm-pi-ai');
9
+ const onStorage = event => {
10
+ if (event.key === STORAGE_KEY || event.key === null) groupPreferences.sync(event.key === null ? null : event.newValue);
11
+ };
12
+ window.addEventListener('storage', onStorage);
13
+ ctx.on('dispose', () => {
14
+ window.removeEventListener('storage', event => onStorage(event));
15
+ document.querySelectorAll('style[data-plugin="dsh-model-groups"]').forEach(style => style.remove());
16
+ });
17
+ return ctx.slots.inject('conversation.input.model', () => ctx.slots.register({
18
+ name: 'conversation.input.model',
19
+ priority: -10,
20
+ locale: 'model',
21
+ inject(sessionId) {
22
+ const directory = ctx.modelDirectories.directoryFor(sessionId);
23
+ const available = ctx.sessions.subagentAddress(sessionId) === undefined;
24
+ return {
25
+ available,
26
+ directory: directory.store,
27
+ providersForm,
28
+ load() { if (available) directory.load().catch(() => {}); },
29
+ select: selection => available ? directory.select(selection) : Promise.resolve(undefined),
30
+ };
31
+ },
32
+ }, ModelSeatBoundary));
33
+ }
@@ -0,0 +1,69 @@
1
+ // Degraded but usable selector: shown only if the grouped picker itself throws.
2
+ // Deliberately primitive-free and flat so it cannot fail the same way.
3
+ function FallbackModelSelect({ available, directory, select, t, providersForm }) {
4
+ const state = (0, react.useSyncExternalStore)((fn) => directory.subscribe(fn), () => directory.getSnapshot());
5
+ const configuration = react.useSyncExternalStore(fn => providersForm?.subscribe(fn) ?? (() => {}), () => providersForm?.getSnapshot() ?? EMPTY_PROVIDERS_SNAPSHOT);
6
+ const providers = configuration.status === 'ready' ? (configuration.value?.providers ?? {}) : undefined;
7
+ const [open, setOpen] = (0, react.useState)(false);
8
+ if (!available) return null;
9
+ const label = typeof t === 'function' ? t('menu.model') : '模型';
10
+ const current = state.current ?? null;
11
+ const models = (state.groups ?? []).flatMap(group => {
12
+ const profile = Object.hasOwn(providers ?? {}, group.id) ? providers[group.id] : undefined;
13
+ const multi = profile?.multiKey === true || (providers === undefined && group.models.some(model => decodeKeyModelId(model.id)));
14
+ return group.models.flatMap(model => {
15
+ const key = decodeKeyModelId(model.id);
16
+ if (key) {
17
+ if (!multi || (providers !== undefined && !Object.hasOwn(profile?.keyEntries ?? {}, key.entryId))) return [];
18
+ } else if (model.id.startsWith(KEY_MODEL_PREFIX) || (multi && (current?.provider !== group.id || current?.model !== model.id))) return [];
19
+ const providerName = group.name ?? group.id;
20
+ return [{ provider: group.id, providerName: key ? `${providerName} / ${keyEntryName(providers, group.id, key.entryId)}` : providerName, model }];
21
+ });
22
+ });
23
+ const active = models.find((entry) => entry.provider === current?.provider && entry.model.id === current?.model);
24
+ const choose = (entry) => {
25
+ setOpen(false);
26
+ select({
27
+ provider: entry.provider,
28
+ model: entry.model.id,
29
+ ...(entry.model.reasoning?.defaultEffort === void 0 ? {} : { reasoningEffort: entry.model.reasoning.defaultEffort }),
30
+ });
31
+ };
32
+ return react.createElement('div', { className: 'dsh-mg-root' },
33
+ react.createElement('button', {
34
+ type: 'button',
35
+ className: 'dsh-mg-trigger',
36
+ 'aria-haspopup': 'menu',
37
+ 'aria-expanded': open,
38
+ onClick: () => setOpen(!open),
39
+ }, react.createElement('span', { className: 'dsh-mg-triggerLabel' }, active?.model.name ?? current?.model ?? label)),
40
+ open ? react.createElement('div', { className: 'dsh-mg-menu', role: 'menu', style: { left: 12, bottom: 60, top: 'auto' } },
41
+ models.map((entry) => react.createElement('button', {
42
+ key: entry.provider + '/' + entry.model.id,
43
+ type: 'button',
44
+ role: 'menuitemradio',
45
+ 'aria-checked': entry === active,
46
+ className: 'dsh-mg-row',
47
+ onClick: () => choose(entry),
48
+ }, entry.model.name ?? entry.model.id, react.createElement('span', { className: 'dsh-mg-rowHint' }, entry.providerName)))) : null);
49
+ }
50
+
51
+ // Last line of defence: a broken grouped picker must never blank the model seat.
52
+ class ModelSeatBoundary extends react.Component {
53
+ static getDerivedStateFromError() {
54
+ return { failed: true };
55
+ }
56
+ constructor(props) {
57
+ super(props);
58
+ this.state = { failed: false };
59
+ }
60
+ componentDidCatch(error) {
61
+ try {
62
+ console.error('[dsh-model-groups] grouped picker failed, falling back', error);
63
+ } catch {}
64
+ }
65
+ render() {
66
+ if (this.state.failed) return react.createElement(FallbackModelSelect, this.props);
67
+ return react.createElement(ModelSelect, this.props);
68
+ }
69
+ }
@@ -0,0 +1,61 @@
1
+ // Only display preferences live here; no models, credentials, or session data.
2
+ export const STORAGE_KEY = 'dsh:model-groups:v1';
3
+ export const MODE_STORAGE_KEY = 'dsh:model-groups:mode';
4
+
5
+ export function getMenuMode(getStorage = () => globalThis.localStorage) {
6
+ try {
7
+ const val = getStorage()?.getItem(MODE_STORAGE_KEY);
8
+ return val === 'inline' ? 'inline' : 'panel';
9
+ } catch {
10
+ return 'panel';
11
+ }
12
+ }
13
+
14
+ export function setMenuMode(mode, getStorage = () => globalThis.localStorage) {
15
+ try {
16
+ getStorage()?.setItem(MODE_STORAGE_KEY, mode === 'inline' ? 'inline' : 'panel');
17
+ } catch { /* private mode */ }
18
+ }
19
+
20
+ export function parsePreferences(text) {
21
+ try {
22
+ const value = JSON.parse(text);
23
+ if (value?.version !== 1 || !value.expanded || Array.isArray(value.expanded)) return {};
24
+ return Object.fromEntries(Object.entries(value.expanded).filter(([, expanded]) => typeof expanded === 'boolean'));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ export function isGroupExpanded(preferences, providerId, currentProvider) {
31
+ return Object.hasOwn(preferences, providerId) ? preferences[providerId] : providerId === currentProvider;
32
+ }
33
+
34
+ /** Search reveals matching models without changing the saved group preference. */
35
+ export function visibleModelGroups(groups, preferences, currentProvider, searching) {
36
+ return groups.map(group => ({
37
+ ...group,
38
+ expanded: searching || isGroupExpanded(preferences, group.id, currentProvider),
39
+ }));
40
+ }
41
+
42
+ /** A storage failure degrades to in-memory preferences, never breaks the picker. */
43
+ export function createGroupPreferences(getStorage = () => globalThis.localStorage) {
44
+ let preferences = {};
45
+ const listeners = new Set();
46
+ const publish = next => {
47
+ preferences = next;
48
+ for (const listener of listeners) listener();
49
+ };
50
+ try { preferences = parsePreferences(getStorage()?.getItem(STORAGE_KEY)); } catch { /* private mode */ }
51
+ return {
52
+ getSnapshot: () => preferences,
53
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
54
+ setExpanded(providerIds, expanded) {
55
+ const next = { ...preferences, ...Object.fromEntries(providerIds.map(id => [id, expanded])) };
56
+ publish(next);
57
+ try { getStorage()?.setItem(STORAGE_KEY, JSON.stringify({ version: 1, expanded: next })); } catch { /* quota / private mode */ }
58
+ },
59
+ sync(text) { publish(parsePreferences(text)); },
60
+ };
61
+ }
package/src/groups.css ADDED
@@ -0,0 +1,280 @@
1
+ /* DSH Model Groups: 100% native token-compatible styling */
2
+
3
+ .dsh-mg-toolbar {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ gap: 4px;
8
+ flex-shrink: 0;
9
+ margin: 2px 0 4px;
10
+ padding: 0 4px;
11
+ color: var(--dsw-alias-label-tertiary);
12
+ }
13
+
14
+ .dsh-mg-summary {
15
+ flex: 1;
16
+ min-width: 0;
17
+ padding: 0 2px;
18
+ color: var(--dsw-alias-label-tertiary);
19
+ font-size: 11px;
20
+ line-height: 16px;
21
+ white-space: nowrap;
22
+ overflow: hidden;
23
+ text-overflow: ellipsis;
24
+ }
25
+
26
+ .dsh-mg-toolbar-actions {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 4px;
30
+ flex: none;
31
+ }
32
+
33
+ .dsh-mg-toolbar button {
34
+ flex: none;
35
+ height: 22px;
36
+ padding: 0 6px;
37
+ border: 0;
38
+ border-radius: var(--dsw-radius-sm);
39
+ background: transparent;
40
+ color: var(--dsw-alias-label-secondary);
41
+ font: inherit;
42
+ font-size: 11px;
43
+ line-height: 22px;
44
+ cursor: pointer;
45
+ transition: background .12s, color .12s;
46
+ }
47
+
48
+ .dsh-mg-toolbar button:hover:not(:disabled) {
49
+ background: var(--dsw-alias-interactive-bg-hover);
50
+ color: var(--dsw-alias-label-primary);
51
+ }
52
+
53
+ .dsh-mg-toolbar button:disabled {
54
+ color: var(--dsw-alias-label-dimmed);
55
+ cursor: default;
56
+ }
57
+
58
+ .dsh-mg-groupStart {
59
+ display: block;
60
+ height: 0;
61
+ pointer-events: none;
62
+ }
63
+
64
+ .dsh-mg-groupHeading {
65
+ border-radius: var(--dsw-radius-md);
66
+ margin: 1px 0;
67
+ }
68
+
69
+ .dsh-mg-majorHeading {
70
+ position: static;
71
+ }
72
+
73
+ .dsh-mg-minorHeading {
74
+ position: relative;
75
+ }
76
+
77
+ .dsh-mg-majorContent {
78
+ margin-left: 6px;
79
+ padding-left: 6px;
80
+ border-left: 1px solid var(--dsw-alias-border-l1);
81
+ }
82
+
83
+ .dsh-mg-minorContent {
84
+ margin-left: 6px;
85
+ padding-left: 6px;
86
+ border-left: 1px solid var(--dsw-alias-border-l1);
87
+ }
88
+
89
+ .dsh-mg-source {
90
+ display: block;
91
+ color: var(--dsw-alias-label-tertiary);
92
+ font-size: 11px;
93
+ line-height: 16px;
94
+ }
95
+
96
+ .dsh-mg-triggerSource {
97
+ color: var(--dsw-alias-label-caption);
98
+ font-size: 13px;
99
+ }
100
+
101
+ .dsh-mg-groupHeading button {
102
+ box-sizing: border-box;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 6px;
106
+ width: 100%;
107
+ min-height: 32px;
108
+ padding: 4px 7px;
109
+ border: 0;
110
+ border-radius: var(--dsw-radius-md);
111
+ background: transparent;
112
+ color: var(--dsw-alias-label-primary);
113
+ font: inherit;
114
+ font-size: 13px;
115
+ font-weight: 500;
116
+ line-height: 18px;
117
+ text-align: left;
118
+ cursor: pointer;
119
+ transition: background .12s;
120
+ }
121
+
122
+ .dsh-mg-groupHeading button:hover:not(:disabled) {
123
+ background: var(--dsw-alias-interactive-bg-hover);
124
+ }
125
+
126
+ .dsh-mg-groupHeading button:disabled {
127
+ cursor: default;
128
+ }
129
+
130
+ .dsh-mg-groupHeading button:focus-visible,
131
+ .dsh-mg-toolbar button:focus-visible {
132
+ outline: none;
133
+ box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
134
+ }
135
+
136
+ .dsh-mg-groupArrow {
137
+ flex: none;
138
+ width: 14px;
139
+ height: 14px;
140
+ color: var(--dsw-alias-label-caption);
141
+ transition: transform .12s;
142
+ }
143
+
144
+ .dsh-mg-groupArrowOpen {
145
+ transform: rotate(90deg);
146
+ }
147
+
148
+ .dsh-mg-groupName {
149
+ flex: 1;
150
+ min-width: 0;
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ white-space: nowrap;
154
+ }
155
+
156
+ .dsh-mg-groupCount {
157
+ flex: none;
158
+ color: var(--dsw-alias-label-tertiary);
159
+ font-size: 11px;
160
+ font-weight: 400;
161
+ line-height: 16px;
162
+ }
163
+
164
+ .dsh-mg-currentBadge {
165
+ flex: none;
166
+ color: var(--dsw-alias-state-business-primary);
167
+ font-size: 11px;
168
+ font-weight: 500;
169
+ line-height: 16px;
170
+ }
171
+
172
+ .dsh-mg-groups {
173
+ min-height: 0;
174
+ }
175
+
176
+ .dsh-mg-groups [hidden] {
177
+ display: none !important;
178
+ }
179
+
180
+ /* ── 飞出卡片:与官方原版菜单 100% 结构一致,剔除 header 伪分层与白色叠加 ── */
181
+ .dsh-mg-flyout-subgroup-list {
182
+ display: flex;
183
+ flex-direction: column;
184
+ }
185
+
186
+ .dsh-mg-subgroup-item {
187
+ box-sizing: border-box;
188
+ border-radius: var(--dsw-radius-md);
189
+ width: auto;
190
+ min-width: 100%;
191
+ min-height: 34px;
192
+ color: var(--dsw-alias-label-primary);
193
+ text-align: left;
194
+ cursor: pointer;
195
+ background: 0 0;
196
+ border: none;
197
+ outline: none;
198
+ align-items: center;
199
+ gap: 6px;
200
+ padding: 5px 7px;
201
+ display: flex;
202
+ font: inherit;
203
+ font-size: 13px;
204
+ line-height: 18px;
205
+ transition: background .12s;
206
+ }
207
+
208
+ .dsh-mg-subgroup-item:hover,
209
+ .dsh-mg-subgroup-item[data-active="true"] {
210
+ background: var(--dsw-alias-interactive-bg-hover);
211
+ }
212
+
213
+ .dsh-mg-subgroup-name {
214
+ flex: 1;
215
+ min-width: 0;
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ }
220
+
221
+ .dsh-mg-subgroup-count {
222
+ flex: none;
223
+ font-size: 11px;
224
+ color: var(--dsw-alias-label-tertiary);
225
+ }
226
+
227
+ .dsh-mg-subgroup-arrow {
228
+ flex: none;
229
+ color: var(--dsw-alias-menu-icon, var(--dsw-alias-label-caption));
230
+ }
231
+
232
+ .dsh-mg-active-heading {
233
+ background: var(--dsw-alias-interactive-bg-hover) !important;
234
+ }
235
+
236
+ /* ── 底部模式切换:原生轻量化,无突兀高饱和蓝块 ── */
237
+ .dsh-mg-modebar {
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: space-between;
241
+ padding: 4px 6px 2px;
242
+ margin-top: 3px;
243
+ border-top: 1px solid var(--dsw-alias-border-l1);
244
+ font-size: 11px;
245
+ color: var(--dsw-alias-label-tertiary);
246
+ }
247
+
248
+ .dsh-mg-mode-btn {
249
+ border: 0;
250
+ background: transparent;
251
+ color: var(--dsw-alias-label-secondary);
252
+ font: inherit;
253
+ font-size: 11px;
254
+ padding: 2px 6px;
255
+ border-radius: var(--dsw-radius-sm);
256
+ cursor: pointer;
257
+ transition: background .12s, color .12s;
258
+ }
259
+
260
+ .dsh-mg-mode-btn:hover {
261
+ background: var(--dsw-alias-interactive-bg-hover);
262
+ color: var(--dsw-alias-label-primary);
263
+ }
264
+
265
+ .dsh-mg-mode-btn[data-active="true"] {
266
+ background: var(--dsw-alias-interactive-bg-active, rgba(255, 255, 255, 0.1));
267
+ color: var(--dsw-alias-label-primary);
268
+ font-weight: 500;
269
+ }
270
+
271
+ @media (pointer: coarse) {
272
+ .dsh-mg-groupHeading button { min-height: 36px; }
273
+ .dsh-mg-toolbar button { height: 26px; }
274
+ .dsh-mg-mode-btn { min-height: 24px; padding: 2px 8px; }
275
+ }
276
+
277
+ @media (prefers-reduced-motion: reduce) {
278
+ .dsh-mg-groupArrow { transition: none; }
279
+ }
280
+