dsh-better-workspace 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-better-workspace
2
2
 
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
3
5
  > 给 [DeepSeek Harness (DSH)](https://www.npmjs.com/package/@deepseek-ai/dsh) 的侧边栏「工作区」列表装上一套**文件夹系统**——工作区还是那个工作区(一个目录),但名字里的 `/` 就是层级,让「网页前端」「网页后端」这类工作区可以归到同一个 `web` 分组下。
4
6
 
5
7
  ```
@@ -31,6 +33,10 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
31
33
 
32
34
  工作区里的会话名称同样用 `/` 分层(如 `测试1/两个插件维护`)。会话分组用**次级配色且无文件夹图标**渲染,和工作区文件夹一眼可辨;会话分组可整组重命名(批量改写成员标题)。
33
35
 
36
+ **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(dsh 客户端 store),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
37
+
38
+ **跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
39
+
34
40
  <table>
35
41
  <tr>
36
42
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="外观自定义弹窗(实时预览)"/></td>
package/README_EN.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-better-workspace
2
2
 
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
3
5
  > A **folder system** for the DeepSeek Harness (DSH) sidebar workspace list — a workspace is still one directory, but every `/` in its name becomes hierarchy, so `web/frontend` and `web/backend` group under one virtual `web` folder.
4
6
 
5
7
  ```
@@ -29,6 +31,10 @@ Right-click any folder row: new subfolder (parent prefix pre-filled), new worksp
29
31
 
30
32
  Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups render in a secondary color without a folder icon, so they are clearly distinct from workspace folders; a session group can be renamed as a whole (rewrites member titles).
31
33
 
34
+ **No title regression after a restart**: the cold list right after a DSH restart only carries titles for sessions that hit the persisted projection cache — fork-born and never-checkpointed sessions temporarily fall back to the workspace *basename*, even though the real title still lives in the session log. This plugin remembers each session's **last real wire title** in the browser (dsh client store) and renders from memory during that window, so `/` grouping keeps working; the moment a session opens, the official data takes over and self-heals.
35
+
36
+ **Manual cross-device sync (web ↔ desktop app)**: appearance, explicit folders, and toggles live in each device's own browser by default. The settings card adds a "Cross-device sync" section — "Pull from desktop app" on the web / "Pull from web" in the desktop app, with an **overwrite-this-device** or **merge-both-sides** mode (the union keeps each side's unique entries); the other surface's data travels through the host settings store (~/.dsh/settings.yaml, shared by both surfaces). A "Send this device's data" button next to it covers history created before this version — push once, and afterwards every edit is written to the host automatically for the other surface to pull. Expansion state stays per-device.
37
+
32
38
  <table>
33
39
  <tr>
34
40
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="Appearance dialog with live preview"/></td>
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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.3",
3
+ "version": "0.9.5",
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": {
@@ -16,7 +16,8 @@
16
16
  "./client": "./src/client.js"
17
17
  },
18
18
  "engines": {
19
- "node": ">=18"
19
+ "node": ">=18",
20
+ "dsh": ">=0.1.0"
20
21
  },
21
22
  "dsh": {
22
23
  "bundle": {
package/src/client.js CHANGED
@@ -92,6 +92,17 @@ window.__ModuleLoader__.load({
92
92
  'custom.done': '完成',
93
93
  'settings.on': '开',
94
94
  'settings.off': '关',
95
+ 'sync.title': '跨端同步',
96
+ 'sync.desc': '外观自定义、显式分组与开关保存在本设备的浏览器里;通过宿主设置存储与另一端互传。平时的新修改会自动写入宿主,另一端点「获取」即可拿到;旧版本的历史数据首次需要点一次「发送」。',
97
+ 'sync.mode.overwrite': '覆盖本设备',
98
+ 'sync.mode.merge': '合并两端',
99
+ 'sync.pull.desktop': '从客户端获取',
100
+ 'sync.pull.web': '从 Web 获取',
101
+ 'sync.push': '发送本设备数据',
102
+ 'sync.done': '已同步',
103
+ 'sync.empty': '另一端暂无数据可获取',
104
+ 'sync.off': '当前环境不支持同步(需要宿主设置服务)',
105
+ 'sync.loading': '正在连接宿主设置…',
95
106
  'flow.title': '添加工作区',
96
107
  'flow.picked': '所选文件夹',
97
108
  'flow.parent': '所属分组',
@@ -181,6 +192,17 @@ window.__ModuleLoader__.load({
181
192
  'custom.done': 'Done',
182
193
  'settings.on': 'On',
183
194
  'settings.off': 'Off',
195
+ 'sync.title': 'Cross-device sync',
196
+ 'sync.desc': "Appearance, explicit folders, and toggles live in this device's browser; they exchange with the other surface (web / desktop app) through the host settings store. New edits are written to the host automatically — the other surface just pulls. History created before this version needs one explicit Send.",
197
+ 'sync.mode.overwrite': 'Overwrite this device',
198
+ 'sync.mode.merge': 'Merge both sides',
199
+ 'sync.pull.desktop': 'Pull from desktop app',
200
+ 'sync.pull.web': 'Pull from web',
201
+ 'sync.push': "Send this device's data",
202
+ 'sync.done': 'Synced',
203
+ 'sync.empty': 'No data on the other surface yet',
204
+ 'sync.off': 'Sync unavailable here (requires the host settings service)',
205
+ 'sync.loading': 'Connecting to host settings…',
184
206
  'flow.title': 'Add workspace',
185
207
  'flow.picked': 'Chosen folder',
186
208
  'flow.parent': 'Parent group',
@@ -304,9 +326,17 @@ window.__ModuleLoader__.load({
304
326
  return p.kind || p.status || p.type || 'pending'
305
327
  }
306
328
 
307
- const sessionTitleOf = (summary, t) => {
329
+ const sessionTitleOf = (summary, t, rememberedTitle) => {
308
330
  if (!summary) return ''
309
331
  if (summary.blank) return t('session.new')
332
+ if (summary.title) return String(summary.title)
333
+ // Cold-restart window: when the wire omits title, displayTitle is the
334
+ // host's basename fallback (dsh list only serves the projection cache,
335
+ // and fork-born/never-checkpointed sessions always miss it — the durable
336
+ // title stays in the session log, unread for a list row). A title
337
+ // remembered from an earlier snapshot restores the "/" grouping until
338
+ // the session opens and the wire catches up.
339
+ if (typeof rememberedTitle === 'string' && rememberedTitle !== '') return rememberedTitle
310
340
  return String(summary.displayTitle || summary.title || '')
311
341
  }
312
342
 
@@ -638,6 +668,13 @@ window.__ModuleLoader__.load({
638
668
  '@keyframes bw-breathe{0%,100%{filter:drop-shadow(0 0 1px var(--bw-pulse-color));opacity:.55}50%{filter:drop-shadow(0 0 6px var(--bw-pulse-color));opacity:1}}',
639
669
  '.bw-pulse-text{animation:bw-breathe-text 1.8s ease-in-out infinite}',
640
670
  '@keyframes bw-breathe-text{0%,100%{text-shadow:0 0 1px var(--bw-pulse-color);opacity:.65}50%{text-shadow:0 0 7px var(--bw-pulse-color);opacity:1}}',
671
+ '.bw-sync-modes{display:flex;gap:6px;margin-top:8px}',
672
+ '.bw-sync-mode{flex:1;padding:5px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;font-size:12px}',
673
+ '.bw-sync-mode-on{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12))}',
674
+ '.bw-sync-actions{display:flex;gap:8px;margin-top:8px}',
675
+ '.bw-sync-btn{padding:5px 12px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;font-size:12px}',
676
+ '.bw-sync-btn:disabled{opacity:.45;cursor:default}',
677
+ '.bw-sync-btn-primary{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6)}',
641
678
  ].join('')
642
679
 
643
680
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -652,6 +689,30 @@ window.__ModuleLoader__.load({
652
689
  // every selector read takes a fallback.
653
690
  persist: 'dsh.betterWorkspace.view.v1',
654
691
  actions: {
692
+ // Manual-sync pull targets (overwrite / merge): the host settings
693
+ // values land in the local store, and rendering keeps reading the
694
+ // local selectors, so scope-less hosts keep working unchanged.
695
+ importHost: (d, host) => {
696
+ if (!host || typeof host !== 'object') return
697
+ if (host.styling && typeof host.styling === 'object') d.styling = host.styling
698
+ if (Array.isArray(host.folders)) d.folders = host.folders.slice()
699
+ if (!d.prefs) d.prefs = {}
700
+ if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
701
+ if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
702
+ },
703
+ // Merge mode: union of both sides, the PULLED (host) copy wins any
704
+ // per-key conflict; prefs take the pulled booleans when present.
705
+ mergeHost: (d, host) => {
706
+ if (!host || typeof host !== 'object') return
707
+ if (host.styling && typeof host.styling === 'object') d.styling = { ...(d.styling || {}), ...host.styling }
708
+ if (Array.isArray(host.folders)) {
709
+ const merged = new Set([...(Array.isArray(d.folders) ? d.folders : []), ...host.folders])
710
+ d.folders = Array.from(merged)
711
+ }
712
+ if (!d.prefs) d.prefs = {}
713
+ if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
714
+ if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
715
+ },
655
716
  setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
656
717
  setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
657
718
  setSessionGroupExpanded: (d, key, value) => { if (!d.sessionGroups) d.sessionGroups = {}; d.sessionGroups[key] = value },
@@ -676,6 +737,135 @@ window.__ModuleLoader__.load({
676
737
  },
677
738
  })
678
739
 
740
+ /* ========================= title cache store ====================== */
741
+
742
+ // Cold-restart title fallback. The host list serves titles only from the
743
+ // persisted projection cache: sessions that never wrote a checkpoint —
744
+ // and every fork-born (seeded) session, which the list skips entirely —
745
+ // come back after a restart with title absent and displayTitle already
746
+ // degraded to the workspace basename (dsh displayTitleOf: title → cwd
747
+ // basename → id). The durable titles live on in each session's log, but
748
+ // nothing reads a log for a cheap list row. This store remembers the
749
+ // last REAL wire title per session id (learned only from snapshots where
750
+ // summary.title exists — never from displayTitle, which is basename
751
+ // degraded in exactly the window being patched), so the tree restores
752
+ // the "/" grouping until the wire catches up. Same-value writes are
753
+ // no-ops (immer produces the same state, nothing persists); entries
754
+ // evict oldest-at past the hard cap. A stale remembered title (renamed
755
+ // elsewhere, cleared browser storage) self-heals the moment the wire
756
+ // carries the truth again; a missing entry just leaves today's fallback.
757
+ const TITLE_CACHE_LIMIT = 3000
758
+ const TITLE_CACHE_KEEP = 2400
759
+ const createTitleCacheStore = () => storeKit.defineStore({
760
+ init: () => ({ byId: {} }),
761
+ persist: 'dsh.betterWorkspace.titles.v1',
762
+ actions: {
763
+ rememberTitle: (d, id, title, at) => {
764
+ if (typeof id !== 'string' || id === '' || typeof title !== 'string' || title === '') return
765
+ if (!d.byId) d.byId = {}
766
+ const prev = d.byId[id]
767
+ if (prev && prev.title === title) return
768
+ d.byId[id] = { title: title, at: typeof at === 'number' ? at : Date.now() }
769
+ const keys = Object.keys(d.byId)
770
+ if (keys.length > TITLE_CACHE_LIMIT) {
771
+ keys.sort((a, b) => ((d.byId[a] && d.byId[a].at) || 0) - ((d.byId[b] && d.byId[b].at) || 0))
772
+ for (let i = 0; i < keys.length - TITLE_CACHE_KEEP; i++) delete d.byId[keys[i]]
773
+ }
774
+ },
775
+ },
776
+ })
777
+
778
+ /* ======================= host settings sync ======================= */
779
+
780
+ // Cross-device preferences: styling / explicit folders / the two toggles
781
+ // live in the HOST settings store (~/.dsh/settings.yaml through the
782
+ // better-workspace namespace). Web and the desktop app share one DSH_HOME,
783
+ // so a style set in the browser follows the user into the Electron app and
784
+ // back. The browser localStorage view store stays the rendering source and
785
+ // the fallback: host values are MIRRORED into it one-way, and every write
786
+ // goes to BOTH stores (local action for immediate echo, scope.set for
787
+ // durable cross-device persistence). Hosts without the settingsScope
788
+ // service — or non-loopback pages where the scope stays process-local —
789
+ // simply never mirror and never sync, which is exactly the pre-0.9.5
790
+ // browser-local behavior.
791
+ let prefsScopeRef = null
792
+ const HOST_SNAP_UNAVAILABLE = { status: 'unavailable', value: undefined }
793
+ const hostPrefsOf = (snap) => (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object'
794
+ ? snap.value
795
+ : null)
796
+ const scopeSet = (field, value) => {
797
+ const scope = prefsScopeRef
798
+ if (!scope || typeof scope.set !== 'function') return
799
+ try {
800
+ Promise.resolve(scope.set(field, value)).catch((error) => {
801
+ console.warn('[dsh-better-workspace] host settings write failed: ' + field, error)
802
+ })
803
+ } catch (error) {
804
+ console.warn('[dsh-better-workspace] host settings write threw: ' + field, error)
805
+ }
806
+ }
807
+ // React hook over the scope snapshot; degrades to a stable "unavailable"
808
+ // constant when the scope is absent so useSyncExternalStore never re-binds.
809
+ // The hook call itself is unconditional (React discipline: no conditional
810
+ // hooks) — capability probing lives inside the callbacks.
811
+ const HAS_USE_SYNC_EXTERNAL_STORE = typeof React.useSyncExternalStore === 'function'
812
+ const useHostScope = () => {
813
+ const scope = prefsScopeRef
814
+ return HAS_USE_SYNC_EXTERNAL_STORE
815
+ ? React.useSyncExternalStore(
816
+ (onChange) => (scope && typeof scope.subscribe === 'function' ? scope.subscribe(onChange) : () => {}),
817
+ () => {
818
+ try { return scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : HOST_SNAP_UNAVAILABLE } catch { return HOST_SNAP_UNAVAILABLE }
819
+ },
820
+ )
821
+ : HOST_SNAP_UNAVAILABLE
822
+ }
823
+
824
+ // MANUAL cross-device sync (user-chosen, never automatic): the settings
825
+ // card offers a pull button ("从客户端获取" on the web / "从 Web 获取" in
826
+ // the desktop app — the other surface's copy arrives through the host
827
+ // settings store) with an overwrite-or-merge mode choice, plus a push
828
+ // button that uploads THIS device's current values (pre-0.9.5 history was
829
+ // never uploaded, so a first explicit push is needed once). Routine new
830
+ // writes already dual-write through makeSharedWrites, so the host copy
831
+ // stays fresh after the first push.
832
+
833
+ // Shared write wrappers: local action first (immediate echo + fallback
834
+ // store), then the durable host write computed from the CURRENT rendered
835
+ // values — after a manual pull the local store already holds the host
836
+ // copy, so host-only entries written on the other surface survive.
837
+ const makeSharedWrites = (actions, stylingMap, foldersList) => ({
838
+ setStyling: (key, value) => {
839
+ if (actions && typeof actions.setStyling === 'function') actions.setStyling(key, value)
840
+ const next = { ...stylingMap }
841
+ if (value === null) delete next[key]
842
+ else next[key] = value
843
+ scopeSet('styling', next)
844
+ },
845
+ addFolder: (path) => {
846
+ if (actions && typeof actions.addFolder === 'function') actions.addFolder(path)
847
+ const p = normPath(path)
848
+ const base = Array.isArray(foldersList) ? foldersList : []
849
+ if (p !== '' && !base.includes(p)) scopeSet('folders', [...base, p])
850
+ },
851
+ removeFolder: (path) => {
852
+ if (actions && typeof actions.removeFolder === 'function') actions.removeFolder(path)
853
+ const base = Array.isArray(foldersList) ? foldersList : []
854
+ scopeSet('folders', base.filter(f => f !== path))
855
+ },
856
+ renameFolder: (oldPath, newPath) => {
857
+ if (actions && typeof actions.renameFolder === 'function') actions.renameFolder(oldPath, newPath)
858
+ const base = Array.isArray(foldersList) ? foldersList : []
859
+ const oo = oldPath + '/'
860
+ const nn = newPath + '/'
861
+ scopeSet('folders', Array.from(new Set(base.map(f => (f === oldPath ? newPath : (f.startsWith(oo) ? nn + f.slice(oo.length) : f))))))
862
+ },
863
+ setPref: (key, value) => {
864
+ if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
865
+ scopeSet(key, value)
866
+ },
867
+ })
868
+
679
869
  /* ============================ flow dialog ========================= */
680
870
 
681
871
  const BTN = (props) => (
@@ -767,6 +957,8 @@ window.__ModuleLoader__.load({
767
957
  // while closed, but its hook sequence must stay stable.
768
958
  const snapshotItems = typeof useWorkspaces === 'function' ? useWorkspaces(s => s.items) : []
769
959
  const storeFolders = typeof useStore === 'function' ? (useStore(s => s.folders) || []) : []
960
+ const storeStyling = typeof useStore === 'function' ? (useStore(s => s.styling) || {}) : {}
961
+ const shared = makeSharedWrites(actions, storeStyling, storeFolders)
770
962
 
771
963
  React.useEffect(() => {
772
964
  if (!open) {
@@ -821,7 +1013,7 @@ window.__ModuleLoader__.load({
821
1013
  onCancel()
822
1014
  return
823
1015
  }
824
- if (prefix !== '' && actions && typeof actions.addFolder === 'function') actions.addFolder(prefix)
1016
+ if (prefix !== '') shared.addFolder(prefix)
825
1017
  onCancel()
826
1018
  })
827
1019
  .catch((reason) => {
@@ -1216,10 +1408,53 @@ window.__ModuleLoader__.load({
1216
1408
 
1217
1409
  /* ------------------------- settings page ------------------------- */
1218
1410
 
1411
+ // The desktop renderer serves the shell over the private dsh-app: scheme;
1412
+ // every other context (http/https) is the web profile.
1413
+ const IS_DESKTOP_SURFACE = typeof location !== 'undefined' && location.protocol === 'dsh-app:'
1414
+
1219
1415
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1220
1416
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1417
+ const localStyling = useStore ? (useStore(s => s.styling) || {}) : {}
1418
+ const localFolders = useStore ? (useStore(s => s.folders) || []) : []
1221
1419
  const compactChains = prefs.compactChains !== false
1222
1420
  const statusPulse = prefs.statusPulse !== false
1421
+ const setPref = (key, value) => {
1422
+ if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
1423
+ scopeSet(key, value)
1424
+ }
1425
+ // Manual cross-device sync state.
1426
+ const [syncMode, setSyncMode] = React.useState('overwrite')
1427
+ const [syncMsg, setSyncMsg] = React.useState(null)
1428
+ const snap = useHostScope()
1429
+ const host = hostPrefsOf(snap)
1430
+ const scopeLive = prefsScopeRef !== null
1431
+ const onPull = () => {
1432
+ if (host === null) { setSyncMsg(t('sync.empty')); return }
1433
+ if (syncMode === 'merge' && actions && typeof actions.mergeHost === 'function') actions.mergeHost(host)
1434
+ else if (actions && typeof actions.importHost === 'function') actions.importHost(host)
1435
+ else { setSyncMsg(t('sync.off')); return }
1436
+ setSyncMsg(t('sync.done'))
1437
+ }
1438
+ const onPush = () => {
1439
+ const folders = Array.isArray(localFolders) ? localFolders.slice() : []
1440
+ scopeSet('styling', localStyling)
1441
+ scopeSet('folders', folders)
1442
+ scopeSet('compactChains', prefs.compactChains !== false)
1443
+ scopeSet('statusPulse', prefs.statusPulse !== false)
1444
+ setSyncMsg(t('sync.done'))
1445
+ }
1446
+ const modeButton = (id, label) => E('button', {
1447
+ type: 'button',
1448
+ className: cls('bw-sync-mode', syncMode === id && 'bw-sync-mode-on'),
1449
+ 'aria-pressed': syncMode === id,
1450
+ onClick: () => { setSyncMode(id); setSyncMsg(null) },
1451
+ }, label)
1452
+ const syncActionButton = (label, onClick, primary, disabled) => E('button', {
1453
+ type: 'button',
1454
+ className: cls('bw-sync-btn', primary && 'bw-sync-btn-primary'),
1455
+ disabled,
1456
+ onClick,
1457
+ }, label)
1223
1458
  return E('div', { className: 'bw-settings' },
1224
1459
  StyleNode(),
1225
1460
  E('div', { className: 'bw-setting-row' },
@@ -1230,7 +1465,7 @@ window.__ModuleLoader__.load({
1230
1465
  'aria-checked': compactChains,
1231
1466
  'aria-label': t('settings.compactChains'),
1232
1467
  className: cls('bw-switch', compactChains && 'bw-switch-on'),
1233
- onClick: () => { actions.setPref('compactChains', !compactChains) },
1468
+ onClick: () => { setPref('compactChains', !compactChains) },
1234
1469
  }, E('span', { className: 'bw-switch-thumb' })),
1235
1470
  ),
1236
1471
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
@@ -1242,10 +1477,23 @@ window.__ModuleLoader__.load({
1242
1477
  'aria-checked': statusPulse,
1243
1478
  'aria-label': t('settings.statusPulse'),
1244
1479
  className: cls('bw-switch', statusPulse && 'bw-switch-on'),
1245
- onClick: () => { actions.setPref('statusPulse', !statusPulse) },
1480
+ onClick: () => { setPref('statusPulse', !statusPulse) },
1246
1481
  }, E('span', { className: 'bw-switch-thumb' })),
1247
1482
  ),
1248
1483
  E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1484
+ E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('sync.title')),
1485
+ E('div', { className: 'bw-hint' }, t('sync.desc')),
1486
+ E('div', { className: 'bw-sync-modes' },
1487
+ modeButton('overwrite', t('sync.mode.overwrite')),
1488
+ modeButton('merge', t('sync.mode.merge')),
1489
+ ),
1490
+ E('div', { className: 'bw-sync-actions' },
1491
+ syncActionButton(t(IS_DESKTOP_SURFACE ? 'sync.pull.web' : 'sync.pull.desktop'), onPull, true, !scopeLive || snap.status === 'loading'),
1492
+ syncActionButton(t('sync.push'), onPush, false, !scopeLive),
1493
+ ),
1494
+ syncMsg !== null ? E('div', { className: 'bw-hint' }, syncMsg)
1495
+ : (scopeLive ? null : E('div', { className: 'bw-hint' }, t('sync.off'))),
1496
+ scopeLive && snap.status === 'loading' ? E('div', { className: 'bw-hint' }, t('sync.loading')) : null,
1249
1497
  )
1250
1498
  }
1251
1499
 
@@ -1302,10 +1550,45 @@ window.__ModuleLoader__.load({
1302
1550
  const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
1303
1551
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1304
1552
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1553
+ // Dual-write wrappers: every preference mutation lands in the local
1554
+ // store (immediate echo + scope-less fallback) AND the host settings
1555
+ // store (durable cross-device copy for the manual pull on the other
1556
+ // surface). Computed from the CURRENT rendered values.
1557
+ const shared = makeSharedWrites(actions, stylingMap, storeFolders)
1305
1558
  const compactChains = prefsMap.compactChains !== false
1306
1559
  const statusPulse = prefsMap.statusPulse !== false
1307
1560
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1308
1561
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1562
+ // Last-known real titles for the cold-restart fallback window (see
1563
+ // createTitleCacheStore). Read per render via getSnapshot: no
1564
+ // subscription, because a cache write only happens when summary.title
1565
+ // exists — at that moment the row already renders the wire truth and
1566
+ // no re-render is wanted.
1567
+ const remembered = (titleCacheRef && typeof titleCacheRef.getSnapshot === 'function')
1568
+ ? (titleCacheRef.getSnapshot().byId || {})
1569
+ : {}
1570
+ const rememberedTitleOf = (id) => (remembered[id] ? remembered[id].title : undefined)
1571
+
1572
+ // Learn real titles from every snapshot: remember exactly what the
1573
+ // wire carried (summary.title), never displayTitle — that is already
1574
+ // basename-degraded in the very window this patches. Blank rows are
1575
+ // provisional New Sessions and never learn. rememberTitle itself
1576
+ // no-ops on unchanged values, so the loop is cheap when idle.
1577
+ React.useEffect(() => {
1578
+ if (!list || !list.byId) return
1579
+ const remember = titleCacheRef && titleCacheRef.actions
1580
+ ? titleCacheRef.actions.rememberTitle
1581
+ : null
1582
+ if (typeof remember !== 'function') return
1583
+ const now = Date.now()
1584
+ for (const id of Object.keys(list.byId)) {
1585
+ const summary = list.byId[id]
1586
+ if (!summary || summary.blank) continue
1587
+ const title = summary.title
1588
+ if (typeof title !== 'string' || title === '') continue
1589
+ remember(String(id), title, now)
1590
+ }
1591
+ }, [list ? list.byId : null])
1309
1592
 
1310
1593
  const [query, setQuery] = React.useState('')
1311
1594
  const [searchOpen, setSearchOpen] = React.useState(false)
@@ -1451,8 +1734,8 @@ window.__ModuleLoader__.load({
1451
1734
  if (accounted.has(id) || !sessionVisible(summary, list.current, archivedSet)) continue
1452
1735
  ungrouped.push({
1453
1736
  id,
1454
- title: sessionTitleOf(summary, t),
1455
- leaf: sessionTitleOf(summary, t),
1737
+ title: sessionTitleOf(summary, t, rememberedTitleOf(id)),
1738
+ leaf: sessionTitleOf(summary, t, rememberedTitleOf(id)),
1456
1739
  blank: !!summary.blank,
1457
1740
  running: !!summary.running,
1458
1741
  completed: summary.completed === true,
@@ -1486,8 +1769,8 @@ window.__ModuleLoader__.load({
1486
1769
  if (!sessionVisible(summary, list ? list.current : undefined, archivedSet)) continue
1487
1770
  rows.push({
1488
1771
  id,
1489
- title: sessionTitleOf(summary, t),
1490
- leaf: sessionTitleOf(summary, t),
1772
+ title: sessionTitleOf(summary, t, rememberedTitleOf(id)),
1773
+ leaf: sessionTitleOf(summary, t, rememberedTitleOf(id)),
1491
1774
  blank: !!summary.blank,
1492
1775
  running: !!summary.running,
1493
1776
  completed: summary.completed === true,
@@ -1868,7 +2151,7 @@ window.__ModuleLoader__.load({
1868
2151
  for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
1869
2152
  }
1870
2153
  if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
1871
- actions.addFolder(p)
2154
+ shared.addFolder(p)
1872
2155
  setDialog(null)
1873
2156
  }
1874
2157
 
@@ -1889,14 +2172,14 @@ window.__ModuleLoader__.load({
1889
2172
  await renameWorkspace(w.workspaceId, nextTitle)
1890
2173
  }
1891
2174
  })
1892
- .then(() => { actions.renameFolder(oldPath, newPath); setDialog(null) })
2175
+ .then(() => { shared.renameFolder(oldPath, newPath); setDialog(null) })
1893
2176
  .catch(fail)
1894
2177
  }
1895
2178
 
1896
2179
  const submitFolderDelete = (path) => {
1897
2180
  const node = findTreeNode(tree, path)
1898
2181
  if (node && countWorkspaces(node) > 0) { setErrorText(t('folder.error.notEmpty')); return }
1899
- actions.removeFolder(path)
2182
+ shared.removeFolder(path)
1900
2183
  setDialog(null)
1901
2184
  }
1902
2185
 
@@ -2259,8 +2542,8 @@ window.__ModuleLoader__.load({
2259
2542
  open: customize !== null,
2260
2543
  kind: customize ? customize.kind : undefined,
2261
2544
  initial: customize ? styleEntry(customize.entryKey) : undefined,
2262
- onChange: (style) => { if (customize) actions.setStyling(customize.entryKey, style) },
2263
- onReset: () => { if (customize) actions.setStyling(customize.entryKey, null) },
2545
+ onChange: (style) => { if (customize) shared.setStyling(customize.entryKey, style) },
2546
+ onReset: () => { if (customize) shared.setStyling(customize.entryKey, null) },
2264
2547
  onClose: () => setCustomize(null),
2265
2548
  t,
2266
2549
  }),
@@ -2276,6 +2559,15 @@ window.__ModuleLoader__.load({
2276
2559
 
2277
2560
  /* ============================ plugin ============================== */
2278
2561
 
2562
+ // Live title-cache store instance, set once per apply() and read directly
2563
+ // by BetterBrowser. NOT a registration store seat: remembered titles only
2564
+ // matter before the wire catches up, and every write happens when the
2565
+ // wire already carries the same truth, so selector-hook re-renders would
2566
+ // be pure noise. One instance per activation (never per mount) keeps the
2567
+ // persist key single-writer — dsh-client-store warns same-key instances
2568
+ // cross-pollinate one localStorage entry.
2569
+ let titleCacheRef = null
2570
+
2279
2571
  const flowSource = (slots, hole) => ({
2280
2572
  getSnapshot: () => {
2281
2573
  try { return slots.entries(hole).length > 0 } catch { return false }
@@ -2393,6 +2685,25 @@ window.__ModuleLoader__.load({
2393
2685
  // same persisted state (expansion, folder list, prefs, styling).
2394
2686
  const viewStore = createViewStore()
2395
2687
 
2688
+ // Cold-restart title fallback instance (see createTitleCacheStore).
2689
+ // getSnapshot() during render is intentional: hydration reads
2690
+ // localStorage synchronously at create(), so the first render after a
2691
+ // restart already sees every remembered title.
2692
+ titleCacheRef = createTitleCacheStore().create()
2693
+
2694
+ // Cross-device settings scope (manual sync, see the host-settings-sync
2695
+ // block). Binding is best-effort: a missing/failed bind leaves the
2696
+ // plugin fully browser-local — the pre-0.9.5 behavior.
2697
+ prefsScopeRef = null
2698
+ try {
2699
+ if (ctx.settingsScope && typeof ctx.settingsScope.bind === 'function') {
2700
+ prefsScopeRef = ctx.settingsScope.bind({ namespace: 'better-workspace' })
2701
+ }
2702
+ } catch (error) {
2703
+ console.warn('[dsh-better-workspace] settings scope bind failed; sync stays local', error)
2704
+ prefsScopeRef = null
2705
+ }
2706
+
2396
2707
  // Settings → Plugins card only (the tab dispatches the intersection of
2397
2708
  // served namespaces — registered host-side — and settings.plugin.item
2398
2709
  // cards). The old left-nav settings.section entry was removed: the
@@ -2424,7 +2735,7 @@ window.__ModuleLoader__.load({
2424
2735
 
2425
2736
  return {
2426
2737
  name: 'dsh-better-workspace',
2427
- inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace'],
2738
+ inject: ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace', 'settingsScope'],
2428
2739
  apply,
2429
2740
  }
2430
2741
  },
package/src/index.js CHANGED
@@ -49,7 +49,17 @@ export function apply(ctx) {
49
49
  const ns = typeof ds.settingsNamespace === 'function'
50
50
  ? ds.settingsNamespace('better-workspace')
51
51
  : 'better-workspace'
52
- settings.register(ns, Schema.object({ compactChains: Schema.boolean().default(true) }))
52
+ settings.register(ns, Schema.object({
53
+ // Cross-device preferences live in the HOST settings store
54
+ // (~/.dsh/settings.yaml): web and the desktop app share one
55
+ // DSH_HOME, so appearance (styling), explicit folders, and the two
56
+ // toggles follow the user across surfaces. Per-device view state
57
+ // (expansion) and the cold-restart title cache stay browser-local.
58
+ compactChains: Schema.boolean().default(true),
59
+ statusPulse: Schema.boolean().default(true),
60
+ folders: Schema.array(Schema.string()).default([]),
61
+ styling: Schema.dict(Schema.any()).default({}),
62
+ }))
53
63
  log('[dsh-better-workspace] settings namespace registered: better-workspace')
54
64
  })
55
65
  .catch((error) => {