dsh-claude-style 0.3.2 → 0.5.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.
package/lib/client.js CHANGED
@@ -16,10 +16,15 @@
16
16
  * - src/overrides/copy.js
17
17
  * - src/overrides/permissions.js
18
18
  * - src/overrides/model-brand.js
19
+ * - src/overrides/model-copy-lookup.js
20
+ * - src/overrides/model-effort.js
19
21
  * - src/overrides/model-picker.js
22
+ * - src/overrides/hero-menu.js
23
+ * - src/overrides/quick-providers.js
20
24
  * - src/overrides/account-footer.js
21
25
  * - src/overrides/ban-screen.js
22
26
  * - src/overrides/theme-flip.js
27
+ * - src/overrides/workspace-view.js
23
28
  * - src/overrides/scheduler.js
24
29
  * - src/settings.js
25
30
  * - src/entry.js
@@ -36,6 +41,7 @@
36
41
  * - src/styles/components/account-footer.css
37
42
  * - src/styles/components/ban-screen.css
38
43
  * - src/styles/components/model-picker.css
44
+ * - src/styles/components/hero-menu.css
39
45
  * - src/styles/components/footer-takeover.css
40
46
  * - src/styles/components/third-party.css
41
47
  * - src/styles/components/settings.css
@@ -55,6 +61,25 @@ window.__ModuleLoader__.load({
55
61
 
56
62
  var STYLE_ID = 'dsh-claude-style-style'
57
63
 
64
+ /**
65
+ * Settings identity, for both host generations.
66
+ *
67
+ * 0.1.5-rc.2 and earlier let a plugin own a namespace by name
68
+ * (`claude-style`, registered host-side). 0.1.7 dropped that registry: a
69
+ * settings namespace IS a profile entry id and its schema IS the entry's
70
+ * Config, so the id below is what both halves address — read off the
71
+ * running loader entry where possible, with the id `cordis.patch.yml`
72
+ * inserts as the fallback.
73
+ *
74
+ * PACKAGE_NAME is the other half of the 0.1.7 contract: a bundle's own
75
+ * configuration is a `plugins.bundle.config` entry keyed by the bundle's
76
+ * package name, which is what makes it render on this plugin's page.
77
+ */
78
+ var SETTINGS_ENTRY_FALLBACK = 'ui-skin-claude-style'
79
+ var PACKAGE_NAME = 'dsh-claude-style'
80
+ var BUNDLE_CONFIG_SLOT = 'plugins.bundle.config'
81
+ var SETTINGS_SECTION_SLOT = 'settings.section'
82
+
58
83
  var COMPOSER_HINT = 'How can I help you today?'
59
84
 
60
85
  /**
@@ -79,9 +104,14 @@ window.__ModuleLoader__.load({
79
104
  var MODEL_EMPTY_LABEL = 'No models available.'
80
105
  var MODEL_EFFORT_LABEL = 'Reasoning effort'
81
106
  var MODEL_EFFORT_DEFAULT = 'Default'
107
+ /** The effort slider's two ends. Kept in English in every locale: they name
108
+ * the axis, not a level, and the level's own name rides beside the label. */
109
+ var MODEL_EFFORT_FASTER = 'Faster'
110
+ var MODEL_EFFORT_SMARTER = 'Smarter'
111
+ /** What the slider reads when the model offers no levels at all. */
112
+ var MODEL_EFFORT_NONE = '—'
82
113
  var MODEL_MORE_LABEL = 'More models'
83
114
  var MODEL_TRIGGER_LABEL = 'Select model, currently {model}'
84
- var MODEL_NO_EFFORT_LABEL = 'This model offers no reasoning levels.'
85
115
 
86
116
  /** English weekday names, indexed by Date#getDay() (0 = Sunday). */
87
117
  var WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
@@ -220,6 +250,14 @@ window.__ModuleLoader__.load({
220
250
  var DEFAULT_BAN_LOCALE = BAN_LOCALE_EN
221
251
  /** Present while the composer restyle applies to the page currently shown. */
222
252
  var COMPOSER_ATTR = 'data-dsh-claude-composer-active'
253
+ /**
254
+ * Stamped on the host's shared menu card while it is the hero row's picker
255
+ * (the workspace chip or the agent-preset seat opened it). The host portals
256
+ * that card to <body> with no marker of its own, so the stylesheet cannot
257
+ * tell it from the host's other menus; src/overrides/hero-menu.js stamps it
258
+ * and components/hero-menu.css switches on this attribute.
259
+ */
260
+ var HERO_MENU_ATTR = 'data-dsh-claude-hero-menu'
223
261
  /**
224
262
  * Present while the browser window does NOT hold focus.
225
263
  *
@@ -231,6 +269,16 @@ window.__ModuleLoader__.load({
231
269
  var WINDOW_BLUR_ATTR = 'data-dsh-window-blur'
232
270
  /** Composer surfaces the restyle may cover, in settings order. */
233
271
  var COMPOSER_SCOPES = ['off', 'hero', 'conversation', 'all']
272
+ /**
273
+ * How eagerly the skin's popovers open on hover: `off` is click-only,
274
+ * `account` auto-opens the sidebar account popover alone, and `all` adds the
275
+ * permission, model, session-stats and the host's two hero-row pickers.
276
+ */
277
+ var AUTO_POPOVER_OFF = 'off'
278
+ var AUTO_POPOVER_ACCOUNT = 'account'
279
+ var AUTO_POPOVER_ALL = 'all'
280
+ var AUTO_POPOVER_SCOPES = [AUTO_POPOVER_OFF, AUTO_POPOVER_ACCOUNT, AUTO_POPOVER_ALL]
281
+ var DEFAULT_AUTO_POPOVER = AUTO_POPOVER_ALL
234
282
  /** Route the browser half reads and writes preferences through (lib/index.js). */
235
283
  var PREFS_ROUTE = '/dsh-claude-style/prefs'
236
284
  /** Route that resolves the host OS user once; never polled. */
@@ -263,13 +311,6 @@ window.__ModuleLoader__.load({
263
311
  " --dsw-font-serif: 'Anthropic Serif Web Text',Georgia,'Times New Roman','Noto Sans SC','Source Han Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;",
264
312
  " --dsw-font-prose: 'Anthropic Serif Web Text',Georgia,'Times New Roman','Noto Sans SC','Source Han Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;",
265
313
  " --dsw-font-code: 'JetBrains Mono','Noto Sans SC','Source Han Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',ui-monospace,'SF Mono','Fira Code',Consolas,'Liberation Mono',Menlo,Courier,monospace;",
266
- " /* The picker's vendor face: a model row may wear the typeface of the vendor",
267
- " that made the model, exactly as it already wears that vendor's mark. Only",
268
- " Gemini has one today — a Latin-only Google Sans Flex subset (standard",
269
- " weight, upright, SIL OFL 1.1, fonts/OFL-GoogleSansFlex.txt) — and the UI",
270
- " face stays behind it in the stack, so a label carrying a character outside",
271
- " the subset still renders instead of falling to tofu. */",
272
- " --dsw-font-brand-gemini: 'Google Sans Flex Picker', var(--dsw-font-family);",
273
314
  " /* Claude's lockup is drawn in Anthropic's serif, so its rows take the same face:",
274
315
  " the wordmark and the rest of the label then read as one piece of lettering.",
275
316
  " The face is already served for the UI (see typography.css); the serif token",
@@ -502,23 +543,6 @@ window.__ModuleLoader__.load({
502
543
  " font-display: swap;",
503
544
  "}",
504
545
  "",
505
- "/* The one vendor typeface the skin borrows: a Gemini row's name is set in",
506
- " Google Sans Flex, so the row reads as Google's model rather than as one more",
507
- " Claude-labelled entry — the row already carries the vendor's mark, and the",
508
- " label is the only string in the picker that names a model instead of the",
509
- " shell. It ships in the package as a Latin-only subset (standard weight,",
510
- " upright, kerned; ~9 KB from a 4.2 MB six-axis variable font) built by",
511
- " scripts/slim-google-sans.py under SIL OFL 1.1 — fonts/OFL-GoogleSansFlex.txt.",
512
- " Latin-only is the point: characters outside the subset fall through to the",
513
- " stack in --dsw-font-brand-gemini, which is why this is a static 400 cut. */",
514
- "@font-face {",
515
- " font-family: 'Google Sans Flex Picker';",
516
- " src: url('/dsh-claude-style/fonts/GoogleSansFlexPicker.woff2') format('woff2');",
517
- " font-weight: 400;",
518
- " font-style: normal;",
519
- " font-display: swap;",
520
- "}",
521
- "",
522
546
  "/* ---------- typography: serif display + sans UI + mono code ---------- */",
523
547
  "body[data-dsh-claude-style] {",
524
548
  " font-family: var(--dsw-font-family);",
@@ -926,9 +950,17 @@ window.__ModuleLoader__.load({
926
950
  " z-index: 100 !important;",
927
951
  "}",
928
952
  "",
953
+ "/* The header actions are NOT inside the composer seat, so unlike the three",
954
+ " overlays above they escape every stacking context and their value is read",
955
+ " against the page: the right Sidebar's panel covers through ui-dockkit's dock",
956
+ " layer (`--dsh-dockkit-dock-layer`, 10 normal and 40 fullscreen), and a",
957
+ " header action above those paints through the fullscreen panel. 9 keeps the",
958
+ " actions over the message layer (pinned headers 7, back-to-bottom 8) while",
959
+ " staying under both panel layers. The composer overlays keep 100 because the",
960
+ " seat's own stacking context caps them. */",
929
961
  "body[data-dsh-claude-style] [data-slot=\"conversation.session.header.actions\"] > * {",
930
962
  " position: relative;",
931
- " z-index: 100 !important;",
963
+ " z-index: 9 !important;",
932
964
  "}",
933
965
  "",
934
966
  "/* The shipped queue dock cancels the stack gap AND pulls 3px further, so the",
@@ -1123,7 +1155,7 @@ window.__ModuleLoader__.load({
1123
1155
  "",
1124
1156
  "/* Commands and attach: 7px corners instead of the shipped full circle, no",
1125
1157
  " resting fill, and a warm plate on hover. */",
1126
- "body[data-dsh-claude-style][data-dsh-claude-composer-active] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]) {",
1158
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]) {",
1127
1159
  " border-radius: 7px;",
1128
1160
  "}",
1129
1161
  "",
@@ -1133,11 +1165,11 @@ window.__ModuleLoader__.load({
1133
1165
  " border-radius: 7px;",
1134
1166
  "}",
1135
1167
  "",
1136
- "body[data-dsh-claude-style][data-dsh-claude-composer-active]:not([data-ds-dark-theme]) :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]) {",
1168
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active]:not([data-ds-dark-theme]) :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]) {",
1137
1169
  " background: transparent;",
1138
1170
  "}",
1139
1171
  "",
1140
- "body[data-dsh-claude-style][data-dsh-claude-composer-active]:not([data-ds-dark-theme]) :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]):hover:not(:disabled) {",
1172
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active]:not([data-ds-dark-theme]) :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]):hover:not(:disabled) {",
1141
1173
  " background: #f6f6f4;",
1142
1174
  "}",
1143
1175
  "",
@@ -1642,8 +1674,8 @@ window.__ModuleLoader__.load({
1642
1674
  " align-items: center !important;",
1643
1675
  " justify-content: center !important;",
1644
1676
  "}",
1645
- "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card][data-composer-variant=\"inline\"] :is(button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]),",
1646
- "body[data-dsh-claude-style][data-dsh-claude-composer-active] [class*=\"composerStack\"]:not([data-composer-variant=\"hero\"]) [data-composer-card] :is(button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]) {",
1677
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card][data-composer-variant=\"inline\"] :is(button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]),",
1678
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [class*=\"composerStack\"]:not([data-composer-variant=\"hero\"]) [data-composer-card] :is(button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]) {",
1647
1679
  " order: 3 !important;",
1648
1680
  " width: 24px !important;",
1649
1681
  " height: 24px !important;",
@@ -1654,8 +1686,8 @@ window.__ModuleLoader__.load({
1654
1686
  " align-items: center !important;",
1655
1687
  " justify-content: center !important;",
1656
1688
  "}",
1657
- "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card][data-composer-variant=\"inline\"] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]):hover:not(:disabled),",
1658
- "body[data-dsh-claude-style][data-dsh-claude-composer-active] [class*=\"composerStack\"]:not([data-composer-variant=\"hero\"]) [data-composer-card] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"]):hover:not(:disabled) {",
1689
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card][data-composer-variant=\"inline\"] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]):hover:not(:disabled),",
1690
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [class*=\"composerStack\"]:not([data-composer-variant=\"hero\"]) [data-composer-card] :is(button[aria-label=\"指令\"], button[aria-label=\"Commands\"], button[aria-label=\"添加附件\"], button[aria-label=\"Add attachment\"], button[aria-label*=\"添加文件\"], button[aria-label*=\"Add files\"]):hover:not(:disabled) {",
1659
1691
  " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
1660
1692
  " color: var(--dsw-alias-label-primary) !important;",
1661
1693
  "}",
@@ -1764,14 +1796,15 @@ window.__ModuleLoader__.load({
1764
1796
  " z-index: 3 !important;",
1765
1797
  " width: auto !important;",
1766
1798
  " max-width: none !important;",
1767
- " margin: 0 8px !important;",
1799
+ " /* Auto width, centered: the sentence hugs its own text (so the hover plate",
1800
+ " covers the text and not the whole row) and the auto margins centre it in",
1801
+ " the air between the left tool cluster and the trailing model group. */",
1802
+ " margin: 0 auto !important;",
1768
1803
  " padding: 0 !important;",
1769
1804
  " height: auto !important;",
1770
1805
  " line-height: 20px !important;",
1771
- " /* Fill the space between the left tool cluster and the trailing model",
1772
- " group, then center the sentence in it: equal air on both sides. */",
1773
1806
  " justify-content: center !important;",
1774
- " flex: 1 1 auto !important;",
1807
+ " flex: 0 1 auto !important;",
1775
1808
  " min-width: 0 !important;",
1776
1809
  "}",
1777
1810
  "/* The stats pills are on-demand: hidden unless the chat window (the composer)",
@@ -1877,6 +1910,26 @@ window.__ModuleLoader__.load({
1877
1910
  " border-radius: 6px !important;",
1878
1911
  "}",
1879
1912
  "",
1913
+ "/* ---------- one type for the composer's bottom line ---------- */",
1914
+ "/* The ring, the model trigger and the stats sentence are three host widgets",
1915
+ " that each brought their own type: the trigger 13px/500 in the secondary",
1916
+ " tone, the stats 12px/400 tertiary, the ring the host's secondary content",
1917
+ " size. They share one line now, so they read as one family — same face, same",
1918
+ " size, same weight, same tone. The stats' inner buttons inherit through the",
1919
+ " `font: inherit` rule above; the ring's trigger is targeted explicitly because",
1920
+ " the host sets its own colour and size on that button. */",
1921
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-context-meter],",
1922
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-context-meter] button,",
1923
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card] [class*=\"_row\"] [data-composer-stats],",
1924
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-composer-card] [class*=\"_row\"] .dsh-claude-model-btn {",
1925
+ " font-family: var(--dsw-font-family) !important;",
1926
+ " font-size: 13px !important;",
1927
+ " font-weight: 500 !important;",
1928
+ " line-height: 20px !important;",
1929
+ " letter-spacing: normal !important;",
1930
+ " color: var(--dsw-alias-label-secondary) !important;",
1931
+ "}",
1932
+ "",
1880
1933
  "/* The host renders two separate stat dialogs; hide them and use one custom",
1881
1934
  " combined popover instead. */",
1882
1935
  "body[data-dsh-claude-style][data-dsh-claude-composer-active] [role=\"dialog\"]:has([data-session-stats-details]),",
@@ -1888,21 +1941,22 @@ window.__ModuleLoader__.load({
1888
1941
  " content below, roomy type. */",
1889
1942
  "body[data-dsh-claude-style][data-dsh-claude-composer-active] .dsh-claude-stats-popover {",
1890
1943
  " position: fixed !important;",
1891
- " z-index: 100000 !important;",
1892
- " min-width: 260px !important;",
1944
+ " z-index: 99999 !important;",
1945
+ " min-width: 248px !important;",
1893
1946
  " max-width: min(380px, calc(100vw - 16px)) !important;",
1894
1947
  " box-sizing: border-box !important;",
1895
- " padding: 8px !important;",
1948
+ " padding: 6px !important;",
1896
1949
  " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
1897
1950
  " border: 1px solid var(--dsw-alias-border-l1, #e8e6dc) !important;",
1898
1951
  " border-radius: 12px !important;",
1899
1952
  " box-shadow: 0 8px 30px rgba(20, 20, 19, 0.12), 0 2px 8px rgba(20, 20, 19, 0.06) !important;",
1900
1953
  " display: flex !important;",
1901
1954
  " flex-direction: column !important;",
1902
- " gap: 4px !important;",
1955
+ " gap: 6px !important;",
1903
1956
  " opacity: 0 !important;",
1904
1957
  " pointer-events: none !important;",
1905
1958
  " transform: translateY(4px) scale(0.98) !important;",
1959
+ " transform-origin: bottom left !important;",
1906
1960
  " transition: opacity 0.15s ease, transform 0.15s ease !important;",
1907
1961
  " font-family: var(--dsw-font-family) !important;",
1908
1962
  "}",
@@ -1940,8 +1994,9 @@ window.__ModuleLoader__.load({
1940
1994
  " display: flex !important;",
1941
1995
  " flex-direction: column !important;",
1942
1996
  " gap: 2px !important;",
1943
- " padding: 6px 8px !important;",
1944
- " border-radius: 8px !important;",
1997
+ " min-height: 32px !important;",
1998
+ " padding: 2px 7px !important;",
1999
+ " border-radius: 6px !important;",
1945
2000
  "}",
1946
2001
  "body[data-dsh-claude-style][data-dsh-claude-composer-active] .dsh-claude-stats-popover-item:hover {",
1947
2002
  " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
@@ -2029,7 +2084,10 @@ window.__ModuleLoader__.load({
2029
2084
  "}",
2030
2085
  "",
2031
2086
  "/* ---------- Claude Code layout: new session button ---------- */",
2032
- "/* Narrow height matching conversation row, persistent hover plate, left-aligned plus icon */",
2087
+ "/* Narrow height matching conversation row, left-aligned plus icon. No plate at",
2088
+ " rest — Claude's own sidebar keeps every row flat until the pointer is on it —",
2089
+ " and the leading glyph is a 20px CHIP: a circular fill with the plus drawn",
2090
+ " inside, which is how Claude marks its primary \"New\" row. */",
2033
2091
  "body[data-dsh-claude-style] button[class*=\"newSession\"] {",
2034
2092
  " display: flex !important;",
2035
2093
  " align-items: center !important;",
@@ -2043,37 +2101,277 @@ window.__ModuleLoader__.load({
2043
2101
  " margin: 0 0 6px 0 !important;",
2044
2102
  " border: none !important;",
2045
2103
  " border-radius: 6px !important;",
2046
- " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
2104
+ " background: transparent !important;",
2047
2105
  " color: var(--dsw-alias-label-primary, #141413) !important;",
2048
2106
  " font-family: var(--dsw-font-family) !important;",
2049
2107
  " font-size: 13px !important;",
2050
2108
  " font-weight: 500 !important;",
2109
+ " /* Explicit, so the label's x does not depend on the host's gap: both sidebar",
2110
+ " rows place their text at padding + 20px icon box + 6px. */",
2111
+ " gap: 6px !important;",
2051
2112
  " cursor: pointer !important;",
2052
2113
  " box-shadow: none !important;",
2053
2114
  " transition: background-color 0.12s ease !important;",
2054
2115
  "}",
2055
2116
  "body[data-dsh-claude-style] button[class*=\"newSession\"]:hover {",
2056
- " background: rgba(0, 0, 0, 0.12) !important;",
2117
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
2057
2118
  "}",
2058
2119
  "body[data-dsh-claude-style][data-ds-dark-theme] button[class*=\"newSession\"] {",
2059
2120
  " color: #f5f4ef !important;",
2060
2121
  "}",
2061
2122
  "body[data-dsh-claude-style][data-ds-dark-theme] button[class*=\"newSession\"]:hover {",
2062
- " background: rgba(255, 255, 255, 0.12) !important;",
2123
+ " background: rgba(255, 255, 255, 0.08) !important;",
2063
2124
  "}",
2064
2125
  "body[data-dsh-claude-style] button[class*=\"newSession\"] svg {",
2065
2126
  " display: none !important;",
2066
2127
  "}",
2128
+ "/* The chip: one 20px box that paints BOTH the circular fill and the plus. A mask",
2129
+ " cannot do that — it clips the element to the glyph, taking the fill with it —",
2130
+ " so the plus is a background image instead, which costs the currentColor",
2131
+ " adaptivity and buys a theme pair below. */",
2067
2132
  "body[data-dsh-claude-style] button[class*=\"newSession\"]::before {",
2068
2133
  " content: \"\";",
2069
2134
  " display: inline-block !important;",
2135
+ " width: 20px !important;",
2136
+ " height: 20px !important;",
2137
+ " margin-right: 0 !important;",
2138
+ " flex: none !important;",
2139
+ " border-radius: 50% !important;",
2140
+ " /* The page inherits `corner-shape: superellipse(1.5)` from <html>, which turns",
2141
+ " a 50% radius into a pebble. True circles say so — exactly what the host's",
2142
+ " own spinner/dot/toggle rules do. */",
2143
+ " corner-shape: round !important;",
2144
+ " background-color: rgba(0, 0, 0, 0.10) !important;",
2145
+ " background-image: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23141413' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='6' x2='12' y2='18'></line><line x1='6' y1='12' x2='18' y2='12'></line></svg>\") !important;",
2146
+ " background-repeat: no-repeat !important;",
2147
+ " background-position: center !important;",
2148
+ " background-size: 12px 12px !important;",
2149
+ " -webkit-mask: none !important;",
2150
+ " mask: none !important;",
2151
+ "}",
2152
+ "body[data-dsh-claude-style][data-ds-dark-theme] button[class*=\"newSession\"]::before {",
2153
+ " background-color: rgba(255, 255, 255, 0.14) !important;",
2154
+ " background-image: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23faf9f5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='6' x2='12' y2='18'></line><line x1='6' y1='12' x2='18' y2='12'></line></svg>\") !important;",
2155
+ "}",
2156
+ "",
2157
+ "/* ---------- Claude Code layout: the sidebar's panel row (插件) ---------- */",
2158
+ "/* DSH 0.1.7 added a panel entry right under the new-session button, styled by",
2159
+ " the host at 36px tall / 14px / 12px corners while the new-session row is",
2160
+ " 28px / 13px / 6px — two rows stacked on top of each other reading as two",
2161
+ " different lists. One row, one look: the same metrics, the same flat rest and",
2162
+ " hover plate as the new-session button, and the same 20px icon box so both",
2163
+ " labels start at the same x. */",
2164
+ "body[data-dsh-claude-style] button[class*=\"panelRow\"] {",
2165
+ " display: flex !important;",
2166
+ " align-items: center !important;",
2167
+ " justify-content: flex-start !important;",
2168
+ " text-align: left !important;",
2169
+ " width: 100% !important;",
2170
+ " box-sizing: border-box !important;",
2171
+ " height: 28px !important;",
2172
+ " /* The host floors this row at 36px with a min-height, which `height` alone",
2173
+ " cannot beat. */",
2174
+ " min-height: 28px !important;",
2175
+ " line-height: 28px !important;",
2176
+ " padding: 0 8px !important;",
2177
+ " margin: 0 0 6px 0 !important;",
2178
+ " border: none !important;",
2179
+ " border-radius: 6px !important;",
2180
+ " background: transparent !important;",
2181
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
2182
+ " font-family: var(--dsw-font-family) !important;",
2183
+ " font-size: 13px !important;",
2184
+ " font-weight: 500 !important;",
2185
+ " gap: 6px !important;",
2186
+ " cursor: pointer !important;",
2187
+ " box-shadow: none !important;",
2188
+ " transition: background-color 0.12s ease !important;",
2189
+ "}",
2190
+ "body[data-dsh-claude-style] button[class*=\"panelRow\"]:hover {",
2191
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
2192
+ "}",
2193
+ "body[data-dsh-claude-style][data-ds-dark-theme] button[class*=\"panelRow\"] {",
2194
+ " color: #f5f4ef !important;",
2195
+ "}",
2196
+ "body[data-dsh-claude-style][data-ds-dark-theme] button[class*=\"panelRow\"]:hover {",
2197
+ " background: rgba(255, 255, 255, 0.08) !important;",
2198
+ "}",
2199
+ "/* A 13px glyph in a 20px box: the same box the new-session chip occupies, so the",
2200
+ " two labels line up. */",
2201
+ "body[data-dsh-claude-style] button[class*=\"panelRow\"] svg {",
2070
2202
  " width: 13px !important;",
2071
2203
  " height: 13px !important;",
2072
- " margin-right: 6px !important;",
2204
+ " margin: 0 3.5px !important;",
2205
+ " flex: none !important;",
2206
+ "}",
2207
+ "",
2208
+ "/* ---------- sidebar rows: the icon turns a quarter on hover ---------- */",
2209
+ "/* Both glyphs are four-fold symmetric, so the turn ends where it started and",
2210
+ " reads as a spin rather than a change of state. The transition is what turns",
2211
+ " the icon back when the pointer leaves. */",
2212
+ "body[data-dsh-claude-style] button[class*=\"newSession\"]::before,",
2213
+ "body[data-dsh-claude-style] button[class*=\"panelRow\"] svg {",
2214
+ " transition: transform 0.25s ease !important;",
2215
+ " transform-origin: center !important;",
2216
+ "}",
2217
+ "body[data-dsh-claude-style] button[class*=\"newSession\"]:hover::before,",
2218
+ "body[data-dsh-claude-style] button[class*=\"panelRow\"]:hover svg {",
2219
+ " transform: rotate(90deg) !important;",
2220
+ "}",
2221
+ "",
2222
+ "/* ---------- workspace section: 进行中 / 已归档 ---------- */",
2223
+ "/* The section label gives way to a two-state segment control (the skin's own",
2224
+ " markup, built by src/overrides/workspace-view.js); the archived state swaps",
2225
+ " the host's tree for the skin's list, and the list's rows carry a delete",
2226
+ " button. */",
2227
+ "body[data-dsh-claude-style] [class*=\"sectionLabel\"][data-dsh-claude-ws-label] {",
2228
+ " display: none !important;",
2229
+ "}",
2230
+ "body[data-dsh-claude-style] .dsh-claude-ws-segments {",
2231
+ " display: inline-flex !important;",
2232
+ " align-items: center !important;",
2233
+ " gap: 2px !important;",
2234
+ " padding: 2px !important;",
2235
+ " border-radius: 7px !important;",
2236
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.06)) !important;",
2237
+ " flex: none !important;",
2238
+ "}",
2239
+ "body[data-dsh-claude-style] .dsh-claude-ws-segment {",
2240
+ " appearance: none !important;",
2241
+ " border: none !important;",
2242
+ " background: transparent !important;",
2243
+ " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
2244
+ " font-family: var(--dsw-font-family) !important;",
2245
+ " font-size: 12px !important;",
2246
+ " font-weight: 500 !important;",
2247
+ " line-height: 18px !important;",
2248
+ " padding: 0 8px !important;",
2249
+ " height: 20px !important;",
2250
+ " border-radius: 5px !important;",
2251
+ " cursor: pointer !important;",
2252
+ " transition: background-color 0.12s ease, color 0.12s ease !important;",
2253
+ "}",
2254
+ "body[data-dsh-claude-style] .dsh-claude-ws-segment:hover {",
2255
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
2256
+ "}",
2257
+ "body[data-dsh-claude-style] .dsh-claude-ws-segment[aria-checked=\"true\"] {",
2258
+ " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
2259
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
2260
+ " box-shadow: 0 1px 2px rgba(20, 20, 19, 0.10) !important;",
2261
+ "}",
2262
+ "body[data-dsh-claude-style][data-ds-dark-theme] .dsh-claude-ws-segment[aria-checked=\"true\"] {",
2263
+ " background: rgba(255, 255, 255, 0.14) !important;",
2264
+ " color: #f5f4ef !important;",
2265
+ " box-shadow: none !important;",
2266
+ "}",
2267
+ "/* ---------- workspace view: the skin's own archived list ---------- */",
2268
+ "/* 已归档 is the skin's list (src/overrides/workspace-view.js), flat on purpose:",
2269
+ " the host's own filter buries archived rows inside collapsed workspace groups",
2270
+ " and its state is not reachable without clicking its menu. */",
2271
+ "body[data-dsh-claude-style] [data-dsh-claude-ws-view=\"archived\"] [data-dsh-claude-ws-tree] {",
2272
+ " display: none !important;",
2273
+ "}",
2274
+ "body[data-dsh-claude-style] [data-dsh-claude-ws-view=\"active\"] .dsh-claude-archive-list {",
2275
+ " display: none !important;",
2276
+ "}",
2277
+ "body[data-dsh-claude-style] .dsh-claude-archive-list {",
2278
+ " display: flex !important;",
2279
+ " flex-direction: column !important;",
2280
+ " gap: 1px !important;",
2281
+ " /* No inset of its own: the region already indents its content by 12px, which is",
2282
+ " where the host's tree rows start (measured: row x=12, title x=36). The 5px on",
2283
+ " the right is the tree's own inset — matching it keeps the two rows the same",
2284
+ " width (251px) instead of letting this one run 5px wider. */",
2285
+ " padding: 0 5px 0 0 !important;",
2286
+ " min-width: 0 !important;",
2287
+ "}",
2288
+ "body[data-dsh-claude-style] .dsh-claude-archive-row {",
2289
+ " display: flex !important;",
2290
+ " align-items: center !important;",
2291
+ " gap: 4px !important;",
2292
+ " height: 28px !important;",
2293
+ " /* The host's own session row: 6px inside the list's 12px, so the row's box and",
2294
+ " the title's x land where the tree's rows put them (measured 12 / 36). */",
2295
+ " padding: 0 6px !important;",
2296
+ " border-radius: 6px !important;",
2297
+ " cursor: pointer !important;",
2298
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
2299
+ " font-family: var(--dsw-font-family) !important;",
2300
+ " font-size: 13px !important;",
2301
+ " font-weight: 400 !important;",
2302
+ " transition: background-color 0.12s ease !important;",
2303
+ "}",
2304
+ "/* The leading slot the host's rows carry (a 5px circle centred in 14px), so the",
2305
+ " two lists start their titles at the same x. */",
2306
+ "body[data-dsh-claude-style] .dsh-claude-archive-row::before {",
2307
+ " content: \"\" !important;",
2308
+ " flex: none !important;",
2309
+ " width: 5px !important;",
2310
+ " height: 5px !important;",
2311
+ " margin: 0 4.5px !important;",
2312
+ " border-radius: 50% !important;",
2313
+ " corner-shape: round !important;",
2314
+ " border: 1.2px solid var(--dsw-alias-label-caption, #a6a094) !important;",
2315
+ " box-sizing: border-box !important;",
2316
+ "}",
2317
+ "body[data-dsh-claude-style] .dsh-claude-archive-row:hover {",
2318
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.06)) !important;",
2319
+ "}",
2320
+ "body[data-dsh-claude-style] .dsh-claude-archive-title {",
2321
+ " flex: 1 1 auto !important;",
2322
+ " min-width: 0 !important;",
2323
+ " overflow: hidden !important;",
2324
+ " white-space: nowrap !important;",
2325
+ " text-overflow: ellipsis !important;",
2326
+ "}",
2327
+ "body[data-dsh-claude-style] .dsh-claude-archive-time {",
2073
2328
  " flex: none !important;",
2074
- " background-color: currentColor !important;",
2075
- " -webkit-mask: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='5' x2='12' y2='19'></line><line x1='5' y1='12' x2='19' y2='12'></line></svg>\") center / contain no-repeat !important;",
2076
- " mask: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='5' x2='12' y2='19'></line><line x1='5' y1='12' x2='19' y2='12'></line></svg>\") center / contain no-repeat !important;",
2329
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
2330
+ " font-size: 11px !important;",
2331
+ "}",
2332
+ "/* The two row actions stay out of the way until the row is hovered: the list is",
2333
+ " for reading, and a column of buttons reads as a toolbar. Each button lives in",
2334
+ " the span its React root renders into. */",
2335
+ "body[data-dsh-claude-style] .dsh-claude-archive-action {",
2336
+ " display: inline-flex !important;",
2337
+ " flex: none !important;",
2338
+ "}",
2339
+ "body[data-dsh-claude-style] .dsh-claude-archive-delete,",
2340
+ "body[data-dsh-claude-style] .dsh-claude-archive-restore {",
2341
+ " flex: none !important;",
2342
+ " display: inline-flex !important;",
2343
+ " align-items: center !important;",
2344
+ " justify-content: center !important;",
2345
+ " width: 22px !important;",
2346
+ " height: 22px !important;",
2347
+ " padding: 0 !important;",
2348
+ " border: none !important;",
2349
+ " border-radius: 5px !important;",
2350
+ " background: transparent !important;",
2351
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
2352
+ " cursor: pointer !important;",
2353
+ " opacity: 0 !important;",
2354
+ " transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease !important;",
2355
+ "}",
2356
+ "body[data-dsh-claude-style] .dsh-claude-archive-row:hover .dsh-claude-archive-delete,",
2357
+ "body[data-dsh-claude-style] .dsh-claude-archive-row:hover .dsh-claude-archive-restore,",
2358
+ "body[data-dsh-claude-style] .dsh-claude-archive-delete:focus-visible,",
2359
+ "body[data-dsh-claude-style] .dsh-claude-archive-restore:focus-visible {",
2360
+ " opacity: 1 !important;",
2361
+ "}",
2362
+ "body[data-dsh-claude-style] .dsh-claude-archive-restore:hover {",
2363
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
2364
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
2365
+ "}",
2366
+ "body[data-dsh-claude-style] .dsh-claude-archive-delete:hover {",
2367
+ " background: rgba(192, 57, 43, 0.12) !important;",
2368
+ " color: var(--dsw-alias-state-error-primary, #c0392b) !important;",
2369
+ "}",
2370
+ "body[data-dsh-claude-style] .dsh-claude-archive-status {",
2371
+ " padding: 8px !important;",
2372
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
2373
+ " font-family: var(--dsw-font-family) !important;",
2374
+ " font-size: 12px !important;",
2077
2375
  "}",
2078
2376
  "",
2079
2377
  "/* ---------- Claude Code layout: workspace & session sidebar ---------- */",
@@ -2238,6 +2536,7 @@ window.__ModuleLoader__.load({
2238
2536
  " width: 5px;",
2239
2537
  " height: 5px;",
2240
2538
  " border-radius: 50%;",
2539
+ " corner-shape: round;",
2241
2540
  " border: 1.2px solid var(--dsw-alias-label-caption, #a6a094);",
2242
2541
  " box-sizing: border-box;",
2243
2542
  "}",
@@ -2248,6 +2547,7 @@ window.__ModuleLoader__.load({
2248
2547
  " height: 10px !important;",
2249
2548
  " box-sizing: border-box !important;",
2250
2549
  " border-radius: 50% !important;",
2550
+ " corner-shape: round !important;",
2251
2551
  " border: 1.5px solid rgba(255, 255, 255, 0.18) !important;",
2252
2552
  " border-top-color: #faf9f5 !important;",
2253
2553
  " animation: 0.85s linear infinite dsh-claude-win11-spin !important;",
@@ -2403,7 +2703,7 @@ window.__ModuleLoader__.load({
2403
2703
  "}",
2404
2704
  "body[data-dsh-claude-style] .dsh-claude-perm-popover {",
2405
2705
  " position: fixed !important;",
2406
- " min-width: 220px !important;",
2706
+ " min-width: 248px !important;",
2407
2707
  " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
2408
2708
  " border: 1px solid var(--dsw-alias-border-l1, #e8e6dc) !important;",
2409
2709
  " border-radius: 12px !important;",
@@ -2527,10 +2827,10 @@ window.__ModuleLoader__.load({
2527
2827
  " box-shadow: 0 8px 30px rgba(20, 20, 19, 0.12), 0 2px 8px rgba(20, 20, 19, 0.06) !important;",
2528
2828
  " padding: 6px !important;",
2529
2829
  " box-sizing: border-box !important;",
2530
- " z-index: 1000 !important;",
2830
+ " z-index: 99999 !important;",
2531
2831
  " display: flex !important;",
2532
2832
  " flex-direction: column !important;",
2533
- " gap: 2px !important;",
2833
+ " gap: 6px !important;",
2534
2834
  " opacity: 0 !important;",
2535
2835
  " pointer-events: none !important;",
2536
2836
  " transform: translateY(6px) scale(0.98) !important;",
@@ -2563,8 +2863,9 @@ window.__ModuleLoader__.load({
2563
2863
  " strip is the inner row, so the hover plate covers the name and not the divider",
2564
2864
  " that follows it. */",
2565
2865
  "body[data-dsh-claude-style] .dsh-claude-account-popover-row {",
2566
- " padding: 6px 8px !important;",
2567
- " border-radius: 8px !important;",
2866
+ " min-height: 32px !important;",
2867
+ " padding: 2px 7px !important;",
2868
+ " border-radius: 6px !important;",
2568
2869
  " cursor: pointer !important;",
2569
2870
  " transition: background-color 0.12s ease !important;",
2570
2871
  "}",
@@ -2903,6 +3204,7 @@ window.__ModuleLoader__.load({
2903
3204
  " height: 24px;",
2904
3205
  " margin-top: 1px;",
2905
3206
  " border-radius: 50%;",
3207
+ " corner-shape: round;",
2906
3208
  " background: var(--dsh-ban-hover);",
2907
3209
  " color: var(--dsh-ban-text);",
2908
3210
  " font-size: 12px;",
@@ -3050,7 +3352,11 @@ window.__ModuleLoader__.load({
3050
3352
  " position: fixed !important;",
3051
3353
  " min-width: 248px !important;",
3052
3354
  " max-width: min(360px, calc(100vw - 16px)) !important;",
3053
- " max-height: min(440px, calc(100vh - 96px)) !important;",
3355
+ " /* The first level is sized by its content like the second: the cap is only",
3356
+ " the viewport, so a short list does not leave a tall empty card. The level-1",
3357
+ " list scrolls inside the body instead (see the scoped rule below); this",
3358
+ " overflow-y stays for the sub popover, whose whole card scrolls. */",
3359
+ " max-height: calc(100vh - 80px) !important;",
3054
3360
  " overflow-y: auto !important;",
3055
3361
  " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
3056
3362
  " border: 1px solid var(--dsw-alias-border-l1, #e8e6dc) !important;",
@@ -3084,17 +3390,35 @@ window.__ModuleLoader__.load({
3084
3390
  " gap: 3px !important;",
3085
3391
  " min-width: 0 !important;",
3086
3392
  "}",
3393
+ "/* Level 1 splits into a scrollable list and a pinned footer: the model rows",
3394
+ " scroll under the divider / effort / more-models rows, which never leave the",
3395
+ " viewport edge of the card. Only the first level does this — the sub popover",
3396
+ " keeps scrolling as one card, with its provider labels pinned (below). */",
3397
+ "body[data-dsh-claude-style] .dsh-claude-model-popover:not(.dsh-claude-model-popover-sub) {",
3398
+ " overflow: hidden !important;",
3399
+ "}",
3400
+ "body[data-dsh-claude-style] .dsh-claude-model-popover:not(.dsh-claude-model-popover-sub) .dsh-claude-model-popover-body {",
3401
+ " flex: 1 1 auto !important;",
3402
+ " min-height: 0 !important;",
3403
+ " overflow-y: auto !important;",
3404
+ "}",
3405
+ "body[data-dsh-claude-style] .dsh-claude-model-footer {",
3406
+ " flex: none !important;",
3407
+ " display: flex !important;",
3408
+ " flex-direction: column !important;",
3409
+ " gap: 2px !important;",
3410
+ " min-width: 0 !important;",
3411
+ "}",
3087
3412
  "body[data-dsh-claude-style] .dsh-claude-model-status {",
3088
3413
  " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
3089
3414
  " font-size: 12px !important;",
3090
3415
  " line-height: 20px !important;",
3091
3416
  " padding: 4px 8px !important;",
3092
3417
  "}",
3093
- "/* The sub popover is taller than the first level; its provider labels stay",
3094
- " pinned to the top while the list scrolls, the next provider replacing the",
3095
- " previous one. */",
3418
+ "/* The sub popover is taller than the first level and sized by its content: the",
3419
+ " cap is only the viewport, so a short provider list does not leave a tall empty",
3420
+ " card. Provider labels stay pinned to the top while the list scrolls. */",
3096
3421
  "body[data-dsh-claude-style] .dsh-claude-model-popover-sub {",
3097
- " max-height: min(540px, calc(100vh - 80px)) !important;",
3098
3422
  " padding-top: 0 !important;",
3099
3423
  "}",
3100
3424
  "",
@@ -3102,7 +3426,9 @@ window.__ModuleLoader__.load({
3102
3426
  " position: relative !important;",
3103
3427
  " display: flex !important;",
3104
3428
  " flex-direction: column !important;",
3105
- " gap: 3px !important;",
3429
+ " /* Level 2 rows sit close together: the 3px the body uses between sections",
3430
+ " read as a gap between models of the same provider. */",
3431
+ " gap: 1px !important;",
3106
3432
  " min-width: 0 !important;",
3107
3433
  "}",
3108
3434
  "",
@@ -3171,16 +3497,6 @@ window.__ModuleLoader__.load({
3171
3497
  " font-weight: 500 !important;",
3172
3498
  " line-height: 16px !important;",
3173
3499
  "}",
3174
- "/* A Gemini row wears Gemini's own face for its name. The row already carries the",
3175
- " vendor's mark, and the name is the one string in the picker that names a model",
3176
- " rather than the shell — so the vendor's typeface is the same signal in a second",
3177
- " material. `data-brand` is the mark id the picker stamps on the row",
3178
- " (src/overrides/model-picker.js); only Gemini resolves to a face today, and a",
3179
- " brand without one simply keeps the UI face. The stack is additive: a name with",
3180
- " a character outside the Latin subset falls through character by character. */",
3181
- "body[data-dsh-claude-style] .dsh-claude-model-option[data-brand='gemini'] .dsh-claude-model-name {",
3182
- " font-family: var(--dsw-font-brand-gemini) !important;",
3183
- "}",
3184
3500
  "/* Claude's wordmark is set in Anthropic's serif, so the rest of the label follows",
3185
3501
  " it — the row reads as one piece of lettering rather than serif art beside sans. */",
3186
3502
  "body[data-dsh-claude-style] .dsh-claude-model-option[data-brand='claude'] .dsh-claude-model-name {",
@@ -3243,13 +3559,43 @@ window.__ModuleLoader__.load({
3243
3559
  " align-items: center !important;",
3244
3560
  " color: var(--dsw-alias-brand-primary, #d97757) !important;",
3245
3561
  "}",
3246
- "body[data-dsh-claude-style] .dsh-claude-model-divider {",
3562
+ "/* The picker's two hairlines share one declaration: the provider header's rule",
3563
+ " and the divider that closes the model list. Same colour, same thickness, same",
3564
+ " insets — the only difference left is that the header's line starts after the",
3565
+ " provider's name, which is the point of that row. */",
3566
+ "body[data-dsh-claude-style] .dsh-claude-model-divider,",
3567
+ "body[data-dsh-claude-style] .dsh-claude-model-rule::after {",
3247
3568
  " height: 1px !important;",
3248
3569
  " background: var(--dsw-alias-border-l1, #e8e6dc) !important;",
3570
+ "}",
3571
+ "body[data-dsh-claude-style] .dsh-claude-model-divider {",
3572
+ " margin: 2px 4px !important;",
3573
+ " flex: none !important;",
3574
+ "}",
3575
+ "/* The provider header: the name first, then the rule running to the right edge.",
3576
+ " The line is what is left of that row, not a separate full-width rule above it",
3577
+ " — one rule per provider boundary, and nothing more. An empty label leaves the",
3578
+ " bare rule, which is how a provider that needs no naming (the official source)",
3579
+ " is separated. */",
3580
+ "body[data-dsh-claude-style] .dsh-claude-model-rule {",
3581
+ " display: flex !important;",
3582
+ " align-items: center !important;",
3583
+ " gap: 6px !important;",
3249
3584
  " margin: 2px 4px !important;",
3250
3585
  " flex: none !important;",
3251
3586
  "}",
3252
- "/* Level-2 row: label + current value + chevron; hover opens its own level. */",
3587
+ "body[data-dsh-claude-style] .dsh-claude-model-rule::after {",
3588
+ " content: \"\" !important;",
3589
+ " flex: 1 !important;",
3590
+ "}",
3591
+ "body[data-dsh-claude-style] .dsh-claude-model-rule-name {",
3592
+ " flex: none !important;",
3593
+ " font-size: 11px !important;",
3594
+ " line-height: 14px !important;",
3595
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
3596
+ " letter-spacing: 0.02em !important;",
3597
+ "}",
3598
+ "/* Level-2 row: label + chevron; hover opens its own level. */",
3253
3599
  "body[data-dsh-claude-style] .dsh-claude-model-cell {",
3254
3600
  " display: flex !important;",
3255
3601
  " align-items: center !important;",
@@ -3273,11 +3619,6 @@ window.__ModuleLoader__.load({
3273
3619
  " flex: 1 !important;",
3274
3620
  " min-width: 0 !important;",
3275
3621
  "}",
3276
- "body[data-dsh-claude-style] .dsh-claude-model-cell-value {",
3277
- " flex: none !important;",
3278
- " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
3279
- " font-size: 12px !important;",
3280
- "}",
3281
3622
  "body[data-dsh-claude-style] .dsh-claude-model-cell-chevron {",
3282
3623
  " flex: none !important;",
3283
3624
  " display: inline-flex !important;",
@@ -3285,42 +3626,151 @@ window.__ModuleLoader__.load({
3285
3626
  " color: var(--dsw-alias-label-caption, #a6a094) !important;",
3286
3627
  "}",
3287
3628
  "",
3288
- "/* Popover Header. Vertical padding only: the clickable account row carries its",
3289
- " own inline padding (components/account-footer.css), and the divider below is a",
3290
- " full-bleed rule that must not be inset by this box. */",
3291
- "body[data-dsh-claude-style] .dsh-claude-account-popover-header {",
3292
- " padding: 6px 0 !important;",
3629
+ "/* ---------- Reasoning-effort slider (first level's footer) ---------- */",
3630
+ "/* The effort ladder drawn as a track instead of a second level. The knob",
3631
+ " travels continuously with the pointer and settles on the nearest level when",
3632
+ " the gesture ends — release, or the pointer leaving the control — so the",
3633
+ " control never reads as a hard switch between fixed stops. The transition is",
3634
+ " what makes that settle read as a settle; it is switched off while the pointer",
3635
+ " owns the knob, which is what `data-dragging` marks. */",
3636
+ "body[data-dsh-claude-style] .dsh-claude-effort {",
3293
3637
  " display: flex !important;",
3294
3638
  " flex-direction: column !important;",
3295
- " gap: 2px !important;",
3639
+ " gap: 3px !important;",
3640
+ " padding: 3px 7px 4px 7px !important;",
3641
+ " min-width: 0 !important;",
3642
+ " user-select: none !important;",
3643
+ " -webkit-user-select: none !important;",
3296
3644
  "}",
3297
- "body[data-dsh-claude-style] .dsh-claude-account-popover-name {",
3298
- " font-size: 14px !important;",
3299
- " font-weight: 600 !important;",
3300
- " color: var(--dsw-alias-label-primary, #141413) !important;",
3301
- " line-height: 18px !important;",
3302
- " font-family: var(--dsw-font-family) !important;",
3645
+ "body[data-dsh-claude-style] .dsh-claude-effort-head {",
3646
+ " display: flex !important;",
3647
+ " align-items: baseline !important;",
3648
+ " gap: 6px !important;",
3649
+ " min-width: 0 !important;",
3303
3650
  "}",
3304
- "body[data-dsh-claude-style] .dsh-claude-account-popover-divider {",
3305
- " height: 1px !important;",
3306
- " background: var(--dsw-alias-border-l1, #e8e6dc) !important;",
3307
- " margin: 2px 0 !important;",
3651
+ "body[data-dsh-claude-style] .dsh-claude-effort-label {",
3308
3652
  " flex: none !important;",
3653
+ " font-size: 13px !important;",
3654
+ " line-height: 16px !important;",
3655
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
3309
3656
  "}",
3310
- "",
3311
- "/* Popover Body & Items */",
3312
- "body[data-dsh-claude-style] .dsh-claude-account-popover-body {",
3657
+ "/* The level's name trails the label instead of sitting at the far edge: the",
3658
+ " control reads as one phrase, `Reasoning effort Default`, the way the ends",
3659
+ " below read as one scale. */",
3660
+ "body[data-dsh-claude-style] .dsh-claude-effort-value {",
3661
+ " flex: 0 1 auto !important;",
3662
+ " min-width: 0 !important;",
3663
+ " overflow: hidden !important;",
3664
+ " text-overflow: ellipsis !important;",
3665
+ " white-space: nowrap !important;",
3666
+ " font-size: 13px !important;",
3667
+ " line-height: 16px !important;",
3668
+ " color: var(--dsw-alias-label-secondary, #787672) !important;",
3669
+ "}",
3670
+ "/* The ends name the axis, so they stay in English in every locale and in the",
3671
+ " caption colour: they are a scale, not a value. */",
3672
+ "body[data-dsh-claude-style] .dsh-claude-effort-ends {",
3313
3673
  " display: flex !important;",
3314
- " flex-direction: column !important;",
3315
- " gap: 2px !important;",
3316
- " max-height: 360px !important;",
3317
- " overflow-y: auto !important;",
3674
+ " align-items: center !important;",
3675
+ " justify-content: space-between !important;",
3676
+ " gap: 8px !important;",
3677
+ " font-size: 11px !important;",
3678
+ " line-height: 14px !important;",
3679
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
3318
3680
  "}",
3319
- "body[data-dsh-claude-style] .dsh-claude-popover-item {",
3320
- " width: 100% !important;",
3321
- " height: 32px !important;",
3681
+ "body[data-dsh-claude-style] .dsh-claude-effort-track {",
3682
+ " position: relative !important;",
3683
+ " height: 18px !important;",
3684
+ " margin-top: 1px !important;",
3685
+ " cursor: pointer !important;",
3686
+ " touch-action: none !important;",
3687
+ "}",
3688
+ "body[data-dsh-claude-style] .dsh-claude-effort-track::before {",
3689
+ " content: \"\" !important;",
3690
+ " position: absolute !important;",
3691
+ " left: 0 !important;",
3692
+ " right: 0 !important;",
3693
+ " top: 50% !important;",
3694
+ " /* Nearly as tall as the 16px knob (Claude's real Effort slider reads as one",
3695
+ " chunky pill, not a bead on a wire): 6px read as a thin rail the knob sat",
3696
+ " ON, 14px reads as a groove the knob rides IN. */",
3697
+ " height: 14px !important;",
3698
+ " margin-top: -7px !important;",
3699
+ " border-radius: 999px !important;",
3700
+ " /* A pill: its caps are meant to be round, and the page inherits superellipse",
3701
+ " corners from <html>. */",
3702
+ " corner-shape: round !important;",
3703
+ " background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08)) !important;",
3704
+ "}",
3705
+ "body[data-dsh-claude-style] .dsh-claude-effort-knob {",
3706
+ " position: absolute !important;",
3707
+ " left: 0 !important;",
3708
+ " top: 50% !important;",
3709
+ " width: 16px !important;",
3710
+ " height: 16px !important;",
3711
+ " margin-top: -8px !important;",
3712
+ " border-radius: 50% !important;",
3713
+ " corner-shape: round !important;",
3714
+ " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
3715
+ " box-shadow: 0 1px 3px rgba(20, 20, 19, 0.28), 0 0 0 1px rgba(20, 20, 19, 0.06) !important;",
3716
+ " transition: transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1) !important;",
3717
+ " will-change: transform !important;",
3718
+ "}",
3719
+ "body[data-dsh-claude-style][data-ds-dark-theme] .dsh-claude-effort-knob {",
3720
+ " box-shadow: 0 1px 3px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(0, 0, 0, 0.4) !important;",
3721
+ "}",
3722
+ "body[data-dsh-claude-style] .dsh-claude-effort[data-dragging] .dsh-claude-effort-knob {",
3723
+ " transition: none !important;",
3724
+ "}",
3725
+ "body[data-dsh-claude-style] .dsh-claude-effort-track:focus-visible {",
3726
+ " outline: 2px solid var(--dsw-alias-brand-primary, #d97757) !important;",
3727
+ " outline-offset: 1px !important;",
3728
+ " border-radius: 6px !important;",
3729
+ "}",
3730
+ "/* No ladder to climb: the track is dimmed and the value reads as a dash, but the",
3731
+ " knob still travels and eases home — the control keeps its shape. */",
3732
+ "body[data-dsh-claude-style] .dsh-claude-effort[data-empty] .dsh-claude-effort-track::before {",
3733
+ " opacity: 0.55 !important;",
3734
+ "}",
3735
+ "body[data-dsh-claude-style] .dsh-claude-effort[data-empty] .dsh-claude-effort-value {",
3736
+ " color: var(--dsw-alias-label-caption, #a6a094) !important;",
3737
+ "}",
3738
+ "",
3739
+ "/* Popover Header. Vertical padding only: the clickable account row carries its",
3740
+ " own inline padding (components/account-footer.css), and the divider below is a",
3741
+ " full-bleed rule that must not be inset by this box. */",
3742
+ "body[data-dsh-claude-style] .dsh-claude-account-popover-header {",
3743
+ " padding: 6px 0 !important;",
3744
+ " display: flex !important;",
3745
+ " flex-direction: column !important;",
3746
+ " gap: 2px !important;",
3747
+ "}",
3748
+ "body[data-dsh-claude-style] .dsh-claude-account-popover-name {",
3749
+ " font-size: 14px !important;",
3750
+ " font-weight: 600 !important;",
3751
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
3752
+ " line-height: 18px !important;",
3753
+ " font-family: var(--dsw-font-family) !important;",
3754
+ "}",
3755
+ "body[data-dsh-claude-style] .dsh-claude-account-popover-divider {",
3756
+ " height: 1px !important;",
3757
+ " background: var(--dsw-alias-border-l1, #e8e6dc) !important;",
3758
+ " margin: 2px 0 !important;",
3759
+ " flex: none !important;",
3760
+ "}",
3761
+ "",
3762
+ "/* Popover Body & Items */",
3763
+ "body[data-dsh-claude-style] .dsh-claude-account-popover-body {",
3764
+ " display: flex !important;",
3765
+ " flex-direction: column !important;",
3766
+ " gap: 2px !important;",
3767
+ " max-height: 360px !important;",
3768
+ " overflow-y: auto !important;",
3769
+ "}",
3770
+ "body[data-dsh-claude-style] .dsh-claude-popover-item {",
3771
+ " width: 100% !important;",
3322
3772
  " min-height: 32px !important;",
3323
- " padding: 0 8px !important;",
3773
+ " padding: 2px 7px !important;",
3324
3774
  " border-radius: 6px !important;",
3325
3775
  " border: none !important;",
3326
3776
  " background: transparent !important;",
@@ -3378,12 +3828,156 @@ window.__ModuleLoader__.load({
3378
3828
  " background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08)) !important;",
3379
3829
  " color: var(--dsw-alias-label-secondary, #787672) !important;",
3380
3830
  "}",
3831
+ "/* A provider the catalog no longer offers: its stored row stays checked but is",
3832
+ " muted, so it reads as \"this used to be picked\" and can be unchecked away. */",
3833
+ "body[data-dsh-claude-style] .dsh-claude-popover-item.dsh-claude-model-stale .dsh-claude-popover-item-text,",
3834
+ "body[data-dsh-claude-style] .dsh-claude-popover-item.dsh-claude-model-stale .dsh-claude-popover-item-badge {",
3835
+ " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
3836
+ "}",
3837
+ "body[data-dsh-claude-style] .dsh-claude-popover-item.dsh-claude-model-stale .dsh-claude-popover-item-badge {",
3838
+ " background: transparent !important;",
3839
+ "}",
3381
3840
  "",
3382
3841
  "/* Ensure popover items only display inside the popover */",
3383
3842
  "body[data-dsh-claude-style] [class*=\"footArea\"] > .dsh-claude-popover-item {",
3384
3843
  " display: none !important;",
3385
3844
  "}",
3386
3845
  "",
3846
+ "/* @composer-gate */",
3847
+ "/* ---------- Host menu primitive: the hero row's pickers ---------- */",
3848
+ "/* The workspace (directory) chip and the agent-preset seat open the host's",
3849
+ " shared Menu primitive, which portals its card to <body>. The card carries no",
3850
+ " marker of its own, so src/overrides/hero-menu.js stamps the one that belongs",
3851
+ " to this row with HERO_MENU_ATTR and these rules reach it alone — the host's",
3852
+ " other menus keep their own design. The metrics are the skin's popover",
3853
+ " language, the same card / rows / hover plate / accent check as",
3854
+ " components/model-picker.css and components/permissions.css. */",
3855
+ "",
3856
+ "/* Two class-name families meet here. The host's client-ui packages hash as",
3857
+ " `<hash>_<local>`, so their fragments read `_itemName` / `_itemDesc`; the",
3858
+ " shared menu ships inside the web shell and hashes as `_<local>_<hash>_<n>`,",
3859
+ " so its fragments read `_itemWrap_` / `_itemLabel_`. Both are the longest",
3860
+ " stable piece of the class — never the bare local name. */",
3861
+ "",
3862
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] {",
3863
+ " min-width: 248px !important;",
3864
+ " max-width: min(360px, calc(100vw - 16px)) !important;",
3865
+ " max-height: min(440px, calc(100vh - 96px)) !important;",
3866
+ " padding: 6px !important;",
3867
+ " gap: 6px !important;",
3868
+ " border: 1px solid var(--dsw-alias-border-l1, #e8e6dc) !important;",
3869
+ " border-radius: 12px !important;",
3870
+ " background: var(--dsw-alias-bg-overlay, #ffffff) !important;",
3871
+ " box-shadow: 0 8px 30px rgba(20, 20, 19, 0.12), 0 2px 8px rgba(20, 20, 19, 0.06) !important;",
3872
+ " z-index: 99999 !important;",
3873
+ " font-family: var(--dsw-font-family) !important;",
3874
+ " /* The host mounts the card instead of toggling a `data-open` attribute, so",
3875
+ " the skin's open transition becomes a one-shot animation with the same",
3876
+ " values. Its origin is the edge nearest the trigger: the card sits beside it. */",
3877
+ " transform-origin: bottom left !important;",
3878
+ " animation: dsh-claude-hero-menu-in 0.15s ease !important;",
3879
+ "}",
3880
+ "@keyframes dsh-claude-hero-menu-in {",
3881
+ " from {",
3882
+ " opacity: 0;",
3883
+ " transform: translateY(4px) scale(0.98);",
3884
+ " }",
3885
+ " to {",
3886
+ " opacity: 1;",
3887
+ " transform: none;",
3888
+ " }",
3889
+ "}",
3890
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active][data-ds-dark-theme] [data-dsh-claude-hero-menu] {",
3891
+ " background: #1e1e1d !important;",
3892
+ " border-color: #2e2c29 !important;",
3893
+ " box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3) !important;",
3894
+ "}",
3895
+ "",
3896
+ "/* One row height, one radius, one hover plate for both pickers. */",
3897
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemWrap_\"] > [role=\"menuitem\"] {",
3898
+ " min-height: 32px !important;",
3899
+ " padding: 2px 7px !important;",
3900
+ " gap: 8px !important;",
3901
+ " border-radius: 6px !important;",
3902
+ " font-size: 13px !important;",
3903
+ " line-height: 20px !important;",
3904
+ " color: var(--dsw-alias-label-primary, #141413) !important;",
3905
+ " transition: background-color 0.12s ease !important;",
3906
+ "}",
3907
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemWrap_\"] > [role=\"menuitem\"]:hover:not(:disabled) {",
3908
+ " background: var(--dsh-claude-hover-bg, rgba(0, 0, 0, 0.08)) !important;",
3909
+ "}",
3910
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemWrap_\"] > [role=\"menuitem\"]:disabled {",
3911
+ " opacity: 0.4 !important;",
3912
+ " cursor: not-allowed !important;",
3913
+ "}",
3914
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemIcon_\"] {",
3915
+ " width: 16px !important;",
3916
+ " height: 16px !important;",
3917
+ " color: var(--dsw-alias-label-secondary, #787672) !important;",
3918
+ "}",
3919
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemIcon_\"] svg {",
3920
+ " width: 16px !important;",
3921
+ " height: 16px !important;",
3922
+ "}",
3923
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemLabel_\"] {",
3924
+ " font-size: 13px !important;",
3925
+ " line-height: 20px !important;",
3926
+ "}",
3927
+ "/* The current row is marked by the accent, as in the model picker. */",
3928
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_check_\"] {",
3929
+ " flex: none !important;",
3930
+ " color: var(--dsw-alias-brand-primary, #d97757) !important;",
3931
+ "}",
3932
+ "",
3933
+ "/* The preset rows are two lines (name + description) and the workspace rows",
3934
+ " one with an icon, so the height above is a floor rather than a cap. */",
3935
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemName\"] {",
3936
+ " font-size: 13px !important;",
3937
+ " font-weight: 500 !important;",
3938
+ " line-height: 16px !important;",
3939
+ "}",
3940
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_itemDesc\"] {",
3941
+ " font-size: 11px !important;",
3942
+ " line-height: 14px !important;",
3943
+ " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
3944
+ "}",
3945
+ "",
3946
+ "/* Heading and separator rows. */",
3947
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] > [class*=\"_viewport_\"] > [role=\"presentation\"] {",
3948
+ " padding: 6px 7px 2px !important;",
3949
+ " font-size: 11px !important;",
3950
+ " font-weight: 600 !important;",
3951
+ " line-height: 16px !important;",
3952
+ " letter-spacing: 0.04em !important;",
3953
+ " text-transform: uppercase !important;",
3954
+ " color: var(--dsw-alias-label-tertiary, #8f8d84) !important;",
3955
+ "}",
3956
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [role=\"separator\"] {",
3957
+ " height: 1px !important;",
3958
+ " margin: 2px 4px !important;",
3959
+ " background: var(--dsw-alias-border-l1, #e8e6dc) !important;",
3960
+ "}",
3961
+ "",
3962
+ "/* The pinned footer (\"添加工作区…\") keeps the host's spacing with the skin's",
3963
+ " hairline. */",
3964
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] > [class*=\"_footer_\"] {",
3965
+ " margin-top: 4px !important;",
3966
+ " padding-top: 4px !important;",
3967
+ " border-top: 1px solid var(--dsw-alias-border-l1, #e8e6dc) !important;",
3968
+ "}",
3969
+ "",
3970
+ "/* The card's padding is the skin's 6px rather than the host's 4px, so a side",
3971
+ " card moves with it: 6px pad + 6px gap. */",
3972
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_submenu_\"] {",
3973
+ " bottom: -6px !important;",
3974
+ " left: calc(100% + 12px) !important;",
3975
+ "}",
3976
+ "body[data-dsh-claude-style][data-dsh-claude-composer-active] [data-dsh-claude-hero-menu] [class*=\"_submenu_\"]::before {",
3977
+ " left: -12px !important;",
3978
+ " width: 12px !important;",
3979
+ "}",
3980
+ "",
3387
3981
  "/* ---------- Footer takeover ----------",
3388
3982
  " Everything in this block modifies the HOST's own footer (it hides entries and",
3389
3983
  " zeroes the containers that host them) and exists only because the skin",
@@ -3680,6 +4274,8 @@ window.__ModuleLoader__.load({
3680
4274
  " width: 16px;",
3681
4275
  " height: 16px;",
3682
4276
  " border-radius: 50%;",
4277
+ " /* A true circle: the page inherits superellipse corners from <html>. */",
4278
+ " corner-shape: round;",
3683
4279
  " background: var(--dsw-alias-bg-overlay, #ffffff);",
3684
4280
  " box-shadow: 0 1px 2px rgba(20, 20, 19, 0.18);",
3685
4281
  " transition: transform .12s ease;",
@@ -3715,6 +4311,32 @@ window.__ModuleLoader__.load({
3715
4311
  " color: var(--dsw-alias-label-caption);",
3716
4312
  "}",
3717
4313
  "",
4314
+ "/* The quick-provider trigger belongs to the same control family as the username",
4315
+ " input: one bordered plate that opens a picker. Its card is the shared popover",
4316
+ " card (components/permissions.css), so only the trigger is styled here. */",
4317
+ "body[data-dsh-claude-style] .dsh-claude-settings-picker {",
4318
+ " flex: none;",
4319
+ " min-width: 140px;",
4320
+ " max-width: 45%;",
4321
+ " box-sizing: border-box;",
4322
+ " border: 1px solid var(--dsw-alias-border-l1);",
4323
+ " border-radius: 8px;",
4324
+ " background: var(--dsw-alias-bg-layer-1);",
4325
+ " color: var(--dsw-alias-label-primary);",
4326
+ " padding: 6px 10px;",
4327
+ " font: inherit;",
4328
+ " font-size: 13px;",
4329
+ " line-height: 18px;",
4330
+ " text-align: left;",
4331
+ " cursor: pointer;",
4332
+ "}",
4333
+ "body[data-dsh-claude-style] .dsh-claude-settings-picker:hover {",
4334
+ " border-color: var(--dsw-alias-border-l2);",
4335
+ "}",
4336
+ "body[data-dsh-claude-style] .dsh-claude-settings-picker[aria-expanded=\"true\"] {",
4337
+ " border-color: var(--dsw-alias-brand-primary, #d97757);",
4338
+ "}",
4339
+ "",
3718
4340
  "/* ============================================================================",
3719
4341
  " 主题翻转:瞬时抑制过渡 (Theme Flip: Transition Suppression)",
3720
4342
  " ============================================================================ */",
@@ -3907,11 +4529,20 @@ window.__ModuleLoader__.load({
3907
4529
  /**
3908
4530
  * Skin preferences.
3909
4531
  *
3910
- * The authoritative store is the host settings namespace `claude-style`,
3911
- * reached through this plugin's own route: the configuration client
3912
- * (`settingsScope`) only reaches namespaces the api-proxy exposes to it, and
3913
- * a plugin's own namespace is not on that list. `src/../lib/index.js` owns
3914
- * the namespace and the route; this side only reads and writes it.
4532
+ * The authoritative store is the host settings namespace, reached one of two
4533
+ * ways depending on the host generation:
4534
+ *
4535
+ * * 0.1.7+ serves every registered namespace to the browser through
4536
+ * `ctx.configForms`, whose per-entry controller carries the values, the
4537
+ * write queue and the revision fence. That is the transport used
4538
+ * whenever the service is present.
4539
+ * * 0.1.5-rc.2 and earlier expose only the namespaces the api-proxy lists,
4540
+ * and a plugin's own is not among them, so this side falls back to the
4541
+ * route its host half registers (`lib/index.js`).
4542
+ *
4543
+ * Both transports carry the same eight fields, so everything below stays
4544
+ * transport-agnostic: `loadPrefs`/`savePrefs` pick one, and the rest of the
4545
+ * skin keeps reading the mirrored `prefs` object.
3915
4546
  *
3916
4547
  * Every value is mirrored onto the document as an attribute, so the
3917
4548
  * stylesheet — not this module — decides what a preference means visually.
@@ -3968,8 +4599,10 @@ window.__ModuleLoader__.load({
3968
4599
  var prefs = {
3969
4600
  brand: DEFAULT_BRAND,
3970
4601
  collapseFooter: true,
3971
- autoPopover: true,
4602
+ autoPopover: DEFAULT_AUTO_POPOVER,
3972
4603
  composerScope: 'all',
4604
+ modelPicker: true,
4605
+ quickProviders: [],
3973
4606
  username: '',
3974
4607
  banLocale: fallbackBanLocale || DEFAULT_BAN_LOCALE,
3975
4608
  }
@@ -3977,6 +4610,84 @@ window.__ModuleLoader__.load({
3977
4610
  var prefsAvailable = false
3978
4611
  var prefsListeners = []
3979
4612
 
4613
+ /**
4614
+ * The official settings form, when this host has one.
4615
+ *
4616
+ * 0.1.7 replaced the imperative namespace registry with Config-derived
4617
+ * forms: `ctx.configForms.get(entryId)` hands back a controller carrying the
4618
+ * values, a write queue and a revision fence, and the namespace is this
4619
+ * plugin's profile entry id. The older host has no such service, so this
4620
+ * stays null there and the route below is the transport instead.
4621
+ */
4622
+ var prefsForm = null
4623
+
4624
+ /** This plugin's settings namespace: the loader entry id, or the patch's id. */
4625
+ function settingsEntryId(ctx) {
4626
+ try {
4627
+ var entry = ctx && ctx.fiber ? ctx.fiber.entry : null
4628
+ var id = entry ? entry.id : null
4629
+ if (typeof id === 'string' && id !== '') return id
4630
+ } catch (error) { /* no loader entry: fall back to the id the patch declares */ }
4631
+ return SETTINGS_ENTRY_FALLBACK
4632
+ }
4633
+
4634
+ /** Whether this host serves namespaces to the browser (0.1.7+). */
4635
+ function hostConfigForms(ctx) {
4636
+ try {
4637
+ if (!ctx || typeof ctx.get !== 'function') return null
4638
+ var forms = ctx.get('configForms')
4639
+ return forms !== null && forms !== undefined && typeof forms.get === 'function' ? forms : null
4640
+ } catch (error) {
4641
+ return null
4642
+ }
4643
+ }
4644
+
4645
+ /** The form's current field values, or null while it is not ready. */
4646
+ function readFormValue() {
4647
+ if (prefsForm === null) return null
4648
+ try {
4649
+ var snapshot = prefsForm.getSnapshot()
4650
+ if (snapshot === null || snapshot === undefined) return null
4651
+ if (snapshot.status !== 'ready') return null
4652
+ return snapshot.value && typeof snapshot.value === 'object' ? snapshot.value : null
4653
+ } catch (error) {
4654
+ return null
4655
+ }
4656
+ }
4657
+
4658
+ /**
4659
+ * Bind the official form when the host offers one.
4660
+ *
4661
+ * Called once per install, before the first read. A host that serves the
4662
+ * service later (or never) simply keeps the route transport, so this never
4663
+ * blocks or fails the skin.
4664
+ */
4665
+ function adoptSettingsForm(ctx) {
4666
+ if (prefsForm !== null) return true
4667
+ var forms = hostConfigForms(ctx)
4668
+ if (forms === null) return false
4669
+ var form = null
4670
+ try {
4671
+ form = forms.get(settingsEntryId(ctx))
4672
+ } catch (error) {
4673
+ form = null
4674
+ }
4675
+ if (form === null || form === undefined || typeof form.getSnapshot !== 'function') return false
4676
+ prefsForm = form
4677
+ if (typeof form.subscribe === 'function') {
4678
+ try {
4679
+ form.subscribe(function () {
4680
+ var value = readFormValue()
4681
+ if (value === null) return
4682
+ prefsAvailable = true
4683
+ adoptPrefs(normalizePrefs(value))
4684
+ replayPendingBanLocale(value)
4685
+ })
4686
+ } catch (error) { /* no subscribe face: reads stay on demand */ }
4687
+ }
4688
+ return true
4689
+ }
4690
+
3980
4691
  /**
3981
4692
  * Browser-local fallback for the custom username.
3982
4693
  *
@@ -4040,8 +4751,23 @@ window.__ModuleLoader__.load({
4040
4751
  /**
4041
4752
  * Read the preferences once. A failure keeps the defaults and leaves the
4042
4753
  * settings page to report that the store is unavailable.
4754
+ *
4755
+ * The official form is read first when it exists: its subscription already
4756
+ * re-reads on every host change, so this call only has to cover the case
4757
+ * where the values are ready before the subscription settles.
4043
4758
  */
4044
4759
  function loadPrefs() {
4760
+ if (prefsForm !== null) {
4761
+ var formValue = readFormValue()
4762
+ if (formValue !== null) {
4763
+ prefsAvailable = true
4764
+ var formName = typeof formValue.username === 'string' ? formValue.username.trim() : ''
4765
+ if (formName) setFallbackUsername('')
4766
+ adoptPrefs(normalizePrefs(formValue))
4767
+ replayPendingBanLocale(formValue)
4768
+ }
4769
+ return
4770
+ }
4045
4771
  if (typeof fetch !== 'function') return
4046
4772
  try {
4047
4773
  fetch(PREFS_ROUTE, {
@@ -4066,14 +4792,46 @@ window.__ModuleLoader__.load({
4066
4792
  } catch (error) { /* no fetch: defaults stay */ }
4067
4793
  }
4068
4794
 
4795
+ /**
4796
+ * Clamp the hover-open preference. It used to be a boolean, and a value
4797
+ * stored in that shape still has to land on a scope: `true` meant every
4798
+ * popover, `false` meant click-only.
4799
+ */
4800
+ function normalizeAutoPopover(value) {
4801
+ if (value === true) return AUTO_POPOVER_ALL
4802
+ if (value === false) return AUTO_POPOVER_OFF
4803
+ return AUTO_POPOVER_SCOPES.indexOf(value) === -1 ? DEFAULT_AUTO_POPOVER : value
4804
+ }
4805
+
4806
+ /**
4807
+ * The provider ids the picker's first level carries. Ids rather than names:
4808
+ * a provider can be renamed by the catalog at any time, and the stored
4809
+ * selection has to survive that. Order is the caller's, duplicates dropped.
4810
+ * The official service is the picker's default, not a choice, so a stored
4811
+ * id for it is dropped: the first level shows it whenever nothing else is
4812
+ * picked, which is what "default" means.
4813
+ */
4814
+ function normalizeQuickProviders(value) {
4815
+ if (!Array.isArray(value)) return []
4816
+ var out = []
4817
+ for (var i = 0; i < value.length; i++) {
4818
+ var id = value[i]
4819
+ if (typeof id !== 'string' || id === '' || id === MODEL_OFFICIAL_GROUP || out.indexOf(id) !== -1) continue
4820
+ out.push(id)
4821
+ }
4822
+ return out
4823
+ }
4824
+
4069
4825
  /** Clamp one host value into the preference shape (the host already did this). */
4070
4826
  function normalizePrefs(value) {
4071
4827
  var section = value && typeof value === 'object' ? value : {}
4072
4828
  return {
4073
4829
  brand: section.brand === BRAND_ANTHROPIC || section.brand === BRAND_OFF ? section.brand : BRAND_CLAUDE,
4074
4830
  collapseFooter: section.collapseFooter !== false,
4075
- autoPopover: section.autoPopover !== false,
4831
+ autoPopover: normalizeAutoPopover(section.autoPopover),
4076
4832
  composerScope: COMPOSER_SCOPES.indexOf(section.composerScope) === -1 ? 'all' : section.composerScope,
4833
+ modelPicker: section.modelPicker !== false,
4834
+ quickProviders: normalizeQuickProviders(section.quickProviders),
4077
4835
  username: (typeof section.username === 'string' ? section.username.trim().slice(0, USERNAME_MAX) : '') || fallbackUsername,
4078
4836
  banLocale: resolveBanLocale(section.banLocale),
4079
4837
  }
@@ -4098,6 +4856,65 @@ window.__ModuleLoader__.load({
4098
4856
  savePrefs({ banLocale: fallbackBanLocale })
4099
4857
  }
4100
4858
 
4859
+ /**
4860
+ * Write a partial change through the official form.
4861
+ *
4862
+ * One `set()` per field, chained: the controller owns the write queue and
4863
+ * takes its revision fence from the last settlement, so a burst of toggles
4864
+ * cannot interleave or lose a field. `set()` also validates the field path
4865
+ * against the entry's Config before anything crosses the wire, which is why
4866
+ * an unknown key is dropped here rather than sent.
4867
+ *
4868
+ * @param patch - preference keys to change.
4869
+ * @returns a promise for the resolved preferences, or null when refused.
4870
+ */
4871
+ function savePrefsViaForm(patch) {
4872
+ var keys = []
4873
+ for (var key in patch) {
4874
+ if (Object.prototype.hasOwnProperty.call(patch, key)) keys.push(key)
4875
+ }
4876
+ var step = function (name) {
4877
+ return function (accepted) {
4878
+ if (accepted === false) return false
4879
+ try {
4880
+ var pending = prefsForm.set(name, patch[name])
4881
+ return pending && typeof pending.then === 'function'
4882
+ ? pending.then(function (ok) { return ok === true })
4883
+ : true
4884
+ } catch (error) {
4885
+ return false
4886
+ }
4887
+ }
4888
+ }
4889
+ var run = Promise.resolve(true)
4890
+ for (var i = 0; i < keys.length; i++) run = run.then(step(keys[i]))
4891
+ return run.then(function (accepted) {
4892
+ if (accepted === false) {
4893
+ // Refused (a stale revision, or a field this Config does not carry):
4894
+ // re-read rather than guess, the way the route path does.
4895
+ loadPrefs()
4896
+ return null
4897
+ }
4898
+ var value = readFormValue()
4899
+ if (value !== null) {
4900
+ prefsAvailable = true
4901
+ if (typeof patch.username === 'string') {
4902
+ var hostName = typeof value.username === 'string' ? value.username.trim() : ''
4903
+ setFallbackUsername(hostName ? '' : patch.username)
4904
+ }
4905
+ // The host echoing the value back is the only proof it knows the
4906
+ // field; anything else means the write did not land and the local
4907
+ // fallback has to keep it.
4908
+ if (typeof patch.banLocale === 'string') {
4909
+ var hostLocale = typeof value.banLocale === 'string' ? value.banLocale : ''
4910
+ setFallbackBanLocale(hostLocale === patch.banLocale ? '' : patch.banLocale)
4911
+ }
4912
+ adoptPrefs(normalizePrefs(value))
4913
+ }
4914
+ return prefs
4915
+ })
4916
+ }
4917
+
4101
4918
  /**
4102
4919
  * Write a partial preference change.
4103
4920
  *
@@ -4109,6 +4926,7 @@ window.__ModuleLoader__.load({
4109
4926
  * @returns a promise for the resolved preferences, or null when unavailable.
4110
4927
  */
4111
4928
  function savePrefs(patch) {
4929
+ if (prefsForm !== null) return savePrefsViaForm(patch)
4112
4930
  if (typeof fetch !== 'function') return Promise.resolve(null)
4113
4931
  var body = { revision: prefsRevision }
4114
4932
  for (var key in patch) body[key] = patch[key]
@@ -4151,19 +4969,6 @@ window.__ModuleLoader__.load({
4151
4969
  })
4152
4970
  }
4153
4971
 
4154
- /**
4155
- * The brand to show. `off` leaves the brand area entirely to the host, so
4156
- * the stylesheet matches neither brand variant for it.
4157
- *
4158
- * @param brand - the stored choice.
4159
- * @returns the brand actually applied.
4160
- */
4161
- function applyBrand(brand) {
4162
- var next = brand === BRAND_ANTHROPIC || brand === BRAND_OFF ? brand : BRAND_CLAUDE
4163
- document.body.setAttribute(BRAND_ATTR, next)
4164
- return next
4165
- }
4166
-
4167
4972
  /**
4168
4973
  * Model & settings localized copy.
4169
4974
  *
@@ -4326,9 +5131,13 @@ window.__ModuleLoader__.load({
4326
5131
  * the picker copy, so the page follows the shell language too — and the
4327
5132
  * English constants stay as the fallback for a failed fetch.
4328
5133
  */
4329
- function settingsCopy(key, fallback) {
5134
+ function settingsCopy(key, fallback, params) {
4330
5135
  var text = modelCopy === null || !modelCopy.settings ? '' : localized(modelCopy.settings[key])
4331
- return text || fallback
5136
+ if (!text) text = fallback
5137
+ if (!params) return text
5138
+ return text.replace(/\{(\w+)\}/g, function (match, name) {
5139
+ return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
5140
+ })
4332
5141
  }
4333
5142
 
4334
5143
  /**
@@ -4386,36 +5195,75 @@ window.__ModuleLoader__.load({
4386
5195
  y = rect.top - (opts.gap || 0) - height
4387
5196
  if (y < margin) y = Math.min(rect.bottom + (opts.gap || 0), Math.max(margin, window.innerHeight - height - margin))
4388
5197
  }
5198
+ // Same-value guard: this runs on every scheduler pass while a popover is
5199
+ // open, and an identical write still dirties layout — the next geometry
5200
+ // read (the drag paths read rect/offset every frame) would then force a
5201
+ // synchronous recalc. Skip the write when the anchor did not move.
5202
+ var leftValue = Math.round(x) + 'px'
5203
+ var topValue = Math.round(y) + 'px'
4389
5204
  if (opts.important) {
4390
- pop.style.setProperty('left', Math.round(x) + 'px', 'important')
4391
- pop.style.setProperty('top', Math.round(y) + 'px', 'important')
5205
+ if (pop.style.left !== leftValue) pop.style.setProperty('left', leftValue, 'important')
5206
+ if (pop.style.top !== topValue) pop.style.setProperty('top', topValue, 'important')
4392
5207
  } else {
4393
- pop.style.left = x + 'px'
4394
- pop.style.top = y + 'px'
5208
+ if (pop.style.left !== leftValue) pop.style.left = leftValue
5209
+ if (pop.style.top !== topValue) pop.style.top = topValue
4395
5210
  }
4396
5211
  return { x: x, y: y }
4397
5212
  }
4398
5213
 
4399
5214
  /**
4400
- * Hover-intent helper shared by the model picker and account popover.
5215
+ * Hover dwell before a popover unfolds, and grace before it closes.
5216
+ *
5217
+ * The dwell is deliberately tiny: it exists to swallow a pointer that merely
5218
+ * CROSSES a trigger on its way somewhere else, not to make the user wait for
5219
+ * the card. The grace is what lets the pointer travel the gap between a
5220
+ * trigger and its card without the card vanishing underneath it.
5221
+ */
5222
+ var POPOVER_OPEN_DELAY = 50
5223
+ var POPOVER_CLOSE_DELAY = 100
5224
+
5225
+ /**
5226
+ * Hover-intent helper shared by the model picker, permission popover and
5227
+ * account popover.
4401
5228
  *
4402
- * The `open` callback is part of the shared signature but the original
4403
- * hover behaviour only schedules the close side (mouseenter calls the
4404
- * feature's open function directly); keeping the parameter makes the two
4405
- * call sites symmetrical.
5229
+ * BOTH sides are scheduled: `scheduleOpen` waits out the dwell (so a pointer
5230
+ * crossing the trigger never unfolds anything) and `scheduleClose` waits out
5231
+ * the grace. `cancel` clears whichever side is pending — entering the card
5232
+ * cancels a close, and leaving the trigger before the dwell cancels the open
5233
+ * (`scheduleClose` drops a pending open as well, so a leave needs only the
5234
+ * one call).
4406
5235
  */
4407
- function createHoverIntent(open, close, delay) {
4408
- var timer = null
5236
+ function createHoverIntent(open, close, openDelay, closeDelay) {
5237
+ var openTimer = null
5238
+ var closeTimer = null
4409
5239
  return {
4410
5240
  cancel: function () {
4411
- if (timer) {
4412
- clearTimeout(timer)
4413
- timer = null
5241
+ if (openTimer) {
5242
+ clearTimeout(openTimer)
5243
+ openTimer = null
5244
+ }
5245
+ if (closeTimer) {
5246
+ clearTimeout(closeTimer)
5247
+ closeTimer = null
4414
5248
  }
4415
5249
  },
5250
+ scheduleOpen: function () {
5251
+ if (openTimer) clearTimeout(openTimer)
5252
+ openTimer = setTimeout(function () {
5253
+ openTimer = null
5254
+ open()
5255
+ }, openDelay)
5256
+ },
4416
5257
  scheduleClose: function () {
4417
- if (timer) clearTimeout(timer)
4418
- timer = setTimeout(close, delay)
5258
+ if (openTimer) {
5259
+ clearTimeout(openTimer)
5260
+ openTimer = null
5261
+ }
5262
+ if (closeTimer) clearTimeout(closeTimer)
5263
+ closeTimer = setTimeout(function () {
5264
+ closeTimer = null
5265
+ close()
5266
+ }, closeDelay)
4419
5267
  },
4420
5268
  }
4421
5269
  }
@@ -4643,6 +5491,10 @@ window.__ModuleLoader__.load({
4643
5491
 
4644
5492
  var permDocPointerListener = null
4645
5493
  var permResizeListener = null
5494
+ /** The context meter this skin last moved out of the host's dock line. */
5495
+ var dockedMeter = null
5496
+ /** Identity of the stats bindings THIS generation installed (see bindStatsHover). */
5497
+ var statsBindingToken = {}
4646
5498
 
4647
5499
  /** Every dismiss route (item pick, outside pointer, resize/scroll, Escape) closes the menu through this one path. */
4648
5500
  function closePermMenu() {
@@ -4727,13 +5579,13 @@ window.__ModuleLoader__.load({
4727
5579
  popover.setAttribute('data-open', 'true')
4728
5580
  }
4729
5581
 
4730
- permHoverIntent = createHoverIntent(openPerm, closePermMenu, 150)
5582
+ permHoverIntent = createHoverIntent(openPerm, closePermMenu, POPOVER_OPEN_DELAY, POPOVER_CLOSE_DELAY)
4731
5583
 
4732
5584
  btn.addEventListener('mouseenter', function () {
4733
- if (readPrefs().autoPopover) openPerm()
5585
+ if (readPrefs().autoPopover === AUTO_POPOVER_ALL) permHoverIntent.scheduleOpen()
4734
5586
  })
4735
5587
  btn.addEventListener('mouseleave', function () {
4736
- if (readPrefs().autoPopover) permHoverIntent.scheduleClose()
5588
+ if (readPrefs().autoPopover === AUTO_POPOVER_ALL) permHoverIntent.scheduleClose()
4737
5589
  })
4738
5590
  popover.addEventListener('mouseenter', function () {
4739
5591
  permHoverIntent.cancel()
@@ -4938,6 +5790,75 @@ window.__ModuleLoader__.load({
4938
5790
  else row.appendChild(stats)
4939
5791
  }
4940
5792
 
5793
+ /** The composer's dock line: the stack child that is not the card. The host
5794
+ * parks the stats pills there, and since 0.1.7 the context meter too. */
5795
+ function composerDock(card) {
5796
+ var stack = card.parentElement
5797
+ if (stack === null) return null
5798
+ for (var i = 0; i < stack.children.length; i++) {
5799
+ if (stack.children[i] !== card) return stack.children[i]
5800
+ }
5801
+ return null
5802
+ }
5803
+
5804
+ /** The meter the host parks in that dock, or null when it is not there. */
5805
+ function dockedContextMeter(dock) {
5806
+ if (dock === null) return null
5807
+ var triggers = dock.querySelectorAll('button[aria-haspopup="dialog"]')
5808
+ for (var i = 0; i < triggers.length; i++) {
5809
+ // The meter's trigger IS the occupancy reading ("42%") and no stats
5810
+ // pill ever is, so the label alone identifies it without a class name
5811
+ // (the host hashes those per build).
5812
+ if (!/^\d{1,3}%$/.test((triggers[i].textContent || '').trim())) continue
5813
+ var node = triggers[i]
5814
+ while (node.parentElement !== null && node.parentElement !== dock) node = node.parentElement
5815
+ return node
5816
+ }
5817
+ return null
5818
+ }
5819
+
5820
+ /**
5821
+ * Merge the context-occupancy meter into the toolbar row beside the stats.
5822
+ * DSH 0.1.7 moved the meter out of the input bar's trailing cluster into
5823
+ * the dock line below the card, so a skin that merges only the stats
5824
+ * leaves it alone on a line of its own — a stray marker on a second row.
5825
+ * Older hosts keep it inside the card, where this finds nothing to move.
5826
+ * A host re-render can put it back, so the move is re-applied every pass.
5827
+ */
5828
+ function mergeContextMeterIntoRow() {
5829
+ var card = document.querySelector('[data-composer-card]')
5830
+ if (card === null) return
5831
+ var dock = composerDock(card)
5832
+ var row = card.querySelector('[class*="_row"]')
5833
+ if (row === null) return
5834
+ if (!ui.copy.isComposerActive()) {
5835
+ if (dockedMeter !== null && dock !== null && row.contains(dockedMeter)) dock.appendChild(dockedMeter)
5836
+ return
5837
+ }
5838
+ var meter = dockedContextMeter(dock)
5839
+ if (meter === null) return
5840
+ dockedMeter = meter
5841
+ // The stamp is what the stylesheet hangs the shared composer-line type
5842
+ // on: the host's own class names are hashed per build, and the ring's
5843
+ // trigger has no other stable hook.
5844
+ meter.setAttribute('data-dsh-claude-context-meter', '')
5845
+ var trailing = row.querySelector('[class*="trailing"]')
5846
+ if (trailing === null) { row.appendChild(meter); return }
5847
+ // Right of the model trigger, which lives inside the trailing cluster:
5848
+ // park the ring after whichever trailing child owns that trigger (our
5849
+ // own button when the skin draws the seat, the host's cluster when the
5850
+ // picker preference hands it back).
5851
+ var anchor = trailing.querySelector('[class*="standardControls"]')
5852
+ var modelBtn = trailing.querySelector('.dsh-claude-model-btn')
5853
+ if (modelBtn !== null) {
5854
+ var node = modelBtn
5855
+ while (node.parentElement !== null && node.parentElement !== trailing) node = node.parentElement
5856
+ if (node.parentElement === trailing) anchor = node
5857
+ }
5858
+ if (anchor !== null) trailing.insertBefore(meter, anchor.nextSibling)
5859
+ else trailing.appendChild(meter)
5860
+ }
5861
+
4941
5862
  // Re-insert when a re-render swapped the host row, then mirror the running preset.
4942
5863
  function syncSegments() {
4943
5864
  var isHero = ui.copy.isHeroView()
@@ -5188,12 +6109,30 @@ window.__ModuleLoader__.load({
5188
6109
  if (statsPopover !== null) statsPopover.setAttribute('data-open', 'false')
5189
6110
  }
5190
6111
 
6112
+ /**
6113
+ * Drop stats cards left behind by a previous client generation.
6114
+ *
6115
+ * Client HMR drops the old fiber's disposals, so the teardown never runs:
6116
+ * the previous generation's card stays in <body> holding its last content
6117
+ * and `data-open="true"` — a second, frozen popover sitting beside the
6118
+ * live one (which is why the two read differently: the stale card shows
6119
+ * whatever sections it was last rendered with). The model picker and the
6120
+ * account footer sweep their own strays the same way; the stats card was
6121
+ * the one that did not.
6122
+ */
6123
+ function sweepStrayStatsPopovers() {
6124
+ var strays = document.querySelectorAll('body > .dsh-claude-stats-popover')
6125
+ for (var i = 0; i < strays.length; i++) {
6126
+ if (strays[i] !== statsPopover) strays[i].parentElement.removeChild(strays[i])
6127
+ }
6128
+ }
6129
+
5191
6130
  function scheduleHideStatsPopover() {
5192
6131
  if (statsHideTimer) clearTimeout(statsHideTimer)
5193
6132
  statsHideTimer = setTimeout(function () {
5194
6133
  statsHideTimer = null
5195
6134
  hideStatsPopover()
5196
- }, 160)
6135
+ }, POPOVER_CLOSE_DELAY)
5197
6136
  }
5198
6137
 
5199
6138
  function renderStatsPopover(sections) {
@@ -5238,18 +6177,55 @@ window.__ModuleLoader__.load({
5238
6177
  }
5239
6178
 
5240
6179
  function bindStatsHover(root) {
5241
- if (root.__dshStatsHoverBound) return
5242
- root.__dshStatsHoverBound = true
5243
- // Gated like the account, model and permission popovers. The setting promises
5244
- // "hover opens these; off makes them click-to-open", and the stats popover was
5245
- // the one that opened on hover no matter what — so it ignored the switch. With
5246
- // the setting off, a click still reaches the host's own stats dialog, which is
5247
- // exactly what that button is for.
6180
+ // Generation-scoped, not a plain boolean: the host owns the stats node
6181
+ // and reuses it across a client HMR reload, so a boolean left by the
6182
+ // previous generation made this one skip binding entirely — the OLD
6183
+ // closure kept serving the card (its content, its stale node) while this
6184
+ // generation's listeners never existed.
6185
+ if (root.__dshStatsHoverToken === statsBindingToken) return
6186
+ root.__dshStatsHoverToken = statsBindingToken
6187
+ var openTimer = null
6188
+ function cancelOpen() {
6189
+ if (openTimer) {
6190
+ clearTimeout(openTimer)
6191
+ openTimer = null
6192
+ }
6193
+ }
6194
+ // Hover still opens the card under "All" — the setting promises exactly
6195
+ // that — but only after a deliberate dwell: the stats sentence sits in
6196
+ // the MIDDLE of the composer row, so a pointer on its way from the
6197
+ // permission selector to the model trigger used to unfold the card on
6198
+ // the way past. A passing pointer never stays the dwell out; a pointer
6199
+ // the user actually parked there does.
5248
6200
  root.addEventListener('mouseenter', function () {
5249
- if (readPrefs().autoPopover) showStatsPopover(root)
6201
+ cancelOpen()
6202
+ if (readPrefs().autoPopover !== AUTO_POPOVER_ALL) return
6203
+ openTimer = setTimeout(function () {
6204
+ openTimer = null
6205
+ showStatsPopover(root)
6206
+ }, 300)
5250
6207
  })
5251
6208
  root.addEventListener('mouseleave', function () {
5252
- if (readPrefs().autoPopover) scheduleHideStatsPopover()
6209
+ cancelOpen()
6210
+ // Unconditional, unlike the open side: a card opened by CLICK has to
6211
+ // close when the pointer leaves, whatever the hover switch says.
6212
+ scheduleHideStatsPopover()
6213
+ })
6214
+ // The host's own stats dialogs are hidden by the stylesheet, so a click
6215
+ // has to land somewhere: it opens this card — the only stats surface
6216
+ // left when the hover switch is off or scoped to the account rail.
6217
+ //
6218
+ // Only a REAL click counts. Collecting the card's content means clicking
6219
+ // the host's two pills to open their dialogs, and those synthetic clicks
6220
+ // bubble back up to this listener: re-entering the collection from
6221
+ // inside itself made two reads race, which is why sections went missing
6222
+ // (the card kept flipping between "会话统计 + Token 用量" and "Token 用量"
6223
+ // alone) and why the card stopped closing — every re-entry cancelled the
6224
+ // pending hide.
6225
+ root.addEventListener('click', function (event) {
6226
+ if (event && event.isTrusted === false) return
6227
+ cancelOpen()
6228
+ showStatsPopover(root)
5253
6229
  })
5254
6230
  }
5255
6231
 
@@ -5292,11 +6268,14 @@ window.__ModuleLoader__.load({
5292
6268
  sync: function () {
5293
6269
  syncAttachmentState()
5294
6270
  mergeStatsIntoRow()
6271
+ mergeContextMeterIntoRow()
6272
+ sweepStrayStatsPopovers()
5295
6273
  syncStatsSummary()
5296
6274
  syncSegments()
5297
6275
  syncChatTabComposer()
5298
6276
  },
5299
- closeMenu: closePermMenu
6277
+ closeMenu: closePermMenu,
6278
+ closeStats: hideStatsPopover
5300
6279
  }
5301
6280
 
5302
6281
  return function () {
@@ -5326,15 +6305,6 @@ window.__ModuleLoader__.load({
5326
6305
  }
5327
6306
  }
5328
6307
 
5329
- // 从 model-picker.js 抽出(体量停止线:该文件已达 737/750 行)。碎片共享一个
5330
- // 工厂作用域,所以这里能直接看到 context/model-copy.js 的 modelCopy 与构建生成
5331
- // 的 COMBINE_SVGS / COMBINE_WORDS,
5332
- // 不需要 import;函数声明会提升,所以本文件在 build.mjs 的 FRAGMENTS 里的位置
5333
- // 不影响调用。
5334
- //
5335
- // modelEl 是行构建器共用的元素工厂,随它的消费者一起搬过来(原先与这些函数同
5336
- // 在 installModelPicker 内)。
5337
-
5338
6308
  function modelEl(tag, cls, text) {
5339
6309
  var el = document.createElement(tag)
5340
6310
  if (cls) el.className = cls
@@ -5353,11 +6323,10 @@ window.__ModuleLoader__.load({
5353
6323
  * model it did not make (an OpenCode row wearing OpenCode's mark beside
5354
6324
  * "LongCat-2.0"), which reads as a wrong answer rather than a missing one.
5355
6325
  *
5356
- * @param groupId - provider route id (unused; kept for call-site symmetry).
5357
6326
  * @param modelId - catalog model id.
5358
6327
  * @returns the vendored lockup's id, or null when no rule claims it.
5359
6328
  */
5360
- function modelBrand(groupId, modelId) {
6329
+ function modelBrand(modelId) {
5361
6330
  if (modelCopy !== null) {
5362
6331
  var id = String(modelId === void 0 || modelId === null ? '' : modelId).toLowerCase()
5363
6332
  for (var i = 0; i < modelCopy.brandRules.length; i++) {
@@ -5419,6 +6388,418 @@ window.__ModuleLoader__.load({
5419
6388
  return el
5420
6389
  }
5421
6390
 
6391
+ /**
6392
+ * Resolving a catalog model to its description line.
6393
+ *
6394
+ * The copy document (src/model-descriptions.json) ships as data beside the
6395
+ * bundle and is fetched at runtime, so this is the only place that knows how
6396
+ * a model's id becomes a sentence. Resolution descends: exact entry (one
6397
+ * model resold by several providers folds to a single key) → family rule →
6398
+ * tier rule → the catalog's own text. Family rules are ordered and anchored
6399
+ * so another vendor's flash tier never borrows DeepSeek's copy; the tier
6400
+ * rules are the last resort, read out of the id itself. A model this table
6401
+ * has never seen and the catalog does not describe resolves to an empty
6402
+ * string on purpose: a name-only row beats an invented line.
6403
+ *
6404
+ * Split out of model-picker.js when the effort slider pushed that fragment
6405
+ * past the repository's size stop line; nothing here touches the picker's
6406
+ * closure, only the shared copy document.
6407
+ */
6408
+ /** Exact entry: `provider/model`, bare id, folded id, then the alias table. */
6409
+ function exactModelCopy(groupId, modelId) {
6410
+ if (modelCopy === null) return null
6411
+ var gid = String(groupId === void 0 || groupId === null ? '' : groupId).toLowerCase()
6412
+ var mid = String(modelId === void 0 || modelId === null ? '' : modelId)
6413
+ var midLower = mid.toLowerCase()
6414
+ var byProvider = modelCopy.exact[groupId + '/' + mid] || modelCopy.exact[gid + '/' + midLower]
6415
+ if (byProvider) return byProvider
6416
+ if (modelCopy.exact[mid]) return modelCopy.exact[mid]
6417
+ if (modelCopy.exact[midLower]) return modelCopy.exact[midLower]
6418
+ var folded = normalizeModelId(mid)
6419
+ if (modelCopy.folded[folded]) return modelCopy.folded[folded]
6420
+ var alias = modelCopy.aliases[mid] || modelCopy.aliases[midLower] || modelCopy.aliases[folded] || (modelCopy.foldedAliases && modelCopy.foldedAliases[folded])
6421
+ if (alias) {
6422
+ if (modelCopy.exact[alias]) return modelCopy.exact[alias]
6423
+ var foldedAlias = normalizeModelId(alias)
6424
+ if (modelCopy.folded[foldedAlias]) return modelCopy.folded[foldedAlias]
6425
+ }
6426
+ return null
6427
+ }
6428
+
6429
+ /**
6430
+ * Family entry. The model id is tried alone first because it is the stronger
6431
+ * signal, then `provider/id` for ids that carry no brand of their own
6432
+ * (`abab6.5s-chat` under a provider called `minimax`).
6433
+ */
6434
+ function familyModelCopy(groupId, modelId) {
6435
+ if (modelCopy === null) return null
6436
+ var id = String(modelId === void 0 || modelId === null ? '' : modelId).toLowerCase()
6437
+ var haystacks = [id, String(groupId === void 0 || groupId === null ? '' : groupId).toLowerCase() + '/' + id]
6438
+ for (var h = 0; h < haystacks.length; h++) {
6439
+ for (var i = 0; i < modelCopy.families.length; i++) {
6440
+ var rule = modelCopy.families[i]
6441
+ if (!rule.re.test(haystacks[h])) continue
6442
+ if (rule.key) return modelCopy.exact[rule.key] || null
6443
+ return rule.text
6444
+ }
6445
+ }
6446
+ return null
6447
+ }
6448
+
6449
+ /** Last-resort tier rule, read out of the id itself. */
6450
+ function tierModelCopy(modelId) {
6451
+ if (modelCopy === null) return null
6452
+ var id = String(modelId === void 0 || modelId === null ? '' : modelId).toLowerCase()
6453
+ for (var i = 0; i < modelCopy.tiers.length; i++) {
6454
+ if (modelCopy.tiers[i].re.test(id)) return modelCopy.tiers[i].text
6455
+ }
6456
+ return null
6457
+ }
6458
+
6459
+ /**
6460
+ * The description line for one catalog model, in the shell's language.
6461
+ * `ctx` is the caller's context, used only to read the shell's locale.
6462
+ */
6463
+ function modelDescription(ctx, groupId, model) {
6464
+ var id = typeof model.id === 'string' ? model.id : ''
6465
+ var pair = exactModelCopy(groupId, id) || familyModelCopy(groupId, id) || tierModelCopy(id)
6466
+ var text = localized(pair, ctx)
6467
+ if (text) return text
6468
+ return typeof model.description === 'string' ? model.description : ''
6469
+ }
6470
+
6471
+ /**
6472
+ * The reasoning-effort slider that sits at the foot of the model picker's
6473
+ * first level.
6474
+ *
6475
+ * The catalog describes a model's reasoning as a discrete ladder
6476
+ * (`reasoning.efforts` plus the level in force), and this control draws that
6477
+ * ladder as a track: the label carries the level's name, `Faster` / `Smarter`
6478
+ * name the direction of travel, and the knob follows the pointer
6479
+ * CONTINUOUSLY rather than hopping stop to stop. The value settles only when
6480
+ * the gesture ends — release, or the pointer leaving the control — and it
6481
+ * settles on the NEAREST level, so dragging never reads as a hard switch
6482
+ * between fixed positions.
6483
+ *
6484
+ * A model that offers no levels still gets the slider, because the picker
6485
+ * always wears the same shape at its foot: the knob travels the same way and
6486
+ * eases back to its resting end, but there is nothing to snap to and nothing
6487
+ * to select.
6488
+ *
6489
+ * The element is built once and reused across the picker's re-renders: a
6490
+ * rebuild mid-gesture would detach it and drop the pointer capture, so the
6491
+ * picker skips its render pass while a drag is in flight (see
6492
+ * src/overrides/model-picker.js).
6493
+ *
6494
+ * @param opts - `{ read, onPick, onDragStart }`. `read()` returns the model
6495
+ * picker's `modelEffort()` result for the current seat (or null), and
6496
+ * `onPick(levelId)` commits one level — `undefined` is the model's own
6497
+ * default level.
6498
+ * @returns `{ el, update, isDragging }`.
6499
+ */
6500
+ function createEffortControl(opts) {
6501
+ var root = modelEl('div', 'dsh-claude-effort')
6502
+ var head = modelEl('div', 'dsh-claude-effort-head')
6503
+ var labelEl = modelEl('span', 'dsh-claude-effort-label')
6504
+ var valueEl = modelEl('span', 'dsh-claude-effort-value')
6505
+ var ends = modelEl('div', 'dsh-claude-effort-ends')
6506
+ var fasterEl = modelEl('span', 'dsh-claude-effort-end')
6507
+ var smarterEl = modelEl('span', 'dsh-claude-effort-end')
6508
+ var track = modelEl('div', 'dsh-claude-effort-track')
6509
+ var knob = modelEl('div', 'dsh-claude-effort-knob')
6510
+
6511
+ head.appendChild(labelEl)
6512
+ head.appendChild(valueEl)
6513
+ ends.appendChild(fasterEl)
6514
+ ends.appendChild(smarterEl)
6515
+ track.appendChild(knob)
6516
+ root.appendChild(head)
6517
+ root.appendChild(ends)
6518
+ root.appendChild(track)
6519
+ track.setAttribute('role', 'slider')
6520
+ track.setAttribute('tabindex', '0')
6521
+
6522
+ /** The ladder in force, the level it is on, and the words around it. */
6523
+ var steps = []
6524
+ var selected = -1
6525
+ var live = -1
6526
+ var dragging = false
6527
+ var painted = false
6528
+ var pointerId = null
6529
+ /** The dash shown when there is no level to name; cached, not re-read per move. */
6530
+ var noneLabel = ''
6531
+
6532
+ /**
6533
+ * The ladder as ordered steps, the step in force, and the words. A model
6534
+ * with no reasoning (or one whose catalog entry has not loaded) resolves to
6535
+ * an empty ladder: the slider is still shown, it just has nothing to
6536
+ * select. The catalog may leave out a default level (`defaultEffort` is
6537
+ * undefined) — that "Default" is a real position on the track, so it leads
6538
+ * the steps with an undefined id.
6539
+ */
6540
+ function state() {
6541
+ var effort = opts.read()
6542
+ var list = []
6543
+ var index = -1
6544
+ if (effort !== null) {
6545
+ if (effort.reasoning.defaultEffort === void 0) list.push({ id: void 0, name: MODEL_EFFORT_DEFAULT })
6546
+ for (var i = 0; i < effort.reasoning.efforts.length; i++) {
6547
+ list.push({ id: effort.reasoning.efforts[i].id, name: effort.reasoning.efforts[i].name })
6548
+ }
6549
+ index = 0
6550
+ for (var s = 0; s < list.length; s++) {
6551
+ if (list[s].id === effort.effective) { index = s; break }
6552
+ }
6553
+ }
6554
+ return {
6555
+ steps: list,
6556
+ index: index,
6557
+ labels: {
6558
+ label: copyLabel('effortLabel', MODEL_EFFORT_LABEL),
6559
+ faster: copyLabel('effortFaster', MODEL_EFFORT_FASTER),
6560
+ smarter: copyLabel('effortSmarter', MODEL_EFFORT_SMARTER),
6561
+ none: MODEL_EFFORT_NONE,
6562
+ },
6563
+ }
6564
+ }
6565
+
6566
+ /** How wide the knob is, and how far its centre may travel. */
6567
+ function geometry() {
6568
+ var size = knob.offsetWidth || 16
6569
+ return { size: size, span: Math.max(0, track.clientWidth - size) }
6570
+ }
6571
+
6572
+ /** The knob's x for one level. No levels (or a single one) rest at the end. */
6573
+ function positionFor(index) {
6574
+ var box = geometry()
6575
+ if (index < 0 || steps.length < 2) return box.span
6576
+ return (index / (steps.length - 1)) * box.span
6577
+ }
6578
+
6579
+ /**
6580
+ * Move the knob. While the gesture runs this is ONE compositor write:
6581
+ * the stylesheet already forces `transition: none` for `data-dragging`,
6582
+ * so an inline transition dance (and the forced reflow it needs to take
6583
+ * effect) would be pure overhead on every pointermove. The inline dance
6584
+ * survives only for the rare non-drag instant move (a ladder swap on
6585
+ * first paint), where the stylesheet's transition would otherwise
6586
+ * animate the jump.
6587
+ */
6588
+ function place(x, animate) {
6589
+ var value = 'translateX(' + Math.round(x) + 'px)'
6590
+ if (animate || dragging) {
6591
+ knob.style.transform = value
6592
+ return
6593
+ }
6594
+ knob.style.transition = 'none'
6595
+ knob.style.transform = value
6596
+ void knob.offsetWidth
6597
+ knob.style.transition = ''
6598
+ }
6599
+
6600
+ /** Where the pointer sits along the track, in knob-travel units. */
6601
+ function pointerTravel(clientX) {
6602
+ var box = geometry()
6603
+ var raw = clientX - track.getBoundingClientRect().left - box.size / 2
6604
+ return Math.max(0, Math.min(box.span, raw))
6605
+ }
6606
+
6607
+ /** The level nearest a travel position: the one a release settles on. */
6608
+ function nearest(x) {
6609
+ if (steps.length === 0) return -1
6610
+ if (steps.length === 1) return 0
6611
+ var box = geometry()
6612
+ var ratio = box.span === 0 ? 0 : x / box.span
6613
+ return Math.max(0, Math.min(steps.length - 1, Math.round(ratio * (steps.length - 1))))
6614
+ }
6615
+
6616
+ /**
6617
+ * The level's name while the gesture runs, the committed one otherwise.
6618
+ * Same-value guard: the write runs per frame while dragging, and an
6619
+ * identical textContent assignment still replaces the text node — the
6620
+ * resulting mutation would feed the scheduler's observer and keep a full
6621
+ * pass running every frame.
6622
+ */
6623
+ function paintValue() {
6624
+ var at = dragging ? live : selected
6625
+ var text = at >= 0 && steps[at] ? steps[at].name : noneLabel
6626
+ if (valueEl.textContent !== text) valueEl.textContent = text
6627
+ }
6628
+
6629
+ function paintAria(labels) {
6630
+ noneLabel = labels.none
6631
+ // Same-value guards throughout: update() runs on every scheduler pass,
6632
+ // and an identical write here (in particular the aria-label, which is
6633
+ // in the observer's attributeFilter) re-schedules the next pass — a
6634
+ // self-sustaining one-pass-per-frame loop.
6635
+ if (labelEl.textContent !== labels.label) labelEl.textContent = labels.label
6636
+ if (fasterEl.textContent !== labels.faster) fasterEl.textContent = labels.faster
6637
+ if (smarterEl.textContent !== labels.smarter) smarterEl.textContent = labels.smarter
6638
+ if (track.getAttribute('aria-label') !== labels.label) track.setAttribute('aria-label', labels.label)
6639
+ if (steps.length === 0) {
6640
+ root.setAttribute('data-empty', '')
6641
+ track.setAttribute('aria-disabled', 'true')
6642
+ track.removeAttribute('aria-valuemin')
6643
+ track.removeAttribute('aria-valuemax')
6644
+ track.removeAttribute('aria-valuenow')
6645
+ track.removeAttribute('aria-valuetext')
6646
+ return
6647
+ }
6648
+ var at = Math.max(0, selected)
6649
+ root.removeAttribute('data-empty')
6650
+ track.removeAttribute('aria-disabled')
6651
+ track.setAttribute('aria-valuemin', '0')
6652
+ track.setAttribute('aria-valuemax', String(steps.length - 1))
6653
+ track.setAttribute('aria-valuenow', String(at))
6654
+ track.setAttribute('aria-valuetext', steps[at] ? steps[at].name : '')
6655
+ }
6656
+
6657
+ /** Commit one level, once: the host is told only when the level moved. */
6658
+ function commit(index) {
6659
+ if (index < 0 || index >= steps.length || index === selected) return
6660
+ selected = index
6661
+ opts.onPick(steps[index].id)
6662
+ }
6663
+
6664
+ function releaseCapture() {
6665
+ try {
6666
+ if (pointerId !== null && track.hasPointerCapture && track.hasPointerCapture(pointerId)) {
6667
+ track.releasePointerCapture(pointerId)
6668
+ }
6669
+ } catch (error) { /* the capture may already be gone */ }
6670
+ pointerId = null
6671
+ }
6672
+
6673
+ /** The gesture ends: the knob lands on the nearest level and commits it. */
6674
+ function settle() {
6675
+ if (!dragging) return
6676
+ if (moveQueued) {
6677
+ // Land the still-pending position first, so the release settles on
6678
+ // where the pointer actually is rather than one event behind.
6679
+ cancelAnimationFrame(pendingFrame)
6680
+ moveQueued = false
6681
+ pendingFrame = 0
6682
+ applyPending()
6683
+ if (!dragging) return
6684
+ }
6685
+ dragging = false
6686
+ root.removeAttribute('data-dragging')
6687
+ releaseCapture()
6688
+ var index = live
6689
+ live = -1
6690
+ place(positionFor(index), true)
6691
+ paintValue()
6692
+ commit(index)
6693
+ }
6694
+
6695
+ function onPointerDown(e) {
6696
+ if (e.button !== 0) return
6697
+ e.preventDefault()
6698
+ e.stopPropagation()
6699
+ dragging = true
6700
+ painted = true
6701
+ root.setAttribute('data-dragging', '')
6702
+ pointerId = e.pointerId
6703
+ try { track.setPointerCapture(e.pointerId) } catch (error) { /* capture is a nicety */ }
6704
+ if (typeof opts.onDragStart === 'function') opts.onDragStart()
6705
+ var x = pointerTravel(e.clientX)
6706
+ place(x, false)
6707
+ live = nearest(x)
6708
+ paintValue()
6709
+ }
6710
+
6711
+ /**
6712
+ * The gesture's latest pointer position. pointermove fires faster than
6713
+ * frames render; the event stores the position and ONE animation-frame
6714
+ * callback applies it — reads first (boundary box, travel geometry),
6715
+ * writes after (one transform + the level name) — so the drag costs one
6716
+ * layout flush per frame instead of one forced reflow per event.
6717
+ */
6718
+ var pendingFrame = 0
6719
+ var moveQueued = false
6720
+ var pendingX = 0
6721
+ var pendingY = 0
6722
+
6723
+ function applyPending() {
6724
+ if (!dragging) return
6725
+ var box = root.getBoundingClientRect()
6726
+ // Leaving the control ends the gesture where it stands — the knob
6727
+ // settles on the nearest level instead of trailing the pointer away.
6728
+ if (pendingX < box.left - 6 || pendingX > box.right + 6 || pendingY < box.top - 6 || pendingY > box.bottom + 6) {
6729
+ settle()
6730
+ return
6731
+ }
6732
+ var x = pointerTravel(pendingX)
6733
+ place(x, false)
6734
+ live = nearest(x)
6735
+ paintValue()
6736
+ }
6737
+
6738
+ function onPointerMove(e) {
6739
+ if (!dragging) return
6740
+ pendingX = e.clientX
6741
+ pendingY = e.clientY
6742
+ if (moveQueued) return
6743
+ moveQueued = true
6744
+ pendingFrame = requestAnimationFrame(function () {
6745
+ moveQueued = false
6746
+ pendingFrame = 0
6747
+ applyPending()
6748
+ })
6749
+ }
6750
+
6751
+ track.addEventListener('pointerdown', onPointerDown)
6752
+ track.addEventListener('pointermove', onPointerMove)
6753
+ track.addEventListener('pointerup', function () { settle() })
6754
+ track.addEventListener('pointercancel', function () { settle() })
6755
+ track.addEventListener('keydown', function (e) {
6756
+ if (steps.length === 0) return
6757
+ var base = selected < 0 ? 0 : selected
6758
+ var next = base
6759
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = Math.max(0, base - 1)
6760
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = Math.min(steps.length - 1, base + 1)
6761
+ else if (e.key === 'Home') next = 0
6762
+ else if (e.key === 'End') next = steps.length - 1
6763
+ else return
6764
+ e.preventDefault()
6765
+ e.stopPropagation()
6766
+ commit(next)
6767
+ place(positionFor(next), true)
6768
+ paintAria(state().labels)
6769
+ paintValue()
6770
+ })
6771
+
6772
+ /**
6773
+ * Re-point the control at the model in force. The ladder is rebuilt only
6774
+ * when the levels themselves change; the knob is only animated when the
6775
+ * selected level moved on its own (the host echoing a selection back).
6776
+ */
6777
+ function update() {
6778
+ var next = state()
6779
+ var changed = next.steps.length !== steps.length
6780
+ if (!changed) {
6781
+ for (var i = 0; i < next.steps.length; i++) {
6782
+ if (next.steps[i].id !== steps[i].id) { changed = true; break }
6783
+ }
6784
+ }
6785
+ if (changed) steps = next.steps.slice()
6786
+ if (!dragging) {
6787
+ var moved = painted && !changed && selected !== next.index
6788
+ selected = next.index
6789
+ place(positionFor(selected), moved)
6790
+ painted = true
6791
+ }
6792
+ paintAria(next.labels)
6793
+ paintValue()
6794
+ }
6795
+
6796
+ return {
6797
+ el: root,
6798
+ update: update,
6799
+ isDragging: function () { return dragging },
6800
+ }
6801
+ }
6802
+
5422
6803
  function installModelPicker(ctx, ui) {
5423
6804
  /**
5424
6805
  * The host's model seat is a click-triggered two-pane menu (Model /
@@ -5438,80 +6819,92 @@ window.__ModuleLoader__.load({
5438
6819
  var modelPop = null
5439
6820
  var modelSubPop = null
5440
6821
  var modelBody = null
6822
+ var modelFooter = null
6823
+ var effortSlider = null
5441
6824
  var modelSubBody = null
5442
- var modelHoverIntent = createHoverIntent(openModelPopover, closeModelPopovers, 180)
6825
+ /**
6826
+ * The picker is two cards wide, and the level-2 card only cancels a pending
6827
+ * close once the pointer is ON it — so the grace has to cover the journey
6828
+ * from a level-1 row, across the gap, onto the sub card. At the shared 100ms
6829
+ * a slow traverse ran it out and both cards folded up mid-journey.
6830
+ */
6831
+ var MODEL_CLOSE_DELAY = 150
6832
+ /** Pending fold of level 2 while the pointer is still crossing level 1. */
6833
+ var subFoldTimer = null
6834
+ var modelHoverIntent = createHoverIntent(openModelPopover, closeModelIfAway, POPOVER_OPEN_DELAY, MODEL_CLOSE_DELAY)
6835
+ /** The More-models cell drills in on the same dwell/grace as the trigger. */
6836
+ var modelSubHoverIntent = createHoverIntent(openModelSub, closeModelIfAway, POPOVER_OPEN_DELAY, MODEL_CLOSE_DELAY)
5443
6837
  var modelDir = null
5444
6838
  var modelSub = null
5445
6839
  var modelSessionId = null
5446
6840
  var modelWarmRequested = false
5447
- var modelSubKind = null
5448
6841
  var modelBodySig = ''
5449
6842
  var modelSubSig = ''
6843
+ /** Settings-page listeners waiting on the provider list. */
6844
+ var providerListeners = []
5450
6845
 
5451
- /** Exact entry: `provider/model`, bare id, folded id, then the alias table. */
5452
- function exactModelCopy(groupId, modelId) {
5453
- if (modelCopy === null) return null
5454
- var gid = String(groupId === void 0 || groupId === null ? '' : groupId).toLowerCase()
5455
- var mid = String(modelId === void 0 || modelId === null ? '' : modelId)
5456
- var midLower = mid.toLowerCase()
5457
- var byProvider = modelCopy.exact[groupId + '/' + mid] || modelCopy.exact[gid + '/' + midLower]
5458
- if (byProvider) return byProvider
5459
- if (modelCopy.exact[mid]) return modelCopy.exact[mid]
5460
- if (modelCopy.exact[midLower]) return modelCopy.exact[midLower]
5461
- var folded = normalizeModelId(mid)
5462
- if (modelCopy.folded[folded]) return modelCopy.folded[folded]
5463
- var alias = modelCopy.aliases[mid] || modelCopy.aliases[midLower] || modelCopy.aliases[folded] || (modelCopy.foldedAliases && modelCopy.foldedAliases[folded])
5464
- if (alias) {
5465
- if (modelCopy.exact[alias]) return modelCopy.exact[alias]
5466
- var foldedAlias = normalizeModelId(alias)
5467
- if (modelCopy.folded[foldedAlias]) return modelCopy.folded[foldedAlias]
6846
+ function cancelCloseModel() {
6847
+ modelHoverIntent.cancel()
6848
+ if (subFoldTimer !== null) {
6849
+ clearTimeout(subFoldTimer)
6850
+ subFoldTimer = null
5468
6851
  }
5469
- return null
6852
+ }
6853
+
6854
+ function scheduleCloseModel() {
6855
+ modelHoverIntent.scheduleClose()
5470
6856
  }
5471
6857
 
5472
6858
  /**
5473
- * Family entry. The model id is tried alone first because it is the
5474
- * stronger signal, then `provider/id` for ids that carry no brand of their
5475
- * own (`abab6.5s-chat` under a provider called `minimax`).
6859
+ * Where the pointer last was, tracked only while a card is up. The two cards
6860
+ * are separate boxes with a sliver of desktop between them, and a pointer
6861
+ * crossing that sliver has not left the picker — without this the grace
6862
+ * simply runs out mid-gap and both cards fold.
5476
6863
  */
5477
- function familyModelCopy(groupId, modelId) {
5478
- if (modelCopy === null) return null
5479
- var id = String(modelId === void 0 || modelId === null ? '' : modelId).toLowerCase()
5480
- var haystacks = [id, String(groupId === void 0 || groupId === null ? '' : groupId).toLowerCase() + '/' + id]
5481
- for (var h = 0; h < haystacks.length; h++) {
5482
- for (var i = 0; i < modelCopy.families.length; i++) {
5483
- var rule = modelCopy.families[i]
5484
- if (!rule.re.test(haystacks[h])) continue
5485
- if (rule.key) return modelCopy.exact[rule.key] || null
5486
- return rule.text
5487
- }
6864
+ var pointer = null
6865
+ var pointerBound = false
6866
+ function trackPointer(event) {
6867
+ pointer = { x: event.clientX, y: event.clientY }
6868
+ }
6869
+ function trackPointerWhileOpen() {
6870
+ var wanted = modelPop !== null && modelPop.getAttribute('data-open') === 'true'
6871
+ if (wanted === pointerBound) return
6872
+ pointerBound = wanted
6873
+ if (wanted) {
6874
+ document.addEventListener('mousemove', trackPointer, true)
6875
+ } else {
6876
+ document.removeEventListener('mousemove', trackPointer, true)
6877
+ pointer = null
5488
6878
  }
5489
- return null
5490
6879
  }
5491
6880
 
5492
- /** Last-resort tier rule, read out of the id itself. */
5493
- function tierModelCopy(modelId) {
5494
- if (modelCopy === null) return null
5495
- var id = String(modelId === void 0 || modelId === null ? '' : modelId).toLowerCase()
5496
- for (var i = 0; i < modelCopy.tiers.length; i++) {
5497
- if (modelCopy.tiers[i].re.test(id)) return modelCopy.tiers[i].text
6881
+ /** True while the pointer sits in either card, or in the gap between them. */
6882
+ function pointerInPicker() {
6883
+ if (pointer === null) return false
6884
+ var cards = [modelPop, modelSubPop]
6885
+ for (var i = 0; i < cards.length; i++) {
6886
+ var card = cards[i]
6887
+ if (card === null || card.getAttribute('data-open') !== 'true') continue
6888
+ var box = card.getBoundingClientRect()
6889
+ if (pointer.x >= box.left - 8 && pointer.x <= box.right + 8 &&
6890
+ pointer.y >= box.top - 8 && pointer.y <= box.bottom + 8) return true
5498
6891
  }
5499
- return null
5500
- }
5501
-
5502
- function cancelCloseModel() {
5503
- modelHoverIntent.cancel()
6892
+ return false
5504
6893
  }
5505
6894
 
5506
- function scheduleCloseModel() {
5507
- modelHoverIntent.scheduleClose()
6895
+ /** Close on leave, but treat the gap between the two cards as still inside. */
6896
+ function closeModelIfAway() {
6897
+ if (pointerInPicker()) {
6898
+ scheduleCloseModel()
6899
+ return
6900
+ }
6901
+ closeModelPopovers()
5508
6902
  }
5509
6903
 
5510
6904
  function closeModelPopovers() {
5511
6905
  cancelCloseModel()
5512
6906
  if (modelPop) modelPop.setAttribute('data-open', 'false')
5513
6907
  if (modelSubPop) modelSubPop.setAttribute('data-open', 'false')
5514
- modelSubKind = null
5515
6908
  }
5516
6909
 
5517
6910
  function openModelPopover() {
@@ -5527,16 +6920,14 @@ window.__ModuleLoader__.load({
5527
6920
  }
5528
6921
  } catch (error) { /* synchronous failure — the store's error surface covers it */ }
5529
6922
  }
5530
- modelSubKind = null
5531
6923
  if (modelSubPop) modelSubPop.setAttribute('data-open', 'false')
5532
6924
  renderModelBody()
5533
6925
  positionModelPopovers()
5534
6926
  if (modelPop) modelPop.setAttribute('data-open', 'true')
5535
6927
  }
5536
6928
 
5537
- function openModelSub(kind) {
6929
+ function openModelSub() {
5538
6930
  cancelCloseModel()
5539
- modelSubKind = kind
5540
6931
  renderModelSub()
5541
6932
  positionModelPopovers()
5542
6933
  if (modelSubPop) modelSubPop.setAttribute('data-open', 'true')
@@ -5597,7 +6988,7 @@ window.__ModuleLoader__.load({
5597
6988
  var store = modelDir.store
5598
6989
  if (store && typeof store.subscribe === 'function') {
5599
6990
  try {
5600
- modelSub = store.subscribe(function () { if (ui.schedule) ui.schedule() })
6991
+ modelSub = store.subscribe(function () { notifyProviders(); if (ui.schedule) ui.schedule() })
5601
6992
  } catch (error) {
5602
6993
  modelSub = null
5603
6994
  }
@@ -5611,6 +7002,37 @@ window.__ModuleLoader__.load({
5611
7002
  try { return modelDir.store.getSnapshot() } catch (error) { return null }
5612
7003
  }
5613
7004
 
7005
+ /**
7006
+ * The catalog's providers, in catalog order, each with its model count.
7007
+ *
7008
+ * The settings page's quick-provider picker is the other consumer, and it
7009
+ * may be opened before the picker itself ever was — so this resolves the
7010
+ * directory and starts the shared catalog load rather than requiring a
7011
+ * first popover open.
7012
+ */
7013
+ function modelProviders() {
7014
+ modelDirectory()
7015
+ warmModelCatalog()
7016
+ var snap = modelSnapshot()
7017
+ var groups = (snap && snap.groups) || []
7018
+ var out = []
7019
+ for (var i = 0; i < groups.length; i++) {
7020
+ if (groups[i].models.length === 0) continue
7021
+ out.push({ id: groups[i].id, name: groups[i].name || groups[i].id, count: groups[i].models.length })
7022
+ }
7023
+ return out
7024
+ }
7025
+
7026
+ /** Tell the settings picker the provider list moved (catalog arrived, changed). */
7027
+ function notifyProviders() {
7028
+ if (providerListeners.length === 0) return
7029
+ var list = modelProviders()
7030
+ var listeners = providerListeners.slice()
7031
+ for (var i = 0; i < listeners.length; i++) {
7032
+ try { listeners[i](list) } catch (error) { /* one listener must not block the rest */ }
7033
+ }
7034
+ }
7035
+
5614
7036
  /**
5615
7037
  * Start the host's model catalog load as soon as a session resolves, rather
5616
7038
  * than waiting for the first popover open.
@@ -5671,34 +7093,58 @@ window.__ModuleLoader__.load({
5671
7093
  }
5672
7094
 
5673
7095
  /**
5674
- * The description line for one catalog model, in the shell's language.
5675
- *
5676
- * Resolution descends: exact entry (one model resold by several providers
5677
- * folds to a single key) → family rule → tier rule → the catalog's own
5678
- * text. Family rules are ordered and anchored (see
5679
- * src/model-descriptions.json) so another vendor's flash tier never
5680
- * borrows DeepSeek's copy. A model this table has never seen and the
5681
- * catalog does not describe resolves to an empty string on purpose: a
5682
- * name-only row beats an invented line.
7096
+ * The effort slider (src/overrides/model-effort.js). It is handed a reader
7097
+ * rather than the seat itself: the control re-reads the catalog on every
7098
+ * pass, so a selection the host echoes back lands on the knob without the
7099
+ * picker having to push it.
5683
7100
  */
5684
- function modelDescription(groupId, model) {
5685
- var id = typeof model.id === 'string' ? model.id : ''
5686
- var pair = exactModelCopy(groupId, id) || familyModelCopy(groupId, id) || tierModelCopy(id)
5687
- var text = localized(pair, ctx)
5688
- if (text) return text
5689
- return typeof model.description === 'string' ? model.description : ''
7101
+ function effortControlElement() {
7102
+ if (effortSlider === null) {
7103
+ effortSlider = createEffortControl({
7104
+ read: function () { return modelEffort(modelSnapshot()) },
7105
+ onPick: pickEffort,
7106
+ // A drag must not be cut short by the hover-close timer: the pointer
7107
+ // is inside the control the whole time.
7108
+ onDragStart: cancelCloseModel,
7109
+ })
7110
+ }
7111
+ return effortSlider.el
7112
+ }
7113
+
7114
+ /** Re-point the slider at the current seat (every render pass). */
7115
+ function updateEffortControl() {
7116
+ if (effortSlider !== null) effortSlider.update()
5690
7117
  }
5691
7118
 
5692
7119
  var MODEL_CHECK_SVG = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.2 3.2L13 5"/></svg>'
5693
7120
  var MODEL_CHEVRON_SVG = '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>'
5694
7121
 
7122
+ /** Catalog order is whatever the provider happened to send; id order is scannable. */
7123
+ function byModelId(a, b) {
7124
+ var left = String(a.id)
7125
+ var right = String(b.id)
7126
+ return left < right ? -1 : left > right ? 1 : 0
7127
+ }
7128
+
7129
+ /**
7130
+ * The rule that separates one provider's models from the next. The provider
7131
+ * name rides the rule itself rather than trailing the model in parentheses:
7132
+ * one quiet line above the group says who serves it, and the model names
7133
+ * stay clean.
7134
+ */
7135
+ function buildProviderRule(name) {
7136
+ var rule = modelEl('div', 'dsh-claude-model-rule')
7137
+ if (name) rule.appendChild(modelEl('span', 'dsh-claude-model-rule-name', name))
7138
+ return rule
7139
+ }
7140
+
5695
7141
  /** One selectable model row: brand mark, name, optional description line and a check when current. */
5696
7142
  function buildModelOption(group, model, selected, withDescription) {
5697
7143
  var item = modelEl('button', 'dsh-claude-model-option')
5698
7144
  item.type = 'button'
5699
7145
  item.setAttribute('role', 'menuitemradio')
5700
7146
  item.setAttribute('aria-checked', selected ? 'true' : 'false')
5701
- var brand = modelBrand(group.id, model.id)
7147
+ var brand = modelBrand(model.id)
5702
7148
  // The brand id is the row's styling hook — it is what gives a vendor's rows
5703
7149
  // their own typography (see .dsh-claude-model-name in
5704
7150
  // styles/components/model-picker.css). The vendor's mark is no longer drawn
@@ -5712,7 +7158,7 @@ window.__ModuleLoader__.load({
5712
7158
  // while "More models" is every provider's full catalog and reads better
5713
7159
  // as names alone. One line, in the shell's language — the copy document is
5714
7160
  // localized rather than stacked, so a row never carries two languages.
5715
- var desc = withDescription ? modelDescription(group.id, model) : ''
7161
+ var desc = withDescription ? modelDescription(ctx, group.id, model) : ''
5716
7162
  if (desc) copy.appendChild(modelEl('span', 'dsh-claude-model-desc', desc))
5717
7163
  item.appendChild(copy)
5718
7164
  var check = modelEl('span', 'dsh-claude-model-check')
@@ -5727,28 +7173,27 @@ window.__ModuleLoader__.load({
5727
7173
  return item
5728
7174
  }
5729
7175
 
5730
- /** One level-2 row: label + current value + chevron, hover opens its level. */
5731
- function buildModelCell(label, value, kind) {
7176
+ /** The More-models row: label + chevron, hover opens the second level. */
7177
+ function buildModelCell(label) {
5732
7178
  var cell = modelEl('button', 'dsh-claude-model-cell')
5733
7179
  cell.type = 'button'
5734
7180
  cell.setAttribute('role', 'menuitem')
5735
7181
  cell.appendChild(modelEl('span', 'dsh-claude-model-cell-label', label))
5736
- if (value) cell.appendChild(modelEl('span', 'dsh-claude-model-cell-value', value))
5737
7182
  var chevron = modelEl('span', 'dsh-claude-model-cell-chevron')
5738
7183
  chevron.innerHTML = MODEL_CHEVRON_SVG
5739
7184
  cell.appendChild(chevron)
5740
- cell.addEventListener('mouseenter', (function (k) {
5741
- return function () {
5742
- if (readPrefs().autoPopover) openModelSub(k)
5743
- }
5744
- })(kind))
5745
- cell.addEventListener('click', (function (k) {
5746
- return function (e) {
5747
- e.stopPropagation()
5748
- if (modelSubKind === k) closeModelPopovers()
5749
- else openModelSub(k)
5750
- }
5751
- })(kind))
7185
+ cell.addEventListener('mouseenter', function () {
7186
+ if (readPrefs().autoPopover === AUTO_POPOVER_ALL) modelSubHoverIntent.scheduleOpen()
7187
+ })
7188
+ cell.addEventListener('mouseleave', function () {
7189
+ // A pointer that only crossed the cell must not drill in behind it.
7190
+ modelSubHoverIntent.cancel()
7191
+ })
7192
+ cell.addEventListener('click', function (e) {
7193
+ e.stopPropagation()
7194
+ if (modelSubPop && modelSubPop.getAttribute('data-open') === 'true') closeModelPopovers()
7195
+ else openModelSub()
7196
+ })
5752
7197
  return cell
5753
7198
  }
5754
7199
 
@@ -5764,6 +7209,7 @@ window.__ModuleLoader__.load({
5764
7209
  closeModelPopovers()
5765
7210
  }
5766
7211
 
7212
+ /** Commit one reasoning level. The slider stays open for the next nudge. */
5767
7213
  function pickEffort(effort) {
5768
7214
  var dir = modelDirectory()
5769
7215
  var snap = modelSnapshot()
@@ -5774,73 +7220,137 @@ window.__ModuleLoader__.load({
5774
7220
  var pending = dir.select(selection)
5775
7221
  if (pending && typeof pending.catch === 'function') pending.catch(function () {})
5776
7222
  } catch (error) { /* rejected selections surface on the host's toast */ }
5777
- closeModelPopovers()
5778
7223
  }
5779
7224
 
5780
- /** Level 1: the official provider's models, divider, effort + more rows. */
7225
+ /**
7226
+ * The providers level 1 lists: the official service first, then the quick
7227
+ * providers the settings page picked. Only when the catalog has no
7228
+ * (non-empty) official service at all does the list fall back to the
7229
+ * picked providers, and with none picked to every provider.
7230
+ */
7231
+ function levelOneSections(groups) {
7232
+ var sections = []
7233
+ var chosen = readPrefs().quickProviders
7234
+ for (var g0 = 0; g0 < groups.length; g0++) {
7235
+ if (groups[g0].id === MODEL_OFFICIAL_GROUP && groups[g0].models.length > 0) {
7236
+ sections.push(groups[g0])
7237
+ break
7238
+ }
7239
+ }
7240
+ for (var g2 = 0; g2 < groups.length; g2++) {
7241
+ if (chosen.indexOf(groups[g2].id) === -1 || groups[g2].id === MODEL_OFFICIAL_GROUP || groups[g2].models.length === 0) continue
7242
+ sections.push(groups[g2])
7243
+ }
7244
+ if (sections.length === 0) {
7245
+ for (var g4 = 0; g4 < groups.length; g4++) {
7246
+ if (groups[g4].models.length > 0) sections.push(groups[g4])
7247
+ }
7248
+ }
7249
+ return sections
7250
+ }
7251
+
7252
+ /**
7253
+ * The provider groups level 1 does NOT show — which is exactly what level 2
7254
+ * is for. Repeating a provider across the two cards made the same models
7255
+ * appear twice, one card apart.
7256
+ *
7257
+ * Only a GROUP counts as shown. The seat level 1 surfaces as a row of its
7258
+ * own does not: that row carries one model, not the provider, so hiding the
7259
+ * provider's remaining models behind it would strand them.
7260
+ */
7261
+ function remainingGroups(groups, sections) {
7262
+ var shown = {}
7263
+ for (var i = 0; i < sections.length; i++) shown[sections[i].id] = true
7264
+ var out = []
7265
+ for (var g = 0; g < groups.length; g++) {
7266
+ if (groups[g].models.length === 0 || shown[groups[g].id] === true) continue
7267
+ out.push(groups[g])
7268
+ }
7269
+ return out
7270
+ }
7271
+
7272
+ /** Level 1: the provider sections, the divider, the effort slider, More models. */
5781
7273
  function renderModelBody() {
5782
7274
  if (!modelBody) return
7275
+ // A drag in flight owns the slider: rebuilding the footer would detach it
7276
+ // and drop its pointer capture mid-gesture. The pass that ends the drag —
7277
+ // settle commits, the host answers, a pass is scheduled — picks it up.
7278
+ if (effortSlider !== null && effortSlider.isDragging()) return
5783
7279
  var snap = modelSnapshot()
5784
7280
  var status = snap ? snap.status : 'idle'
5785
7281
  var groups = (snap && snap.groups) || []
5786
7282
  var current = modelCurrent(snap)
5787
7283
  var effort = modelEffort(snap)
5788
- var sig = [status, activeLocale(), current ? current.group.id + '/' + current.model.id : '', effort ? String(effort.effective) : ''].join('|')
7284
+ var sig = [status, activeLocale(), current ? current.group.id + '/' + current.model.id : '', effort ? String(effort.effective) : '', readPrefs().quickProviders.join(',')].join('|')
5789
7285
  for (var g = 0; g < groups.length; g++) sig += ';' + groups[g].id + ':' + groups[g].models.length
5790
- if (sig === modelBodySig) return
7286
+ if (sig === modelBodySig) {
7287
+ // The list is unchanged, but the slider still has to follow the seat:
7288
+ // its geometry tracks the card's width, its value a selection the host
7289
+ // echoed back.
7290
+ updateEffortControl()
7291
+ return
7292
+ }
5791
7293
  modelBodySig = sig
5792
7294
  while (modelBody.firstChild) modelBody.removeChild(modelBody.firstChild)
5793
-
5794
- if (status === 'idle' || status === 'loading' || status === 'selecting') {
7295
+ while (modelFooter && modelFooter.firstChild) modelFooter.removeChild(modelFooter.firstChild)
7296
+
7297
+ // A seat whose data is still in flight must not blank a picker that
7298
+ // already has a list. The host marks the directory `selecting` for the
7299
+ // WHOLE selectModel round-trip, and that round-trip runs for seconds on
7300
+ // providers whose adapters resolve over the network — the effort slider
7301
+ // commits through the very same RPC, so nudging it used to empty the
7302
+ // card (list, slider and More-models row all gone) for exactly as long
7303
+ // as the host took to answer. Only a directory with nothing to show
7304
+ // yet falls back to the loading line.
7305
+ var seated = groups.length > 0 && current !== null
7306
+ if (!seated && (status === 'idle' || status === 'loading' || status === 'selecting')) {
5795
7307
  modelBody.appendChild(modelEl('div', 'dsh-claude-model-status', copyLabel('loading', MODEL_LOADING_LABEL)))
5796
7308
  } else {
5797
- var official = null
5798
- for (var g2 = 0; g2 < groups.length; g2++) {
5799
- if (groups[g2].id === MODEL_OFFICIAL_GROUP) { official = groups[g2]; break }
5800
- }
5801
- var rows = []
5802
- if (official) {
5803
- for (var m = 0; m < official.models.length; m++) rows.push({ group: official, model: official.models[m] })
5804
- } else {
5805
- for (var g3 = 0; g3 < groups.length; g3++) {
5806
- for (var m2 = 0; m2 < groups[g3].models.length; m2++) rows.push({ group: groups[g3], model: groups[g3].models[m2] })
5807
- }
5808
- }
5809
- if (rows.length === 0) {
7309
+ var sections = levelOneSections(groups)
7310
+ if (sections.length === 0) {
5810
7311
  modelBody.appendChild(modelEl('div', 'dsh-claude-model-status', copyLabel('empty', MODEL_EMPTY_LABEL)))
5811
7312
  } else {
5812
- for (var r = 0; r < rows.length; r++) {
5813
- var selected = current !== null && current.group.id === rows[r].group.id && current.model.id === rows[r].model.id
5814
- modelBody.appendChild(buildModelOption(rows[r].group, rows[r].model, selected, true))
7313
+ for (var s = 0; s < sections.length; s++) {
7314
+ var section = sections[s]
7315
+ // The official source needs no naming, and the first section needs
7316
+ // no rule: a bare line above the list would be one line too many.
7317
+ var sectionLabel = section.id === MODEL_OFFICIAL_GROUP ? '' : (section.name || section.id)
7318
+ if (sectionLabel !== '' || s > 0) modelBody.appendChild(buildProviderRule(sectionLabel))
7319
+ var sectionModels = section.models.slice().sort(byModelId)
7320
+ for (var m = 0; m < sectionModels.length; m++) {
7321
+ var selected = current !== null && current.group.id === section.id && current.model.id === sectionModels[m].id
7322
+ modelBody.appendChild(buildModelOption(section, sectionModels[m], selected, true))
7323
+ }
5815
7324
  }
5816
7325
  }
5817
- modelBody.appendChild(modelEl('div', 'dsh-claude-model-divider'))
5818
- // When the active model is not on the official service, surface it under
5819
- // the official list so the current seat is still visible before the
5820
- // effort/More rows. It reads `model (provider)` in display names, never
5821
- // ids, and the provider trails the model instead of leading it: a
5822
- // wordmark may stand at the front of the label, and the seat has to say
5823
- // both which model it is on and which provider serves it — a wordmark
5824
- // names the vendor, not the route. It carries a description like any
5825
- // level-1 row, because this is the row the seat is read from.
5826
- if (current !== null && current.group.id !== MODEL_OFFICIAL_GROUP) {
7326
+ // The current seat is surfaced under the list when none of the sections
7327
+ // above already carries it, so the row the seat is read from is always
7328
+ // on screen. Its provider rides a rule of its own rather than trailing
7329
+ // the model in parentheses — the same idiom the sections use. It stays
7330
+ // above the divider: the divider closes the model list, so anything
7331
+ // that belongs to the list has to sit on its side of it.
7332
+ var currentListed = false
7333
+ for (var c = 0; c < sections.length; c++) {
7334
+ if (current !== null && sections[c].id === current.group.id) currentListed = true
7335
+ }
7336
+ if (current !== null && !currentListed) {
7337
+ // The divider above already draws a line, so a provider that needs no
7338
+ // naming (the official source) adds nothing here.
7339
+ var currentRuleName = current.group.id === MODEL_OFFICIAL_GROUP ? '' : (current.group.name || current.group.id)
7340
+ if (currentRuleName !== '') modelBody.appendChild(buildProviderRule(currentRuleName))
5827
7341
  var currentRow = modelEl('button', 'dsh-claude-model-option')
5828
7342
  currentRow.type = 'button'
5829
7343
  currentRow.setAttribute('role', 'menuitemradio')
5830
7344
  currentRow.setAttribute('aria-checked', 'true')
5831
- var currentBrand = modelBrand(current.group.id, current.model.id)
7345
+ var currentBrand = modelBrand(current.model.id)
5832
7346
  var currentName = current.model.name || current.model.id
5833
7347
  // The brand id is the row's styling hook here too, so this row wears
5834
7348
  // the same vendor lockup and face as the list entry it stands for.
5835
7349
  if (currentBrand) currentRow.setAttribute('data-brand', currentBrand)
5836
7350
  var currentCopy = modelEl('span', 'dsh-claude-model-copy')
5837
7351
  var currentLabel = buildModelLabel(currentName, currentBrand)
5838
- // The provider's own display name; the route id only stands in when
5839
- // the catalog gives the group no name.
5840
- var currentProvider = current.group.name || current.group.id
5841
- if (currentProvider) currentLabel.appendChild(document.createTextNode(' (' + currentProvider + ')'))
5842
7352
  currentCopy.appendChild(currentLabel)
5843
- var currentDesc = modelDescription(current.group.id, current.model)
7353
+ var currentDesc = modelDescription(ctx, current.group.id, current.model)
5844
7354
  if (currentDesc) currentCopy.appendChild(modelEl('span', 'dsh-claude-model-desc', currentDesc))
5845
7355
  currentRow.appendChild(currentCopy)
5846
7356
  var currentCheck = modelEl('span', 'dsh-claude-model-check')
@@ -5852,60 +7362,51 @@ window.__ModuleLoader__.load({
5852
7362
  })
5853
7363
  modelBody.appendChild(currentRow)
5854
7364
  }
5855
- if (effort) modelBody.appendChild(buildModelCell(copyLabel('effortLabel', MODEL_EFFORT_LABEL), effort.label, 'effort'))
5856
- modelBody.appendChild(buildModelCell(copyLabel('moreLabel', MODEL_MORE_LABEL), '', 'more'))
7365
+ // The divider closes the model list and the two drill rows follow it;
7366
+ // all three live in the footer, OUTSIDE the scroll area — the list
7367
+ // above scrolls under them while the controls stay reachable.
7368
+ if (modelFooter) {
7369
+ // The slider belongs to models that actually offer levels: without
7370
+ // reasoning metadata there is nothing to configure, so the row is not
7371
+ // drawn at all instead of being drawn inert.
7372
+ var showEffort = effort !== null
7373
+ // "More models" carries what level 1 does not. With every provider
7374
+ // already on screen the row would only open an empty card, so it goes
7375
+ // away with the last remaining provider.
7376
+ var showMore = remainingGroups(groups, sections).length > 0
7377
+ // The divider exists to close the list off from what follows it. With
7378
+ // both rows gone there is nothing left to close off, and a bare line
7379
+ // under the list reads as a stray rule.
7380
+ if (showEffort || showMore) {
7381
+ modelFooter.appendChild(modelEl('div', 'dsh-claude-model-divider'))
7382
+ if (showEffort) modelFooter.appendChild(effortControlElement())
7383
+ if (showMore) modelFooter.appendChild(buildModelCell(copyLabel('moreLabel', MODEL_MORE_LABEL)))
7384
+ }
7385
+ }
5857
7386
  }
7387
+ updateEffortControl()
5858
7388
  }
5859
7389
 
5860
- /** Level 2: the effort ladder, or every provider group's models. */
7390
+ /** Level 2: the providers level 1 does NOT show, each headed by its name. */
5861
7391
  function renderModelSub() {
5862
7392
  if (!modelSubBody) return
5863
7393
  var snap = modelSnapshot()
5864
- if (modelSubKind === 'effort') {
5865
- var effort = modelEffort(snap)
5866
- var sig = 'effort:' + (effort ? String(effort.effective) : 'none')
5867
- if (sig === modelSubSig) return
5868
- modelSubSig = sig
5869
- while (modelSubBody.firstChild) modelSubBody.removeChild(modelSubBody.firstChild)
5870
- if (effort === null) {
5871
- modelSubBody.appendChild(modelEl('div', 'dsh-claude-model-status', copyLabel('noEffort', MODEL_NO_EFFORT_LABEL)))
5872
- return
5873
- }
5874
- var levels = []
5875
- if (effort.reasoning.defaultEffort === void 0) levels.push({ effort: void 0, label: MODEL_EFFORT_DEFAULT })
5876
- for (var i = 0; i < effort.reasoning.efforts.length; i++) {
5877
- levels.push({ effort: effort.reasoning.efforts[i].id, label: effort.reasoning.efforts[i].name })
5878
- }
5879
- for (var l = 0; l < levels.length; l++) {
5880
- (function (level, active) {
5881
- var item = modelEl('button', 'dsh-claude-model-option')
5882
- item.type = 'button'
5883
- item.setAttribute('role', 'menuitemradio')
5884
- item.setAttribute('aria-checked', active ? 'true' : 'false')
5885
- item.appendChild(modelEl('span', 'dsh-claude-model-copy', level.label))
5886
- var check = modelEl('span', 'dsh-claude-model-check')
5887
- check.innerHTML = active ? MODEL_CHECK_SVG : ''
5888
- item.appendChild(check)
5889
- item.addEventListener('click', function (e) {
5890
- e.stopPropagation()
5891
- pickEffort(level.effort)
5892
- })
5893
- modelSubBody.appendChild(item)
5894
- })(levels[l], effort.effective === levels[l].effort)
5895
- }
5896
- return
5897
- }
5898
- // 'more': every provider group, headed by its name.
5899
7394
  var groups = (snap && snap.groups) || []
5900
7395
  var current = modelCurrent(snap)
5901
- var sig2 = 'more'
7396
+ // What level 2 holds depends on what level 1 lists, so the signature has
7397
+ // to carry level 1's provider ids as well.
7398
+ var sections = levelOneSections(groups)
7399
+ var listed = []
7400
+ for (var s0 = 0; s0 < sections.length; s0++) listed.push(sections[s0].id)
7401
+ var sig2 = 'more|' + listed.join(',')
5902
7402
  for (var g = 0; g < groups.length; g++) sig2 += ';' + groups[g].id + ':' + groups[g].models.length
5903
7403
  if (current) sig2 += '#' + current.group.id + '/' + current.model.id
5904
7404
  if (sig2 === modelSubSig) return
5905
7405
  modelSubSig = sig2
5906
7406
  while (modelSubBody.firstChild) modelSubBody.removeChild(modelSubBody.firstChild)
5907
- for (var g2 = 0; g2 < groups.length; g2++) {
5908
- var group = groups[g2]
7407
+ var rest = remainingGroups(groups, sections)
7408
+ for (var g2 = 0; g2 < rest.length; g2++) {
7409
+ var group = rest[g2]
5909
7410
  if (group.models.length === 0) continue
5910
7411
  var groupSection = modelEl('div', 'dsh-claude-model-group-section')
5911
7412
  var groupRow = modelEl('div', 'dsh-claude-model-group-row')
@@ -5918,11 +7419,7 @@ window.__ModuleLoader__.load({
5918
7419
  // A provider's models read in id order, so the list is scannable and stays
5919
7420
  // put between visits; the catalog's own order is whatever the provider
5920
7421
  // happened to send. Sorted on a copy — the snapshot belongs to the store.
5921
- var groupModels = group.models.slice().sort(function (a, b) {
5922
- var left = String(a.id)
5923
- var right = String(b.id)
5924
- return left < right ? -1 : left > right ? 1 : 0
5925
- })
7422
+ var groupModels = group.models.slice().sort(byModelId)
5926
7423
  for (var m = 0; m < groupModels.length; m++) {
5927
7424
  var selected = current !== null && current.group.id === group.id && current.model.id === groupModels[m].id
5928
7425
  groupSection.appendChild(buildModelOption(group, groupModels[m], selected, false))
@@ -5935,22 +7432,39 @@ window.__ModuleLoader__.load({
5935
7432
  }
5936
7433
 
5937
7434
  function positionModelPopovers() {
7435
+ trackPointerWhileOpen()
5938
7436
  if (!modelBtn || !modelPop) return
5939
7437
  var pos = positionAnchoredPopover(modelBtn, modelPop, { side: 'above', gap: 6 })
5940
7438
  if (modelSubPop && modelSubPop.getAttribute('data-open') === 'true') {
5941
7439
  var w2 = modelSubPop.offsetWidth
5942
7440
  var h2 = modelSubPop.offsetHeight
7441
+ var h1 = modelPop.offsetHeight
5943
7442
  // Beside the first level; flip to its left when the viewport is tight.
5944
- var x2 = pos.x + modelPop.offsetWidth + 4
7443
+ var x2 = pos.x + modelPop.offsetWidth + 2
5945
7444
  if (x2 + w2 > window.innerWidth - POPOVER_MARGIN) x2 = Math.max(POPOVER_MARGIN, pos.x - 4 - w2)
5946
- var y2 = Math.max(POPOVER_MARGIN, Math.min(pos.y, window.innerHeight - h2 - POPOVER_MARGIN))
7445
+ // Bottom-aligned with the first level: a short level 2 used to hang from
7446
+ // the top, leaving dead space under it that the pointer had to cross to
7447
+ // reach the card from the More-models row. A level 2 TALLER than level 1
7448
+ // keeps its top instead, so it cannot push itself off the top edge.
7449
+ var y2 = pos.y + Math.max(0, h1 - h2)
7450
+ y2 = Math.max(POPOVER_MARGIN, Math.min(y2, window.innerHeight - h2 - POPOVER_MARGIN))
5947
7451
  modelSubPop.style.left = x2 + 'px'
5948
7452
  modelSubPop.style.top = y2 + 'px'
5949
7453
  }
5950
7454
  }
5951
7455
 
5952
7456
  function ensureModelChrome() {
5953
- if (modelPop === null) {
7457
+ // Idempotence guard, not just a null check: the picker's own teardown
7458
+ // sweep, or a hot-reload generation's stray sweep, can REMOVE the
7459
+ // popover node while the closure still references it. With a null-only
7460
+ // check the reference stays non-null-but-detached and is never rebuilt
7461
+ // — the trigger then toggles a popover that is not in the document and
7462
+ // the picker silently loses its click effect. The account footer's
7463
+ // equivalent guard (footArea.contains) is the established pattern.
7464
+ // Rebuilding also re-points the body/footer children and resets the
7465
+ // render signatures so the next pass repaints into the fresh nodes.
7466
+ if (modelPop === null || modelPop.parentElement === null) {
7467
+ if (modelPop !== null && modelPop.parentElement !== null) modelPop.parentElement.removeChild(modelPop)
5954
7468
  modelPop = document.createElement('div')
5955
7469
  modelPop.className = 'dsh-claude-model-popover'
5956
7470
  modelPop.setAttribute('role', 'menu')
@@ -5958,11 +7472,43 @@ window.__ModuleLoader__.load({
5958
7472
  modelBody = document.createElement('div')
5959
7473
  modelBody.className = 'dsh-claude-model-popover-body'
5960
7474
  modelPop.appendChild(modelBody)
7475
+ modelFooter = document.createElement('div')
7476
+ modelFooter.className = 'dsh-claude-model-footer'
7477
+ modelPop.appendChild(modelFooter)
5961
7478
  modelPop.addEventListener('mouseenter', cancelCloseModel)
5962
- modelPop.addEventListener('mouseleave', scheduleCloseModel)
7479
+ // The second level belongs to its More-models cell: while it is open,
7480
+ // the pointer landing anywhere else on the first level (the effort
7481
+ // slider, a model row, bare card) folds it — the first level itself
7482
+ // stays open, it is the hover-intent host. Delegated mouseover, not
7483
+ // mouseenter: moving from the cell to the slider never crosses the
7484
+ // popover's boundary, so a boundary event would never fire.
7485
+ modelPop.addEventListener('mouseover', function (e) {
7486
+ if (modelSubPop === null || modelSubPop.getAttribute('data-open') !== 'true') return
7487
+ var target = e.target
7488
+ if (target && typeof target.closest === 'function' && target.closest('.dsh-claude-model-cell')) return
7489
+ // Delayed, not immediate: the sub card sits BESIDE level 1, so a
7490
+ // pointer on its way from the cell to the sub crosses level 1's own
7491
+ // rows — folding on the spot made that journey impossible at any
7492
+ // speed. Folding now waits out the same grace, and stands down if the
7493
+ // pointer has meanwhile reached either card.
7494
+ if (subFoldTimer !== null) clearTimeout(subFoldTimer)
7495
+ subFoldTimer = setTimeout(function () {
7496
+ subFoldTimer = null
7497
+ if (pointerInPicker()) return
7498
+ if (modelSubPop !== null) modelSubPop.setAttribute('data-open', 'false')
7499
+ }, MODEL_CLOSE_DELAY)
7500
+ })
7501
+ modelPop.addEventListener('mouseleave', function () {
7502
+ // A drag in flight must not be cut short by the hover-close timer: the
7503
+ // pointer is working the slider, not leaving the card.
7504
+ if (effortSlider !== null && effortSlider.isDragging()) return
7505
+ scheduleCloseModel()
7506
+ })
5963
7507
  document.body.appendChild(modelPop)
7508
+ modelBodySig = ''
5964
7509
  }
5965
- if (modelSubPop === null) {
7510
+ if (modelSubPop === null || modelSubPop.parentElement === null) {
7511
+ if (modelSubPop !== null && modelSubPop.parentElement !== null) modelSubPop.parentElement.removeChild(modelSubPop)
5966
7512
  modelSubPop = document.createElement('div')
5967
7513
  modelSubPop.className = 'dsh-claude-model-popover dsh-claude-model-popover-sub'
5968
7514
  modelSubPop.setAttribute('role', 'menu')
@@ -5973,12 +7519,16 @@ window.__ModuleLoader__.load({
5973
7519
  modelSubPop.addEventListener('mouseenter', cancelCloseModel)
5974
7520
  modelSubPop.addEventListener('mouseleave', scheduleCloseModel)
5975
7521
  document.body.appendChild(modelSubPop)
7522
+ modelSubSig = ''
5976
7523
  }
5977
7524
  }
5978
7525
 
5979
7526
  /** Build/refresh the trigger, its label and the popover rows. */
5980
7527
  function syncModelControl() {
5981
- if (!ui.copy.isComposerActive()) {
7528
+ // Two ways to be off: the composer restyle does not apply to this page, or
7529
+ // the picker preference is off. Both hand the host's own model seat and
7530
+ // menu back, so both run the same sweep.
7531
+ if (!ui.copy.isComposerActive() || !readPrefs().modelPicker) {
5982
7532
  var allHosts = document.querySelectorAll('[data-dsh-claude-model-host]')
5983
7533
  for (var h = 0; h < allHosts.length; h++) {
5984
7534
  allHosts[h].removeAttribute('data-dsh-claude-model-host')
@@ -5995,8 +7545,8 @@ window.__ModuleLoader__.load({
5995
7545
  modelPop = null
5996
7546
  modelSubPop = null
5997
7547
  modelBody = null
7548
+ modelFooter = null
5998
7549
  modelSubBody = null
5999
- modelSubKind = null
6000
7550
  modelBodySig = ''
6001
7551
  modelSubSig = ''
6002
7552
  cancelCloseModel()
@@ -6014,6 +7564,18 @@ window.__ModuleLoader__.load({
6014
7564
  var slot = document.querySelector('[data-slot="conversation.input.model"]')
6015
7565
  if (slot === null) return
6016
7566
  // Hide the host's own seat (React owns the node; re-mark on swap).
7567
+ // Idempotent against a torn-down-less reload, like the account footer:
7568
+ // client HMR drops the old fiber's disposals, so a previous generation's
7569
+ // trigger and popovers are still in the DOM while this fresh scope starts
7570
+ // from null. Sweep the strays, or the seat renders twice.
7571
+ var strayBtns = slot.querySelectorAll('.dsh-claude-model-btn')
7572
+ for (var sb = 0; sb < strayBtns.length; sb++) {
7573
+ if (strayBtns[sb] !== modelBtn) strayBtns[sb].parentElement.removeChild(strayBtns[sb])
7574
+ }
7575
+ var strayPops = document.querySelectorAll('body > .dsh-claude-model-popover')
7576
+ for (var sp = 0; sp < strayPops.length; sp++) {
7577
+ if (strayPops[sp] !== modelPop && strayPops[sp] !== modelSubPop) strayPops[sp].parentElement.removeChild(strayPops[sp])
7578
+ }
6017
7579
  var hostRoot = slot.firstElementChild
6018
7580
  if (hostRoot !== null && !hostRoot.hasAttribute('data-dsh-claude-model-host')) {
6019
7581
  hostRoot.setAttribute('data-dsh-claude-model-host', '')
@@ -6025,13 +7587,13 @@ window.__ModuleLoader__.load({
6025
7587
  modelBtn.className = 'dsh-claude-model-btn'
6026
7588
  modelBtn.setAttribute('aria-haspopup', 'menu')
6027
7589
  modelBtn.innerHTML = '<span class="dsh-claude-model-btn-label"></span>'
6028
- // Same contract as the account trigger: hover unless the preference
6029
- // says click-only.
7590
+ // Same contract as the account trigger: hover under the "All" scope,
7591
+ // click-only otherwise.
6030
7592
  modelBtn.addEventListener('mouseenter', function () {
6031
- if (readPrefs().autoPopover) openModelPopover()
7593
+ if (readPrefs().autoPopover === AUTO_POPOVER_ALL) modelHoverIntent.scheduleOpen()
6032
7594
  })
6033
7595
  modelBtn.addEventListener('mouseleave', function () {
6034
- if (readPrefs().autoPopover) scheduleCloseModel()
7596
+ if (readPrefs().autoPopover === AUTO_POPOVER_ALL) scheduleCloseModel()
6035
7597
  })
6036
7598
  modelBtn.addEventListener('click', function (e) {
6037
7599
  e.stopPropagation()
@@ -6045,11 +7607,24 @@ window.__ModuleLoader__.load({
6045
7607
  var snap = modelSnapshot()
6046
7608
  var current = modelCurrent(snap)
6047
7609
  var effort = modelEffort(snap)
7610
+ var groupsNow = (snap && snap.groups) || []
6048
7611
  var label = current ? current.model.name : copyLabel('fallbackLabel', MODEL_FALLBACK_LABEL)
6049
7612
  var labelEl = modelBtn.querySelector('.dsh-claude-model-btn-label')
6050
7613
  if (labelEl) {
6051
- labelEl.textContent = label
6052
- labelEl.classList.toggle('dsh-claude-model-btn-loading', !!(snap && (snap.status === 'loading' || snap.status === 'idle' || snap.status === 'selecting')))
7614
+ // Same-value guards: syncModelControl runs on every scheduler pass,
7615
+ // and an identical write still mutates the DOM (textContent replaces
7616
+ // the text node; setAttribute queues an attribute record — and
7617
+ // aria-label is in the observer's attributeFilter). Unguarded, each
7618
+ // pass feeds the observer that schedules the next pass, keeping one
7619
+ // full pass running every frame even at idle.
7620
+ if (labelEl.textContent !== label) labelEl.textContent = label
7621
+ // The dimmed tone means "no seat to name yet". A selection in flight
7622
+ // still names the model in force, so it keeps the normal tone — the
7623
+ // host can hold `selecting` for seconds (the effort slider commits
7624
+ // through that same RPC), and a greyed-out trigger for that long
7625
+ // reads as broken rather than busy.
7626
+ var unsettled = !!(snap && (snap.status === 'loading' || snap.status === 'idle' || snap.status === 'selecting')) && !(groupsNow.length > 0 && current !== null)
7627
+ labelEl.classList.toggle('dsh-claude-model-btn-loading', unsettled)
6053
7628
  }
6054
7629
  var effortEl = modelBtn.querySelector('.dsh-claude-model-btn-effort')
6055
7630
  if (effort) {
@@ -6057,19 +7632,22 @@ window.__ModuleLoader__.load({
6057
7632
  effortEl = modelEl('span', 'dsh-claude-model-btn-effort')
6058
7633
  modelBtn.insertBefore(effortEl, modelBtn.firstChild ? labelEl.nextSibling : null)
6059
7634
  }
6060
- effortEl.textContent = '· ' + effort.label
7635
+ var effortText = '· ' + effort.label
7636
+ if (effortEl.textContent !== effortText) effortEl.textContent = effortText
6061
7637
  } else if (effortEl !== null && effortEl.parentElement) {
6062
7638
  effortEl.parentElement.removeChild(effortEl)
6063
7639
  }
6064
- modelBtn.setAttribute('aria-label', copyLabel('triggerLabel', MODEL_TRIGGER_LABEL, { model: label }))
7640
+ var triggerAria = copyLabel('triggerLabel', MODEL_TRIGGER_LABEL, { model: label })
7641
+ if (modelBtn.getAttribute('aria-label') !== triggerAria) modelBtn.setAttribute('aria-label', triggerAria)
6065
7642
  modelBtn.disabled = false
6066
7643
 
6067
7644
  renderModelBody()
6068
- if (modelSubKind !== null) {
7645
+ if (modelSubPop && modelSubPop.getAttribute('data-open') === 'true') {
6069
7646
  renderModelSub()
6070
- if (modelSubPop && modelSubPop.getAttribute('data-open') === 'true') positionModelPopovers()
7647
+ positionModelPopovers()
7648
+ } else if (modelPop && modelPop.getAttribute('data-open') === 'true') {
7649
+ positionModelPopovers()
6071
7650
  }
6072
- if (modelPop && modelPop.getAttribute('data-open') === 'true') positionModelPopovers()
6073
7651
  }
6074
7652
 
6075
7653
 
@@ -6083,6 +7661,14 @@ window.__ModuleLoader__.load({
6083
7661
  (modelSubPop !== null && modelSubPop.contains(target))
6084
7662
  },
6085
7663
  reposition: positionModelPopovers,
7664
+ providers: modelProviders,
7665
+ onProviders: function (listener) {
7666
+ providerListeners.push(listener)
7667
+ return function () {
7668
+ var at = providerListeners.indexOf(listener)
7669
+ if (at !== -1) providerListeners.splice(at, 1)
7670
+ }
7671
+ },
6086
7672
  invalidateCopy: function () {
6087
7673
  modelBodySig = ''
6088
7674
  modelSubSig = ''
@@ -6097,8 +7683,9 @@ window.__ModuleLoader__.load({
6097
7683
  modelPop = null
6098
7684
  modelSubPop = null
6099
7685
  modelBody = null
7686
+ modelFooter = null
7687
+ effortSlider = null
6100
7688
  modelSubBody = null
6101
- modelSubKind = null
6102
7689
  modelBodySig = ''
6103
7690
  modelSubSig = ''
6104
7691
  }
@@ -6107,11 +7694,361 @@ window.__ModuleLoader__.load({
6107
7694
  return ui.model.teardown
6108
7695
  }
6109
7696
 
7697
+ /**
7698
+ * The hero row's two pickers — the workspace (directory) chip and the
7699
+ * agent-preset seat — both open the host's shared menu primitive, which
7700
+ * portals its card to <body>. That card carries no marker of its own, so a
7701
+ * stylesheet cannot tell it apart from the host's other menus (sidebar row
7702
+ * menus, the settings permission row, submenus) — and it has to: this skin
7703
+ * redraws these two and leaves the rest alone.
7704
+ *
7705
+ * So the browser half stamps the open card rather than guessing in CSS.
7706
+ * While either trigger reports `aria-expanded="true"`, the one host menu
7707
+ * sitting in <body> is that card; HERO_MENU_ATTR is what
7708
+ * components/hero-menu.css switches on.
7709
+ *
7710
+ * The scheduler's attributeFilter does not watch `aria-expanded`, but the
7711
+ * card is inserted into <body> as it opens — a childList mutation the pass
7712
+ * already observes — so the stamp lands on the frame the menu appears. Two
7713
+ * candidates mean an unrelated menu is open as well: the pass then leaves
7714
+ * every card alone rather than stamping the wrong one.
7715
+ *
7716
+ * The host drops that card below its trigger, which is exactly where the
7717
+ * composer sits: it would land on the input area and on the controls in it.
7718
+ * This row is *above* the composer, so the card belongs beside the trigger
7719
+ * instead — bottom-aligned with it and growing upward into the empty hero
7720
+ * space. The host re-places the card on every scroll and resize, so this
7721
+ * position is written with `important` (which beats its inline write) and
7722
+ * re-applied from the scheduler's scroll/resize hook.
7723
+ *
7724
+ * Hover is the host's own click handler driven from here: the "Open popovers
7725
+ * on hover" preference's `all` scope covers these two, and the host offers no
7726
+ * hover of its own. The listeners are delegated from the document because
7727
+ * React replaces the triggers, and the close waits out the gap between the
7728
+ * trigger and the card so crossing it does not shut the menu.
7729
+ */
7730
+ function installHeroMenu(ctx, ui) {
7731
+ /** Air between the trigger and the card, and the viewport's own margin. */
7732
+ var GAP = 6
7733
+ var MARGIN = 12
7734
+ /** Long enough to cross the gap above, short enough to still read as hover. */
7735
+ var CLOSE_DELAY = POPOVER_CLOSE_DELAY
7736
+ /** The two triggers, and the card once it is stamped. */
7737
+ var TRIGGER_SELECTOR = '[class*="heroWorkspaceRow"] [aria-haspopup="menu"]'
7738
+ var CARD_SELECTOR = '[' + HERO_MENU_ATTR + ']'
7739
+ var stamped = null
7740
+ var stampedTrigger = null
7741
+ var closeTimer = null
7742
+ var openTimer = null
7743
+ var openedByHover = false
7744
+
7745
+ function cancelHoverClose() {
7746
+ if (closeTimer === null) return
7747
+ clearTimeout(closeTimer)
7748
+ closeTimer = null
7749
+ }
7750
+
7751
+ function cancelHoverOpen() {
7752
+ if (openTimer === null) return
7753
+ clearTimeout(openTimer)
7754
+ openTimer = null
7755
+ }
7756
+
7757
+ /**
7758
+ * Open what hover asked for, once the pointer has stayed the dwell out.
7759
+ * The host's menu has no hover of its own — this clicks its trigger — so
7760
+ * the dwell is what keeps a pointer merely crossing the hero row from
7761
+ * unfolding the card.
7762
+ */
7763
+ function openFromHover(trigger) {
7764
+ openTimer = null
7765
+ if (!hoverEnabled()) return
7766
+ if (trigger.getAttribute('aria-expanded') === 'true') return
7767
+ trigger.click()
7768
+ openedByHover = true
7769
+ }
7770
+
7771
+ function scheduleHoverOpen(trigger) {
7772
+ cancelHoverOpen()
7773
+ openTimer = setTimeout(function () { openFromHover(trigger) }, POPOVER_OPEN_DELAY)
7774
+ }
7775
+
7776
+ /** Close what hover opened; a click-opened menu is left alone. */
7777
+ function closeFromHover() {
7778
+ closeTimer = null
7779
+ if (!openedByHover) return
7780
+ var trigger = openTrigger()
7781
+ if (trigger !== null) trigger.click()
7782
+ openedByHover = false
7783
+ }
7784
+
7785
+ function scheduleHoverClose() {
7786
+ cancelHoverClose()
7787
+ closeTimer = setTimeout(closeFromHover, CLOSE_DELAY)
7788
+ }
7789
+
7790
+ /** The `all` scope only, and only while the composer restyle is in play. */
7791
+ function hoverEnabled() {
7792
+ return readPrefs().autoPopover === AUTO_POPOVER_ALL &&
7793
+ ui.copy !== undefined && ui.copy.isComposerActive()
7794
+ }
7795
+
7796
+ function closestWithin(target, selector) {
7797
+ if (target === null || target === undefined || typeof target.closest !== 'function') return null
7798
+ return target.closest(selector)
7799
+ }
7800
+
7801
+ function onHeroPointerOver(e) {
7802
+ if (!hoverEnabled()) return
7803
+ var target = e.target
7804
+ if (closestWithin(target, CARD_SELECTOR) !== null) {
7805
+ cancelHoverClose()
7806
+ return
7807
+ }
7808
+ var trigger = closestWithin(target, TRIGGER_SELECTOR)
7809
+ if (trigger === null) return
7810
+ cancelHoverClose()
7811
+ if (trigger.getAttribute('aria-expanded') !== 'true') scheduleHoverOpen(trigger)
7812
+ }
7813
+
7814
+ function onHeroPointerOut(e) {
7815
+ if (!hoverEnabled()) return
7816
+ var target = e.target
7817
+ if (closestWithin(target, CARD_SELECTOR) === null && closestWithin(target, TRIGGER_SELECTOR) === null) return
7818
+ // Moving onto the other half — the card, or the trigger — is not a leave.
7819
+ var next = e.relatedTarget
7820
+ if (closestWithin(next, CARD_SELECTOR) !== null || closestWithin(next, TRIGGER_SELECTOR) !== null) return
7821
+ cancelHoverOpen()
7822
+ scheduleHoverClose()
7823
+ }
7824
+
7825
+ document.addEventListener('mouseover', onHeroPointerOver, true)
7826
+ document.addEventListener('mouseout', onHeroPointerOut, true)
7827
+
7828
+ function clearStamp() {
7829
+ if (stamped === null) return
7830
+ stamped.removeAttribute(HERO_MENU_ATTR)
7831
+ stamped = null
7832
+ stampedTrigger = null
7833
+ }
7834
+
7835
+ /** The trigger whose picker is open, or null. */
7836
+ function openTrigger() {
7837
+ return document.querySelector('[class*="heroWorkspaceRow"] [aria-haspopup="menu"][aria-expanded="true"]') ||
7838
+ document.querySelector('[class*="cardWorkspaceTrigger"] [aria-expanded="true"]')
7839
+ }
7840
+
7841
+ /**
7842
+ * Park the card beside its trigger: to its right, bottom-aligned so it
7843
+ * grows upward, flipped to the left when the viewport is tight, and clamped
7844
+ * to the viewport either way. A card that has not been laid out yet is left
7845
+ * for the next pass rather than pinned to a zero-sized guess.
7846
+ */
7847
+ function placeCard(trigger, card) {
7848
+ var rect = trigger.getBoundingClientRect()
7849
+ var width = card.offsetWidth
7850
+ var height = card.offsetHeight
7851
+ if (width === 0 || height === 0) return
7852
+ var left = rect.right + GAP
7853
+ if (left + width > window.innerWidth - MARGIN) left = rect.left - GAP - width
7854
+ left = Math.max(MARGIN, Math.min(left, window.innerWidth - width - MARGIN))
7855
+ var top = rect.bottom - height
7856
+ top = Math.max(MARGIN, Math.min(top, window.innerHeight - height - MARGIN))
7857
+ card.style.setProperty('left', Math.round(left) + 'px', 'important')
7858
+ card.style.setProperty('top', Math.round(top) + 'px', 'important')
7859
+ }
7860
+
7861
+ /** Re-place an open card after a scroll or a resize moved its anchor. */
7862
+ function repositionHeroMenu() {
7863
+ if (stamped === null || stampedTrigger === null) return
7864
+ placeCard(stampedTrigger, stamped)
7865
+ }
7866
+
7867
+ function syncHeroMenu() {
7868
+ var trigger = openTrigger()
7869
+ if (trigger === null) {
7870
+ // The menu is shut — by a click, by Escape or by an outside press — so
7871
+ // whatever hover opened it no longer owns it.
7872
+ openedByHover = false
7873
+ cancelHoverClose()
7874
+ clearStamp()
7875
+ return
7876
+ }
7877
+ // The skin's own popovers carry their own classes, so excluding them
7878
+ // leaves the host's menus only.
7879
+ var cards = document.querySelectorAll('body > [role="menu"]:not([class*="dsh-claude"])')
7880
+ if (cards.length !== 1) {
7881
+ clearStamp()
7882
+ return
7883
+ }
7884
+ if (stamped !== cards[0]) {
7885
+ clearStamp()
7886
+ stamped = cards[0]
7887
+ stampedTrigger = trigger
7888
+ stamped.setAttribute(HERO_MENU_ATTR, '')
7889
+ }
7890
+ placeCard(trigger, stamped)
7891
+ }
7892
+
7893
+ ui.heroMenu = { sync: syncHeroMenu, reposition: repositionHeroMenu }
7894
+ return function () {
7895
+ cancelHoverClose()
7896
+ cancelHoverOpen()
7897
+ openedByHover = false
7898
+ clearStamp()
7899
+ document.removeEventListener('mouseover', onHeroPointerOver, true)
7900
+ document.removeEventListener('mouseout', onHeroPointerOut, true)
7901
+ delete ui.heroMenu
7902
+ }
7903
+ }
7904
+
7905
+ /**
7906
+ * The settings page's quick-provider picker — the providers whose models the
7907
+ * model picker's first level carries.
7908
+ *
7909
+ * It is the permission popover's design in multi-select form: the same card
7910
+ * and the same rows (`.dsh-claude-perm-popover` is that shared card, the name
7911
+ * is historical), with the accent check marking the chosen providers. It is a
7912
+ * settings control rather than a takeover, so it opens on click only —
7913
+ * hovering a settings row should not unfold a menu — and it stays open while
7914
+ * rows are toggled.
7915
+ *
7916
+ * The card is appended to <body> rather than rendered inside the settings row:
7917
+ * a dialog clips and may transform its own subtree, so a `position: fixed`
7918
+ * card in there would be positioned against the dialog, not the viewport.
7919
+ */
7920
+ function installQuickProviders(ctx, ui) {
7921
+ var CHECK_SVG = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.2 3.2L13 5"/></svg>'
7922
+ var card = null
7923
+ var cardBody = null
7924
+ var anchor = null
7925
+ var onWrite = null
7926
+ var providers = []
7927
+ var unsubscribe = null
7928
+
7929
+ function close() {
7930
+ if (card !== null && card.parentElement) card.parentElement.removeChild(card)
7931
+ if (anchor !== null) anchor.setAttribute('aria-expanded', 'false')
7932
+ card = null
7933
+ cardBody = null
7934
+ anchor = null
7935
+ onWrite = null
7936
+ document.removeEventListener('pointerdown', onOutsidePointerDown, true)
7937
+ document.removeEventListener('keydown', onKeyDown, true)
7938
+ }
7939
+
7940
+ function onOutsidePointerDown(e) {
7941
+ if (card === null) return
7942
+ var target = e.target
7943
+ if (card.contains(target)) return
7944
+ if (anchor !== null && anchor.contains(target)) return
7945
+ close()
7946
+ }
7947
+
7948
+ function onKeyDown(e) {
7949
+ if (e.key === 'Escape') close()
7950
+ }
7951
+
7952
+ /** One provider: its name, how many models it offers, and a check when chosen. */
7953
+ function buildRow(provider, chosen, stale) {
7954
+ var row = modelEl('button', 'dsh-claude-popover-item')
7955
+ if (stale) row.classList.add('dsh-claude-model-stale')
7956
+ row.type = 'button'
7957
+ row.setAttribute('role', 'menuitemcheckbox')
7958
+ row.setAttribute('aria-checked', chosen ? 'true' : 'false')
7959
+ row.appendChild(modelEl('span', 'dsh-claude-popover-item-text', provider.name))
7960
+ row.appendChild(modelEl('span', 'dsh-claude-popover-item-badge', stale ? settingsCopy('quickRemoved', 'Removed') : String(provider.count)))
7961
+ var check = modelEl('span', 'dsh-claude-model-check')
7962
+ check.innerHTML = chosen ? CHECK_SVG : ''
7963
+ row.appendChild(check)
7964
+ row.addEventListener('click', function (e) {
7965
+ e.stopPropagation()
7966
+ var next = readPrefs().quickProviders.slice()
7967
+ var at = next.indexOf(provider.id)
7968
+ if (at === -1) next.push(provider.id)
7969
+ else next.splice(at, 1)
7970
+ if (typeof onWrite === 'function') onWrite(next)
7971
+ renderBody()
7972
+ })
7973
+ return row
7974
+ }
7975
+
7976
+ function renderBody() {
7977
+ if (cardBody === null) return
7978
+ while (cardBody.firstChild) cardBody.removeChild(cardBody.firstChild)
7979
+ if (providers.length === 0) {
7980
+ cardBody.appendChild(modelEl('div', 'dsh-claude-model-status', settingsCopy('quickLoading', 'Loading providers…')))
7981
+ return
7982
+ }
7983
+ var chosen = readPrefs().quickProviders
7984
+ // The official service always leads the first level, so it is not a
7985
+ // choice here: it is never listed, and a stored id for it is pruned on
7986
+ // load. A provider that vanished from the catalog still appears, marked
7987
+ // removed, so it can be unchecked out of the stored list instead of
7988
+ // lingering invisibly.
7989
+ var known = {}
7990
+ for (var i = 0; i < providers.length; i++) {
7991
+ if (providers[i].id === MODEL_OFFICIAL_GROUP) continue
7992
+ known[providers[i].id] = true
7993
+ cardBody.appendChild(buildRow(providers[i], chosen.indexOf(providers[i].id) !== -1, false))
7994
+ }
7995
+ for (var s = 0; s < chosen.length; s++) {
7996
+ if (chosen[s] === MODEL_OFFICIAL_GROUP || known[chosen[s]]) continue
7997
+ cardBody.appendChild(buildRow({ id: chosen[s], name: chosen[s], count: 0 }, true, true))
7998
+ }
7999
+ }
8000
+
8001
+ function open(trigger, write) {
8002
+ anchor = trigger
8003
+ anchor.setAttribute('aria-expanded', 'true')
8004
+ onWrite = write
8005
+ providers = ui.model && typeof ui.model.providers === 'function' ? ui.model.providers() : []
8006
+ card = modelEl('div', 'dsh-claude-perm-popover')
8007
+ card.setAttribute('role', 'menu')
8008
+ card.setAttribute('data-open', 'true')
8009
+ cardBody = modelEl('div', 'dsh-claude-model-popover-body')
8010
+ card.appendChild(cardBody)
8011
+ renderBody()
8012
+ document.body.appendChild(card)
8013
+ positionAnchoredPopover(trigger, card, { side: 'above', gap: 6, important: true })
8014
+ document.addEventListener('pointerdown', onOutsidePointerDown, true)
8015
+ document.addEventListener('keydown', onKeyDown, true)
8016
+ }
8017
+
8018
+ function toggle(trigger, write) {
8019
+ if (card === null) open(trigger, write)
8020
+ else close()
8021
+ }
8022
+
8023
+ // The catalog arrives after the settings page may already be open, so an
8024
+ // open card follows it rather than showing an empty list until reopened.
8025
+ if (ui.model && typeof ui.model.onProviders === 'function') {
8026
+ unsubscribe = ui.model.onProviders(function (list) {
8027
+ providers = list
8028
+ if (card !== null) renderBody()
8029
+ })
8030
+ }
8031
+
8032
+ ui.quickProviders = {
8033
+ toggle: toggle,
8034
+ close: close,
8035
+ isOpen: function () { return card !== null },
8036
+ }
8037
+ return function () {
8038
+ close()
8039
+ if (unsubscribe !== null) {
8040
+ try { unsubscribe() } catch (error) { /* already disposed */ }
8041
+ unsubscribe = null
8042
+ }
8043
+ delete ui.quickProviders
8044
+ }
8045
+ }
8046
+
6110
8047
  function installAccountFooter(ctx, ui) {
6111
8048
  var accountBtn = null
6112
8049
  var accountPopover = null
6113
8050
  var popoverBody = null
6114
- var popoverHoverIntent = createHoverIntent(openPopover, closePopover, 150)
8051
+ var popoverHoverIntent = createHoverIntent(openPopover, closePopover, POPOVER_OPEN_DELAY, POPOVER_CLOSE_DELAY)
6115
8052
  // Whether the popover is up because it was CLICKED (rather than hovered).
6116
8053
  // Clicking the account row opens the ban-screen easter egg and leaves the
6117
8054
  // pointer inside the popover, so without this the row's own mouseleave
@@ -6696,6 +8633,20 @@ window.__ModuleLoader__.load({
6696
8633
  }
6697
8634
  var username = getUsername(ctx)
6698
8635
 
8636
+ // Idempotent against a torn-down-less reload: client HMR drops the old
8637
+ // fiber's disposals instead of running them, so a previous generation's
8638
+ // button and popover are still in the DOM and this fresh scope knows
8639
+ // nothing of them. Sweep the strays before deciding whether to build —
8640
+ // otherwise the row renders twice (and the orphan keeps a stale name).
8641
+ var strayBtns = footArea.querySelectorAll('.dsh-claude-account-btn')
8642
+ for (var sb = 0; sb < strayBtns.length; sb++) {
8643
+ if (strayBtns[sb] !== accountBtn) strayBtns[sb].parentElement.removeChild(strayBtns[sb])
8644
+ }
8645
+ var strayPops = document.querySelectorAll('body > .dsh-claude-account-popover')
8646
+ for (var sp = 0; sp < strayPops.length; sp++) {
8647
+ if (strayPops[sp] !== accountPopover) strayPops[sp].parentElement.removeChild(strayPops[sp])
8648
+ }
8649
+
6699
8650
  if (accountBtn === null || !footArea.contains(accountBtn)) {
6700
8651
  if (accountBtn && accountBtn.parentElement) accountBtn.parentElement.removeChild(accountBtn)
6701
8652
  accountBtn = document.createElement('div')
@@ -6714,12 +8665,13 @@ window.__ModuleLoader__.load({
6714
8665
  // Hover is the default way in; the "Open popovers on hover"
6715
8666
  // preference turns it off, leaving the click handler below as the only
6716
8667
  // way in (and the only way out, so a click-opened popover does not
6717
- // vanish when the pointer leaves).
8668
+ // vanish when the pointer leaves). The account row is the one popover
8669
+ // the `account` scope keeps on hover.
6718
8670
  accountBtn.addEventListener('mouseenter', function () {
6719
- if (readPrefs().autoPopover) openPopover()
8671
+ if (readPrefs().autoPopover !== AUTO_POPOVER_OFF) popoverHoverIntent.scheduleOpen()
6720
8672
  })
6721
8673
  accountBtn.addEventListener('mouseleave', function () {
6722
- if (readPrefs().autoPopover && !popoverOpenedByClick) scheduleClosePopover()
8674
+ if (readPrefs().autoPopover !== AUTO_POPOVER_OFF && !popoverOpenedByClick) scheduleClosePopover()
6723
8675
  })
6724
8676
  accountBtn.addEventListener('click', function (e) {
6725
8677
  e.stopPropagation()
@@ -7279,6 +9231,419 @@ window.__ModuleLoader__.load({
7279
9231
  }
7280
9232
  }
7281
9233
 
9234
+ /**
9235
+ * The sidebar's workspace section, re-cut as a two-state view.
9236
+ *
9237
+ * 进行中 needs no filtering of its own: the host's tree already leaves
9238
+ * archived sessions out (measured on 0.1.7-alpha.1 — 10 rows, 6 archived, zero
9239
+ * overlap). 已归档 is the skin's own flat list of archived conversations, built
9240
+ * from the archive registry (ids) plus the session list (titles and times),
9241
+ * with a delete button on every row.
9242
+ *
9243
+ * The host DOES ship this filter, but its state lives in a store created per
9244
+ * view instance (`createWorkspaceViewStore`) and is not reachable as a client
9245
+ * service — the only way to use it is to click its options menu, which flashes
9246
+ * a popover in the user's face and still leaves the archived rows buried in
9247
+ * collapsed workspace groups. Listing them here is quieter and flat.
9248
+ *
9249
+ * @param ctx - client context.
9250
+ * @param ui - shared handle table.
9251
+ * @returns teardown.
9252
+ */
9253
+ function installWorkspaceView(ctx, ui) {
9254
+ /** Trash can for one archived row. */
9255
+ var DELETE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.6 4.2h10.8"/><path d="M6.4 4.2V3a.8.8 0 0 1 .8-.8h1.6a.8.8 0 0 1 .8.8v1.2"/><path d="M4.2 4.2l.6 8.3a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.3"/><path d="M6.7 6.8v4M9.3 6.8v4"/></svg>'
9256
+ /** Tray with an up arrow: put this conversation back among the live ones. */
9257
+ var RESTORE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.6 9.2v3.4a1 1 0 0 0 1 1h8.8a1 1 0 0 0 1-1V9.2"/><path d="M8 10.4V2.6"/><path d="M5.2 5.4L8 2.6l2.8 2.8"/></svg>'
9258
+ /**
9259
+ * The host's own Tooltip and icons, reached through the plugin loader's
9260
+ * `require` — the same packages its UI uses, so these row actions look and
9261
+ * behave like the host's. `@deepseek-ai/dsh-client-ui-primitives` exports
9262
+ * `Tooltip`, `IconUnarchiveOutlineRegular` and `IconTrashOutlineRegular`.
9263
+ * Guarded: a loader that hands over nothing leaves the skin's own SVG and a
9264
+ * native title in place.
9265
+ */
9266
+ var primitives = null
9267
+ var react = null
9268
+ var reactDom = null
9269
+ try { primitives = require('@deepseek-ai/dsh-client-ui-primitives') } catch (error) { primitives = null }
9270
+ try { react = require('react') } catch (error) { react = null }
9271
+ try { reactDom = require('react-dom/client') } catch (error) { reactDom = null }
9272
+ /** React roots holding the row actions, unmounted when the list is rebuilt. */
9273
+ var actionRoots = []
9274
+ var VIEW_ATTR = 'data-dsh-claude-ws-view'
9275
+ var LABEL_ATTR = 'data-dsh-claude-ws-label'
9276
+ var TREE_ATTR = 'data-dsh-claude-ws-tree'
9277
+ var SEGMENTS = [
9278
+ { id: 'active', key: 'archiveActive', fallback: 'Active' },
9279
+ { id: 'archived', key: 'archiveArchived', fallback: 'Archived' }
9280
+ ]
9281
+ var LABEL_TEXTS = ['工作区', 'Workspace']
9282
+ var view = 'active'
9283
+ var control = null
9284
+ var listHost = null
9285
+ var markedLabel = null
9286
+ var markedTree = null
9287
+ /** `null` until the registry answers; then `[{ id, title, at }]`. */
9288
+ var items = null
9289
+ var loading = false
9290
+ /**
9291
+ * Whether this host archives conversations at all. DSH 0.1.5 has no archive
9292
+ * feature and no `workspaceRegistry` remote, so the section keeps its plain
9293
+ * label there instead of growing a control that can only ever show an empty
9294
+ * list. `null` = not known yet.
9295
+ */
9296
+ var supported = null
9297
+ var disposed = false
9298
+ var retryTimer = null
9299
+
9300
+ function service(name) {
9301
+ try { return ctx.get(name) } catch (error) { return undefined }
9302
+ }
9303
+
9304
+ function findSection() {
9305
+ var labels = document.querySelectorAll('[class*="sectionLabel"]')
9306
+ var first = null
9307
+ for (var i = 0; i < labels.length; i++) {
9308
+ var text = (labels[i].textContent || '').trim()
9309
+ if (first === null) first = labels[i]
9310
+ for (var t = 0; t < LABEL_TEXTS.length; t++) {
9311
+ if (text === LABEL_TEXTS[t]) return labels[i]
9312
+ }
9313
+ }
9314
+ return first
9315
+ }
9316
+
9317
+ /**
9318
+ * The tree's rows. `data-row-key` is what 0.1.7 stamps on them, but the
9319
+ * bundled 0.1.5 has no such attribute at all — matching only that made the
9320
+ * whole section bail out on 0.1.5, control and all. The row CLASSES are
9321
+ * stable across both, and the skin's own stylesheet already keys off them.
9322
+ */
9323
+ var TREE_ROW_SELECTOR = '[data-row-key], [class*="sessionRow"], [class*="projectRow"]'
9324
+
9325
+ function findTree(label) {
9326
+ var region = label.parentElement
9327
+ while (region !== null && region !== document.body) {
9328
+ var tree = region.querySelector('[role="tree"], [class*="list"]')
9329
+ if (tree !== null && tree.querySelector(TREE_ROW_SELECTOR) !== null) return tree
9330
+ region = region.parentElement
9331
+ }
9332
+ return null
9333
+ }
9334
+
9335
+ function relativeTime(at) {
9336
+ if (typeof at !== 'number' || !isFinite(at)) return ''
9337
+ var minutes = Math.floor((Date.now() - at) / 60000)
9338
+ if (minutes < 1) return copyLabel('archiveJustNow', 'Just now')
9339
+ if (minutes < 60) return copyLabel('archiveMinutes', '{count} min', { count: minutes })
9340
+ var hours = Math.floor(minutes / 60)
9341
+ if (hours < 24) return copyLabel('archiveHours', '{count} h', { count: hours })
9342
+ return copyLabel('archiveDays', '{count} d', { count: Math.floor(hours / 24) })
9343
+ }
9344
+
9345
+ /** Ids → `{ title, updatedAt }`, from the session list the shell already has. */
9346
+ function titlesFor() {
9347
+ var sessions = service('remote.session')
9348
+ if (sessions === undefined || sessions === null || typeof sessions.list !== 'function') return Promise.resolve({})
9349
+ return sessions.list({}).then(function (result) {
9350
+ var map = {}
9351
+ var rows = (result && result.ok === true && result.value && result.value.items) || []
9352
+ for (var i = 0; i < rows.length; i++) {
9353
+ var values = rows[i].projections && rows[i].projections.values
9354
+ map[rows[i].sessionId] = { title: values ? values.title : null, updatedAt: rows[i].updatedAt }
9355
+ }
9356
+ return map
9357
+ }).catch(function () { return {} })
9358
+ }
9359
+
9360
+ function loadArchived(attempt) {
9361
+ function retry() {
9362
+ if (disposed || attempt >= 4) {
9363
+ // No archive service answered: this host does not have one (0.1.5), so
9364
+ // the section goes back to being a plain label.
9365
+ supported = false
9366
+ items = []
9367
+ loading = false
9368
+ renderList()
9369
+ return
9370
+ }
9371
+ loading = true
9372
+ renderList()
9373
+ retryTimer = setTimeout(function () {
9374
+ retryTimer = null
9375
+ loadArchived(attempt + 1)
9376
+ }, 600)
9377
+ }
9378
+ var registry = service('remote.workspaceRegistry')
9379
+ if (registry === undefined || registry === null || typeof registry.archivedSessionMetadata !== 'function') {
9380
+ retry()
9381
+ return
9382
+ }
9383
+ loading = true
9384
+ renderList()
9385
+ registry.archivedSessionMetadata().then(function (result) {
9386
+ if (!result || result.ok !== true) throw new Error('archived metadata unavailable')
9387
+ var rows = (result.value && result.value.items) || []
9388
+ supported = true
9389
+ items = rows.map(function (row) { return { id: row.sessionId, title: null, at: row.createdAt } })
9390
+ items.sort(function (a, b) { return (b.at || 0) - (a.at || 0) })
9391
+ loading = false
9392
+ renderList()
9393
+ return fillTitles(0)
9394
+ }).catch(function () { retry() })
9395
+ }
9396
+
9397
+ /**
9398
+ * Titles and times ride the session list, which the shell may still be
9399
+ * loading when the registry answers — a row falls back to its id until they
9400
+ * land, and a couple of re-reads fill them in. Bounded, and abandoned the
9401
+ * moment the view is left or the fragment is torn down.
9402
+ */
9403
+ function fillTitles(attempt) {
9404
+ return titlesFor().then(function (titles) {
9405
+ if (items === null) return
9406
+ var missing = false
9407
+ for (var i = 0; i < items.length; i++) {
9408
+ var meta = titles[items[i].id]
9409
+ if (meta && meta.title) items[i].title = meta.title
9410
+ if (meta && meta.updatedAt) items[i].at = meta.updatedAt
9411
+ if (!items[i].title) missing = true
9412
+ }
9413
+ items.sort(function (a, b) { return (b.at || 0) - (a.at || 0) })
9414
+ renderList()
9415
+ if (!missing || attempt >= 4 || view !== 'archived' || disposed) return
9416
+ return new Promise(function (resolve) {
9417
+ retryTimer = setTimeout(resolve, 500)
9418
+ }).then(function () {
9419
+ retryTimer = null
9420
+ if (disposed || view !== 'archived') return
9421
+ return fillTitles(attempt + 1)
9422
+ })
9423
+ })
9424
+ }
9425
+
9426
+ function openArchived(id) {
9427
+ var sessions = service('sessions')
9428
+ if (sessions !== undefined && sessions !== null && typeof sessions.open === 'function') sessions.open(id)
9429
+ }
9430
+
9431
+ function removeArchived(id) {
9432
+ var registry = service('remote.workspaceRegistry')
9433
+ if (registry === undefined || registry === null || typeof registry.deleteSession !== 'function') return
9434
+ registry.deleteSession(id).then(function (result) {
9435
+ if (!result || result.ok !== true) return
9436
+ if (items !== null) items = items.filter(function (row) { return row.id !== id })
9437
+ renderList()
9438
+ }).catch(function () { /* the row stays; the next read tells the truth */ })
9439
+ }
9440
+
9441
+ /** Put a conversation back among the live ones; it leaves this list. */
9442
+ function restoreArchived(id) {
9443
+ var registry = service('remote.workspaceRegistry')
9444
+ if (registry === undefined || registry === null || typeof registry.unarchiveSession !== 'function') return
9445
+ registry.unarchiveSession(id).then(function (result) {
9446
+ if (!result || result.ok !== true) return
9447
+ if (items !== null) items = items.filter(function (row) { return row.id !== id })
9448
+ renderList()
9449
+ // The host's own tree has to be told to pick the conversation back up.
9450
+ var sessions = service('sessions')
9451
+ if (sessions !== undefined && sessions !== null && typeof sessions.refresh === 'function') sessions.refresh()
9452
+ }).catch(function () { /* the row stays; the next read tells the truth */ })
9453
+ }
9454
+
9455
+ /**
9456
+ * One row action: the host's icon inside the host's tooltip when the loader
9457
+ * gives us both, and the skin's own SVG plus a native title when it does not.
9458
+ */
9459
+ function actionButton(kind, label, fallbackSvg, onClick) {
9460
+ var wrapper = modelEl('span', 'dsh-claude-archive-action')
9461
+ var className = kind === 'restore' ? 'dsh-claude-archive-restore' : 'dsh-claude-archive-delete'
9462
+ var Icon = primitives === null ? null : (kind === 'restore' ? primitives.IconUnarchiveOutlineRegular : primitives.IconTrashOutlineRegular)
9463
+ if (react !== null && reactDom !== null && primitives !== null && primitives.Tooltip && Icon) {
9464
+ var root = reactDom.createRoot(wrapper)
9465
+ actionRoots.push(root)
9466
+ root.render(react.createElement(primitives.Tooltip, { label: label, side: 'top', delayMs: 500 },
9467
+ react.createElement('button', { type: 'button', className: className, 'aria-label': label, title: label, onClick: onClick },
9468
+ react.createElement(Icon, { size: 14 }))))
9469
+ return wrapper
9470
+ }
9471
+ var button = modelEl('button', className)
9472
+ button.type = 'button'
9473
+ button.setAttribute('aria-label', label)
9474
+ button.setAttribute('title', label)
9475
+ button.innerHTML = fallbackSvg
9476
+ button.addEventListener('click', onClick)
9477
+ wrapper.appendChild(button)
9478
+ return wrapper
9479
+ }
9480
+
9481
+ function buildArchivedRow(item) {
9482
+ var row = modelEl('div', 'dsh-claude-archive-row')
9483
+ row.setAttribute('role', 'button')
9484
+ row.setAttribute('tabindex', '0')
9485
+ row.setAttribute('data-session-id', item.id)
9486
+ row.appendChild(modelEl('span', 'dsh-claude-archive-title', item.title || copyLabel('archiveUntitled', 'Untitled conversation')))
9487
+ row.appendChild(modelEl('span', 'dsh-claude-archive-time', relativeTime(item.at)))
9488
+ // The host's archived rows offer an unarchive action; the skin's list
9489
+ // carries the same pair, so leaving the archived view is not the only way
9490
+ // back to a conversation.
9491
+ row.appendChild(actionButton('restore', copyLabel('archiveRestore', 'Unarchive conversation'), RESTORE_SVG, function (event) {
9492
+ event.stopPropagation()
9493
+ restoreArchived(item.id)
9494
+ }))
9495
+ row.appendChild(actionButton('delete', copyLabel('archiveDelete', 'Delete conversation'), DELETE_SVG, function (event) {
9496
+ event.stopPropagation()
9497
+ removeArchived(item.id)
9498
+ }))
9499
+ row.addEventListener('click', function () { openArchived(item.id) })
9500
+ return row
9501
+ }
9502
+
9503
+ function renderList() {
9504
+ if (listHost === null) return
9505
+ // The actions live in React roots; drop them before the rows go, or every
9506
+ // rebuild would leave a tree behind.
9507
+ for (var r = 0; r < actionRoots.length; r++) {
9508
+ try { actionRoots[r].unmount() } catch (error) { /* already gone */ }
9509
+ }
9510
+ actionRoots = []
9511
+ while (listHost.firstChild) listHost.removeChild(listHost.firstChild)
9512
+ if (loading) {
9513
+ listHost.appendChild(modelEl('div', 'dsh-claude-archive-status', copyLabel('archiveLoading', 'Loading…')))
9514
+ return
9515
+ }
9516
+ if (items === null) return
9517
+ if (items.length === 0) {
9518
+ listHost.appendChild(modelEl('div', 'dsh-claude-archive-status', copyLabel('archiveEmpty', 'No archived conversations')))
9519
+ return
9520
+ }
9521
+ for (var i = 0; i < items.length; i++) listHost.appendChild(buildArchivedRow(items[i]))
9522
+ }
9523
+
9524
+ function buildControl() {
9525
+ var group = modelEl('div', 'dsh-claude-ws-segments')
9526
+ group.setAttribute('role', 'radiogroup')
9527
+ for (var i = 0; i < SEGMENTS.length; i++) {
9528
+ var item = modelEl('button', 'dsh-claude-ws-segment', '')
9529
+ item.type = 'button'
9530
+ item.setAttribute('role', 'radio')
9531
+ item.setAttribute('data-view', SEGMENTS[i].id)
9532
+ group.appendChild(item)
9533
+ }
9534
+ group.addEventListener('click', function (event) {
9535
+ var target = event.target
9536
+ var item = target !== null && typeof target.closest === 'function' ? target.closest('.dsh-claude-ws-segment') : null
9537
+ if (item === null) return
9538
+ event.stopPropagation()
9539
+ event.preventDefault()
9540
+ setView(item.getAttribute('data-view'))
9541
+ })
9542
+ return group
9543
+ }
9544
+
9545
+ function setView(next) {
9546
+ if (next !== 'active' && next !== 'archived') return
9547
+ if (next === view) return
9548
+ view = next
9549
+ if (view === 'archived' && items === null && !loading) loadArchived(0)
9550
+ sync()
9551
+ }
9552
+
9553
+ function sync() {
9554
+ var label = findSection()
9555
+ if (label === null || label.parentElement === null) return
9556
+ var header = label.parentElement
9557
+ // Find out whether this host archives at all, once, on the first pass —
9558
+ // the answer decides whether the section keeps its label.
9559
+ if (supported === null && !loading) loadArchived(0)
9560
+ if (supported !== true) {
9561
+ if (control !== null) {
9562
+ if (control.parentElement !== null) control.parentElement.removeChild(control)
9563
+ control = null
9564
+ }
9565
+ if (listHost !== null) {
9566
+ if (listHost.parentElement !== null) listHost.parentElement.removeChild(listHost)
9567
+ listHost = null
9568
+ }
9569
+ if (markedLabel !== null) {
9570
+ markedLabel.removeAttribute(LABEL_ATTR)
9571
+ markedLabel = null
9572
+ }
9573
+ return
9574
+ }
9575
+ if (markedLabel !== label) {
9576
+ if (markedLabel !== null) markedLabel.removeAttribute(LABEL_ATTR)
9577
+ markedLabel = label
9578
+ label.setAttribute(LABEL_ATTR, '')
9579
+ }
9580
+ if (control === null || control.parentElement !== header) {
9581
+ if (control !== null && control.parentElement !== null) control.parentElement.removeChild(control)
9582
+ control = buildControl()
9583
+ header.insertBefore(control, header.firstChild)
9584
+ }
9585
+ for (var i = 0; i < control.children.length; i++) {
9586
+ var item = control.children[i]
9587
+ var id = item.getAttribute('data-view')
9588
+ for (var s = 0; s < SEGMENTS.length; s++) {
9589
+ if (SEGMENTS[s].id !== id) continue
9590
+ var text = copyLabel(SEGMENTS[s].key, SEGMENTS[s].fallback)
9591
+ if (item.textContent !== text) item.textContent = text
9592
+ }
9593
+ var on = id === view
9594
+ if (item.getAttribute('aria-checked') !== (on ? 'true' : 'false')) item.setAttribute('aria-checked', on ? 'true' : 'false')
9595
+ }
9596
+ var tree = findTree(label)
9597
+ if (tree === null) return
9598
+ if (markedTree !== tree) {
9599
+ if (markedTree !== null) markedTree.removeAttribute(TREE_ATTR)
9600
+ markedTree = tree
9601
+ tree.setAttribute(TREE_ATTR, '')
9602
+ }
9603
+ var host = tree.parentElement
9604
+ if (host === null) return
9605
+ if (listHost === null || listHost.parentElement !== host) {
9606
+ if (listHost !== null && listHost.parentElement !== null) listHost.parentElement.removeChild(listHost)
9607
+ listHost = modelEl('div', 'dsh-claude-archive-list')
9608
+ host.insertBefore(listHost, tree.nextSibling)
9609
+ renderList()
9610
+ }
9611
+ // The sidebar re-renders its own way: when React replaces the container
9612
+ // that held the list, the old copy can stay in the document while a new
9613
+ // one is built elsewhere. Sweep every copy but the live one, the same way
9614
+ // the stats popover sweeps its strays.
9615
+ var strays = document.querySelectorAll('.dsh-claude-archive-list')
9616
+ for (var k = 0; k < strays.length; k++) {
9617
+ if (strays[k] === listHost) continue
9618
+ if (strays[k].parentElement !== null) strays[k].parentElement.removeChild(strays[k])
9619
+ }
9620
+ if (host.getAttribute(VIEW_ATTR) !== view) host.setAttribute(VIEW_ATTR, view)
9621
+ }
9622
+
9623
+ ui.workspace = { sync: sync }
9624
+
9625
+ return function () {
9626
+ disposed = true
9627
+ for (var r = 0; r < actionRoots.length; r++) {
9628
+ try { actionRoots[r].unmount() } catch (error) { /* already gone */ }
9629
+ }
9630
+ actionRoots = []
9631
+ if (retryTimer !== null) {
9632
+ clearTimeout(retryTimer)
9633
+ retryTimer = null
9634
+ }
9635
+ if (control !== null && control.parentElement !== null) control.parentElement.removeChild(control)
9636
+ if (listHost !== null && listHost.parentElement !== null) listHost.parentElement.removeChild(listHost)
9637
+ if (markedLabel !== null) markedLabel.removeAttribute(LABEL_ATTR)
9638
+ if (markedTree !== null) markedTree.removeAttribute(TREE_ATTR)
9639
+ control = null
9640
+ listHost = null
9641
+ markedLabel = null
9642
+ markedTree = null
9643
+ delete ui.workspace
9644
+ }
9645
+ }
9646
+
7282
9647
  function installScheduler(ctx, ui) {
7283
9648
  function onGlobalPointerDown(e) {
7284
9649
  var target = e.target
@@ -7339,6 +9704,28 @@ window.__ModuleLoader__.load({
7339
9704
  }
7340
9705
  }
7341
9706
 
9707
+ // Focus moving into the composer means the user is about to type: every
9708
+ // popover the skin keeps open around the card is in the way there, so all
9709
+ // of them close. `focusin` bubbles (unlike focus), so one listener covers
9710
+ // the card and everything inside it; the observer's attributeFilter does
9711
+ // not watch focus events, so this cannot feed itself another pass.
9712
+ function onComposerFocusIn(e) {
9713
+ var target = e.target
9714
+ if (!target || typeof target.closest !== 'function') return
9715
+ if (target.closest('[data-composer-card]') === null) return
9716
+ if (ui.model) ui.model.close()
9717
+ if (ui.permissions) ui.permissions.closeMenu()
9718
+ if (ui.permissions && ui.permissions.closeStats) ui.permissions.closeStats()
9719
+ if (ui.footer) ui.footer.close()
9720
+ // The hero menu has no close of its own — it lives and dies with the
9721
+ // host's hover state, and syncHeroMenu notices when it is gone — so the
9722
+ // call is guarded like closeStats above. Unguarded it threw a TypeError
9723
+ // on every composer focus-in, which also aborted the rest of this
9724
+ // handler for that event.
9725
+ if (ui.heroMenu && ui.heroMenu.close) ui.heroMenu.close()
9726
+ if (ui.quickProviders) ui.quickProviders.close()
9727
+ }
9728
+
7342
9729
  function onComposerInput(e) {
7343
9730
  var target = e.target
7344
9731
  if (!target) return
@@ -7352,12 +9739,14 @@ window.__ModuleLoader__.load({
7352
9739
  document.addEventListener('keydown', onGlobalKeyDown, true)
7353
9740
  document.addEventListener('input', onComposerInput, true)
7354
9741
  document.addEventListener('compositionend', onComposerInput, true)
9742
+ document.addEventListener('focusin', onComposerFocusIn, true)
7355
9743
 
7356
9744
  // Both fixed popovers are anchored to their trigger; scroll of the page
7357
9745
  // (not the conversation's own auto-stick) and resizes move the anchor, so
7358
9746
  // whichever is open must re-resolve it.
7359
9747
  function onFixedPopoverViewportChange() {
7360
9748
  if (ui.model) ui.model.reposition()
9749
+ if (ui.heroMenu) ui.heroMenu.reposition()
7361
9750
  if (ui.footer && ui.footer.isOpen()) ui.footer.reposition()
7362
9751
  }
7363
9752
  window.addEventListener('resize', onFixedPopoverViewportChange)
@@ -7429,7 +9818,9 @@ window.__ModuleLoader__.load({
7429
9818
  if (ui.copy) ui.copy.sync()
7430
9819
  if (ui.permissions) ui.permissions.sync()
7431
9820
  if (ui.model) ui.model.sync()
9821
+ if (ui.heroMenu) ui.heroMenu.sync()
7432
9822
  if (ui.footer) ui.footer.sync()
9823
+ if (ui.workspace) ui.workspace.sync()
7433
9824
  if (ui.settings) ui.settings.sync()
7434
9825
  // Covers the rail toggle (and any reflow) while the popover is open:
7435
9826
  // its anchor moved without a window resize or a page scroll.
@@ -7497,6 +9888,7 @@ window.__ModuleLoader__.load({
7497
9888
  document.removeEventListener('keydown', onGlobalKeyDown, true)
7498
9889
  document.removeEventListener('input', onComposerInput, true)
7499
9890
  document.removeEventListener('compositionend', onComposerInput, true)
9891
+ document.removeEventListener('focusin', onComposerFocusIn, true)
7500
9892
  // Safety-net DOM sweep. Feature teardowns run after this and tolerate
7501
9893
  // nodes already being detached.
7502
9894
  var leftoverItems = document.querySelectorAll('.dsh-claude-popover-item, .dsh-claude-popover-embed, .dsh-claude-account-popover, .dsh-claude-account-btn, .dsh-claude-perm-container, .dsh-claude-perm-popover, .dsh-claude-segments[data-composer-segments], .dsh-claude-model-btn, .dsh-claude-model-popover, .dsh-claude-ban, [data-dsh-synthetic-placeholder]')
@@ -7516,11 +9908,15 @@ window.__ModuleLoader__.load({
7516
9908
  }
7517
9909
 
7518
9910
  /**
7519
- * The settings page section, mounted by the host into the `settings.section`
7520
- * slot. That slot hands a section only `{ close }` plus the standard hooks,
7521
- * so this component owns no store of its own: it reads and writes the skin
7522
- * preferences through src/context/prefs.js, which owns the host round trip, and
7523
- * follows changes the same way the rest of the skin does.
9911
+ * The skin's settings page, mounted by whichever seat this host has.
9912
+ *
9913
+ * 0.1.7 moved a bundle's own configuration onto its plugin page, so there
9914
+ * the rows register as a `plugins.bundle.config` entry keyed by the package
9915
+ * name. Older hosts have no such slot and keep the full-page
9916
+ * `settings.section` entry. Both render the same component, and neither
9917
+ * seat hands it a store: it reads and writes the skin preferences through
9918
+ * src/context/prefs.js, which owns the host round trip, and follows changes
9919
+ * the same way the rest of the skin does.
7524
9920
  *
7525
9921
  * Copy comes from the model copy document's `settings` block, so the page
7526
9922
  * follows the shell language like every other string the skin paints. The
@@ -7530,7 +9926,14 @@ window.__ModuleLoader__.load({
7530
9926
  * `.dsh-claude-segment` classes — the same control the composer's permission
7531
9927
  * picker uses — so the two read as one design instead of two lookalikes.
7532
9928
  */
7533
- function ClaudeStyleSettingsSection() {
9929
+ /**
9930
+ * The quick-provider popover (src/overrides/quick-providers.js). The settings
9931
+ * row is React-rendered while that card is imperative, so the row reaches the
9932
+ * popover through this handle.
9933
+ */
9934
+ var quickProviderApi = null
9935
+
9936
+ function ClaudeStyleSettingsSection(props) {
7534
9937
  var state = React.useState(readPrefs())
7535
9938
  var prefs = state[0]
7536
9939
  var setPrefs = state[1]
@@ -7541,6 +9944,7 @@ window.__ModuleLoader__.load({
7541
9944
  var username = usernameState[0]
7542
9945
  var setUsername = usernameState[1]
7543
9946
  var usernameTimer = React.useRef(null)
9947
+ var quickTrigger = React.useRef(null)
7544
9948
 
7545
9949
  // The skin's own apply-side writes land here too (a reload, a conflict
7546
9950
  // re-read), so the page never drifts from what the document says.
@@ -7669,6 +10073,17 @@ window.__ModuleLoader__.load({
7669
10073
  { value: BAN_LOCALE_ZH, label: settingsCopy('banLocaleZh', '中文') },
7670
10074
  { value: BAN_LOCALE_EN, label: settingsCopy('banLocaleEn', 'English') },
7671
10075
  ]
10076
+ var autoPopoverOptions = [
10077
+ { value: AUTO_POPOVER_OFF, label: settingsCopy('autoPopoverOff', 'Off') },
10078
+ { value: AUTO_POPOVER_ACCOUNT, label: settingsCopy('autoPopoverAccount', 'Account only') },
10079
+ { value: AUTO_POPOVER_ALL, label: settingsCopy('autoPopoverAll', 'All') },
10080
+ ]
10081
+
10082
+ /** What the quick-provider trigger reads: how many, or nothing chosen. */
10083
+ var quickSummary = function (chosen) {
10084
+ if (chosen.length === 0) return settingsCopy('quickNone', 'None')
10085
+ return settingsCopy('quickCount', '{count} providers', { count: chosen.length })
10086
+ }
7672
10087
 
7673
10088
  var rows = [
7674
10089
  row(
@@ -7715,8 +10130,8 @@ window.__ModuleLoader__.load({
7715
10130
  row(
7716
10131
  'autoPopover',
7717
10132
  settingsCopy('autoPopoverTitle', 'Open popovers on hover'),
7718
- settingsCopy('autoPopoverDesc', 'Hover opens the account, model, and permission popovers. Off switches them to click-to-open.'),
7719
- toggle(prefs.autoPopover, function (value) { write({ autoPopover: value }) }),
10133
+ settingsCopy('autoPopoverDesc', 'Which popovers hover opens. Off leaves them all click-to-open.'),
10134
+ segment(autoPopoverOptions, prefs.autoPopover, function (value) { write({ autoPopover: value }) }),
7720
10135
  ),
7721
10136
  row(
7722
10137
  'composerScope',
@@ -7724,6 +10139,28 @@ window.__ModuleLoader__.load({
7724
10139
  settingsCopy('composerDesc', 'Which input area the skin restyles: the new-conversation page, the conversation, or both.'),
7725
10140
  segment(scopeOptions, prefs.composerScope, function (value) { write({ composerScope: value }) }),
7726
10141
  ),
10142
+ row(
10143
+ 'modelPicker',
10144
+ settingsCopy('pickerTitle', 'Redraw the model picker'),
10145
+ settingsCopy('pickerDesc', 'Replace the composer\'s model seat with the two-level Claude-style menu. Off hands the model menu back to the host; the rest of the composer restyle is unaffected.'),
10146
+ toggle(prefs.modelPicker, function (value) { write({ modelPicker: value }) }),
10147
+ ),
10148
+ row(
10149
+ 'quickProviders',
10150
+ settingsCopy('quickTitle', 'Quick providers'),
10151
+ settingsCopy('quickDesc', 'Providers whose models the picker\'s first level lists, one rule between providers. Nothing picked keeps the default: the official service.'),
10152
+ React.createElement('button', {
10153
+ type: 'button',
10154
+ ref: quickTrigger,
10155
+ className: 'dsh-claude-settings-picker',
10156
+ 'aria-haspopup': 'menu',
10157
+ 'aria-expanded': 'false',
10158
+ onClick: function () {
10159
+ if (quickProviderApi === null || quickTrigger.current === null) return
10160
+ quickProviderApi.toggle(quickTrigger.current, function (next) { write({ quickProviders: next }) })
10161
+ },
10162
+ }, quickSummary(prefs.quickProviders)),
10163
+ ),
7727
10164
  row(
7728
10165
  'banLocale',
7729
10166
  settingsCopy('banLocaleTitle', 'Account-hold easter egg language'),
@@ -7736,14 +10173,35 @@ window.__ModuleLoader__.load({
7736
10173
  rows.push(React.createElement('div', { className: 'dsh-claude-settings-error', key: 'error' }, error))
7737
10174
  }
7738
10175
 
10176
+ // The plugin page already heads the form with the bundle's own title and
10177
+ // description, so the embedded rendering drops the skin's title rather
10178
+ // than printing it twice.
10179
+ var embedded = !!(props && props.embed)
7739
10180
  return React.createElement(
7740
10181
  'div',
7741
- { className: 'dsh-claude-settings' },
7742
- React.createElement('div', { className: 'dsh-claude-settings-title' }, settingsCopy('title', 'Claude Style')),
10182
+ { className: embedded ? 'dsh-claude-settings dsh-claude-settings-embedded' : 'dsh-claude-settings' },
10183
+ embedded ? null : React.createElement('div', { className: 'dsh-claude-settings-title' }, settingsCopy('title', 'Claude Style')),
7743
10184
  rows,
7744
10185
  )
7745
10186
  }
7746
10187
 
10188
+ /**
10189
+ * The plugin page's configuration entry (0.1.7+).
10190
+ *
10191
+ * A `plugins.bundle.config` entry is asked for two views: `summary` is the
10192
+ * one-liner on the bundle's card, `page` is the form itself. The host does
10193
+ * not hand this seat a form — the entry is keyed to the package, not to a
10194
+ * namespace — so the rows read and write through src/context/prefs.js like
10195
+ * every other surface in the skin, which is where the official form is
10196
+ * bound.
10197
+ */
10198
+ function ClaudeStyleBundleConfig(props) {
10199
+ if (props && props.view === 'summary') {
10200
+ return React.createElement('span', null, settingsCopy('title', 'Claude Style'))
10201
+ }
10202
+ return React.createElement(ClaudeStyleSettingsSection, { embed: true })
10203
+ }
10204
+
7747
10205
  /**
7748
10206
  * Register the settings section.
7749
10207
  *
@@ -7780,20 +10238,49 @@ window.__ModuleLoader__.load({
7780
10238
 
7781
10239
  function installSettingsSection(ctx, ui) {
7782
10240
  loadModelCopy()
10241
+ // The settings services are up by now even when they were not at apply
10242
+ // time, so retry the official-form binding before choosing a seat. It is
10243
+ // idempotent: a host without `configForms` keeps the plugin's own route.
10244
+ adoptSettingsForm(ctx)
7783
10245
  if (ui) {
7784
10246
  ui.settings = {
7785
10247
  sync: syncSettingsNav,
7786
10248
  }
10249
+ quickProviderApi = ui.quickProviders || null
7787
10250
  }
7788
10251
  if (typeof ctx.inject !== 'function') return function () {}
7789
10252
  var fiber = ctx.inject(['slots'], function (scope) {
7790
10253
  var slots = scope.get('slots')
7791
10254
  if (slots === void 0 || slots === null || typeof slots.inject !== 'function') return
10255
+ // 0.1.7 keeps a bundle's own configuration on the plugin's page: the
10256
+ // entry is keyed by the bundle's package name and rendered there —
10257
+ // `view: 'page'` for the form, `view: 'summary'` for the card's
10258
+ // one-liner. The slot is declared by that host's plugin manager, so this
10259
+ // registration is a no-op on an older host.
10260
+ scope.effect(function () {
10261
+ return slots.inject(BUNDLE_CONFIG_SLOT, function () {
10262
+ return slots.register(
10263
+ {
10264
+ name: BUNDLE_CONFIG_SLOT,
10265
+ key: PACKAGE_NAME,
10266
+ label: function () { return settingsCopy('title', 'Claude Style') },
10267
+ },
10268
+ ClaudeStyleBundleConfig,
10269
+ )
10270
+ })
10271
+ }, 'dsh-claude-style: plugin page')
10272
+ // The full-page section is the older host's seat. 0.1.7 still declares
10273
+ // the slot, but there the skin's settings live on its plugin page — and
10274
+ // a host that serves `configForms` is by definition the newer one, so
10275
+ // that check tells the two apart without a version probe. (The settings
10276
+ // shell cannot have declared this slot before `configForms` mounted: it
10277
+ // injects the service itself.)
7792
10278
  scope.effect(function () {
7793
- return slots.inject('settings.section', function () {
10279
+ return slots.inject(SETTINGS_SECTION_SLOT, function () {
10280
+ if (hostConfigForms(ctx) !== null) return function () {}
7794
10281
  return slots.register(
7795
10282
  {
7796
- name: 'settings.section',
10283
+ name: SETTINGS_SECTION_SLOT,
7797
10284
  id: 'claude-style',
7798
10285
  order: 22,
7799
10286
  // A function, so the navigation entry localizes once the copy
@@ -7809,6 +10296,7 @@ window.__ModuleLoader__.load({
7809
10296
  if (ui && ui.settings) {
7810
10297
  delete ui.settings
7811
10298
  }
10299
+ quickProviderApi = null
7812
10300
  try {
7813
10301
  if (fiber && typeof fiber.dispose === 'function') fiber.dispose()
7814
10302
  } catch (error) {
@@ -7821,6 +10309,10 @@ window.__ModuleLoader__.load({
7821
10309
  var body = document.body
7822
10310
  body.setAttribute('data-dsh-claude-style', '')
7823
10311
  setHostContext(ctx)
10312
+ // Bind the official settings form before anything reads a preference:
10313
+ // 0.1.7+ serves namespaces through `ctx.configForms`, and a host without
10314
+ // it keeps the plugin's own route. The choice is made once, here.
10315
+ adoptSettingsForm(ctx)
7824
10316
  loadModelCopy()
7825
10317
  loadUsername()
7826
10318
  // Preferences are read asynchronously from the host settings namespace;
@@ -7844,9 +10336,12 @@ window.__ModuleLoader__.load({
7844
10336
  teardowns.push(installCopy(ctx, ui))
7845
10337
  teardowns.push(installPermissions(ctx, ui))
7846
10338
  teardowns.push(installModelPicker(ctx, ui))
10339
+ teardowns.push(installHeroMenu(ctx, ui)) // hero 行的目录/预设弹层:打标记给样式表用
10340
+ teardowns.push(installQuickProviders(ctx, ui)) // 设置页的「快捷供应商」多选弹层
7847
10341
  teardowns.push(installAccountFooter(ctx, ui))
7848
10342
  teardowns.push(installBanScreen(ctx, ui)) // 账户横条的封号彩蛋(账户弹层把点击交给 ui.ban)
7849
10343
  teardowns.push(installThemeFlip()) // 主题翻转瞬间抑制过渡,修掉「先色后样」
10344
+ teardowns.push(installWorkspaceView(ctx, ui)) // 侧栏工作区:进行中 / 已归档 分段 + 归档行删除
7850
10345
  var stopSettings = installSettingsSection(ctx, ui)
7851
10346
  teardowns.push(installScheduler(ctx, ui)) // 最后装,回调中惰性读 ui 句柄
7852
10347