dsh-better-workspace 0.9.0 → 0.9.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/README.md CHANGED
@@ -61,7 +61,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
61
61
  - **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标);会话分组同样可收起;搜索时自动全部展开。
62
62
  - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组;会话行可拖拽——同工作区拖到另一会话上/下调整顺序,拖到会话子分组即移动进该分组。显示顺序 = 宿主手动序,拖拽结果立即生效。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
63
63
  - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
64
- - **URL 标题感知**:会话/工作区标题里的 URL(如 `https://host/api/v1/x`) URL 语义分层——域名与协议合成一段、路径各段自成层级,同前缀 URL 自动聚到同一分组下,不再被撕成伪分组。
64
+ - **URL/斜杠标题不拆层**:宿主自动生成的会话标题落地时若含 `/`(如复述了 URL),整串自动包上 `“”` 原样显示,绝不解析成层级;引号内(含手动添加) `/` 永不切分;无引号但含 URL 的旧标题也有平铺兜底。你手动命名的 `/` 分组标题不受影响。
65
65
  - **对话空态页同样生效**:会话空态页的「添加工作区」菜单走的也是本插件的拾取交互,同样带所属分组弹窗。
66
66
 
67
67
  ## 安装
package/README_EN.md CHANGED
@@ -59,7 +59,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
59
59
  - **Folder-style collapse**: clicking a workspace row collapses/expands all of its sessions (the row keeps a session counter when collapsed); session groups collapse too; search force-expands everything.
60
60
  - **Native drag restored**: drag a workspace row above/below another to reorder (writes back the host registry order) or onto a folder row to move it into that group; drag session rows to reorder within the same workspace or onto a session group to move. Display order = host manual order, drag results are visible immediately. While dragging a workspace, merged single-chain rows (e.g. work/A) temporarily re-expand into folder rows — every level of the path becomes a drop target, and chains merge back when the drag ends.
61
61
  - **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session (the + button always expands the workspace first so the new session is immediately visible), current-session highlight, official status dots (blue running ring / amber pending / green done), **status breathing lights** (dots hidden by collapse bubble outward: workspace/folder rows breathe on their icon in the status color — amber pending / green done, custom-glow labels breathe along; running breathes blue only on the session row itself and never relays; off-able in settings), **active-schedule badge** (alarm clock icon for sessions with active Schedule records, matching the official rows), ungrouped-session fallback, zh/en localization.
62
- - **URL-aware titles**: URLs in session/workspace titles (e.g. `https://host/api/v1/x`) split the way URLs mean itscheme://host as one segment, each path segment a level so same-prefix URLs nest together instead of shredding into pseudo-folders.
62
+ - **Slash-bearing titles never nest from URLs**: when a host-generated session title lands containing `/` (e.g. echoing a pasted URL), it is auto-wrapped in `“”` and shown verbatim quotes (manual ones too) never split on `/`; unquoted URL tails still fall back flat. Your manually named `/` groupings are untouched.
63
63
  - **Conversation hero too**: the empty-state “Add workspace” menu uses the same picking interaction with the same group popup.
64
64
 
65
65
  ## Install
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区层级树:名称中的 / 即虚拟分组(web/前端 · web/后端),添加工作区的目录流附所属分组弹窗,重命名即时重排层级,可新建空分组。Hierarchy tree for the DSH sidebar workspace list.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "DSH web plugin: a hierarchical workspace tree for the sidebar. Workspace titles containing \"/\" group into virtual folders (web/前端 · web/后端); the add-workspace directory flow gains a parent-group popup; renames re-derive the tree; a new-folder button creates empty levels.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -217,45 +217,58 @@ window.__ModuleLoader__.load({
217
217
  return i === -1 ? s : s.slice(i + 1)
218
218
  }
219
219
  /**
220
- * URL-aware segment splitting for titles. A bare "/" split shreds URLs
221
- * ("https:" → empty → host → path...), so when a "://" shows up the tail is
222
- * parsed as a URL and split the way URLs mean it: scheme://host is ONE
223
- * segment (the authority is not a hierarchy level), each pathname segment
224
- * is a level, and query/hash glue to the last segment. Consequences:
225
- * several sessions sharing a URL prefix (".../v1/m", ".../v1/n") nest
226
- * under the same derived folder instead of piling up as long flat rows,
227
- * while "docs/see https://x/a/b" keeps the plain "docs" head. Titles whose
228
- * URL-looking tail does not parse stay opaque from the last "/" before the
229
- * scheme marker. Grouping is a pure projection of titles, so sessions that
230
- * were previously shredded into pseudo-folders re-flow on reload.
220
+ * Plain-text splitting with the URL tail kept opaque: a bare "/" split
221
+ * shreds URLs ("https:" → empty → host → path...), so from the first
222
+ * "://" onward the tail is ONE leaf; text before the marker splits
223
+ * normally at the last "/" before it. This is the FALLBACK guard —
224
+ * freshly generated titles get their whole "/"-bearing title wrapped in
225
+ * quotes by the quote-on-land effect (see BetterBrowser), which is the
226
+ * primary mechanism.
231
227
  */
232
- const urlSegsOf = (raw) => {
233
- let u = null
234
- try { u = new URL(raw) } catch { return null }
235
- const host = u.protocol + '//' + u.host
236
- const pathSegs = u.pathname.split('/').filter(Boolean)
237
- const tail = (u.search || '') + (u.hash || '')
238
- if (pathSegs.length === 0) return [host + tail]
239
- if (tail !== '') pathSegs[pathSegs.length - 1] += tail
240
- return [host].concat(pathSegs)
241
- }
242
-
243
- const splitTitleSegs = (title) => {
244
- const s = String(title || '')
228
+ const splitPlainSegs = (text) => {
229
+ const s = String(text || '')
245
230
  const schemeAt = s.indexOf('://')
246
231
  if (schemeAt === -1) return s.split('/').map(x => x.trim()).filter(Boolean)
247
- let start = schemeAt
248
- while (start > 0 && /[a-zA-Z0-9+.-]/.test(s[start - 1])) start--
249
- const head = s.slice(0, start).split('/').map(x => x.trim()).filter(Boolean)
250
- const urlSegs = urlSegsOf(s.slice(start).trim())
251
- if (urlSegs !== null) return head.concat(urlSegs)
252
- // Not a parseable URL after all: opaque from the last "/" before the marker.
253
232
  const cut = s.lastIndexOf('/', schemeAt)
254
233
  const segs = (cut === -1 ? '' : s.slice(0, cut)).split('/').map(x => x.trim()).filter(Boolean)
255
- const tailText = s.slice(cut + 1).trim()
256
- if (tailText !== '') segs.push(tailText)
234
+ const tail = s.slice(cut + 1).trim()
235
+ if (tail !== '') segs.push(tail)
257
236
  return segs
258
237
  }
238
+
239
+ /**
240
+ * Title → hierarchy segments, QUOTE-AWARE: spans wrapped in “…” (or "…")
241
+ * are verbatim — slashes inside quotes never split. Quoted spans stay one
242
+ * leaf including the quotes; text between quoted spans splits through
243
+ * splitPlainSegs (URL-tail opaque). An unterminated opening quote makes
244
+ * the rest of the title one verbatim span. Grouping is a pure projection,
245
+ * so previously shredded titles re-flow on reload.
246
+ */
247
+ const splitTitleSegs = (title) => {
248
+ const s = String(title || '')
249
+ if (!/["“]/.test(s)) return splitPlainSegs(s)
250
+ const out = []
251
+ let plain = ''
252
+ let i = 0
253
+ while (i < s.length) {
254
+ const ch = s[i]
255
+ if (ch === '"' || ch === '“') {
256
+ for (const seg of splitPlainSegs(plain)) out.push(seg)
257
+ plain = ''
258
+ const close = ch === '“' ? '”' : '"'
259
+ const j = s.indexOf(close, i + 1)
260
+ const end = j === -1 ? s.length : j + 1
261
+ const quoted = s.slice(i, end).trim()
262
+ if (quoted !== '') out.push(quoted)
263
+ i = end
264
+ continue
265
+ }
266
+ plain += ch
267
+ i += 1
268
+ }
269
+ for (const seg of splitPlainSegs(plain)) out.push(seg)
270
+ return out
271
+ }
259
272
  const normPath = (p) => splitTitleSegs(p).join('/')
260
273
 
261
274
  /** Render a primitives icon by name; unknown names degrade to null, never crash. */
@@ -1301,6 +1314,44 @@ window.__ModuleLoader__.load({
1301
1314
  // source element, and Chromium cancels the whole gesture. dragEnd clears
1302
1315
  // the timer so a same-tick cancel never leaves a ghost drag behind.
1303
1316
  const wsDragArmTimer = React.useRef(null)
1317
+ // Session ids that have EVER had a durable title this mount. The
1318
+ // blank→titled transition marks the host's automatic title landing
1319
+ // (LLM provider or deterministic fallback — the wire shape is the
1320
+ // same); user renames always happen AFTER landing and never re-trigger.
1321
+ // The FIRST snapshot only registers: titles that predate this mount may
1322
+ // be deliberate user groupings and must never be touched.
1323
+ const titledSeenRef = React.useRef(null)
1324
+
1325
+ // Quote-on-land: when a freshly landed automatic title contains "/",
1326
+ // wrap the WHOLE title in “…” so splitTitleSegs keeps it verbatim (the
1327
+ // AI/fallback title echoing a URL must not nest). Idempotent — quoted
1328
+ // titles and already-seen sessions are skipped — and best-effort: a
1329
+ // failed rename never retries (the unquoted URL-tail fallback in
1330
+ // splitPlainSegs still keeps the display flat).
1331
+ React.useEffect(() => {
1332
+ if (!list || !list.byId || typeof renameSession !== 'function') return
1333
+ let seen = titledSeenRef.current
1334
+ const first = seen === null
1335
+ if (first) { seen = new Set(); titledSeenRef.current = seen }
1336
+ const fixes = []
1337
+ for (const id of Object.keys(list.byId)) {
1338
+ const summary = list.byId[id]
1339
+ if (!summary || summary.blank) continue
1340
+ const title = summary.title
1341
+ if (!title || String(title).trim() === '') continue
1342
+ if (seen.has(id)) continue
1343
+ seen.add(id)
1344
+ if (first) continue // pre-existing on load: register only
1345
+ const text = String(title)
1346
+ if (!text.includes('/')) continue
1347
+ if (text.includes('“') || text.includes('"')) continue
1348
+ fixes.push([id, '“' + text + '”'])
1349
+ }
1350
+ if (fixes.length === 0) return
1351
+ Promise.resolve()
1352
+ .then(async () => { for (const [id, next] of fixes) await renameSession(id, next) })
1353
+ .catch(() => { /* keep the flat fallback display */ })
1354
+ }, [list])
1304
1355
  const normalizedQuery = query.trim().toLowerCase()
1305
1356
  const now = Date.now()
1306
1357