dsh-multi-folder 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -36,7 +36,7 @@ Then **restart the DSH backend** (host composition loads at process start) and *
36
36
 
37
37
  ## Usage
38
38
 
39
- A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page** (fixed launcher in the bottom-right corner while the new-session screen is shown; an inline chip beside the workspace picker once the upstream `conversation.hero.workspaceExtras` slot is available). The panel lets you:
39
+ A Multi-folder button appears in the session header, and a second entry appears on the **session-creation page**: a chip row directly above the composer card (the same band the git-branch chip uses), aligned with the workspace/preset chips of the new-session screen. Clicking it opens the panel as a popover anchored to the chip. Exactly one session-creation entry is ever shown — the plugin registers three candidate seats and elects the best available one (upstream `conversation.hero.workspaceExtras` chip > `conversation.input.dock` row > fixed bottom-right launcher for shells declaring neither). The panel lets you:
40
40
 
41
41
  | Action | Behavior |
42
42
  | ------ | -------- |
@@ -82,7 +82,7 @@ When a shell run ends in such a denial and references a configured secondary dir
82
82
  | ---- | ------- |
83
83
  | `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
84
84
  | `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
85
- | `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (hero launcher / upstream hero chip) |
85
+ | `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (input-dock chip / upstream hero chip / fixed fallback launcher) |
86
86
  | `test/` | Runtime-free behavior tests (see Development) |
87
87
  | `docs/` | Design and analysis documents |
88
88
 
package/README.zh.md CHANGED
@@ -36,7 +36,7 @@ dsh plugin --profile web add dsh-multi-folder
36
36
 
37
37
  ## 使用
38
38
 
39
- 会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder");**会话创建页**也有入口(新会话界面右下角的浮动按钮;当上游 DSH 声明 `conversation.hero.workspaceExtras` 插槽后,还会在工作区选择器旁显示内联 chip)。打开面板即可:
39
+ 会话头部出现「多工作目录」按钮(英文界面显示 "Multi-folder");**会话创建页**的入口位于**输入框上方**——与 git 分支胶囊同一条 dock 带,左边缘对齐新会话界面的工作区/预设胶囊,点击后面板以锚定在该胶囊上的浮层展开。会话创建页始终只显示**一个**入口:插件注册三个候选座位并选用当前可用的最佳者(上游 `conversation.hero.workspaceExtras` chip > `conversation.input.dock` 行 > 两者皆未声明时的右下角浮动按钮)。打开面板即可:
40
40
 
41
41
  | 操作 | 行为 |
42
42
  | ---- | ---- |
@@ -82,7 +82,7 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
82
82
  | ---- | ---- |
83
83
  | `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
84
84
  | `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
85
- | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(hero 浮动按钮 / 上游 hero chip |
85
+ | `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮) |
86
86
  | `test/` | 免 DSH 运行时的行为测试(见开发) |
87
87
  | `docs/` | 设计与分析文档 |
88
88
 
package/docs/design.md CHANGED
@@ -15,7 +15,7 @@ agent informed. No new tools are added.
15
15
  | Half | File | Role |
16
16
  | ---- | ---- | ---- |
17
17
  | Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
18
- | Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (hero launcher + upstream hero chip), both driving the host through the Remote BFF / shared RPC channel |
18
+ | Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher one at a time), all driving the host through the Remote BFF / shared RPC channel |
19
19
 
20
20
  The package declares both faces: `dsh.bundle.patch` (the host row inserted by
21
21
  `cordis.patch.yml`) and `dsh.client` (the web bundle at `exports["./client"]`).
@@ -157,9 +157,12 @@ window.__ModuleLoader__.load({
157
157
  `@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-locale`,
158
158
  `@deepseek-ai/dsh-client-runtime`).
159
159
  - UI registrations: `conversation.session.header.actions` (session-scoped button),
160
- `shell.overlay` panel, `shell.overlay` hero launcher (root-scoped, fixed
161
- position), and `conversation.hero.workspaceExtras` (upstream slot; see
162
- below). One module-level store is shared by all of them.
160
+ `shell.overlay` panel, `conversation.input.dock` chip row (session-scoped
161
+ list entry above the composer card — the session-creation page's shipped
162
+ seat), `shell.overlay` hero launcher (root-scoped fixed-position fallback),
163
+ and `conversation.hero.workspaceExtras` (upstream slot; see below). One
164
+ module-level store is shared by all of them, and only ONE session-creation
165
+ entry ever renders (see "hero seat election").
163
166
  - **Localization (zh / en).** All client copy goes through
164
167
  `@deepseek-ai/dsh-client-locale` (always composed by the standard web
165
168
  profile). The bundle registers a `multi-folder` dictionary namespace with
@@ -189,20 +192,48 @@ window.__ModuleLoader__.load({
189
192
  - Caching: per-session cache (`sessionCache`) keeps pure reads off the
190
193
  conversation; per-workspace cache (`workspaceCache`) plays the same role for
191
194
  the sessionless channel.
192
- - Hero (session-creation page) support:
193
- - The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`)
194
- subscribes to `sessions.list` + `workspaces.list` and observes the
195
- conversation root's `data-phase="hero"` attribute (MutationObserver on
196
- `document.body`). While the hero is visible it renders a fixed-position
197
- 「多工作目录」 button; the workspace path is derived from the current
198
- (blank) session's `WorkspaceView.path`, falling back to `SessionSummary.cwd`.
199
- - Clicking it opens the panel in workspace mode; without a selected
200
- workspace the panel shows the "pick a workspace first" hint.
195
+ - Hero (session-creation page) support — **three candidate seats, one visible
196
+ entry**:
197
+ - The **dock chip** (`conversation.input.dock`, id `multi-folder`,
198
+ order 120) is the shipped seat: a `list` slot the rc.6 shell declares and
199
+ renders directly ABOVE the composer card, in the same band as the
200
+ git-branch chip. The entry receives the dock owner share (`{ session,
201
+ input }`) plus the standard `useSessions` / `useWorkspaces` selector hooks,
202
+ so the hero phase (`composerPhase === 'blank' && (openState === 'open' ||
203
+ blank)`) and the target workspace come from framework props instead of DOM
204
+ probing. The row stays **in flow** — `display:flex` with the official hero
205
+ row's 20px indent, no absolute positioning — so the framework's list-slot
206
+ arrangement keeps it clear of every other plugin's dock row. It renders
207
+ only on the session-creation page; an active session keeps its entry in the
208
+ session header, so the two never appear together.
201
209
  - The **hero chip** registers into `conversation.hero.workspaceExtras` via
202
210
  `slots.inject`, which waits for the declaration: with an upstream DSH
203
211
  build that declares the slot, the chip renders inline beside the workspace
204
- picker; without one, the registration is a harmless no-op and the fixed
205
- launcher covers the page.
212
+ picker; without one, the registration contributes nothing.
213
+ - The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`) is the
214
+ last-resort fallback for shells that declare neither slot. Only then does
215
+ it subscribe to `sessions.list` + `workspaces.list` and observe the
216
+ conversation root's `data-phase="hero"` attribute (MutationObserver on
217
+ `document.body`) to render a fixed-position button; the
218
+ workspace path is derived from the current (blank) session's
219
+ `WorkspaceView.path`, falling back to `SessionSummary.cwd`.
220
+ - **Hero seat election.** Each seat claims a token while its slot declaration
221
+ is live (`slots.inject` fires only for declared slots and disposes on
222
+ collapse); the components render only while holding the best live claim
223
+ (`extras` > `dock` > fallback). The framework arranges *different plugins*
224
+ on a shared `list` slot but has no opinion about one plugin holding several
225
+ alternative seats, so this election is the plugin's own duty.
226
+ - Clicking any of them opens the panel in workspace mode; without a selected
227
+ workspace the panel shows the "pick a workspace first" hint.
228
+ - Panel placement: one `panelBody(store, t)` function returns the panel's
229
+ children, spread by whichever wrapper owns the panel — the fixed
230
+ `shell.overlay` panel (session-header path) or an `AnchoredPanel` popover
231
+ rendered by the chip itself (opening upward from the dock row, downward from
232
+ the hero row). `store.anchor` names the owner, and the overlay wrapper stands
233
+ down whenever a chip owns it, so the panel never renders twice.
234
+ - Styling: all surfaces use the official `--dsw-alias-*` design tokens
235
+ (`dsh-client-ui-theme`) with inert fallbacks, so themes and applied skins
236
+ restyle this plugin's chip and panel along with the shell's own controls.
206
237
 
207
238
  ## Known limitations
208
239
 
@@ -244,12 +275,26 @@ window.__ModuleLoader__.load({
244
275
  visible in the conversation UI by framework design; they are log-only and never
245
276
  reach the model. Workspace-mode (session-creation page) operations avoid them
246
277
  entirely by using the sessionless remote channel.
247
- - The hero launcher relies on the conversation root's `data-phase="hero"`
248
- attribute and the `sessions.list`/`workspaces.list` snapshot shapes DOM and
249
- client-runtime internals rather than documented APIs. They are guarded
250
- defensively (missing services or DOM degrade to "launcher hidden"), and the
251
- upstream `conversation.hero.workspaceExtras` slot (see
252
- [upstream-hero-slot.md](upstream-hero-slot.md)) is the long-term surface.
278
+ - The session-creation entry depends on shell internals to different degrees per
279
+ seat. The **dock chip** reads only declared contract surfaces (the
280
+ `conversation.input.dock` declaration, its owner share, and the standard
281
+ selector hooks), but its 20px indent is tuned to the shipped hero row's
282
+ padding — a restyled shell would misalign it, never break it. The **fallback
283
+ launcher** relies on the conversation root's `data-phase="hero"` attribute and
284
+ the `sessions.list`/`workspaces.list` snapshot shapes — DOM and client-runtime
285
+ internals rather than documented APIs; they are guarded defensively (missing
286
+ services or DOM degrade to "launcher hidden") and it only mounts when no
287
+ declared seat exists. The upstream `conversation.hero.workspaceExtras` slot
288
+ (see [upstream-hero-slot.md](upstream-hero-slot.md)) remains the long-term
289
+ surface.
290
+ - Sharing the `conversation.input.dock` band is safe by construction (unique
291
+ `id`, explicit `order`, in-flow layout) but `order` is a shared number space,
292
+ not an enforced allocation: another plugin may pick the same `order` and the
293
+ tie is then broken by registration sequence. The rows still stack without
294
+ overlapping — only their vertical sequence is unspecified. Absolute-positioned
295
+ neighbours (the git-branch chip lifts itself into the hero row) are outside
296
+ the framework's arrangement entirely; this plugin deliberately does not do the
297
+ same.
253
298
  - The `multiFolder/*` endpoints use hand-written `src-json` Typert descriptors
254
299
  registered through `ctx.typert.register`. `src-json` gives JSON-safety
255
300
  boundary checks, not schema validation; the shared core performs all
@@ -265,5 +310,11 @@ canonicalization, the config guard, both notification channels, notice
265
310
  gating, command flows, the panel's session-switch/caching behavior, the
266
311
  sessionless remote contribution shape and behavior (list/add/set/remove,
267
312
  idempotence, sanitization, error prefixing, cross-channel cache coherence),
268
- and the hero/workspace-mode client flows (launcher visibility, RPC routing,
269
- workspace-mode mutations and error surfacing).
313
+ and the hero/workspace-mode client flows. The client test's `slots.inject` mock
314
+ is declaration-aware like the real service (a wait fires only while its slot is
315
+ declared, and a collapse disposes the registration), so it covers all three
316
+ session-creation seats: the dock chip on an rc.6-style shell (registration
317
+ shape, in-flow row, hero-only visibility, RPC routing, anchored popover), the
318
+ upstream hero chip taking over the moment its slot is declared, and the fixed
319
+ launcher returning once both declarations collapse — asserting at each step that
320
+ the other two surfaces stand down.
@@ -8,10 +8,14 @@ place configuration UI next to the workspace picker.
8
8
 
9
9
  This document specifies the small upstream change to
10
10
  `@deepseek-ai/dsh-client-ui-conversation` that adds one, and explains how the
11
- plugin consumes it. Until this lands in a DSH release, the plugin's fixed
12
- `shell.overlay` hero launcher covers the same page (B2); the slot registration
13
- below is a waiting no-op (`slots.inject` fires only once the declaration
14
- exists), so the plugin works with and without the upstream change.
11
+ plugin consumes it. Until this lands in a DSH release, the plugin's
12
+ `conversation.input.dock` chip row covers the same page (an in-flow chip
13
+ directly above the composer card, in the band the git-branch chip uses); the
14
+ slot registration below is a waiting no-op (`slots.inject` fires only once the
15
+ declaration exists), so the plugin works with and without the upstream change.
16
+ When the declaration does arrive, the hero chip claims the better seat and the
17
+ dock row stands down automatically — see the "hero seat election" section of
18
+ [design.md](design.md).
15
19
 
16
20
  ## 1. Declare the slot
17
21
 
@@ -85,19 +89,24 @@ active locale (English: "Multi-folder", Chinese: 「多工作目录」):
85
89
 
86
90
  ```js
87
91
  slots.inject('conversation.hero.workspaceExtras', function () {
88
- return slots.register(
92
+ var release = claimHeroSeat('extras');
93
+ var dispose = slots.register(
89
94
  { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: () => t('label'), locale: NS },
90
95
  function (props) { return React.createElement(HeroChip, props); },
91
96
  );
97
+ return function () { dispose(); release(); };
92
98
  });
93
99
  ```
94
100
 
95
101
  (`NS` is the plugin's `multi-folder` namespace and `t` its bound translator —
96
- see the localization section of [design.md](design.md).)
102
+ see the localization section of [design.md](design.md). `claimHeroSeat('extras')`
103
+ is the seat election: while this declaration lives, the `conversation.input.dock`
104
+ row and the fixed fallback launcher render nothing.)
97
105
 
98
106
  `HeroChip` prefers the owner-supplied `props.workspacePath` and falls back to
99
- the store-derived hero workspace. It opens the same overlay panel in workspace
100
- mode, which reads and writes the configuration through the sessionless
107
+ the store-derived hero workspace. It opens the shared panel body as its own
108
+ popover anchored under the chip (matching the official workspace picker on the
109
+ same row), reading and writing the configuration through the sessionless
101
110
  `multiFolder/*` endpoints (see [design.md](design.md)).
102
111
 
103
112
  ## Compiled-bundle equivalent (for local patching)
@@ -118,10 +127,14 @@ bundle; the same change there is three touchpoints:
118
127
 
119
128
  ## Verification
120
129
 
121
- - Unit: the plugin's `test/smoke-client.mjs` registers and drives `HeroChip`
122
- with a mock owner share (`workspacePath`), asserting the panel opens in
123
- workspace mode and reuses the per-workspace cache.
130
+ - Unit: the plugin's `test/smoke-client.mjs` declares the slot mid-run through
131
+ a declaration-aware `slots.inject` mock, then drives `HeroChip` with a mock
132
+ owner share (`workspacePath`), asserting the chip registers on declaration,
133
+ opens its popover in workspace mode from the per-workspace cache, and that
134
+ the `conversation.input.dock` row plus the fixed launcher both stand down
135
+ while the slot lives (and that the dock row returns once it collapses).
124
136
  - Manual: with the patched build, the Multi-folder chip (「多工作目录」 in the
125
137
  Chinese UI) renders in the hero workspace row between the preset chip and
126
138
  the composer; clicking it lists the workspace's secondary directories
127
- before any message is sent.
139
+ before any message is sent, and the chip row above the composer card
140
+ disappears (one entry, not two).
package/lib/client.js CHANGED
@@ -11,20 +11,42 @@
11
11
  * human-readable result carrying a `[MF:JSON]` line the panel parses for
12
12
  * structured state.
13
13
  *
14
- * Session-creation page UI (no session exists yet):
15
- * - a hero-phase launcher in `shell.overlay` (fixed-position entry) whose
16
- * visibility follows the conversation root's `data-phase="hero"` attribute;
17
- * - an inline chip registered into the upstream `conversation.hero.workspaceExtras`
18
- * slot when that slot exists (the `slots.inject` wait is a no-op until the
19
- * DSH core declares it).
20
- * Both open the same panel in WORKSPACE mode and drive the sessionless
14
+ * Session-creation page UI (no message sent yet). Three candidate seats are
15
+ * registered, best first, and EXACTLY ONE renders see "hero seat election":
16
+ * - `conversation.hero.workspaceExtras` (upstream additive hero row; the
17
+ * `slots.inject` wait is a no-op until a DSH core declares it);
18
+ * - `conversation.input.dock` (shipped since 0.1.9, declared by rc.6): an
19
+ * in-flow chip row directly ABOVE the composer card, left-aligned with the
20
+ * official hero chip row — the same band the git-branch chip uses;
21
+ * - `shell.overlay` fixed-position launcher: last-resort fallback for shells
22
+ * that declare neither slot.
23
+ * All three open the same panel in WORKSPACE mode and drive the sessionless
21
24
  * `multiFolder/*` endpoints over the shared RPC channel
22
25
  * (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
23
26
  * path instead of sessionId.
24
27
  *
25
- * Layout shape: an entry in the session header action row plus a frame-wide
26
- * overlay panel. Both read one tiny module-scoped store; opening the panel
28
+ * Layout shape: an entry in the session header action row, one chip row above
29
+ * the composer card on the session-creation page, and a frame-wide overlay
30
+ * panel. Every surface reads one tiny module-scoped store; opening the panel
27
31
  * refreshes the list from the Host.
32
+ *
33
+ * ## Hero seat election (multi-plugin coexistence)
34
+ *
35
+ * `conversation.input.dock` is a `list` slot, so the framework already
36
+ * arranges co-registered entries for us: entries are sorted by
37
+ * `(priority, order)`, a duplicate `(id, priority)` pair is rejected at
38
+ * registration with an error naming the sitting occupant, and the outlet
39
+ * renders `display:contents` so each entry becomes its own row of the
40
+ * composer stack. This plugin therefore stays a good citizen by construction:
41
+ * one unique `id`, one explicit `order`, and NO absolute positioning that
42
+ * would escape the framework's arrangement and overlap a neighbour.
43
+ *
44
+ * What the framework does NOT do is stop ONE plugin from occupying several
45
+ * alternative seats at once (each is a legitimate, differently-declared
46
+ * slot). That is this plugin's own duty: each seat claims a token when its
47
+ * declaration arrives (`slots.inject` fires only for declared slots), and the
48
+ * components render only while they hold the best live claim, so the
49
+ * session-creation page never shows two Multi-folder entries.
28
50
  */
29
51
  window.__ModuleLoader__.load({
30
52
  id: 'dsh-multi-folder',
@@ -35,7 +57,24 @@ window.__ModuleLoader__.load({
35
57
 
36
58
  // ------------------------------------------------------------- store
37
59
  var listeners = new Set();
38
- var state = { open: false, mode: null, sessionId: null, dirs: [], workspace: null, busy: false, error: null, hero: false, heroWorkspace: null };
60
+ var state = {
61
+ open: false,
62
+ mode: null,
63
+ sessionId: null,
64
+ dirs: [],
65
+ workspace: null,
66
+ busy: false,
67
+ error: null,
68
+ /** Fallback launcher only: hero visibility derived from the DOM. */
69
+ hero: false,
70
+ heroWorkspace: null,
71
+ /** Live hero-seat claims, in claim order (see "hero seat election"). */
72
+ heroClaims: [],
73
+ /** Which surface owns the open panel: 'overlay' | 'dock' | 'extras'. */
74
+ anchor: 'overlay',
75
+ /** Bumped on every workspaceCache write so chips re-read their count. */
76
+ cacheRev: 0,
77
+ };
39
78
  function patch(next) {
40
79
  state = Object.assign({}, state, next);
41
80
  listeners.forEach(function (fn) { fn(); });
@@ -64,6 +103,58 @@ window.__ModuleLoader__.load({
64
103
  try { return JSON.parse(m[1]); } catch (_) { return null; }
65
104
  }
66
105
 
106
+ /** Stand-in for an absent standard-kit selector hook: runs the selector
107
+ * against no snapshot, so callers keep one unconditional call site. */
108
+ function selectNothing(selector) { return selector(undefined); }
109
+
110
+ // -------------------------------------------------- hero seat election
111
+ /** Candidate seats for the session-creation page, best first. */
112
+ var HERO_SEATS = ['extras', 'dock'];
113
+ /** Order of this plugin's `conversation.input.dock` row. The shipped band
114
+ * already carries other plugins' entries (the aionui drop inlay at 90/91
115
+ * and the git-branch chip at 100); 120 keeps this row closest to the
116
+ * composer card without contesting theirs. */
117
+ var DOCK_ORDER = 120;
118
+
119
+ /** The best seat currently claimed, or null when only the fallback is left. */
120
+ function bestHeroSeat(claims) {
121
+ for (var i = 0; i < HERO_SEATS.length; i++) {
122
+ if (claims.indexOf(HERO_SEATS[i]) >= 0) return HERO_SEATS[i];
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /** Claim a seat for as long as its slot declaration lives. */
128
+ function claimHeroSeat(kind) {
129
+ patch({ heroClaims: getSnapshot().heroClaims.concat([kind]) });
130
+ return function () {
131
+ var claims = getSnapshot().heroClaims.slice();
132
+ var at = claims.indexOf(kind);
133
+ if (at >= 0) claims.splice(at, 1);
134
+ patch({ heroClaims: claims });
135
+ };
136
+ }
137
+
138
+ // ------------------------------------------------------------- theme
139
+ /** Official `--dsw-alias-*` design tokens (dsh-client-ui-theme) with inert
140
+ * fallbacks, so the surfaces follow the active theme and any applied skin
141
+ * instead of guessing a palette. */
142
+ var TOKEN = {
143
+ border: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
144
+ borderSoft: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.20))',
145
+ surface: 'var(--dsw-alias-bg-overlay, #ffffff)',
146
+ subtle: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
147
+ fill: 'var(--dsw-alias-button-tool-bar-fill, rgba(127,127,127,0.10))',
148
+ hover: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.14))',
149
+ ink: 'var(--dsw-alias-label-primary, #1a1a1a)',
150
+ inkSoft: 'var(--dsw-alias-label-secondary, #4a4a4a)',
151
+ inkMuted: 'var(--dsw-alias-label-tertiary, #6b6b6b)',
152
+ inkFaint: 'var(--dsw-alias-label-caption, #8a8a8a)',
153
+ danger: 'var(--dsw-alias-state-error-primary, #c62828)',
154
+ accent: 'var(--dsw-alias-brand-primary, #4c6ef5)',
155
+ shadow: '0 8px 24px var(--dsw-alias-bg-mask-2, rgba(0,0,0,0.18))',
156
+ };
157
+
67
158
  // ------------------------------------------------------------- i18n
68
159
  /** Locale namespace owned by this plugin. Dictionaries are registered
69
160
  * with the `locale` service (`@deepseek-ai/dsh-client-locale`, always
@@ -74,7 +165,9 @@ window.__ModuleLoader__.load({
74
165
  var zhDict = {
75
166
  'label': '多工作目录',
76
167
  'label.open': '多工作目录 ▾',
168
+ 'label.withCount': '多工作目录 · {count}',
77
169
  'label.heroLauncher': '多工作目录(新会话)',
170
+ 'label.heroDock': '多工作目录(输入框上方)',
78
171
  'title.header': '多工作目录(副工作目录)',
79
172
  'title.remove': '移除此副工作目录',
80
173
  'title.close': '关闭',
@@ -96,7 +189,9 @@ window.__ModuleLoader__.load({
96
189
  var enDict = {
97
190
  'label': 'Multi-folder',
98
191
  'label.open': 'Multi-folder ▾',
192
+ 'label.withCount': 'Multi-folder · {count}',
99
193
  'label.heroLauncher': 'Multi-folder (new session)',
194
+ 'label.heroDock': 'Multi-folder (above the composer)',
100
195
  'title.header': 'Multi-folder (secondary working directories)',
101
196
  'title.remove': 'Remove this secondary working directory',
102
197
  'title.close': 'Close',
@@ -161,17 +256,42 @@ window.__ModuleLoader__.load({
161
256
  });
162
257
  }
163
258
 
259
+ /** Write one workspace's list into the cache and return it normalized. */
260
+ function storeWorkspace(workspacePath, value) {
261
+ var normalized = value && Array.isArray(value.dirs)
262
+ ? value
263
+ : { workspace: workspacePath, dirs: [] };
264
+ workspaceCache[workspacePathKey(workspacePath)] = normalized;
265
+ return normalized;
266
+ }
267
+
268
+ /** Whether the open panel is the one showing `workspacePath`. */
269
+ function panelTargets(workspacePath) {
270
+ var current = getSnapshot();
271
+ return !current.sessionId
272
+ && current.mode === 'workspace'
273
+ && !!current.workspace
274
+ && workspacePathKey(current.workspace) === workspacePathKey(workspacePath);
275
+ }
276
+
277
+ /** Patch after a workspace-mode read/write: always publish the new cache
278
+ * revision (chips show the configured count), and refresh the panel
279
+ * body only when it is the panel for this workspace. */
280
+ function publishWorkspace(workspacePath, value) {
281
+ var normalized = storeWorkspace(workspacePath, value);
282
+ var next = { busy: false, cacheRev: getSnapshot().cacheRev + 1 };
283
+ if (panelTargets(workspacePath)) {
284
+ next.workspace = normalized.workspace;
285
+ next.dirs = normalized.dirs || [];
286
+ }
287
+ patch(next);
288
+ }
289
+
164
290
  function refreshWorkspace(workspacePath) {
165
291
  if (!workspacePath) return;
166
292
  patch({ busy: true, error: null });
167
293
  remoteCall('multiFolder/list', { workspace: workspacePath }).then(function (value) {
168
- workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
169
- var current = getSnapshot();
170
- if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
171
- patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
172
- } else {
173
- patch({ busy: false });
174
- }
294
+ publishWorkspace(workspacePath, value);
175
295
  }).catch(function (e) {
176
296
  patch({ busy: false, error: String(e && e.message ? e.message : e) });
177
297
  });
@@ -182,23 +302,27 @@ window.__ModuleLoader__.load({
182
302
  patch({ busy: true, error: null });
183
303
  var payload = Object.assign({ workspace: workspacePath }, args);
184
304
  return remoteCall(endpoint, payload).then(function (value) {
185
- workspaceCache[workspacePathKey(workspacePath)] = value || { workspace: workspacePath, dirs: [] };
186
- var current = getSnapshot();
187
- if (!current.sessionId && current.mode === 'workspace' && current.workspace && workspacePathKey(current.workspace) === workspacePathKey(workspacePath)) {
188
- patch({ busy: false, workspace: value.workspace, dirs: value.dirs || [] });
189
- } else {
190
- patch({ busy: false });
191
- }
305
+ publishWorkspace(workspacePath, value);
192
306
  }).catch(function (e) {
193
307
  patch({ busy: false, error: String(e && e.message ? e.message : e) });
194
308
  });
195
309
  }
196
310
 
311
+ /** Configured directory count for a workspace, or null while unread. */
312
+ function dirCountOf(workspacePath) {
313
+ if (!workspacePath) return null;
314
+ var cached = workspaceCache[workspacePathKey(workspacePath)];
315
+ return cached && Array.isArray(cached.dirs) ? cached.dirs.length : null;
316
+ }
317
+
197
318
  /** Open the panel in WORKSPACE mode (session-creation page): a null
198
- * workspace shows the "pick a workspace first" hint instead. */
199
- function openForWorkspace(workspacePath) {
319
+ * workspace shows the "pick a workspace first" hint instead.
320
+ * `anchor` names the surface that owns the panel — an inline chip
321
+ * renders it as its own popover, everything else uses the overlay. */
322
+ function openForWorkspace(workspacePath, anchor) {
323
+ var seat = anchor || 'overlay';
200
324
  if (!workspacePath) {
201
- patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null });
325
+ patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
202
326
  return;
203
327
  }
204
328
  var cached = workspaceCache[workspacePathKey(workspacePath)];
@@ -209,6 +333,7 @@ window.__ModuleLoader__.load({
209
333
  workspace: workspacePath,
210
334
  dirs: cached ? cached.dirs : [],
211
335
  error: null,
336
+ anchor: seat,
212
337
  });
213
338
  if (!cached) refreshWorkspace(workspacePath);
214
339
  }
@@ -293,6 +418,7 @@ window.__ModuleLoader__.load({
293
418
  dirs: cached ? cached.dirs : [],
294
419
  workspace: cached ? cached.workspace : null,
295
420
  error: null,
421
+ anchor: 'overlay',
296
422
  });
297
423
  if (cached === undefined) refresh(sessionId);
298
424
  }
@@ -336,9 +462,9 @@ window.__ModuleLoader__.load({
336
462
  gap: 6,
337
463
  padding: '4px 10px',
338
464
  borderRadius: 8,
339
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
340
- background: open ? 'var(--color-bg-accent, rgba(80,120,255,0.18))' : 'transparent',
341
- color: 'var(--color-text, inherit)',
465
+ border: '1px solid ' + TOKEN.border,
466
+ background: open ? TOKEN.hover : 'transparent',
467
+ color: TOKEN.ink,
342
468
  fontSize: 13,
343
469
  cursor: 'pointer',
344
470
  },
@@ -348,11 +474,11 @@ window.__ModuleLoader__.load({
348
474
  return btn;
349
475
  }
350
476
 
351
- // Overlay panel ------------------------------------------------------
352
- function Panel(props) {
353
- var store = useStore();
354
- var t = props.t;
355
- if (!store.open || !store.mode) return null;
477
+ // Panel body ---------------------------------------------------------
478
+ /** The panel's children, shared verbatim by the overlay panel and the
479
+ * chip popover. Returned as an ARRAY so each wrapper can spread it as
480
+ * its own direct children (one DOM shape, two placements). */
481
+ function panelBody(store, t) {
356
482
  var sessionMode = store.mode === 'session';
357
483
  var usable = sessionMode || !!store.workspace;
358
484
  var rows = (store.dirs || []).map(function (dir, index) {
@@ -366,7 +492,7 @@ window.__ModuleLoader__.load({
366
492
  gap: 8,
367
493
  padding: '6px 8px',
368
494
  borderRadius: 6,
369
- background: 'var(--color-bg-subtle, rgba(127,127,127,0.08))',
495
+ background: TOKEN.subtle,
370
496
  marginBottom: 6,
371
497
  },
372
498
  },
@@ -391,42 +517,24 @@ window.__ModuleLoader__.load({
391
517
  mutateWorkspace(store.workspace, 'multiFolder/remove', { path: dir });
392
518
  }
393
519
  },
394
- style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
520
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.danger, cursor: 'pointer' },
395
521
  },
396
522
  t('panel.remove'),
397
523
  ),
398
524
  );
399
525
  });
400
- return React.createElement(
401
- 'div',
402
- {
403
- style: {
404
- position: 'fixed',
405
- top: 64,
406
- right: 20,
407
- width: 380,
408
- maxWidth: 'calc(100vw - 40px)',
409
- zIndex: 400,
410
- background: 'var(--color-bg-elevated, #ffffff)',
411
- color: 'var(--color-text, #1a1a1a)',
412
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
413
- borderRadius: 12,
414
- boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
415
- padding: 14,
416
- fontSize: 13,
417
- },
418
- },
526
+ return [
419
527
  React.createElement(
420
528
  'div',
421
529
  { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
422
- React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t('panel.title')),
530
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14, color: TOKEN.ink } }, t('panel.title')),
423
531
  React.createElement(
424
532
  'button',
425
533
  {
426
534
  type: 'button',
427
535
  title: t('title.close'),
428
536
  onClick: function () { patch({ open: false }); },
429
- style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
537
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
430
538
  },
431
539
  '✕',
432
540
  ),
@@ -434,21 +542,21 @@ window.__ModuleLoader__.load({
434
542
  store.workspace
435
543
  ? React.createElement(
436
544
  'div',
437
- { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
545
+ { style: { marginBottom: 8, color: TOKEN.inkMuted, fontSize: 12, wordBreak: 'break-all' } },
438
546
  t('panel.project', { path: store.workspace }),
439
547
  )
440
548
  : (!sessionMode
441
549
  ? React.createElement(
442
550
  'div',
443
- { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } },
551
+ { style: { marginBottom: 8, color: TOKEN.inkMuted } },
444
552
  t('panel.noWorkspaceHint'),
445
553
  )
446
554
  : null),
447
555
  usable
448
- ? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, t('panel.empty')))
449
- : React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } }, t('panel.pickWorkspaceHint')),
556
+ ? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.empty')))
557
+ : React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.pickWorkspaceHint')),
450
558
  store.error
451
- ? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
559
+ ? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap' } }, String(store.error))
452
560
  : null,
453
561
  React.createElement(
454
562
  'div',
@@ -463,9 +571,9 @@ window.__ModuleLoader__.load({
463
571
  flex: 1,
464
572
  padding: '6px 10px',
465
573
  borderRadius: 8,
466
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
467
- background: 'var(--color-bg-accent, rgba(80,120,255,0.14))',
468
- color: 'var(--color-text, inherit)',
574
+ border: '1px solid ' + TOKEN.border,
575
+ background: TOKEN.fill,
576
+ color: TOKEN.ink,
469
577
  cursor: store.busy || !usable ? 'default' : 'pointer',
470
578
  opacity: store.busy || !usable ? 0.6 : 1,
471
579
  },
@@ -487,9 +595,9 @@ window.__ModuleLoader__.load({
487
595
  style: {
488
596
  padding: '6px 10px',
489
597
  borderRadius: 8,
490
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
598
+ border: '1px solid ' + TOKEN.border,
491
599
  background: 'transparent',
492
- color: 'var(--color-text, inherit)',
600
+ color: TOKEN.ink,
493
601
  cursor: store.busy || !usable ? 'default' : 'pointer',
494
602
  opacity: store.busy || !usable ? 0.6 : 1,
495
603
  },
@@ -504,33 +612,262 @@ window.__ModuleLoader__.load({
504
612
  marginTop: 10,
505
613
  fontSize: 11,
506
614
  lineHeight: 1.5,
507
- color: 'var(--color-text-muted, #6b6b6b)',
615
+ color: TOKEN.inkMuted,
508
616
  },
509
617
  },
510
618
  t('panel.footnote'),
511
619
  ),
620
+ ];
621
+ }
622
+
623
+ /** Spread `children` as the direct children of one element (keeps the
624
+ * panel's DOM shape identical across both placements). */
625
+ function element(type, props, children) {
626
+ return React.createElement.apply(null, [type, props].concat(children));
627
+ }
628
+
629
+ // Overlay panel ------------------------------------------------------
630
+ function Panel(props) {
631
+ var store = useStore();
632
+ var t = props.t;
633
+ if (!store.open || !store.mode) return null;
634
+ // An inline chip owns its own popover; the overlay stands down.
635
+ if (store.anchor !== 'overlay') return null;
636
+ return element(
637
+ 'div',
638
+ {
639
+ style: {
640
+ position: 'fixed',
641
+ top: 64,
642
+ right: 20,
643
+ width: 380,
644
+ maxWidth: 'calc(100vw - 40px)',
645
+ zIndex: 400,
646
+ background: TOKEN.surface,
647
+ color: TOKEN.ink,
648
+ border: '1px solid ' + TOKEN.border,
649
+ borderRadius: 12,
650
+ boxShadow: TOKEN.shadow,
651
+ padding: 14,
652
+ fontSize: 13,
653
+ },
654
+ },
655
+ panelBody(store, t),
656
+ );
657
+ }
658
+
659
+ // Anchored popover ---------------------------------------------------
660
+ /** The same panel body as a popover anchored to a chip. `direction`
661
+ * 'up' opens above the chip (the dock row sits above the composer
662
+ * card), 'down' opens below it (the hero chip row). */
663
+ function AnchoredPanel(props) {
664
+ var store = useStore();
665
+ var t = props.t;
666
+ var up = props.direction !== 'down';
667
+ var placement = up ? { bottom: 'calc(100% + 6px)' } : { top: 'calc(100% + 6px)' };
668
+ return element(
669
+ 'div',
670
+ {
671
+ style: Object.assign(
672
+ {
673
+ position: 'absolute',
674
+ left: 0,
675
+ zIndex: 40,
676
+ width: 380,
677
+ maxWidth: 'calc(100vw - 40px)',
678
+ // The composer stack lives in a scroll container with
679
+ // `overflow: hidden auto`; capping the height keeps a tall
680
+ // list inside the viewport instead of clipping it.
681
+ maxHeight: 'min(60vh, 420px)',
682
+ overflowY: 'auto',
683
+ background: TOKEN.surface,
684
+ color: TOKEN.ink,
685
+ border: '1px solid ' + TOKEN.border,
686
+ borderRadius: 12,
687
+ boxShadow: TOKEN.shadow,
688
+ padding: 14,
689
+ fontSize: 13,
690
+ textAlign: 'left',
691
+ },
692
+ placement,
693
+ ),
694
+ },
695
+ panelBody(store, t),
696
+ );
697
+ }
698
+
699
+ /** Click-outside catcher for an open popover. */
700
+ function Backdrop() {
701
+ return React.createElement('div', {
702
+ onClick: function () { patch({ open: false }); },
703
+ style: { position: 'fixed', inset: 0, zIndex: 30 },
704
+ });
705
+ }
706
+
707
+ /** One chip + its popover, wrapped in the positioning context the
708
+ * popover anchors to. Shared by the dock row and the hero chip. */
709
+ function chipWithPopover(seat, direction, t, button) {
710
+ var store = getSnapshot();
711
+ var open = store.open && store.anchor === seat;
712
+ return React.createElement(
713
+ 'div',
714
+ { style: { position: 'relative', display: 'inline-flex', minWidth: 0 } },
715
+ button,
716
+ open ? React.createElement(Backdrop, { key: 'backdrop' }) : null,
717
+ open ? React.createElement(AnchoredPanel, { key: 'panel', t: t, direction: direction }) : null,
718
+ );
719
+ }
720
+
721
+ /** The chip pill, styled from the official chip recipe. */
722
+ function chipButton(options) {
723
+ return React.createElement(
724
+ 'button',
725
+ {
726
+ type: 'button',
727
+ title: options.title,
728
+ onClick: options.onClick,
729
+ style: {
730
+ display: 'inline-flex',
731
+ alignItems: 'center',
732
+ gap: 6,
733
+ height: 24,
734
+ padding: '0 10px',
735
+ borderRadius: 999,
736
+ border: '1px solid ' + (options.open ? TOKEN.accent : TOKEN.border),
737
+ background: options.open ? TOKEN.hover : TOKEN.fill,
738
+ color: options.open ? TOKEN.ink : TOKEN.inkSoft,
739
+ fontSize: 12,
740
+ lineHeight: 1,
741
+ whiteSpace: 'nowrap',
742
+ cursor: 'pointer',
743
+ maxWidth: 260,
744
+ overflow: 'hidden',
745
+ opacity: options.dimmed ? 0.7 : 1,
746
+ },
747
+ },
748
+ React.createElement(
749
+ 'span',
750
+ { style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
751
+ options.label,
752
+ ),
753
+ React.createElement('span', { style: { color: TOKEN.inkFaint, fontSize: 10 } }, '▾'),
512
754
  );
513
755
  }
514
756
 
515
757
  // Hero (session-creation page) support --------------------------------
758
+ /** The shell's own hero predicate, read from the dock's owner share: a
759
+ * blank conversation with an open session. A still-loading blank
760
+ * session already lists as blank, so it may enter the hero phase
761
+ * before the composer snapshot settles to `open`. */
762
+ function isHeroPhase(session, blank) {
763
+ if (!session) return false;
764
+ return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
765
+ }
766
+
767
+ /** The workspace path a session belongs to, from the workspaces list. */
768
+ function workspacePathIn(snapshot, sessionId) {
769
+ var items = snapshot && snapshot.items ? snapshot.items : [];
770
+ for (var i = 0; i < items.length; i++) {
771
+ var workspace = items[i];
772
+ if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(sessionId) >= 0) {
773
+ return workspace.path;
774
+ }
775
+ }
776
+ return null;
777
+ }
778
+
516
779
  /** Workspace path of the current session (blank-session hero), or null
517
- * while no session/workspace is selected at all. */
780
+ * while no session/workspace is selected at all. Store-read twin of
781
+ * `workspacePathIn` for the fallback launcher, which has no props. */
518
782
  function heroWorkspacePath() {
519
783
  var sessionList = sessions && sessions.list ? sessions.list.getSnapshot() : null;
520
784
  var id = sessionList && sessionList.current;
521
785
  if (!id) return null;
522
786
  var workspaceList = workspaces && workspaces.list ? workspaces.list.getSnapshot() : null;
523
- var items = workspaceList && workspaceList.items ? workspaceList.items : [];
524
- for (var i = 0; i < items.length; i++) {
525
- var workspace = items[i];
526
- if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(id) >= 0) {
527
- return workspace.path;
528
- }
529
- }
787
+ var fromWorkspaces = workspacePathIn(workspaceList, id);
788
+ if (fromWorkspaces !== null) return fromWorkspaces;
530
789
  var row = sessionList && sessionList.byId ? sessionList.byId[id] : undefined;
531
790
  return row && row.cwd ? row.cwd : null;
532
791
  }
533
792
 
793
+ /**
794
+ * Dock chip (session-creation page, preferred shipped seat): one in-flow
795
+ * row in `conversation.input.dock`, directly above the composer card and
796
+ * left-aligned with the official hero chip row. The row stays IN FLOW —
797
+ * the framework arranges co-registered dock entries as sibling rows, so
798
+ * absolute positioning here would silently overlap a neighbour's chip.
799
+ *
800
+ * Renders only on the session-creation page: an active session keeps its
801
+ * entry in the session header, so the two never appear at once.
802
+ */
803
+ function DockChip(props) {
804
+ var store = useStore();
805
+ var t = props.t;
806
+ var sessionId = props.sessionId;
807
+ // The standard kit always supplies these selector hooks; the absent
808
+ // form keeps the hook call unconditional (stable hook order) for
809
+ // hosts and tests that render the entry without the kit.
810
+ var useSessions = typeof props.useSessions === 'function' ? props.useSessions : selectNothing;
811
+ var useWorkspaces = typeof props.useWorkspaces === 'function' ? props.useWorkspaces : selectNothing;
812
+ var blank = useSessions(function (s) {
813
+ return !!(s && s.byId && sessionId !== undefined && s.byId[sessionId] && s.byId[sessionId].blank === true);
814
+ });
815
+ var cwd = useSessions(function (s) {
816
+ var row = s && s.byId && sessionId !== undefined ? s.byId[sessionId] : undefined;
817
+ return row && row.cwd ? row.cwd : null;
818
+ });
819
+ var fromWorkspaces = useWorkspaces(function (s) { return workspacePathIn(s, sessionId); });
820
+ var workspacePath = fromWorkspaces || cwd;
821
+ var hero = isHeroPhase(props.session, blank);
822
+ var mine = bestHeroSeat(store.heroClaims) === 'dock';
823
+ // Fill the count cache once per workspace. Workspace mode rides the
824
+ // sessionless RPC, so this read produces no conversation row.
825
+ React.useEffect(
826
+ function () {
827
+ if (!hero || !mine || !workspacePath) return undefined;
828
+ if (workspaceCache[workspacePathKey(workspacePath)] === undefined) refreshWorkspace(workspacePath);
829
+ return undefined;
830
+ },
831
+ [hero, mine, workspacePath],
832
+ );
833
+ if (!hero || !mine) return null;
834
+ var open = store.open && store.anchor === 'dock';
835
+ var count = dirCountOf(workspacePath);
836
+ return React.createElement(
837
+ 'div',
838
+ {
839
+ style: {
840
+ // The dock outlet is display:contents, so this row is a direct
841
+ // child of the composer stack — the same 20px indent as the
842
+ // official hero chip row above it.
843
+ display: 'flex',
844
+ alignItems: 'center',
845
+ gap: 6,
846
+ minWidth: 0,
847
+ paddingLeft: 20,
848
+ },
849
+ },
850
+ chipWithPopover(
851
+ 'dock',
852
+ 'up',
853
+ t,
854
+ chipButton({
855
+ open: open,
856
+ dimmed: !workspacePath,
857
+ title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
858
+ label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
859
+ onClick: function () {
860
+ if (open) {
861
+ patch({ open: false });
862
+ } else {
863
+ openForWorkspace(workspacePath, 'dock');
864
+ }
865
+ },
866
+ }),
867
+ ),
868
+ );
869
+ }
870
+
534
871
  /** Re-read the conversation root's `data-phase` attribute (authoritative
535
872
  * hero signal) and the derivable workspace, then patch the store. */
536
873
  function syncHero() {
@@ -546,13 +883,17 @@ window.__ModuleLoader__.load({
546
883
  }
547
884
  }
548
885
 
549
- /** Fixed-position hero launcher (B2): visible only while the
550
- * conversation root reports the hero phase. */
886
+ /** Fixed-position hero launcher: the last-resort fallback, mounted only
887
+ * while NO declared slot seat is available. Its DOM/store probing (and
888
+ * the MutationObserver behind it) stays unwired whenever a real seat
889
+ * holds the page. */
551
890
  function HeroLauncher(props) {
552
891
  var store = useStore();
553
892
  var t = props.t;
893
+ var seat = bestHeroSeat(store.heroClaims);
554
894
  React.useEffect(
555
895
  function () {
896
+ if (bestHeroSeat(getSnapshot().heroClaims) !== null) return undefined;
556
897
  var disposers = [];
557
898
  if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
558
899
  disposers.push(sessions.list.subscribe(syncHero));
@@ -576,15 +917,16 @@ window.__ModuleLoader__.load({
576
917
  if (observer) observer.disconnect();
577
918
  };
578
919
  },
579
- [],
920
+ [seat],
580
921
  );
922
+ if (seat !== null) return null;
581
923
  if (!store.hero) return null;
582
924
  return React.createElement(
583
925
  'button',
584
926
  {
585
927
  type: 'button',
586
928
  title: store.heroWorkspace ? t('title.heroLauncher.hasWorkspace') : t('title.heroLauncher.noWorkspace'),
587
- onClick: function () { openForWorkspace(store.heroWorkspace); },
929
+ onClick: function () { openForWorkspace(store.heroWorkspace, 'overlay'); },
588
930
  style: {
589
931
  position: 'fixed',
590
932
  bottom: 24,
@@ -595,12 +937,12 @@ window.__ModuleLoader__.load({
595
937
  gap: 6,
596
938
  padding: '6px 12px',
597
939
  borderRadius: 999,
598
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
599
- background: 'var(--color-bg-elevated, #ffffff)',
600
- color: 'var(--color-text, inherit)',
940
+ border: '1px solid ' + TOKEN.border,
941
+ background: TOKEN.surface,
942
+ color: TOKEN.ink,
601
943
  fontSize: 13,
602
944
  cursor: 'pointer',
603
- boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
945
+ boxShadow: TOKEN.shadow,
604
946
  opacity: store.heroWorkspace ? 1 : 0.7,
605
947
  },
606
948
  },
@@ -609,32 +951,33 @@ window.__ModuleLoader__.load({
609
951
  }
610
952
 
611
953
  /** Inline chip for the upstream `conversation.hero.workspaceExtras`
612
- * slot (B1): rendered beside the workspace picker once the DSH core
613
- * declares the slot; a no-op registration until then. */
954
+ * slot: rendered beside the workspace picker once the DSH core declares
955
+ * the slot; a no-op registration until then. Its popover opens
956
+ * DOWNWARD, matching the official workspace picker on the same row. */
614
957
  function HeroChip(props) {
615
958
  var store = useStore();
616
959
  var t = props.t;
617
960
  var workspacePath = props && props.workspacePath ? props.workspacePath : store.heroWorkspace;
618
- return React.createElement(
619
- 'button',
620
- {
621
- type: 'button',
622
- title: t('title.heroChip'),
623
- onClick: function () { openForWorkspace(workspacePath); },
624
- style: {
625
- display: 'inline-flex',
626
- alignItems: 'center',
627
- gap: 6,
628
- padding: '4px 10px',
629
- borderRadius: 999,
630
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
631
- background: 'transparent',
632
- color: 'var(--color-text, inherit)',
633
- fontSize: 13,
634
- cursor: 'pointer',
961
+ if (bestHeroSeat(store.heroClaims) !== 'extras') return null;
962
+ var open = store.open && store.anchor === 'extras';
963
+ var count = dirCountOf(workspacePath);
964
+ return chipWithPopover(
965
+ 'extras',
966
+ 'down',
967
+ t,
968
+ chipButton({
969
+ open: open,
970
+ dimmed: !workspacePath,
971
+ title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
972
+ label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
973
+ onClick: function () {
974
+ if (open) {
975
+ patch({ open: false });
976
+ } else {
977
+ openForWorkspace(workspacePath, 'extras');
978
+ }
635
979
  },
636
- },
637
- t('label'),
980
+ }),
638
981
  );
639
982
  }
640
983
 
@@ -651,19 +994,40 @@ window.__ModuleLoader__.load({
651
994
  function (props) { return React.createElement(Panel, props); },
652
995
  );
653
996
  });
997
+ // Session-creation page, shipped seat: the official input dock band (a
998
+ // session-scoped `list` slot rendered directly above the composer
999
+ // card). One unique id, one explicit order, one in-flow row — the
1000
+ // framework arranges this row against every other plugin's dock entry.
1001
+ slots.inject('conversation.input.dock', function () {
1002
+ var release = claimHeroSeat('dock');
1003
+ var dispose = slots.register(
1004
+ { name: 'conversation.input.dock', id: 'multi-folder', order: DOCK_ORDER, label: function () { return t('label.heroDock'); }, locale: NS },
1005
+ function (props) { return React.createElement(DockChip, props); },
1006
+ );
1007
+ return function () {
1008
+ dispose();
1009
+ release();
1010
+ };
1011
+ });
654
1012
  slots.inject('shell.overlay', function () {
655
1013
  return slots.register(
656
1014
  { name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: function () { return t('label.heroLauncher'); }, locale: NS },
657
1015
  function (props) { return React.createElement(HeroLauncher, props); },
658
1016
  );
659
1017
  });
660
- // Upstream slot (B1): the callback fires only once a DSH build declares
661
- // `conversation.hero.workspaceExtras`; until then this contributes nothing.
1018
+ // Upstream slot: the callback fires only once a DSH build declares
1019
+ // `conversation.hero.workspaceExtras`; until then this contributes
1020
+ // nothing and the dock row above holds the page.
662
1021
  slots.inject('conversation.hero.workspaceExtras', function () {
663
- return slots.register(
1022
+ var release = claimHeroSeat('extras');
1023
+ var dispose = slots.register(
664
1024
  { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
665
1025
  function (props) { return React.createElement(HeroChip, props); },
666
1026
  );
1027
+ return function () {
1028
+ dispose();
1029
+ release();
1030
+ };
667
1031
  });
668
1032
  }
669
1033
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-multi-folder",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
5
5
  "keywords": [
6
6
  "dsh-plugin",