dsh-multi-folder 0.1.4 → 0.1.6

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
@@ -174,7 +177,9 @@ window.__ModuleLoader__.load({
174
177
  Language preference in Settings; the English UI reads "Multi-folder", the
175
178
  Chinese UI keeps 「多工作目录」.
176
179
  - Host communication, two channels:
177
- - session mode: `ctx.remote.commands.execute(sessionId, line)`. The return
180
+ - session mode: `ctx.remote.commands.execute(sessionId, line, [])`. Since
181
+ DSH 0.1.1 the remote takes the composer-images argument as its third
182
+ business argument (empty array for a plain invocation). The return
178
183
  value is the RPC envelope `{ ok, value }` where `value` is the
179
184
  `CommandExecution`; command result text carries a `[MF:JSON] {…}` line the
180
185
  panel parses for structured state.
@@ -189,20 +194,48 @@ window.__ModuleLoader__.load({
189
194
  - Caching: per-session cache (`sessionCache`) keeps pure reads off the
190
195
  conversation; per-workspace cache (`workspaceCache`) plays the same role for
191
196
  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.
197
+ - Hero (session-creation page) support — **three candidate seats, one visible
198
+ entry**:
199
+ - The **dock chip** (`conversation.input.dock`, id `multi-folder`,
200
+ order 120) is the shipped seat: a `list` slot the rc.6 shell declares and
201
+ renders directly ABOVE the composer card, in the same band as the
202
+ git-branch chip. The entry receives the dock owner share (`{ session,
203
+ input }`) plus the standard `useSessions` / `useWorkspaces` selector hooks,
204
+ so the hero phase (`composerPhase === 'blank' && (openState === 'open' ||
205
+ blank)`) and the target workspace come from framework props instead of DOM
206
+ probing. The row stays **in flow** — `display:flex` with the official hero
207
+ row's 20px indent, no absolute positioning — so the framework's list-slot
208
+ arrangement keeps it clear of every other plugin's dock row. It renders
209
+ only on the session-creation page; an active session keeps its entry in the
210
+ session header, so the two never appear together.
201
211
  - The **hero chip** registers into `conversation.hero.workspaceExtras` via
202
212
  `slots.inject`, which waits for the declaration: with an upstream DSH
203
213
  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.
214
+ picker; without one, the registration contributes nothing.
215
+ - The **hero launcher** (`shell.overlay` entry, `multi-folder-hero`) is the
216
+ last-resort fallback for shells that declare neither slot. Only then does
217
+ it subscribe to `sessions.list` + `workspaces.list` and observe the
218
+ conversation root's `data-phase="hero"` attribute (MutationObserver on
219
+ `document.body`) to render a fixed-position button; the
220
+ workspace path is derived from the current (blank) session's
221
+ `WorkspaceView.path`, falling back to `SessionSummary.cwd`.
222
+ - **Hero seat election.** Each seat claims a token while its slot declaration
223
+ is live (`slots.inject` fires only for declared slots and disposes on
224
+ collapse); the components render only while holding the best live claim
225
+ (`extras` > `dock` > fallback). The framework arranges *different plugins*
226
+ on a shared `list` slot but has no opinion about one plugin holding several
227
+ alternative seats, so this election is the plugin's own duty.
228
+ - Clicking any of them opens the panel in workspace mode; without a selected
229
+ workspace the panel shows the "pick a workspace first" hint.
230
+ - Panel placement: one `panelBody(store, t)` function returns the panel's
231
+ children, spread by whichever wrapper owns the panel — the fixed
232
+ `shell.overlay` panel (session-header path) or an `AnchoredPanel` popover
233
+ rendered by the chip itself (opening upward from the dock row, downward from
234
+ the hero row). `store.anchor` names the owner, and the overlay wrapper stands
235
+ down whenever a chip owns it, so the panel never renders twice.
236
+ - Styling: all surfaces use the official `--dsw-alias-*` design tokens
237
+ (`dsh-client-ui-theme`) with inert fallbacks, so themes and applied skins
238
+ restyle this plugin's chip and panel along with the shell's own controls.
206
239
 
207
240
  ## Known limitations
208
241
 
@@ -244,12 +277,26 @@ window.__ModuleLoader__.load({
244
277
  visible in the conversation UI by framework design; they are log-only and never
245
278
  reach the model. Workspace-mode (session-creation page) operations avoid them
246
279
  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.
280
+ - The session-creation entry depends on shell internals to different degrees per
281
+ seat. The **dock chip** reads only declared contract surfaces (the
282
+ `conversation.input.dock` declaration, its owner share, and the standard
283
+ selector hooks), but its 20px indent is tuned to the shipped hero row's
284
+ padding — a restyled shell would misalign it, never break it. The **fallback
285
+ launcher** relies on the conversation root's `data-phase="hero"` attribute and
286
+ the `sessions.list`/`workspaces.list` snapshot shapes — DOM and client-runtime
287
+ internals rather than documented APIs; they are guarded defensively (missing
288
+ services or DOM degrade to "launcher hidden") and it only mounts when no
289
+ declared seat exists. The upstream `conversation.hero.workspaceExtras` slot
290
+ (see [upstream-hero-slot.md](upstream-hero-slot.md)) remains the long-term
291
+ surface.
292
+ - Sharing the `conversation.input.dock` band is safe by construction (unique
293
+ `id`, explicit `order`, in-flow layout) but `order` is a shared number space,
294
+ not an enforced allocation: another plugin may pick the same `order` and the
295
+ tie is then broken by registration sequence. The rows still stack without
296
+ overlapping — only their vertical sequence is unspecified. Absolute-positioned
297
+ neighbours (the git-branch chip lifts itself into the hero row) are outside
298
+ the framework's arrangement entirely; this plugin deliberately does not do the
299
+ same.
253
300
  - The `multiFolder/*` endpoints use hand-written `src-json` Typert descriptors
254
301
  registered through `ctx.typert.register`. `src-json` gives JSON-safety
255
302
  boundary checks, not schema validation; the shared core performs all
@@ -265,5 +312,11 @@ canonicalization, the config guard, both notification channels, notice
265
312
  gating, command flows, the panel's session-switch/caching behavior, the
266
313
  sessionless remote contribution shape and behavior (list/add/set/remove,
267
314
  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).
315
+ and the hero/workspace-mode client flows. The client test's `slots.inject` mock
316
+ is declaration-aware like the real service (a wait fires only while its slot is
317
+ declared, and a collapse disposes the registration), so it covers all three
318
+ session-creation seats: the dock chip on an rc.6-style shell (registration
319
+ shape, in-flow row, hero-only visibility, RPC routing, anchored popover), the
320
+ upstream hero chip taking over the moment its slot is declared, and the fixed
321
+ launcher returning once both declarations collapse — asserting at each step that
322
+ 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
@@ -7,24 +7,48 @@
7
7
  * secondary working directories. All copy goes through the framework locale
8
8
  * service (`@deepseek-ai/dsh-client-locale`) — see the "i18n" section below.
9
9
  * Mutations go through the Host `/multi-folder` command via the Remote BFF
10
- * (`ctx.remote.commands.execute(sessionId, line)`); the Host answers with a
10
+ * (`ctx.remote.commands.execute(sessionId, line, [])` DSH 0.1.1 added the
11
+ * composer-images business argument, empty for a plain invocation); the Host
12
+ * answers with a
11
13
  * human-readable result carrying a `[MF:JSON]` line the panel parses for
12
14
  * structured state.
13
15
  *
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
16
+ * Session-creation page UI (no message sent yet). Three candidate seats are
17
+ * registered, best first, and EXACTLY ONE renders see "hero seat election":
18
+ * - `conversation.hero.workspaceExtras` (upstream additive hero row; the
19
+ * `slots.inject` wait is a no-op until a DSH core declares it);
20
+ * - `conversation.input.dock` (shipped since 0.1.9, declared by rc.6): an
21
+ * in-flow chip row directly ABOVE the composer card, left-aligned with the
22
+ * official hero chip row — the same band the git-branch chip uses;
23
+ * - `shell.overlay` fixed-position launcher: last-resort fallback for shells
24
+ * that declare neither slot.
25
+ * All three open the same panel in WORKSPACE mode and drive the sessionless
21
26
  * `multiFolder/*` endpoints over the shared RPC channel
22
27
  * (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
23
28
  * path instead of sessionId.
24
29
  *
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
30
+ * Layout shape: an entry in the session header action row, one chip row above
31
+ * the composer card on the session-creation page, and a frame-wide overlay
32
+ * panel. Every surface reads one tiny module-scoped store; opening the panel
27
33
  * refreshes the list from the Host.
34
+ *
35
+ * ## Hero seat election (multi-plugin coexistence)
36
+ *
37
+ * `conversation.input.dock` is a `list` slot, so the framework already
38
+ * arranges co-registered entries for us: entries are sorted by
39
+ * `(priority, order)`, a duplicate `(id, priority)` pair is rejected at
40
+ * registration with an error naming the sitting occupant, and the outlet
41
+ * renders `display:contents` so each entry becomes its own row of the
42
+ * composer stack. This plugin therefore stays a good citizen by construction:
43
+ * one unique `id`, one explicit `order`, and NO absolute positioning that
44
+ * would escape the framework's arrangement and overlap a neighbour.
45
+ *
46
+ * What the framework does NOT do is stop ONE plugin from occupying several
47
+ * alternative seats at once (each is a legitimate, differently-declared
48
+ * slot). That is this plugin's own duty: each seat claims a token when its
49
+ * declaration arrives (`slots.inject` fires only for declared slots), and the
50
+ * components render only while they hold the best live claim, so the
51
+ * session-creation page never shows two Multi-folder entries.
28
52
  */
29
53
  window.__ModuleLoader__.load({
30
54
  id: 'dsh-multi-folder',
@@ -35,7 +59,24 @@ window.__ModuleLoader__.load({
35
59
 
36
60
  // ------------------------------------------------------------- store
37
61
  var listeners = new Set();
38
- var state = { open: false, mode: null, sessionId: null, dirs: [], workspace: null, busy: false, error: null, hero: false, heroWorkspace: null };
62
+ var state = {
63
+ open: false,
64
+ mode: null,
65
+ sessionId: null,
66
+ dirs: [],
67
+ workspace: null,
68
+ busy: false,
69
+ error: null,
70
+ /** Fallback launcher only: hero visibility derived from the DOM. */
71
+ hero: false,
72
+ heroWorkspace: null,
73
+ /** Live hero-seat claims, in claim order (see "hero seat election"). */
74
+ heroClaims: [],
75
+ /** Which surface owns the open panel: 'overlay' | 'dock' | 'extras'. */
76
+ anchor: 'overlay',
77
+ /** Bumped on every workspaceCache write so chips re-read their count. */
78
+ cacheRev: 0,
79
+ };
39
80
  function patch(next) {
40
81
  state = Object.assign({}, state, next);
41
82
  listeners.forEach(function (fn) { fn(); });
@@ -64,6 +105,58 @@ window.__ModuleLoader__.load({
64
105
  try { return JSON.parse(m[1]); } catch (_) { return null; }
65
106
  }
66
107
 
108
+ /** Stand-in for an absent standard-kit selector hook: runs the selector
109
+ * against no snapshot, so callers keep one unconditional call site. */
110
+ function selectNothing(selector) { return selector(undefined); }
111
+
112
+ // -------------------------------------------------- hero seat election
113
+ /** Candidate seats for the session-creation page, best first. */
114
+ var HERO_SEATS = ['extras', 'dock'];
115
+ /** Order of this plugin's `conversation.input.dock` row. The shipped band
116
+ * already carries other plugins' entries (the aionui drop inlay at 90/91
117
+ * and the git-branch chip at 100); 120 keeps this row closest to the
118
+ * composer card without contesting theirs. */
119
+ var DOCK_ORDER = 120;
120
+
121
+ /** The best seat currently claimed, or null when only the fallback is left. */
122
+ function bestHeroSeat(claims) {
123
+ for (var i = 0; i < HERO_SEATS.length; i++) {
124
+ if (claims.indexOf(HERO_SEATS[i]) >= 0) return HERO_SEATS[i];
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** Claim a seat for as long as its slot declaration lives. */
130
+ function claimHeroSeat(kind) {
131
+ patch({ heroClaims: getSnapshot().heroClaims.concat([kind]) });
132
+ return function () {
133
+ var claims = getSnapshot().heroClaims.slice();
134
+ var at = claims.indexOf(kind);
135
+ if (at >= 0) claims.splice(at, 1);
136
+ patch({ heroClaims: claims });
137
+ };
138
+ }
139
+
140
+ // ------------------------------------------------------------- theme
141
+ /** Official `--dsw-alias-*` design tokens (dsh-client-ui-theme) with inert
142
+ * fallbacks, so the surfaces follow the active theme and any applied skin
143
+ * instead of guessing a palette. */
144
+ var TOKEN = {
145
+ border: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
146
+ borderSoft: 'var(--dsw-alias-border-l1, rgba(127,127,127,0.20))',
147
+ surface: 'var(--dsw-alias-bg-overlay, #ffffff)',
148
+ subtle: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
149
+ fill: 'var(--dsw-alias-button-tool-bar-fill, rgba(127,127,127,0.10))',
150
+ hover: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.14))',
151
+ ink: 'var(--dsw-alias-label-primary, #1a1a1a)',
152
+ inkSoft: 'var(--dsw-alias-label-secondary, #4a4a4a)',
153
+ inkMuted: 'var(--dsw-alias-label-tertiary, #6b6b6b)',
154
+ inkFaint: 'var(--dsw-alias-label-caption, #8a8a8a)',
155
+ danger: 'var(--dsw-alias-state-error-primary, #c62828)',
156
+ accent: 'var(--dsw-alias-brand-primary, #4c6ef5)',
157
+ shadow: '0 8px 24px var(--dsw-alias-bg-mask-2, rgba(0,0,0,0.18))',
158
+ };
159
+
67
160
  // ------------------------------------------------------------- i18n
68
161
  /** Locale namespace owned by this plugin. Dictionaries are registered
69
162
  * with the `locale` service (`@deepseek-ai/dsh-client-locale`, always
@@ -74,7 +167,9 @@ window.__ModuleLoader__.load({
74
167
  var zhDict = {
75
168
  'label': '多工作目录',
76
169
  'label.open': '多工作目录 ▾',
170
+ 'label.withCount': '多工作目录 · {count}',
77
171
  'label.heroLauncher': '多工作目录(新会话)',
172
+ 'label.heroDock': '多工作目录(输入框上方)',
78
173
  'title.header': '多工作目录(副工作目录)',
79
174
  'title.remove': '移除此副工作目录',
80
175
  'title.close': '关闭',
@@ -96,7 +191,9 @@ window.__ModuleLoader__.load({
96
191
  var enDict = {
97
192
  'label': 'Multi-folder',
98
193
  'label.open': 'Multi-folder ▾',
194
+ 'label.withCount': 'Multi-folder · {count}',
99
195
  'label.heroLauncher': 'Multi-folder (new session)',
196
+ 'label.heroDock': 'Multi-folder (above the composer)',
100
197
  'title.header': 'Multi-folder (secondary working directories)',
101
198
  'title.remove': 'Remove this secondary working directory',
102
199
  'title.close': 'Close',
@@ -161,17 +258,42 @@ window.__ModuleLoader__.load({
161
258
  });
162
259
  }
163
260
 
261
+ /** Write one workspace's list into the cache and return it normalized. */
262
+ function storeWorkspace(workspacePath, value) {
263
+ var normalized = value && Array.isArray(value.dirs)
264
+ ? value
265
+ : { workspace: workspacePath, dirs: [] };
266
+ workspaceCache[workspacePathKey(workspacePath)] = normalized;
267
+ return normalized;
268
+ }
269
+
270
+ /** Whether the open panel is the one showing `workspacePath`. */
271
+ function panelTargets(workspacePath) {
272
+ var current = getSnapshot();
273
+ return !current.sessionId
274
+ && current.mode === 'workspace'
275
+ && !!current.workspace
276
+ && workspacePathKey(current.workspace) === workspacePathKey(workspacePath);
277
+ }
278
+
279
+ /** Patch after a workspace-mode read/write: always publish the new cache
280
+ * revision (chips show the configured count), and refresh the panel
281
+ * body only when it is the panel for this workspace. */
282
+ function publishWorkspace(workspacePath, value) {
283
+ var normalized = storeWorkspace(workspacePath, value);
284
+ var next = { busy: false, cacheRev: getSnapshot().cacheRev + 1 };
285
+ if (panelTargets(workspacePath)) {
286
+ next.workspace = normalized.workspace;
287
+ next.dirs = normalized.dirs || [];
288
+ }
289
+ patch(next);
290
+ }
291
+
164
292
  function refreshWorkspace(workspacePath) {
165
293
  if (!workspacePath) return;
166
294
  patch({ busy: true, error: null });
167
295
  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
- }
296
+ publishWorkspace(workspacePath, value);
175
297
  }).catch(function (e) {
176
298
  patch({ busy: false, error: String(e && e.message ? e.message : e) });
177
299
  });
@@ -182,23 +304,27 @@ window.__ModuleLoader__.load({
182
304
  patch({ busy: true, error: null });
183
305
  var payload = Object.assign({ workspace: workspacePath }, args);
184
306
  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
- }
307
+ publishWorkspace(workspacePath, value);
192
308
  }).catch(function (e) {
193
309
  patch({ busy: false, error: String(e && e.message ? e.message : e) });
194
310
  });
195
311
  }
196
312
 
313
+ /** Configured directory count for a workspace, or null while unread. */
314
+ function dirCountOf(workspacePath) {
315
+ if (!workspacePath) return null;
316
+ var cached = workspaceCache[workspacePathKey(workspacePath)];
317
+ return cached && Array.isArray(cached.dirs) ? cached.dirs.length : null;
318
+ }
319
+
197
320
  /** 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) {
321
+ * workspace shows the "pick a workspace first" hint instead.
322
+ * `anchor` names the surface that owns the panel — an inline chip
323
+ * renders it as its own popover, everything else uses the overlay. */
324
+ function openForWorkspace(workspacePath, anchor) {
325
+ var seat = anchor || 'overlay';
200
326
  if (!workspacePath) {
201
- patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null });
327
+ patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
202
328
  return;
203
329
  }
204
330
  var cached = workspaceCache[workspacePathKey(workspacePath)];
@@ -209,12 +335,16 @@ window.__ModuleLoader__.load({
209
335
  workspace: workspacePath,
210
336
  dirs: cached ? cached.dirs : [],
211
337
  error: null,
338
+ anchor: seat,
212
339
  });
213
340
  if (!cached) refreshWorkspace(workspacePath);
214
341
  }
215
342
 
216
343
  function runCommand(sessionId, line) {
217
- return remote.commands.execute(sessionId, line).then(function (envelope) {
344
+ // DSH 0.1.1: commands/execute takes three business arguments —
345
+ // (sessionId, line, images) — plus an optional AbortSignal. The
346
+ // plugin never attaches composer images, so the third is `[]`.
347
+ return remote.commands.execute(sessionId, line, []).then(function (envelope) {
218
348
  if (!envelope || envelope.ok !== true) {
219
349
  throw new Error('multi-folder: ' + (envelope && envelope.error !== undefined ? String(envelope.error) : 'remote call failed'));
220
350
  }
@@ -293,6 +423,7 @@ window.__ModuleLoader__.load({
293
423
  dirs: cached ? cached.dirs : [],
294
424
  workspace: cached ? cached.workspace : null,
295
425
  error: null,
426
+ anchor: 'overlay',
296
427
  });
297
428
  if (cached === undefined) refresh(sessionId);
298
429
  }
@@ -336,9 +467,9 @@ window.__ModuleLoader__.load({
336
467
  gap: 6,
337
468
  padding: '4px 10px',
338
469
  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)',
470
+ border: '1px solid ' + TOKEN.border,
471
+ background: open ? TOKEN.hover : 'transparent',
472
+ color: TOKEN.ink,
342
473
  fontSize: 13,
343
474
  cursor: 'pointer',
344
475
  },
@@ -348,11 +479,11 @@ window.__ModuleLoader__.load({
348
479
  return btn;
349
480
  }
350
481
 
351
- // Overlay panel ------------------------------------------------------
352
- function Panel(props) {
353
- var store = useStore();
354
- var t = props.t;
355
- if (!store.open || !store.mode) return null;
482
+ // Panel body ---------------------------------------------------------
483
+ /** The panel's children, shared verbatim by the overlay panel and the
484
+ * chip popover. Returned as an ARRAY so each wrapper can spread it as
485
+ * its own direct children (one DOM shape, two placements). */
486
+ function panelBody(store, t) {
356
487
  var sessionMode = store.mode === 'session';
357
488
  var usable = sessionMode || !!store.workspace;
358
489
  var rows = (store.dirs || []).map(function (dir, index) {
@@ -366,7 +497,7 @@ window.__ModuleLoader__.load({
366
497
  gap: 8,
367
498
  padding: '6px 8px',
368
499
  borderRadius: 6,
369
- background: 'var(--color-bg-subtle, rgba(127,127,127,0.08))',
500
+ background: TOKEN.subtle,
370
501
  marginBottom: 6,
371
502
  },
372
503
  },
@@ -391,42 +522,24 @@ window.__ModuleLoader__.load({
391
522
  mutateWorkspace(store.workspace, 'multiFolder/remove', { path: dir });
392
523
  }
393
524
  },
394
- style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: 'var(--color-danger, #c62828)', cursor: 'pointer' },
525
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.danger, cursor: 'pointer' },
395
526
  },
396
527
  t('panel.remove'),
397
528
  ),
398
529
  );
399
530
  });
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
- },
531
+ return [
419
532
  React.createElement(
420
533
  'div',
421
534
  { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
422
- React.createElement('div', { style: { fontWeight: 600, fontSize: 14 } }, t('panel.title')),
535
+ React.createElement('div', { style: { fontWeight: 600, fontSize: 14, color: TOKEN.ink } }, t('panel.title')),
423
536
  React.createElement(
424
537
  'button',
425
538
  {
426
539
  type: 'button',
427
540
  title: t('title.close'),
428
541
  onClick: function () { patch({ open: false }); },
429
- style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', cursor: 'pointer' },
542
+ style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
430
543
  },
431
544
  '✕',
432
545
  ),
@@ -434,21 +547,21 @@ window.__ModuleLoader__.load({
434
547
  store.workspace
435
548
  ? React.createElement(
436
549
  'div',
437
- { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)', fontSize: 12, wordBreak: 'break-all' } },
550
+ { style: { marginBottom: 8, color: TOKEN.inkMuted, fontSize: 12, wordBreak: 'break-all' } },
438
551
  t('panel.project', { path: store.workspace }),
439
552
  )
440
553
  : (!sessionMode
441
554
  ? React.createElement(
442
555
  'div',
443
- { style: { marginBottom: 8, color: 'var(--color-text-muted, #6b6b6b)' } },
556
+ { style: { marginBottom: 8, color: TOKEN.inkMuted } },
444
557
  t('panel.noWorkspaceHint'),
445
558
  )
446
559
  : null),
447
560
  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')),
561
+ ? (rows.length > 0 ? rows : React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.empty')))
562
+ : React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted } }, t('panel.pickWorkspaceHint')),
450
563
  store.error
451
- ? React.createElement('div', { style: { marginBottom: 8, color: 'var(--color-danger, #c62828)', whiteSpace: 'pre-wrap' } }, String(store.error))
564
+ ? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap' } }, String(store.error))
452
565
  : null,
453
566
  React.createElement(
454
567
  'div',
@@ -463,9 +576,9 @@ window.__ModuleLoader__.load({
463
576
  flex: 1,
464
577
  padding: '6px 10px',
465
578
  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)',
579
+ border: '1px solid ' + TOKEN.border,
580
+ background: TOKEN.fill,
581
+ color: TOKEN.ink,
469
582
  cursor: store.busy || !usable ? 'default' : 'pointer',
470
583
  opacity: store.busy || !usable ? 0.6 : 1,
471
584
  },
@@ -487,9 +600,9 @@ window.__ModuleLoader__.load({
487
600
  style: {
488
601
  padding: '6px 10px',
489
602
  borderRadius: 8,
490
- border: '1px solid var(--color-border, rgba(127,127,127,0.35))',
603
+ border: '1px solid ' + TOKEN.border,
491
604
  background: 'transparent',
492
- color: 'var(--color-text, inherit)',
605
+ color: TOKEN.ink,
493
606
  cursor: store.busy || !usable ? 'default' : 'pointer',
494
607
  opacity: store.busy || !usable ? 0.6 : 1,
495
608
  },
@@ -504,33 +617,262 @@ window.__ModuleLoader__.load({
504
617
  marginTop: 10,
505
618
  fontSize: 11,
506
619
  lineHeight: 1.5,
507
- color: 'var(--color-text-muted, #6b6b6b)',
620
+ color: TOKEN.inkMuted,
508
621
  },
509
622
  },
510
623
  t('panel.footnote'),
511
624
  ),
625
+ ];
626
+ }
627
+
628
+ /** Spread `children` as the direct children of one element (keeps the
629
+ * panel's DOM shape identical across both placements). */
630
+ function element(type, props, children) {
631
+ return React.createElement.apply(null, [type, props].concat(children));
632
+ }
633
+
634
+ // Overlay panel ------------------------------------------------------
635
+ function Panel(props) {
636
+ var store = useStore();
637
+ var t = props.t;
638
+ if (!store.open || !store.mode) return null;
639
+ // An inline chip owns its own popover; the overlay stands down.
640
+ if (store.anchor !== 'overlay') return null;
641
+ return element(
642
+ 'div',
643
+ {
644
+ style: {
645
+ position: 'fixed',
646
+ top: 64,
647
+ right: 20,
648
+ width: 380,
649
+ maxWidth: 'calc(100vw - 40px)',
650
+ zIndex: 400,
651
+ background: TOKEN.surface,
652
+ color: TOKEN.ink,
653
+ border: '1px solid ' + TOKEN.border,
654
+ borderRadius: 12,
655
+ boxShadow: TOKEN.shadow,
656
+ padding: 14,
657
+ fontSize: 13,
658
+ },
659
+ },
660
+ panelBody(store, t),
661
+ );
662
+ }
663
+
664
+ // Anchored popover ---------------------------------------------------
665
+ /** The same panel body as a popover anchored to a chip. `direction`
666
+ * 'up' opens above the chip (the dock row sits above the composer
667
+ * card), 'down' opens below it (the hero chip row). */
668
+ function AnchoredPanel(props) {
669
+ var store = useStore();
670
+ var t = props.t;
671
+ var up = props.direction !== 'down';
672
+ var placement = up ? { bottom: 'calc(100% + 6px)' } : { top: 'calc(100% + 6px)' };
673
+ return element(
674
+ 'div',
675
+ {
676
+ style: Object.assign(
677
+ {
678
+ position: 'absolute',
679
+ left: 0,
680
+ zIndex: 40,
681
+ width: 380,
682
+ maxWidth: 'calc(100vw - 40px)',
683
+ // The composer stack lives in a scroll container with
684
+ // `overflow: hidden auto`; capping the height keeps a tall
685
+ // list inside the viewport instead of clipping it.
686
+ maxHeight: 'min(60vh, 420px)',
687
+ overflowY: 'auto',
688
+ background: TOKEN.surface,
689
+ color: TOKEN.ink,
690
+ border: '1px solid ' + TOKEN.border,
691
+ borderRadius: 12,
692
+ boxShadow: TOKEN.shadow,
693
+ padding: 14,
694
+ fontSize: 13,
695
+ textAlign: 'left',
696
+ },
697
+ placement,
698
+ ),
699
+ },
700
+ panelBody(store, t),
701
+ );
702
+ }
703
+
704
+ /** Click-outside catcher for an open popover. */
705
+ function Backdrop() {
706
+ return React.createElement('div', {
707
+ onClick: function () { patch({ open: false }); },
708
+ style: { position: 'fixed', inset: 0, zIndex: 30 },
709
+ });
710
+ }
711
+
712
+ /** One chip + its popover, wrapped in the positioning context the
713
+ * popover anchors to. Shared by the dock row and the hero chip. */
714
+ function chipWithPopover(seat, direction, t, button) {
715
+ var store = getSnapshot();
716
+ var open = store.open && store.anchor === seat;
717
+ return React.createElement(
718
+ 'div',
719
+ { style: { position: 'relative', display: 'inline-flex', minWidth: 0 } },
720
+ button,
721
+ open ? React.createElement(Backdrop, { key: 'backdrop' }) : null,
722
+ open ? React.createElement(AnchoredPanel, { key: 'panel', t: t, direction: direction }) : null,
723
+ );
724
+ }
725
+
726
+ /** The chip pill, styled from the official chip recipe. */
727
+ function chipButton(options) {
728
+ return React.createElement(
729
+ 'button',
730
+ {
731
+ type: 'button',
732
+ title: options.title,
733
+ onClick: options.onClick,
734
+ style: {
735
+ display: 'inline-flex',
736
+ alignItems: 'center',
737
+ gap: 6,
738
+ height: 24,
739
+ padding: '0 10px',
740
+ borderRadius: 999,
741
+ border: '1px solid ' + (options.open ? TOKEN.accent : TOKEN.border),
742
+ background: options.open ? TOKEN.hover : TOKEN.fill,
743
+ color: options.open ? TOKEN.ink : TOKEN.inkSoft,
744
+ fontSize: 12,
745
+ lineHeight: 1,
746
+ whiteSpace: 'nowrap',
747
+ cursor: 'pointer',
748
+ maxWidth: 260,
749
+ overflow: 'hidden',
750
+ opacity: options.dimmed ? 0.7 : 1,
751
+ },
752
+ },
753
+ React.createElement(
754
+ 'span',
755
+ { style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
756
+ options.label,
757
+ ),
758
+ React.createElement('span', { style: { color: TOKEN.inkFaint, fontSize: 10 } }, '▾'),
512
759
  );
513
760
  }
514
761
 
515
762
  // Hero (session-creation page) support --------------------------------
763
+ /** The shell's own hero predicate, read from the dock's owner share: a
764
+ * blank conversation with an open session. A still-loading blank
765
+ * session already lists as blank, so it may enter the hero phase
766
+ * before the composer snapshot settles to `open`. */
767
+ function isHeroPhase(session, blank) {
768
+ if (!session) return false;
769
+ return session.composerPhase === 'blank' && (session.openState === 'open' || blank === true);
770
+ }
771
+
772
+ /** The workspace path a session belongs to, from the workspaces list. */
773
+ function workspacePathIn(snapshot, sessionId) {
774
+ var items = snapshot && snapshot.items ? snapshot.items : [];
775
+ for (var i = 0; i < items.length; i++) {
776
+ var workspace = items[i];
777
+ if (workspace && workspace.path && Array.isArray(workspace.sessionIds) && workspace.sessionIds.indexOf(sessionId) >= 0) {
778
+ return workspace.path;
779
+ }
780
+ }
781
+ return null;
782
+ }
783
+
516
784
  /** Workspace path of the current session (blank-session hero), or null
517
- * while no session/workspace is selected at all. */
785
+ * while no session/workspace is selected at all. Store-read twin of
786
+ * `workspacePathIn` for the fallback launcher, which has no props. */
518
787
  function heroWorkspacePath() {
519
788
  var sessionList = sessions && sessions.list ? sessions.list.getSnapshot() : null;
520
789
  var id = sessionList && sessionList.current;
521
790
  if (!id) return null;
522
791
  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
- }
792
+ var fromWorkspaces = workspacePathIn(workspaceList, id);
793
+ if (fromWorkspaces !== null) return fromWorkspaces;
530
794
  var row = sessionList && sessionList.byId ? sessionList.byId[id] : undefined;
531
795
  return row && row.cwd ? row.cwd : null;
532
796
  }
533
797
 
798
+ /**
799
+ * Dock chip (session-creation page, preferred shipped seat): one in-flow
800
+ * row in `conversation.input.dock`, directly above the composer card and
801
+ * left-aligned with the official hero chip row. The row stays IN FLOW —
802
+ * the framework arranges co-registered dock entries as sibling rows, so
803
+ * absolute positioning here would silently overlap a neighbour's chip.
804
+ *
805
+ * Renders only on the session-creation page: an active session keeps its
806
+ * entry in the session header, so the two never appear at once.
807
+ */
808
+ function DockChip(props) {
809
+ var store = useStore();
810
+ var t = props.t;
811
+ var sessionId = props.sessionId;
812
+ // The standard kit always supplies these selector hooks; the absent
813
+ // form keeps the hook call unconditional (stable hook order) for
814
+ // hosts and tests that render the entry without the kit.
815
+ var useSessions = typeof props.useSessions === 'function' ? props.useSessions : selectNothing;
816
+ var useWorkspaces = typeof props.useWorkspaces === 'function' ? props.useWorkspaces : selectNothing;
817
+ var blank = useSessions(function (s) {
818
+ return !!(s && s.byId && sessionId !== undefined && s.byId[sessionId] && s.byId[sessionId].blank === true);
819
+ });
820
+ var cwd = useSessions(function (s) {
821
+ var row = s && s.byId && sessionId !== undefined ? s.byId[sessionId] : undefined;
822
+ return row && row.cwd ? row.cwd : null;
823
+ });
824
+ var fromWorkspaces = useWorkspaces(function (s) { return workspacePathIn(s, sessionId); });
825
+ var workspacePath = fromWorkspaces || cwd;
826
+ var hero = isHeroPhase(props.session, blank);
827
+ var mine = bestHeroSeat(store.heroClaims) === 'dock';
828
+ // Fill the count cache once per workspace. Workspace mode rides the
829
+ // sessionless RPC, so this read produces no conversation row.
830
+ React.useEffect(
831
+ function () {
832
+ if (!hero || !mine || !workspacePath) return undefined;
833
+ if (workspaceCache[workspacePathKey(workspacePath)] === undefined) refreshWorkspace(workspacePath);
834
+ return undefined;
835
+ },
836
+ [hero, mine, workspacePath],
837
+ );
838
+ if (!hero || !mine) return null;
839
+ var open = store.open && store.anchor === 'dock';
840
+ var count = dirCountOf(workspacePath);
841
+ return React.createElement(
842
+ 'div',
843
+ {
844
+ style: {
845
+ // The dock outlet is display:contents, so this row is a direct
846
+ // child of the composer stack — the same 20px indent as the
847
+ // official hero chip row above it.
848
+ display: 'flex',
849
+ alignItems: 'center',
850
+ gap: 6,
851
+ minWidth: 0,
852
+ paddingLeft: 20,
853
+ },
854
+ },
855
+ chipWithPopover(
856
+ 'dock',
857
+ 'up',
858
+ t,
859
+ chipButton({
860
+ open: open,
861
+ dimmed: !workspacePath,
862
+ title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
863
+ label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
864
+ onClick: function () {
865
+ if (open) {
866
+ patch({ open: false });
867
+ } else {
868
+ openForWorkspace(workspacePath, 'dock');
869
+ }
870
+ },
871
+ }),
872
+ ),
873
+ );
874
+ }
875
+
534
876
  /** Re-read the conversation root's `data-phase` attribute (authoritative
535
877
  * hero signal) and the derivable workspace, then patch the store. */
536
878
  function syncHero() {
@@ -546,13 +888,17 @@ window.__ModuleLoader__.load({
546
888
  }
547
889
  }
548
890
 
549
- /** Fixed-position hero launcher (B2): visible only while the
550
- * conversation root reports the hero phase. */
891
+ /** Fixed-position hero launcher: the last-resort fallback, mounted only
892
+ * while NO declared slot seat is available. Its DOM/store probing (and
893
+ * the MutationObserver behind it) stays unwired whenever a real seat
894
+ * holds the page. */
551
895
  function HeroLauncher(props) {
552
896
  var store = useStore();
553
897
  var t = props.t;
898
+ var seat = bestHeroSeat(store.heroClaims);
554
899
  React.useEffect(
555
900
  function () {
901
+ if (bestHeroSeat(getSnapshot().heroClaims) !== null) return undefined;
556
902
  var disposers = [];
557
903
  if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
558
904
  disposers.push(sessions.list.subscribe(syncHero));
@@ -576,15 +922,16 @@ window.__ModuleLoader__.load({
576
922
  if (observer) observer.disconnect();
577
923
  };
578
924
  },
579
- [],
925
+ [seat],
580
926
  );
927
+ if (seat !== null) return null;
581
928
  if (!store.hero) return null;
582
929
  return React.createElement(
583
930
  'button',
584
931
  {
585
932
  type: 'button',
586
933
  title: store.heroWorkspace ? t('title.heroLauncher.hasWorkspace') : t('title.heroLauncher.noWorkspace'),
587
- onClick: function () { openForWorkspace(store.heroWorkspace); },
934
+ onClick: function () { openForWorkspace(store.heroWorkspace, 'overlay'); },
588
935
  style: {
589
936
  position: 'fixed',
590
937
  bottom: 24,
@@ -595,12 +942,12 @@ window.__ModuleLoader__.load({
595
942
  gap: 6,
596
943
  padding: '6px 12px',
597
944
  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)',
945
+ border: '1px solid ' + TOKEN.border,
946
+ background: TOKEN.surface,
947
+ color: TOKEN.ink,
601
948
  fontSize: 13,
602
949
  cursor: 'pointer',
603
- boxShadow: '0 4px 16px rgba(0,0,0,0.14)',
950
+ boxShadow: TOKEN.shadow,
604
951
  opacity: store.heroWorkspace ? 1 : 0.7,
605
952
  },
606
953
  },
@@ -609,32 +956,33 @@ window.__ModuleLoader__.load({
609
956
  }
610
957
 
611
958
  /** 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. */
959
+ * slot: rendered beside the workspace picker once the DSH core declares
960
+ * the slot; a no-op registration until then. Its popover opens
961
+ * DOWNWARD, matching the official workspace picker on the same row. */
614
962
  function HeroChip(props) {
615
963
  var store = useStore();
616
964
  var t = props.t;
617
965
  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',
966
+ if (bestHeroSeat(store.heroClaims) !== 'extras') return null;
967
+ var open = store.open && store.anchor === 'extras';
968
+ var count = dirCountOf(workspacePath);
969
+ return chipWithPopover(
970
+ 'extras',
971
+ 'down',
972
+ t,
973
+ chipButton({
974
+ open: open,
975
+ dimmed: !workspacePath,
976
+ title: workspacePath ? t('title.heroChip') : t('title.heroLauncher.noWorkspace'),
977
+ label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
978
+ onClick: function () {
979
+ if (open) {
980
+ patch({ open: false });
981
+ } else {
982
+ openForWorkspace(workspacePath, 'extras');
983
+ }
635
984
  },
636
- },
637
- t('label'),
985
+ }),
638
986
  );
639
987
  }
640
988
 
@@ -651,19 +999,40 @@ window.__ModuleLoader__.load({
651
999
  function (props) { return React.createElement(Panel, props); },
652
1000
  );
653
1001
  });
1002
+ // Session-creation page, shipped seat: the official input dock band (a
1003
+ // session-scoped `list` slot rendered directly above the composer
1004
+ // card). One unique id, one explicit order, one in-flow row — the
1005
+ // framework arranges this row against every other plugin's dock entry.
1006
+ slots.inject('conversation.input.dock', function () {
1007
+ var release = claimHeroSeat('dock');
1008
+ var dispose = slots.register(
1009
+ { name: 'conversation.input.dock', id: 'multi-folder', order: DOCK_ORDER, label: function () { return t('label.heroDock'); }, locale: NS },
1010
+ function (props) { return React.createElement(DockChip, props); },
1011
+ );
1012
+ return function () {
1013
+ dispose();
1014
+ release();
1015
+ };
1016
+ });
654
1017
  slots.inject('shell.overlay', function () {
655
1018
  return slots.register(
656
1019
  { name: 'shell.overlay', id: 'multi-folder-hero', order: 200, label: function () { return t('label.heroLauncher'); }, locale: NS },
657
1020
  function (props) { return React.createElement(HeroLauncher, props); },
658
1021
  );
659
1022
  });
660
- // Upstream slot (B1): the callback fires only once a DSH build declares
661
- // `conversation.hero.workspaceExtras`; until then this contributes nothing.
1023
+ // Upstream slot: the callback fires only once a DSH build declares
1024
+ // `conversation.hero.workspaceExtras`; until then this contributes
1025
+ // nothing and the dock row above holds the page.
662
1026
  slots.inject('conversation.hero.workspaceExtras', function () {
663
- return slots.register(
1027
+ var release = claimHeroSeat('extras');
1028
+ var dispose = slots.register(
664
1029
  { name: 'conversation.hero.workspaceExtras', id: 'multi-folder', order: 30, label: function () { return t('label'); }, locale: NS },
665
1030
  function (props) { return React.createElement(HeroChip, props); },
666
1031
  );
1032
+ return function () {
1033
+ dispose();
1034
+ release();
1035
+ };
667
1036
  });
668
1037
  }
669
1038
 
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.6",
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",