@lihengxi20/dsh-tabs 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lihengxi20
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,65 @@
1
- # Temporary Holding Version
1
+ # dsh-tabs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH(DeepSeek Harness)Web GUI 的**顶部会话标签页**插件:在会话头部下方加一行会话标签,像浏览器标签一样管理打开过的会话。
4
+
5
+ > GitHub 仓库:`lihengxi20/dsh-session-tabs` · npm 包名:`@lihengxi20/dsh-tabs`(同一份代码)。
6
+
7
+ > **English summary** — A session-tab bar for the DSH web GUI: open sessions as tabs, switch / close / rename them, with `workspace / title` labels and a persisted tab list. Published as `@lihengxi20/dsh-tabs`; plugin-only, no DSH core changes.
8
+
9
+ ## 截图
10
+
11
+ ![会话标签:工作区 / 会话标题与状态圆点](assets/screenshot-tabs.png)
12
+
13
+ ![标签右键菜单:重命名 / 关闭 / 关闭其他 / 全部关闭](assets/screenshot-menu.png)
14
+
15
+ ![重命名会话弹层](assets/screenshot-rename.png)
16
+
17
+ ## 功能
18
+
19
+ - **单/多会话**:只有一个会话时也显示一个标签;多会话时按打开顺序排列,从侧边栏、搜索或新建会话打开时自动加入。
20
+ - **标签文案**:`工作区 / 会话标题`;工作区取侧边栏的工作区标题(缺省回退目录名),空白会话显示 `工作区 / 新会话`。
21
+ - **切换与关闭**:点击切换;悬停出现 `×`(单个标签也可关闭);中键点击关闭。关闭只移除标签,不归档、不停止任务;关闭最后一个标签会回到「未选中会话」的空白页。
22
+ - **右键菜单**:重命名 / 关闭 / 关闭其他 (n) / 全部关闭 (n);重命名走 Host 的 `session/rename`,空白会话禁用。
23
+ - **状态圆点**:运行中(蓝/脉冲)、等待审批或回答(橙)、完成未读(绿)。
24
+ - **持久化**:标签顺序、活动标签、标题与工作区缓存存在浏览器 `localStorage`(键 `dsh-session-tabs.v1`),刷新/重开客户端后恢复。
25
+ - **跟随主题**:全部使用 DSH 主题变量,深浅色、字体大小自动适配。
26
+
27
+ 标签是导航状态:DSH 同一时刻仍只渲染一个会话,后台任务照常运行。
28
+
29
+ ## 兼容性
30
+
31
+ - 面向 **DSH 0.2.2-rc.1**(desktop profile)开发并实机验证。
32
+ - 依赖会话头部的 `conversation.header.leading`、`conversation.session.header.actions` 槽位,以及 `sessions` / `uiWorkspace` / `uiSession` 客户端服务;DSH 升级后请按 README 的验收清单复验,槽位不可用时插件会降级为不显示标签,不影响会话页面。
33
+
34
+ ## 安装
35
+
36
+ 通过 DSH 自带插件管理器添加:
37
+
38
+ ```sh
39
+ # npm
40
+ dsh plugin --profile desktop add @lihengxi20/dsh-tabs
41
+
42
+ # 或直接从 GitHub 仓库安装(始终跟最新代码)
43
+ dsh plugin --profile desktop add github:lihengxi20/dsh-session-tabs
44
+ ```
45
+
46
+ `--profile` 换成目标 profile(如 `web`)。装好后在 **设置 → 插件** 中启用/禁用它;插件市场(dshmarket)收录后也可以直接在市场里搜索安装。
47
+
48
+ ## 开发
49
+
50
+ - 无需构建:`lib/client.js` 是真实的浏览器 CJS bundle,`lib/index.js` 是让 Loader 拥有条目的 host 半侧占位。
51
+ - 本地开发可直接以目录方式安装:
52
+
53
+ ```sh
54
+ dsh plugin --profile desktop add link:C:/path/to/dsh-session-tabs
55
+ ```
56
+
57
+ - 修改 `lib/client.js` 后,在插件管理器里禁用再启用该条目即可热重载浏览器端,无需重启 DSH。
58
+
59
+ ## 数据与隐私
60
+
61
+ 插件不发起网络请求、不上报遥测;只在浏览器 `localStorage['dsh-session-tabs.v1']` 保存标签 id、标题缓存与活动标签。
62
+
63
+ ## License
64
+
65
+ [MIT](./LICENSE)
Binary file
Binary file
Binary file
@@ -0,0 +1,8 @@
1
+ # npm package: @lihengxi20/dsh-tabs (repo: dsh-session-tabs) profile patch layer.
2
+ #
3
+ # One loader entry is all this bundle needs: the host half (lib/index.js) is an
4
+ # inert plugin, and the browser half is picked up by dsh-client-modules through
5
+ # the package's `dsh.client` declaration, exactly like the shipped ui-* rows.
6
+ - insert:
7
+ - id: session-tabs
8
+ name: '@lihengxi20/dsh-tabs'
package/lib/client.js ADDED
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * dsh-session-tabs — browser half (client plugin bundle).
3
+ *
4
+ * Loaded by dsh-client-modules as the package client bundle (@lihengxi20/dsh-tabs) and run
5
+ * through the vendored Loader's lazy-CJS module table
6
+ * (window.__ModuleLoader__.load). Plain CJS, exactly the shape the shipped
7
+ * ui-* bundles emit: `require("react")` / `require("react-dom")` resolve
8
+ * against the shell's platform module table.
9
+ *
10
+ * What it does:
11
+ * - fills the conversation header's empty `conversation.header.leading` slot
12
+ * with the current session's workspace folder + name (the active-session
13
+ * info row);
14
+ * - appends one grid row to the conversation <header> and portals a session
15
+ * tab strip into it (one tab per opened session, active one highlighted);
16
+ * - tracks "opened sessions" across navigations, persists the tab list and
17
+ * the active tab in localStorage, and switches sessions through
18
+ * ctx.uiWorkspace.openSession(id).
19
+ *
20
+ * The tab strip is a navigation affordance, not a second renderer: DSH still
21
+ * renders exactly one session at a time, and closing a tab never archives the
22
+ * session, cancels its turn, or stops background work.
23
+ */
24
+ (function (root) {
25
+ "use strict";
26
+ if (!root || !root.__ModuleLoader__ || typeof root.__ModuleLoader__.load !== "function") return;
27
+
28
+ root.__ModuleLoader__.load({
29
+ id: "@lihengxi20/dsh-tabs",
30
+ factory: function (require) {
31
+ var module = { exports: {} };
32
+ var exports = module.exports;
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
34
+
35
+ var React = null;
36
+ try { React = require("react") || null; } catch (e) { React = null; }
37
+ var ReactDOM = null;
38
+ try { ReactDOM = require("react-dom") || null; } catch (e) { ReactDOM = null; }
39
+ var h = React.createElement;
40
+
41
+ // ─────────────────────────────────────────────────────────────────────
42
+ // constants
43
+ // ─────────────────────────────────────────────────────────────────────
44
+ var NS = "dsh-session-tabs";
45
+ var LS_KEY = "dsh-session-tabs.v1";
46
+ var STYLE_ID = "dsh-session-tabs-style";
47
+ var MAX_TABS = 50;
48
+
49
+ var inject = ["sessions", "slots", "locale"];
50
+
51
+ var UI = {
52
+ zh: {
53
+ "tabs.aria": "会话标签",
54
+ "newSession": "新会话",
55
+ "untitled": "未命名",
56
+ "close": "关闭标签(不停止任务)",
57
+ "workspace": "工作区",
58
+ "menu.rename": "重命名",
59
+ "menu.close": "关闭",
60
+ "menu.closeOthers": "关闭其他",
61
+ "menu.closeAll": "全部关闭",
62
+ "rename.title": "重命名会话",
63
+ "rename.placeholder": "输入会话标题",
64
+ "rename.cancel": "取消",
65
+ "rename.save": "保存",
66
+ "rename.empty": "标题不能为空",
67
+ "rename.failed": "重命名失败"
68
+ },
69
+ en: {
70
+ "tabs.aria": "Session tabs",
71
+ "newSession": "New Session",
72
+ "untitled": "Untitled",
73
+ "close": "Close tab (does not stop the task)",
74
+ "workspace": "Workspace",
75
+ "menu.rename": "Rename",
76
+ "menu.close": "Close",
77
+ "menu.closeOthers": "Close Others",
78
+ "menu.closeAll": "Close All",
79
+ "rename.title": "Rename session",
80
+ "rename.placeholder": "Session title",
81
+ "rename.cancel": "Cancel",
82
+ "rename.save": "Save",
83
+ "rename.empty": "Title cannot be empty",
84
+ "rename.failed": "Rename failed"
85
+ }
86
+ };
87
+
88
+ // ─────────────────────────────────────────────────────────────────────
89
+ // tiny store (React useSyncExternalStore)
90
+ // ─────────────────────────────────────────────────────────────────────
91
+ var state = {
92
+ tabs: [], // session ids, opening order
93
+ titles: {}, // sessionId -> last known durable title
94
+ wsTitles: {}, // sessionId -> workspace display title
95
+ currentId: null, // the session the header is actually showing
96
+ active: null, // last current session (survives list churn)
97
+ probeId: null, // strict-header probe's session id
98
+ selectionId: null, // uiWorkspace.selection session id
99
+ list: null, // latest sessions list snapshot
100
+ statuses: null, // latest sessionStatus Map snapshot
101
+ ready: false,
102
+ uiBound: false, // sessionStatus source bound
103
+ closed: null, // {id, at}: just-closed tab that must not resurrect
104
+ clearGuardUntil: 0 // suppress list-derived current after "close all"
105
+ };
106
+ var listeners = new Set();
107
+
108
+ function getState() { return state; }
109
+ function subscribe(fn) {
110
+ listeners.add(fn);
111
+ return function () { listeners.delete(fn); };
112
+ }
113
+ function emit() {
114
+ Array.from(listeners).forEach(function (fn) {
115
+ try { fn(); } catch (e) { /* one consumer must not break the rest */ }
116
+ });
117
+ }
118
+
119
+ var lastPersisted = null;
120
+ function persist() {
121
+ try {
122
+ var payload = JSON.stringify({ tabs: state.tabs, titles: state.titles, wsTitles: state.wsTitles, active: state.active });
123
+ if (payload === lastPersisted) return;
124
+ root.localStorage.setItem(LS_KEY, payload);
125
+ lastPersisted = payload;
126
+ } catch (e) { /* private mode / quota — tabs still work for this page */ }
127
+ }
128
+
129
+ function loadPersisted() {
130
+ try {
131
+ var raw = root.localStorage.getItem(LS_KEY);
132
+ if (raw === null) return;
133
+ var parsed = JSON.parse(raw);
134
+ if (!parsed || typeof parsed !== "object") return;
135
+ var tabs = Array.isArray(parsed.tabs)
136
+ ? parsed.tabs.filter(function (id) { return typeof id === "string" && id.length > 0; }).slice(0, MAX_TABS)
137
+ : [];
138
+ var titles = {};
139
+ if (parsed.titles && typeof parsed.titles === "object") {
140
+ Object.keys(parsed.titles).forEach(function (id) {
141
+ var value = parsed.titles[id];
142
+ if (typeof value === "string") titles[id] = value;
143
+ });
144
+ }
145
+ var wsTitles = {};
146
+ if (parsed.wsTitles && typeof parsed.wsTitles === "object") {
147
+ Object.keys(parsed.wsTitles).forEach(function (id) {
148
+ var value = parsed.wsTitles[id];
149
+ if (typeof value === "string") wsTitles[id] = value;
150
+ });
151
+ }
152
+ state = Object.assign({}, state, {
153
+ tabs: tabs,
154
+ titles: titles,
155
+ wsTitles: wsTitles,
156
+ active: typeof parsed.active === "string" ? parsed.active : null
157
+ });
158
+ } catch (e) { /* ignore malformed storage */ }
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────
162
+ // session list helpers
163
+ // ─────────────────────────────────────────────────────────────────────
164
+ function currentSessionId(list) {
165
+ if (!list || !Array.isArray(list.ids)) return null;
166
+ for (var i = 0; i < list.ids.length; i++) {
167
+ var id = list.ids[i];
168
+ var row = list.byId ? list.byId[id] : undefined;
169
+ if (row && row.retainedBy && (row.retainedBy.mainView || 0) > 0) return id;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function rowTitle(row) {
175
+ if (!row) return "";
176
+ if (typeof row.title === "string" && row.title.trim() !== "") return row.title.trim();
177
+ if (typeof row.displayTitle === "string" && row.displayTitle.trim() !== "") return row.displayTitle.trim();
178
+ return "";
179
+ }
180
+
181
+ function syncFromList(list) {
182
+ if (!list) return;
183
+ var tabs = state.tabs.slice();
184
+ var titles = Object.assign({}, state.titles);
185
+ var changed = false;
186
+ // The header's own current session comes from uiSession's main binding;
187
+ // the list's retainedBy is only a fallback for the first paint. After
188
+ // "close all" it stays suppressed until the cleared main view settles.
189
+ var guardingClear = state.clearGuardUntil > Date.now();
190
+ var current = state.currentId || (guardingClear ? null : currentSessionId(list));
191
+ var byId = list.byId || {};
192
+ var ids = Array.isArray(list.ids) ? list.ids : [];
193
+ var ready = list.phase !== "pending" && ids.length > 0;
194
+
195
+ // Refresh cached titles (tab labels survive a reload before the list
196
+ // finishes loading, because they are persisted).
197
+ for (var t = 0; t < tabs.length; t++) {
198
+ var row = byId[tabs[t]];
199
+ if (row) {
200
+ var title = rowTitle(row);
201
+ if (title && titles[tabs[t]] !== title) {
202
+ titles[tabs[t]] = title;
203
+ changed = true;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Prune tabs whose session vanished (deleted / archived away / no
209
+ // longer catalogued). Never prune the currently open session.
210
+ if (ready) {
211
+ var kept = tabs.filter(function (id) {
212
+ return id === current || Object.prototype.hasOwnProperty.call(byId, id);
213
+ });
214
+ if (kept.length !== tabs.length) { tabs = kept; changed = true; }
215
+ }
216
+
217
+ // Opening a session (sidebar / search / new session / tab click) adds
218
+ // it. The current session is always present, so even the first session
219
+ // of a fresh window gets exactly one tab.
220
+ if (current && tabs.indexOf(current) < 0 && !isSuppressed(current)) {
221
+ tabs.push(current);
222
+ if (tabs.length > MAX_TABS) tabs = tabs.slice(tabs.length - MAX_TABS);
223
+ changed = true;
224
+ }
225
+
226
+ var active = current || state.active || (tabs.length > 0 ? tabs[tabs.length - 1] : null);
227
+ if (active !== state.active) changed = true;
228
+
229
+ state = Object.assign({}, state, {
230
+ list: list,
231
+ tabs: tabs,
232
+ titles: titles,
233
+ active: active,
234
+ ready: state.ready || ready
235
+ });
236
+ if (changed) persist();
237
+ emit();
238
+ }
239
+
240
+ function syncFromStatuses(statuses) {
241
+ state = Object.assign({}, state, { statuses: statuses });
242
+ emit();
243
+ }
244
+
245
+ /** Workspace display title for a session: live snapshot, then cached, then cwd basename. */
246
+ function workspaceTitleFor(store, id, row) {
247
+ if (id && store.wsTitles && typeof store.wsTitles[id] === "string" && store.wsTitles[id] !== "") {
248
+ return store.wsTitles[id];
249
+ }
250
+ var cwd = row && typeof row.cwd === "string" ? row.cwd : "";
251
+ return basename(cwd);
252
+ }
253
+
254
+ function syncFromWorkspaces(snapshot) {
255
+ if (!snapshot) return;
256
+ var items = Array.isArray(snapshot.items) ? snapshot.items : [];
257
+ var changed = false;
258
+ var wsTitles = state.wsTitles;
259
+ // Ignore the empty pre-ready snapshot so cached labels do not flash away.
260
+ if (items.length > 0 || snapshot.phase === "ready") {
261
+ var next = {};
262
+ for (var i = 0; i < items.length; i++) {
263
+ var item = items[i];
264
+ if (!item || !Array.isArray(item.sessionIds)) continue;
265
+ var title = typeof item.title === "string" ? item.title.trim() : "";
266
+ if (title === "") continue;
267
+ for (var j = 0; j < item.sessionIds.length; j++) next[item.sessionIds[j]] = title;
268
+ }
269
+ if (JSON.stringify(next) !== JSON.stringify(wsTitles)) {
270
+ wsTitles = next;
271
+ changed = true;
272
+ }
273
+ }
274
+ state = Object.assign({}, state, { wsTitles: wsTitles });
275
+ if (changed) persist();
276
+ emit();
277
+ }
278
+
279
+ /**
280
+ * Two authoritative navigation signals, in order:
281
+ * - `uiWorkspace.selection` (set synchronously by replaceMain, also
282
+ * covers blank Sessions whose strict header hides the actions slot);
283
+ * - the strict Session header probe (exact session id the shell renders).
284
+ * The catalog's retainedBy projection is only the last fallback: it lags
285
+ * behind navigation while old main-view references drain.
286
+ */
287
+ function isSuppressed(id) {
288
+ return state.closed !== null && state.closed.id === id && Date.now() - state.closed.at < 2000;
289
+ }
290
+
291
+ function applyCurrentId(id) {
292
+ var tabs = state.tabs.slice();
293
+ var titles = Object.assign({}, state.titles);
294
+ if (id && tabs.indexOf(id) < 0 && !isSuppressed(id)) {
295
+ tabs.push(id);
296
+ if (tabs.length > MAX_TABS) tabs = tabs.slice(tabs.length - MAX_TABS);
297
+ }
298
+ state = Object.assign({}, state, {
299
+ currentId: id,
300
+ active: id || state.active,
301
+ tabs: tabs,
302
+ titles: titles
303
+ });
304
+ persist();
305
+ emit();
306
+ }
307
+
308
+ function syncSelection(selection) {
309
+ var id = selection && typeof selection.sessionId === "string" ? selection.sessionId : null;
310
+ state = Object.assign({}, state, { selectionId: id });
311
+ if (id !== null) applyCurrentId(id);
312
+ else emit();
313
+ }
314
+
315
+ function setProbeId(id) {
316
+ state = Object.assign({}, state, { probeId: id });
317
+ applyCurrentId(state.selectionId || id);
318
+ }
319
+
320
+ function clearProbeId(id) {
321
+ if (state.probeId !== id) return;
322
+ state = Object.assign({}, state, { probeId: null });
323
+ // Never fall back to the catalog's retainedBy here: it lags behind
324
+ // navigation and would re-create a tab the user just closed.
325
+ if (state.selectionId !== null) applyCurrentId(state.selectionId);
326
+ else emit();
327
+ }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────
330
+ // actions
331
+ // ─────────────────────────────────────────────────────────────────────
332
+ function openSession(ctx, id) {
333
+ if (!id) return;
334
+ try {
335
+ var uiWorkspace = ctx.get("uiWorkspace");
336
+ if (uiWorkspace && typeof uiWorkspace.openSession === "function") {
337
+ uiWorkspace.openSession(id);
338
+ }
339
+ } catch (e) {
340
+ try { console.warn("[dsh-session-tabs] openSession failed", e); } catch (e2) { /* ignore */ }
341
+ }
342
+ }
343
+
344
+ function closeTab(ctx, id) {
345
+ var idx = state.tabs.indexOf(id);
346
+ if (idx < 0) return;
347
+ // Closing the last remaining tab behaves like "close all": the tab row
348
+ // disappears and the main view returns to the unselected-session page.
349
+ if (state.tabs.length <= 1) {
350
+ closeAllTabs(ctx);
351
+ return;
352
+ }
353
+ var tabs = state.tabs.slice();
354
+ tabs.splice(idx, 1);
355
+ var titles = Object.assign({}, state.titles);
356
+ delete titles[id];
357
+ var wasCurrent = (state.currentId || currentSessionId(state.list)) === id;
358
+ var nextActive = tabs[Math.min(idx, tabs.length - 1)] || null;
359
+ state = Object.assign({}, state, {
360
+ tabs: tabs,
361
+ titles: titles,
362
+ active: nextActive,
363
+ closed: { id: id, at: Date.now() }
364
+ });
365
+ persist();
366
+ emit();
367
+ if (wasCurrent && nextActive) openSession(ctx, nextActive);
368
+ }
369
+
370
+ function closeOtherTabs(ctx, keepId) {
371
+ if (state.tabs.length <= 1 || state.tabs.indexOf(keepId) < 0) return;
372
+ openSession(ctx, keepId);
373
+ var titles = {};
374
+ if (state.titles[keepId]) titles[keepId] = state.titles[keepId];
375
+ state = Object.assign({}, state, {
376
+ tabs: [keepId],
377
+ titles: titles,
378
+ active: keepId,
379
+ currentId: keepId,
380
+ closed: null
381
+ });
382
+ persist();
383
+ emit();
384
+ }
385
+
386
+ function closeAllTabs(ctx) {
387
+ if (state.tabs.length === 0) return;
388
+ state = Object.assign({}, state, {
389
+ tabs: [],
390
+ titles: {},
391
+ active: null,
392
+ currentId: null,
393
+ probeId: null,
394
+ selectionId: null,
395
+ clearGuardUntil: Date.now() + 1500,
396
+ closed: null
397
+ });
398
+ persist();
399
+ emit();
400
+ try {
401
+ var uiWorkspace = ctx.get("uiWorkspace");
402
+ if (uiWorkspace && typeof uiWorkspace.clearMain === "function") uiWorkspace.clearMain();
403
+ } catch (e) {
404
+ try { console.warn("[dsh-session-tabs] clearMain failed", e); } catch (e2) { /* ignore */ }
405
+ }
406
+ }
407
+
408
+ function renameSession(ctx, id, title) {
409
+ var sessions = null;
410
+ try { sessions = ctx.get("sessions"); } catch (e) { sessions = null; }
411
+ if (!sessions || !sessions.manager || typeof sessions.manager.rename !== "function") {
412
+ return Promise.reject(new Error("rename unavailable"));
413
+ }
414
+ return sessions.manager.rename(id, title).then(function (result) {
415
+ if (result && result.ok === true) return true;
416
+ var message = result && result.error && result.error.message ? result.error.message : "rename failed";
417
+ throw new Error(message);
418
+ });
419
+ }
420
+
421
+ // ─────────────────────────────────────────────────────────────────────
422
+ // icons
423
+ // ─────────────────────────────────────────────────────────────────────
424
+ function FolderIcon() {
425
+ return h("svg", {
426
+ width: 16, height: 16, viewBox: "0 0 16 16", fill: "none",
427
+ "aria-hidden": "true", className: "dshst-icon"
428
+ }, h("path", {
429
+ d: "M1.75 4.25c0-.69.56-1.25 1.25-1.25h3.1c.33 0 .65.13.88.37l.9.9c.24.24.56.37.89.37h4.23c.69 0 1.25.56 1.25 1.25v6.36c0 .69-.56 1.25-1.25 1.25H3c-.69 0-1.25-.56-1.25-1.25V4.25Z",
430
+ stroke: "currentColor", strokeWidth: 1.2, strokeLinejoin: "round"
431
+ }));
432
+ }
433
+
434
+ function CloseIcon() {
435
+ return h("svg", {
436
+ width: 10, height: 10, viewBox: "0 0 10 10", fill: "none",
437
+ "aria-hidden": "true"
438
+ }, h("path", {
439
+ d: "M1.5 1.5l7 7M8.5 1.5l-7 7",
440
+ stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round"
441
+ }));
442
+ }
443
+
444
+ // ─────────────────────────────────────────────────────────────────────
445
+ // components
446
+ // ─────────────────────────────────────────────────────────────────────
447
+ function basename(p) {
448
+ if (typeof p !== "string" || p === "") return "";
449
+ var s = p.replace(/[\\/]+$/, "");
450
+ if (s === "") return "";
451
+ var i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
452
+ return i < 0 ? s : s.slice(i + 1);
453
+ }
454
+
455
+ function useStore() {
456
+ return React.useSyncExternalStore(subscribe, getState, getState);
457
+ }
458
+
459
+ function statusOf(store, row) {
460
+ var statuses = store.statuses;
461
+ if (statuses && typeof statuses.get === "function") {
462
+ var status = statuses.get(row.id);
463
+ if (status) {
464
+ if (status.pendingInteraction) return "pending";
465
+ if (status.running) return "running";
466
+ if (status.completionUnread) return "done";
467
+ return "idle";
468
+ }
469
+ }
470
+ return row.running ? "running" : "idle";
471
+ }
472
+
473
+ function CloseButton(props) {
474
+ return h("button", {
475
+ type: "button",
476
+ className: "dshst-close",
477
+ title: props.t("close"),
478
+ "aria-label": props.t("close"),
479
+ onClick: function (event) {
480
+ event.stopPropagation();
481
+ closeTab(props.ctx, props.id);
482
+ },
483
+ onContextMenu: function (event) {
484
+ event.preventDefault();
485
+ event.stopPropagation();
486
+ }
487
+ }, CloseIcon());
488
+ }
489
+
490
+ function useMenuPosition(x, y, width, height) {
491
+ var ref = React.useRef(null);
492
+ var posState = React.useState({ x: x, y: y });
493
+ var pos = posState[0];
494
+ var setPos = posState[1];
495
+ React.useLayoutEffect(function () {
496
+ var viewportW = root.innerWidth || 1024;
497
+ var viewportH = root.innerHeight || 768;
498
+ var w = width;
499
+ var hgt = height;
500
+ var el = ref.current;
501
+ if (el && typeof el.getBoundingClientRect === "function") {
502
+ var rect = el.getBoundingClientRect();
503
+ if (rect.width > 0) w = rect.width;
504
+ if (rect.height > 0) hgt = rect.height;
505
+ }
506
+ var nextX = Math.max(8, Math.min(x, viewportW - w - 8));
507
+ var nextY = Math.max(8, Math.min(y, viewportH - hgt - 8));
508
+ if (nextX !== pos.x || nextY !== pos.y) setPos({ x: nextX, y: nextY });
509
+ }, [x, y, width, height]);
510
+ return { ref: ref, pos: pos };
511
+ }
512
+
513
+ function TabContextMenu(props) {
514
+ var ctx = props.ctx;
515
+ var t = props.t;
516
+ var targetId = props.targetId;
517
+ var total = props.total;
518
+ var row = props.row;
519
+ var canRename = true;
520
+ if (row && row.blank) canRename = false;
521
+ else if (typeof props.useSessionAccess === "function") {
522
+ canRename = props.useSessionAccess(targetId, "session/rename") !== false;
523
+ }
524
+ var items = [
525
+ { key: "rename", label: t("menu.rename"), disabled: !canRename, onSelect: props.onRename },
526
+ { key: "close", label: t("menu.close"), disabled: total === 0, onSelect: props.onCloseTab },
527
+ { key: "closeOthers", label: t("menu.closeOthers"), count: Math.max(0, total - 1), disabled: total <= 1, onSelect: props.onCloseOthers },
528
+ { key: "closeAll", label: t("menu.closeAll"), count: total, disabled: total === 0, onSelect: props.onCloseAll }
529
+ ];
530
+ var itemsRef = React.useRef(items);
531
+ itemsRef.current = items;
532
+ var indexState = React.useState(0);
533
+ var activeIndex = indexState[0];
534
+ var setActiveIndex = indexState[1];
535
+ var position = useMenuPosition(props.x, props.y, 180, 4 * 32 + 8);
536
+
537
+ React.useEffect(function () {
538
+ var firstEnabled = 0;
539
+ for (var i = 0; i < itemsRef.current.length; i++) {
540
+ if (!itemsRef.current[i].disabled) { firstEnabled = i; break; }
541
+ }
542
+ setActiveIndex(firstEnabled);
543
+ }, []);
544
+
545
+ React.useEffect(function () {
546
+ function onPointerDown(event) {
547
+ var el = position.ref.current;
548
+ if (el && event.target instanceof Node && el.contains(event.target)) return;
549
+ props.onClose();
550
+ }
551
+ function onKey(event) {
552
+ if (event.key === "Escape") {
553
+ event.preventDefault();
554
+ event.stopPropagation();
555
+ props.onClose();
556
+ return;
557
+ }
558
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
559
+ event.preventDefault();
560
+ var list = itemsRef.current;
561
+ var step = event.key === "ArrowDown" ? 1 : -1;
562
+ var idx = activeIndex;
563
+ for (var guard = 0; guard < list.length; guard++) {
564
+ idx = (idx + step + list.length) % list.length;
565
+ if (!list[idx].disabled) break;
566
+ }
567
+ setActiveIndex(idx);
568
+ return;
569
+ }
570
+ if (event.key === "Enter") {
571
+ event.preventDefault();
572
+ var item = itemsRef.current[activeIndex];
573
+ if (item && !item.disabled) item.onSelect();
574
+ }
575
+ }
576
+ document.addEventListener("mousedown", onPointerDown, true);
577
+ document.addEventListener("keydown", onKey, true);
578
+ document.addEventListener("scroll", props.onClose, true);
579
+ root.addEventListener("blur", props.onClose);
580
+ return function () {
581
+ document.removeEventListener("mousedown", onPointerDown, true);
582
+ document.removeEventListener("keydown", onKey, true);
583
+ document.removeEventListener("scroll", props.onClose, true);
584
+ root.removeEventListener("blur", props.onClose);
585
+ };
586
+ }, [activeIndex, props]);
587
+
588
+ var menu = h("div", {
589
+ ref: position.ref,
590
+ className: "dshst-menu",
591
+ role: "menu",
592
+ tabIndex: -1,
593
+ style: { left: position.pos.x + "px", top: position.pos.y + "px" }
594
+ }, items.map(function (item, index) {
595
+ return h("div", {
596
+ key: item.key,
597
+ role: "menuitem",
598
+ className: "dshst-menu-item",
599
+ "data-hovered": index === activeIndex && !item.disabled ? "true" : "false",
600
+ "data-disabled": item.disabled ? "true" : "false",
601
+ onMouseEnter: function () { if (!item.disabled) setActiveIndex(index); },
602
+ onClick: function () { if (!item.disabled) item.onSelect(); }
603
+ },
604
+ h("span", { className: "dshst-menu-label" }, item.label),
605
+ typeof item.count === "number"
606
+ ? h("span", { className: "dshst-menu-count" }, "(" + item.count + ")")
607
+ : null
608
+ );
609
+ }));
610
+ return ReactDOM.createPortal(menu, document.body);
611
+ }
612
+
613
+ function RenamePopup(props) {
614
+ var ctx = props.ctx;
615
+ var t = props.t;
616
+ var valueState = React.useState(props.initial || "");
617
+ var value = valueState[0];
618
+ var setValue = valueState[1];
619
+ var busyState = React.useState(false);
620
+ var busy = busyState[0];
621
+ var setBusy = busyState[1];
622
+ var errorState = React.useState("");
623
+ var error = errorState[0];
624
+ var setError = errorState[1];
625
+ var inputRef = React.useRef(null);
626
+ var position = useMenuPosition(props.x, props.y, 280, 120);
627
+
628
+ React.useEffect(function () {
629
+ var input = inputRef.current;
630
+ if (input === null) return;
631
+ try {
632
+ input.focus();
633
+ if (typeof input.select === "function") input.select();
634
+ } catch (e) { /* ignore */ }
635
+ }, []);
636
+
637
+ React.useEffect(function () {
638
+ function onPointerDown(event) {
639
+ var el = position.ref.current;
640
+ if (el && event.target instanceof Node && el.contains(event.target)) return;
641
+ if (!busy) props.onClose();
642
+ }
643
+ function onKey(event) {
644
+ if (event.key === "Escape" && !busy) {
645
+ event.preventDefault();
646
+ event.stopPropagation();
647
+ props.onClose();
648
+ }
649
+ }
650
+ document.addEventListener("mousedown", onPointerDown, true);
651
+ document.addEventListener("keydown", onKey, true);
652
+ return function () {
653
+ document.removeEventListener("mousedown", onPointerDown, true);
654
+ document.removeEventListener("keydown", onKey, true);
655
+ };
656
+ }, [busy, props]);
657
+
658
+ function submit() {
659
+ if (busy) return;
660
+ var next = value.trim();
661
+ if (next === "") {
662
+ setError(t("rename.empty"));
663
+ return;
664
+ }
665
+ setBusy(true);
666
+ setError("");
667
+ renameSession(ctx, props.id, next)
668
+ .then(function () {
669
+ try { syncFromList(ctx.get("sessions").list.getSnapshot()); } catch (e) { /* ignore */ }
670
+ props.onClose();
671
+ })
672
+ .catch(function (reason) {
673
+ setBusy(false);
674
+ setError((reason && reason.message ? String(reason.message) : t("rename.failed")));
675
+ });
676
+ }
677
+
678
+ var popup = h("div", {
679
+ ref: position.ref,
680
+ className: "dshst-rename",
681
+ role: "dialog",
682
+ "aria-label": t("rename.title"),
683
+ style: { left: position.pos.x + "px", top: position.pos.y + "px" }
684
+ },
685
+ h("div", { className: "dshst-rename-title" }, t("rename.title")),
686
+ h("input", {
687
+ ref: inputRef,
688
+ className: "dshst-rename-input",
689
+ type: "text",
690
+ value: value,
691
+ placeholder: t("rename.placeholder"),
692
+ disabled: busy,
693
+ "data-invalid": error !== "" ? "true" : "false",
694
+ onChange: function (event) { setValue(event.target.value); if (error !== "") setError(""); },
695
+ onKeyDown: function (event) {
696
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
697
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); props.onClose(); }
698
+ }
699
+ }),
700
+ error !== "" ? h("div", { className: "dshst-rename-error" }, error) : null,
701
+ h("div", { className: "dshst-rename-actions" },
702
+ h("button", {
703
+ type: "button",
704
+ className: "dshst-btn",
705
+ disabled: busy,
706
+ onClick: function () { props.onClose(); }
707
+ }, t("rename.cancel")),
708
+ h("button", {
709
+ type: "button",
710
+ className: "dshst-btn",
711
+ "data-primary": "true",
712
+ disabled: busy,
713
+ onClick: submit
714
+ }, t("rename.save"))
715
+ )
716
+ );
717
+ return ReactDOM.createPortal(popup, document.body);
718
+ }
719
+
720
+ function TabStrip(props) {
721
+ var store = props.store;
722
+ var list = store.list || { byId: {} };
723
+ var tabs = store.tabs || [];
724
+ var ctx = props.ctx;
725
+ var t = props.t;
726
+ var current = store.currentId || currentSessionId(list) || store.active;
727
+ var menuState = React.useState(null);
728
+ var menu = menuState[0];
729
+ var setMenu = menuState[1];
730
+ var renameState = React.useState(null);
731
+ var rename = renameState[0];
732
+ var setRename = renameState[1];
733
+
734
+ if (tabs.length === 0) return null;
735
+
736
+ function openTabMenu(event, id) {
737
+ event.preventDefault();
738
+ event.stopPropagation();
739
+ setMenu({ id: id, x: event.clientX, y: event.clientY });
740
+ }
741
+
742
+ var strip = h("div", {
743
+ className: "dshst-tabs",
744
+ role: "tablist",
745
+ "aria-label": t("tabs.aria")
746
+ }, tabs.map(function (id) {
747
+ var row = list.byId ? list.byId[id] : undefined;
748
+ var label;
749
+ if (row && row.blank) label = t("newSession");
750
+ else if (row && row.title && row.title.trim() !== "") label = row.title.trim();
751
+ else if (store.titles[id]) label = store.titles[id];
752
+ else label = t("untitled");
753
+ var workspace = workspaceTitleFor(store, id, row);
754
+ var active = id === current;
755
+ var status = row ? statusOf(store, row) : "idle";
756
+ var title = (workspace ? workspace + " / " : "") + label + (row && row.cwd ? "\n" + row.cwd : "");
757
+ return h("div", {
758
+ key: id,
759
+ className: "dshst-tab",
760
+ role: "tab",
761
+ tabIndex: active ? 0 : -1,
762
+ "aria-selected": active ? "true" : "false",
763
+ "data-active": active ? "true" : "false",
764
+ "data-status": status,
765
+ "data-session-id": id,
766
+ "data-menu-target": menu !== null && menu.id === id ? "true" : "false",
767
+ title: title,
768
+ onClick: function () { openSession(ctx, id); },
769
+ onAuxClick: function (event) {
770
+ if (event.button === 1) closeTab(ctx, id);
771
+ },
772
+ onContextMenu: function (event) { openTabMenu(event, id); },
773
+ onKeyDown: function (event) {
774
+ if (event.key === "Enter" || event.key === " ") {
775
+ event.preventDefault();
776
+ openSession(ctx, id);
777
+ }
778
+ }
779
+ },
780
+ h("span", { className: "dshst-dot", "aria-hidden": "true" }),
781
+ workspace
782
+ ? h("span", { className: "dshst-ws", title: workspace }, workspace)
783
+ : null,
784
+ workspace
785
+ ? h("span", { className: "dshst-sep", "aria-hidden": "true" }, "/")
786
+ : null,
787
+ h("span", { className: "dshst-label" }, label),
788
+ h(CloseButton, { ctx: ctx, id: id, t: t })
789
+ );
790
+ }));
791
+
792
+ var overlays = [];
793
+ if (menu !== null) {
794
+ var menuTarget = menu.id;
795
+ var menuRow = list.byId ? list.byId[menuTarget] : undefined;
796
+ overlays.push(h(TabContextMenu, {
797
+ key: "menu",
798
+ ctx: ctx,
799
+ t: t,
800
+ x: menu.x,
801
+ y: menu.y,
802
+ targetId: menuTarget,
803
+ row: menuRow,
804
+ total: tabs.length,
805
+ useSessionAccess: props.useSessionAccess,
806
+ onClose: function () { setMenu(null); },
807
+ onRename: function () {
808
+ setMenu(null);
809
+ setRename({ id: menuTarget, x: menu.x, y: menu.y });
810
+ },
811
+ onCloseTab: function () { setMenu(null); closeTab(ctx, menuTarget); },
812
+ onCloseOthers: function () { setMenu(null); closeOtherTabs(ctx, menuTarget); },
813
+ onCloseAll: function () { setMenu(null); closeAllTabs(ctx); }
814
+ }));
815
+ }
816
+ if (rename !== null) {
817
+ var renameRow = list.byId ? list.byId[rename.id] : undefined;
818
+ var initialTitle = renameRow && typeof renameRow.title === "string" ? renameRow.title : "";
819
+ overlays.push(h(RenamePopup, {
820
+ key: "rename",
821
+ ctx: ctx,
822
+ t: t,
823
+ id: rename.id,
824
+ initial: initialTitle,
825
+ x: rename.x,
826
+ y: rename.y,
827
+ onClose: function () { setRename(null); }
828
+ }));
829
+ }
830
+ var overlay = overlays.length > 0
831
+ ? ReactDOM.createPortal(h(React.Fragment, null, overlays), document.body)
832
+ : null;
833
+ return h(React.Fragment, null, strip, overlay);
834
+ }
835
+
836
+ /**
837
+ * Headless current-session probe: lives in the strict Session header's
838
+ * actions list, so it is mounted with the exact `sessionId` being shown.
839
+ */
840
+ function CurrentProbe(props) {
841
+ var id = typeof props.sessionId === "string" ? props.sessionId : null;
842
+ React.useEffect(function () {
843
+ if (id !== null) setProbeId(id);
844
+ return function () { clearProbeId(id); };
845
+ }, [id]);
846
+ return null;
847
+ }
848
+
849
+ /**
850
+ * Header-leading occupant: renders the workspace info inline, and portals
851
+ * the tab strip into one extra header row (the conversation root header is
852
+ * a grid; an appended child becomes the row below the View tabs).
853
+ */
854
+ function Leading(props) {
855
+ var ctx = props.ctx;
856
+ var t = props.t;
857
+ var store = useStore();
858
+ var anchorRef = React.useRef(null);
859
+ var rowState = React.useState(null);
860
+ var row = rowState[0];
861
+ var setRow = rowState[1];
862
+
863
+ React.useLayoutEffect(function () {
864
+ var anchor = anchorRef.current;
865
+ if (!anchor || typeof anchor.closest !== "function") return undefined;
866
+ var header = anchor.closest("header");
867
+ if (!header || header.querySelector("[data-dsh-session-tabs]")) return undefined;
868
+ var el = document.createElement("div");
869
+ el.className = "dshst-row";
870
+ el.setAttribute("data-dsh-session-tabs", "");
871
+ header.appendChild(el);
872
+ setRow(el);
873
+ // The header is React-managed: when React mounts/reorders its own grid
874
+ // children it can insert them after our appended row, which would flip
875
+ // the auto-placement order. Keep our row last on every child mutation.
876
+ var observer = null;
877
+ if (typeof MutationObserver === "function") {
878
+ observer = new MutationObserver(function () {
879
+ if (el.parentNode === header && header.lastElementChild !== el) header.appendChild(el);
880
+ });
881
+ observer.observe(header, { childList: true });
882
+ }
883
+ return function () {
884
+ if (observer !== null) observer.disconnect();
885
+ if (el.parentNode) el.parentNode.removeChild(el);
886
+ setRow(null);
887
+ };
888
+ }, []);
889
+
890
+ var list = store.list;
891
+ var current = store.currentId || currentSessionId(list);
892
+ var rowData = current && list && list.byId ? list.byId[current] : undefined;
893
+ var cwd = rowData && typeof rowData.cwd === "string" ? rowData.cwd : "";
894
+ var workspace = workspaceTitleFor(store, current, rowData);
895
+
896
+ var leading = h("span", { className: "dshst-leading", ref: anchorRef },
897
+ workspace
898
+ ? h(React.Fragment, null,
899
+ h(FolderIcon),
900
+ h("span", { className: "dshst-ws", title: cwd }, workspace),
901
+ h("span", { className: "dshst-sep", "aria-hidden": "true" }, "/"))
902
+ : null
903
+ );
904
+
905
+ var strip = null;
906
+ if (row && typeof ReactDOM.createPortal === "function") {
907
+ strip = ReactDOM.createPortal(
908
+ h(TabStrip, { store: store, ctx: ctx, t: t, useSessionAccess: props.useSessionAccess }),
909
+ row
910
+ );
911
+ }
912
+
913
+ return h(React.Fragment, null, leading, strip);
914
+ }
915
+
916
+ // ─────────────────────────────────────────────────────────────────────
917
+ // styles
918
+ // ─────────────────────────────────────────────────────────────────────
919
+ var CSS = [
920
+ ".dshst-leading{display:inline-flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:20px;-webkit-app-region:no-drag}",
921
+ ".dshst-icon{flex:none;opacity:.9}",
922
+ ".dshst-ws{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
923
+ ".dshst-leading .dshst-ws{max-width:180px}",
924
+ ".dshst-sep{color:var(--dsw-alias-label-caption);flex:none}",
925
+ ".dshst-row{--dshst-tab-accent:#e6cca0;grid-column:1/-1;min-width:0;padding-left:8px;margin-top:0;display:flex;align-items:flex-start;-webkit-app-region:no-drag}",
926
+ "header:has(> [data-dsh-session-tabs]){padding-bottom:0}",
927
+ ".dshst-tabs{display:flex;align-items:center;gap:0;min-width:0;width:100%;overflow-x:auto;scrollbar-width:none;padding:0}",
928
+ ".dshst-tabs::-webkit-scrollbar{display:none}",
929
+ ".dshst-tab{display:inline-flex;align-items:center;gap:6px;flex:none;max-width:300px;min-width:0;min-height:24px;padding:4px 10px 7px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;font-weight:500;line-height:16px;cursor:pointer;position:relative;-webkit-app-region:no-drag}",
930
+ ".dshst-tab:before{content:\"\";position:absolute;inset:0;border:1px solid transparent;border-bottom:0;border-radius:6px 6px 0 0;pointer-events:none}",
931
+ ".dshst-tab .dshst-ws{max-width:110px;flex:none;color:var(--dsw-alias-label-caption);font-size:12px;font-weight:500;line-height:16px}",
932
+ ".dshst-tab .dshst-sep{font-size:12px;opacity:.9}",
933
+ "@media (hover:hover){.dshst-tab:hover{color:var(--dsw-alias-label-secondary)}.dshst-tab:hover:before{border-color:var(--dsw-alias-border-l3)}}",
934
+ ".dshst-tab:focus-visible:before{border-color:var(--dsw-alias-border-l3)}",
935
+ ".dshst-tab[data-active=\"true\"]{color:var(--dsw-alias-state-business-primary)}",
936
+ ".dshst-tab[data-active=\"true\"]:before{border-color:var(--dsw-alias-border-l4)}",
937
+ ".dshst-tab[data-active=\"true\"] .dshst-ws,.dshst-tab[data-active=\"true\"] .dshst-sep{color:var(--dsw-alias-state-business-primary);opacity:.65}",
938
+ ".dshst-tab[data-active=\"true\"]:after{content:\"\";position:absolute;left:0;right:0;bottom:0;height:2px;border-radius:2px 2px 0 0;background:var(--dshst-tab-accent)}",
939
+ ".dshst-dot{width:6px;height:6px;border-radius:50%;flex:none;background:var(--dsw-alias-state-idle-primary)}",
940
+ ".dshst-tab[data-status=\"running\"] .dshst-dot{background:var(--dsw-alias-state-business-primary);animation:dshst-pulse 1.6s ease-in-out infinite}",
941
+ ".dshst-tab[data-status=\"pending\"] .dshst-dot{background:var(--dsw-alias-state-warn-primary)}",
942
+ ".dshst-tab[data-status=\"done\"] .dshst-dot{background:var(--dsw-alias-state-success-primary)}",
943
+ "@keyframes dshst-pulse{0%,100%{opacity:1}50%{opacity:.35}}",
944
+ ".dshst-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
945
+ ".dshst-close{display:inline-flex;align-items:center;justify-content:center;flex:none;width:14px;height:14px;margin-left:2px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0}",
946
+ ".dshst-tab:hover .dshst-close,.dshst-tab:focus-within .dshst-close{opacity:1}",
947
+ "@media (hover:hover){.dshst-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}}",
948
+ "@media (prefers-reduced-motion:reduce){.dshst-tab[data-status=\"running\"] .dshst-dot{animation:none}}",
949
+ ".dshst-tab[data-menu-target=\"true\"]{background:var(--dsw-alias-interactive-bg-hover)}",
950
+ ".dshst-menu{position:fixed;z-index:1200;min-width:180px;box-sizing:border-box;padding:4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);box-shadow:0 10px 30px rgba(0,0,0,.35);outline:none;-webkit-app-region:no-drag}",
951
+ ".dshst-menu-item{display:flex;align-items:center;justify-content:space-between;gap:18px;height:32px;padding:0 10px;border-radius:5px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:16px;cursor:pointer;white-space:nowrap;user-select:none}",
952
+ ".dshst-menu-item[data-hovered=\"true\"]{background:var(--dsw-alias-interactive-bg-hover)}",
953
+ ".dshst-menu-item[data-disabled=\"true\"]{color:var(--dsw-alias-label-dimmed);cursor:default;background:transparent}",
954
+ ".dshst-menu-count{color:var(--dsw-alias-label-caption);font-size:12px;font-variant-numeric:tabular-nums}",
955
+ ".dshst-menu-item[data-disabled=\"true\"] .dshst-menu-count{color:var(--dsw-alias-label-dimmed)}",
956
+ ".dshst-rename{position:fixed;z-index:1201;width:280px;box-sizing:border-box;padding:12px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);box-shadow:0 12px 32px rgba(0,0,0,.4);-webkit-app-region:no-drag}",
957
+ ".dshst-rename-title{margin-bottom:8px;color:var(--dsw-alias-label-caption);font-size:12px;line-height:16px}",
958
+ ".dshst-rename-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;outline:none}",
959
+ ".dshst-rename-input:focus{border-color:var(--dsw-alias-state-business-primary)}",
960
+ ".dshst-rename-input[data-invalid=\"true\"]{border-color:var(--dsw-alias-label-error)}",
961
+ ".dshst-rename-error{margin-top:6px;color:var(--dsw-alias-label-error);font-size:12px;line-height:16px}",
962
+ ".dshst-rename-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}",
963
+ ".dshst-btn{height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;font-weight:500;cursor:pointer}",
964
+ ".dshst-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
965
+ ".dshst-btn[data-primary=\"true\"]{border-color:transparent;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground)}",
966
+ ".dshst-btn:disabled{opacity:.6;cursor:default}"
967
+ ].join("\n");
968
+
969
+ function installStyles() {
970
+ if (document.getElementById(STYLE_ID) !== null) return;
971
+ var style = document.createElement("style");
972
+ style.id = STYLE_ID;
973
+ style.textContent = CSS;
974
+ (document.head || document.documentElement).appendChild(style);
975
+ }
976
+
977
+ function removeStyles() {
978
+ var style = document.getElementById(STYLE_ID);
979
+ if (style !== null && style.parentNode !== null) style.parentNode.removeChild(style);
980
+ }
981
+
982
+ // ─────────────────────────────────────────────────────────────────────
983
+ // plugin body
984
+ // ─────────────────────────────────────────────────────────────────────
985
+ function apply(ctx) {
986
+ if (!React || !ReactDOM) return;
987
+ loadPersisted();
988
+
989
+ try {
990
+ ctx.effect(function () {
991
+ return ctx.locale.register(NS, UI);
992
+ }, "dsh-session-tabs: locale dictionaries");
993
+ } catch (e) { /* locale optional: fall back to zh strings below */ }
994
+
995
+ var t;
996
+ try {
997
+ t = ctx.locale.bind(NS);
998
+ } catch (e) {
999
+ t = function (key) {
1000
+ var dict = UI.zh;
1001
+ return dict[key] !== undefined ? dict[key] : key;
1002
+ };
1003
+ }
1004
+
1005
+ installStyles();
1006
+
1007
+ ctx.effect(function () {
1008
+ return function () { removeStyles(); };
1009
+ }, "dsh-session-tabs: styles");
1010
+
1011
+ ctx.effect(function () {
1012
+ var unsubs = [];
1013
+ var uiSession = null;
1014
+ var statusStore = null;
1015
+ var workspaceService = null;
1016
+ var selectionStore = null;
1017
+ var workspacesService = null;
1018
+ var workspacesStore = null;
1019
+ var retryTimer = null;
1020
+
1021
+ if (ctx.sessions && ctx.sessions.list) {
1022
+ var listStore = ctx.sessions.list;
1023
+ if (typeof listStore.getSnapshot === "function") syncFromList(listStore.getSnapshot());
1024
+ if (typeof listStore.subscribe === "function") {
1025
+ unsubs.push(listStore.subscribe(function () { syncFromList(listStore.getSnapshot()); }));
1026
+ }
1027
+ }
1028
+
1029
+ function bindUiSession() {
1030
+ if (statusStore !== null) return true;
1031
+ if (uiSession === null) {
1032
+ try { uiSession = ctx.get("uiSession"); } catch (e) { uiSession = null; }
1033
+ }
1034
+ if (!uiSession || !uiSession.sessionStatus) return false;
1035
+ statusStore = uiSession.sessionStatus;
1036
+ if (typeof statusStore.getSnapshot === "function") syncFromStatuses(statusStore.getSnapshot());
1037
+ if (typeof statusStore.subscribe === "function") {
1038
+ unsubs.push(statusStore.subscribe(function () { syncFromStatuses(statusStore.getSnapshot()); }));
1039
+ }
1040
+ if (!state.uiBound) {
1041
+ state = Object.assign({}, state, { uiBound: true });
1042
+ emit();
1043
+ }
1044
+ return true;
1045
+ }
1046
+
1047
+ function bindWorkspaceSelection() {
1048
+ if (selectionStore !== null) return true;
1049
+ if (workspaceService === null) {
1050
+ try { workspaceService = ctx.get("uiWorkspace"); } catch (e) { workspaceService = null; }
1051
+ }
1052
+ var selection = workspaceService && workspaceService.selection;
1053
+ if (!selection || typeof selection.subscribe !== "function") return false;
1054
+ selectionStore = selection;
1055
+ if (typeof selectionStore.getSnapshot === "function") syncSelection(selectionStore.getSnapshot());
1056
+ unsubs.push(selectionStore.subscribe(function () { syncSelection(selectionStore.getSnapshot()); }));
1057
+ return true;
1058
+ }
1059
+
1060
+ function bindWorkspaces() {
1061
+ if (workspacesStore !== null) return true;
1062
+ if (workspacesService === null) {
1063
+ try { workspacesService = ctx.get("workspaces"); } catch (e) { workspacesService = null; }
1064
+ }
1065
+ var store = workspacesService && workspacesService.list;
1066
+ if (!store || typeof store.subscribe !== "function") return false;
1067
+ workspacesStore = store;
1068
+ if (typeof store.getSnapshot === "function") syncFromWorkspaces(store.getSnapshot());
1069
+ unsubs.push(store.subscribe(function () { syncFromWorkspaces(store.getSnapshot()); }));
1070
+ return true;
1071
+ }
1072
+
1073
+ function bindAll() {
1074
+ return bindUiSession() && bindWorkspaceSelection() && bindWorkspaces();
1075
+ }
1076
+
1077
+ if (!bindAll()) {
1078
+ var attempts = 0;
1079
+ retryTimer = root.setInterval(function () {
1080
+ attempts += 1;
1081
+ if (bindAll() || attempts >= 60) {
1082
+ root.clearInterval(retryTimer);
1083
+ retryTimer = null;
1084
+ }
1085
+ }, 1000);
1086
+ }
1087
+
1088
+ return function () {
1089
+ if (retryTimer !== null) root.clearInterval(retryTimer);
1090
+ for (var i = 0; i < unsubs.length; i++) {
1091
+ try { unsubs[i](); } catch (e) { /* ignore */ }
1092
+ }
1093
+ };
1094
+ }, "dsh-session-tabs: session subscriptions");
1095
+
1096
+ // The strict session header's actions list is a per-session list slot;
1097
+ // the probe entry gives us the exact session id the shell renders.
1098
+ try {
1099
+ var disposeProbe = ctx.slots.inject("conversation.session.header.actions", function () {
1100
+ return ctx.slots.register({
1101
+ name: "conversation.session.header.actions",
1102
+ id: "dsh-session-tabs-current",
1103
+ order: -100,
1104
+ inject: function () { return { ctx: ctx }; }
1105
+ }, CurrentProbe);
1106
+ });
1107
+ ctx.effect(function () {
1108
+ return function () {
1109
+ if (typeof disposeProbe === "function") disposeProbe();
1110
+ };
1111
+ }, "dsh-session-tabs: current-session probe");
1112
+ } catch (e) {
1113
+ try { console.warn("[dsh-session-tabs] session header actions slot unavailable", e); } catch (e2) { /* ignore */ }
1114
+ }
1115
+
1116
+ // The header-leading slot is declared by ui-conversation; inject() waits
1117
+ // for that declaration (and re-registers after plugin reloads).
1118
+ try {
1119
+ var disposeRegistration = ctx.slots.inject("conversation.header.leading", function () {
1120
+ return ctx.slots.register({
1121
+ name: "conversation.header.leading",
1122
+ locale: NS,
1123
+ inject: function () { return { ctx: ctx }; }
1124
+ }, Leading);
1125
+ });
1126
+ ctx.effect(function () {
1127
+ return function () {
1128
+ if (typeof disposeRegistration === "function") disposeRegistration();
1129
+ };
1130
+ }, "dsh-session-tabs: header registration");
1131
+ } catch (e) {
1132
+ try { console.warn("[dsh-session-tabs] header-leading slot unavailable", e); } catch (e2) { /* ignore */ }
1133
+ }
1134
+
1135
+ try {
1136
+ console.log("[dsh-session-tabs] active (tabs: " + state.tabs.length + ")");
1137
+ } catch (e) { /* ignore */ }
1138
+ }
1139
+
1140
+ exports.inject = inject;
1141
+ exports.apply = apply;
1142
+ return module.exports;
1143
+ }
1144
+ });
1145
+ })(typeof window !== "undefined" ? window : typeof self !== "undefined" ? self : null);
package/lib/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @lihengxi20/dsh-tabs (GitHub repo: dsh-session-tabs) — host half.
3
+ *
4
+ * This plugin is browser-only: the top session tabs live entirely in the web
5
+ * client (lib/client.js), which dsh-client-modules serves as the package's
6
+ * `/client` bundle. The host entry exists so the Loader tree owns a row for the
7
+ * package; without it dsh-client-modules would never scan the `dsh.client`
8
+ * declaration. Keep this module inert: DSH also loads it on non-web surfaces
9
+ * (CLI, headless) where the tab UI must not exist.
10
+ */
11
+
12
+ /** Plugin identity for cordis.yml rows. */
13
+ export const name = "@lihengxi20/dsh-tabs";
14
+
15
+ /** Inert host plugin: the browser half owns every behavior. */
16
+ export function apply() {}
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@lihengxi20/dsh-tabs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Session tabs in the DSH conversation header: switch, close, rename and persist open sessions, with workspace/title labels and live status.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.js",
15
+ "lib/client.js",
16
+ "cordis.patch.yml",
17
+ "assets",
18
+ "screenshots.json",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "deepseek-harness",
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "dsh-web",
30
+ "session",
31
+ "tabs",
32
+ "会话标签",
33
+ "顶部标签页"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/lihengxi20/dsh-session-tabs.git"
38
+ },
39
+ "homepage": "https://github.com/lihengxi20/dsh-session-tabs#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/lihengxi20/dsh-session-tabs/issues"
42
+ },
43
+ "author": "lihengxi20",
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "inject": [
54
+ "@deepseek-ai/dsh-client-ui-session",
55
+ "@deepseek-ai/dsh-client-ui-conversation",
56
+ "@deepseek-ai/dsh-client-locale"
57
+ ],
58
+ "platform": "web"
59
+ },
60
+ "compatibility": {
61
+ "dshReleases": {
62
+ "0.2.2-rc.1": "compatible"
63
+ }
64
+ }
65
+ }
6
66
  }
@@ -0,0 +1,5 @@
1
+ [
2
+ "assets/screenshot-tabs.png",
3
+ "assets/screenshot-menu.png",
4
+ "assets/screenshot-rename.png"
5
+ ]