dsh-sidenote 0.3.2 → 0.3.4

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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * better-sidebar >= 0.19 / DSH 0.1.5 bridge.
3
+ *
4
+ * From 0.19 on, better-sidebar no longer owns the right column: it hands it to
5
+ * DSH's native right sidebar (`dsh-client-ui-sidebar-right`) and routes tab
6
+ * lifecycle calls to that surface (`openTab`/`activateTab`/`updateTab`).
7
+ * Sidechat tabs opened that way never enter the legacy layout store
8
+ * (splits/bottomSplits/floats) that `collectSideTabs` walks, so the minted tab
9
+ * id cannot be recovered from the snapshot — after `openTab` there is no
10
+ * `updateTab` target until the panel mounts and publishes itself. Drafts must
11
+ * therefore ride the `openTab` seed.meta (which the native surface honors) or
12
+ * wait for the panel's registration.
13
+ *
14
+ * This module keeps that missing link: a registry of live side-chat panels
15
+ * (their native tab id, their draft sink, their current meta) plus a lazy
16
+ * root-context holder. The root context is the plugin's own fiber — unlike the
17
+ * slot-provided context a panel receives, its inject list contains
18
+ * `workspaces`, so host calls made from it keep working on 0.1.5.
19
+ *
20
+ * Authority: dsh-better-sidebar `src/client/service.ts` (>= 0.19 surface
21
+ * routing) and `@deepseek-ai/dsh-api-session-controller` (remote session face).
22
+ */
23
+ import type { Context } from '../host/contracts.ts'
24
+
25
+ /** better-sidebar version that moved the right column to DSH's native sidebar. */
26
+ const NATIVE_SIDEBAR_MINOR = 19
27
+
28
+ let root: Context | undefined
29
+
30
+ /** Remember the plugin root context at activation (see module docstring). */
31
+ export function setRootContext(ctx: Context): void {
32
+ root = ctx
33
+ }
34
+
35
+ /** Plugin root context; falls back to the caller's context (legacy behaviour). */
36
+ export function rootContext(fallback: Context): Context {
37
+ return root ?? fallback
38
+ }
39
+
40
+ /**
41
+ * True when the host keeps sidebar tabs outside the legacy layout store
42
+ * (better-sidebar >= 0.19). Version parse is best-effort: an unparsable
43
+ * version reports false, which keeps the pre-0.19 behaviour.
44
+ */
45
+ export function nativeSidebarHost(ctx: Context): boolean {
46
+ try {
47
+ const [major = 0, minor = 0] = ctx.betterSidebar.version.split('.').map(part => Number.parseInt(part, 10))
48
+ return major > 0 || minor >= NATIVE_SIDEBAR_MINOR
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ interface LiveSideChat {
55
+ readonly tabId: string
56
+ readonly sessionId: string
57
+ /** Write text into the panel composer (draft delivery). */
58
+ seedDraft(text: string): void
59
+ /** Current tab meta, read at call time (the panel re-renders on meta change). */
60
+ readMeta(): unknown
61
+ /** Current tab title(聚焦列表/编号数据源的读取面)。 */
62
+ readTitle(): string
63
+ }
64
+
65
+ /** Native tab id → live panel. Native layouts are memory-only, so is this. */
66
+ const liveSideChats = new Map<string, LiveSideChat>()
67
+
68
+ /**
69
+ * tabId → 首次注册序号(打开序的近似)。tab.id 跨重挂载稳定,重注册不改写
70
+ * ——遍历 Map 的插入序会随重挂漂移,「最近打开」只能锚在首次序号上。
71
+ */
72
+ const birthOrder = new Map<string, number>()
73
+ let birthSeq = 0
74
+
75
+ /**
76
+ * Publish a mounted panel so programmatic entry points can focus it and seed
77
+ * its draft. Returns the disposer for the panel's effect cleanup.
78
+ */
79
+ export function registerLiveSideChat(
80
+ sessionId: string,
81
+ tabId: string,
82
+ seedDraft: (text: string) => void,
83
+ readMeta: () => unknown,
84
+ readTitle: () => string,
85
+ ): () => void {
86
+ const entry: LiveSideChat = { tabId, sessionId, seedDraft, readMeta, readTitle }
87
+ liveSideChats.set(tabId, entry)
88
+ if (!birthOrder.has(tabId)) birthOrder.set(tabId, ++birthSeq)
89
+ // 挂载即回放在 openTab→注册窗口内暂存的草稿(seedDraft 内有相位门)。
90
+ const opening = openings.get(sessionId)
91
+ if (opening !== undefined) {
92
+ openings.delete(sessionId)
93
+ for (const draft of opening.drafts) seedDraft(draft)
94
+ }
95
+ return () => {
96
+ if (liveSideChats.get(tabId) === entry) liveSideChats.delete(tabId)
97
+ }
98
+ }
99
+
100
+ // ── openTab→面板挂载窗口的 in-flight 标记 ────────────────────────────────────
101
+ // native tab 铸造后对布局快照与 registry 同时不可见,直到面板挂载登记;窗口内
102
+ // 的重复触发必须落进标记(草稿暂存、注册时回放),而不是再开一个 tab
103
+ // (双开 = 双 fork 出孤儿会话)。legacy 宿主无此窗口(openTab 同步落快照)。
104
+
105
+ /** 标记失效应力:openTab 被静默拒绝/面板未挂载时,超时后允许重试。 */
106
+ const OPENING_TTL_MS = 10_000
107
+
108
+ interface OpeningMark {
109
+ readonly at: number
110
+ /** 窗口内到达的待投递草稿(面板注册时经 seedDraft 回放)。 */
111
+ readonly drafts: string[]
112
+ }
113
+
114
+ const openings = new Map<string, OpeningMark>()
115
+
116
+ /** 标记一次已发起、面板尚未注册的 native 打开(create/reopen 铸造后调用)。 */
117
+ export function markSideChatOpening(sessionId: string): void {
118
+ openings.set(sessionId, { at: Date.now(), drafts: [] })
119
+ }
120
+
121
+ /** 进行中的打开;TTL 外视为 openTab 被拒/面板未挂载,清除并返回 undefined。 */
122
+ export function sideChatOpening(sessionId: string): OpeningMark | undefined {
123
+ const mark = openings.get(sessionId)
124
+ if (mark === undefined) return undefined
125
+ if (Date.now() - mark.at > OPENING_TTL_MS) {
126
+ openings.delete(sessionId)
127
+ return undefined
128
+ }
129
+ return mark
130
+ }
131
+
132
+ /** Live panel for a native tab id (undefined for legacy layout tabs). */
133
+ export function liveSideChat(tabId: string): LiveSideChat | undefined {
134
+ return liveSideChats.get(tabId)
135
+ }
136
+
137
+ /** 一个会话的全部存活面板(按打开序升序;native 下枚举 side tab 的唯一数据源)。 */
138
+ export function liveSideChatsOf(sessionId: string): LiveSideChat[] {
139
+ return [...liveSideChats.values()]
140
+ .filter(entry => entry.sessionId === sessionId)
141
+ .sort((a, b) => (birthOrder.get(a.tabId) ?? 0) - (birthOrder.get(b.tabId) ?? 0))
142
+ }
143
+
144
+ /**
145
+ * Most recently OPENED live panel of a session — the open-or-focus target
146
+ * when the legacy snapshot cannot see the tab (mirrors "last opened wins").
147
+ * 排序锚在首次注册序号上:面板重挂载不翻序(见 birthOrder)。
148
+ */
149
+ export function lastLiveSideChat(sessionId: string): LiveSideChat | undefined {
150
+ const all = liveSideChatsOf(sessionId)
151
+ return all[all.length - 1]
152
+ }
153
+
154
+ /** 插件卸载/重载时清场(apply 的 ctx.effect disposer 调用)。 */
155
+ export function clearNativeRuntime(): void {
156
+ root = undefined
157
+ liveSideChats.clear()
158
+ openings.clear()
159
+ birthOrder.clear()
160
+ }
161
+
162
+ /** Native tab shell for reads that go through the layout snapshot (`readTab`). */
163
+ export function nativeTabShell(tabId: string, type: string): { id: string; type: string; title: string; meta: unknown } | undefined {
164
+ const entry = liveSideChats.get(tabId)
165
+ if (entry === undefined) return undefined
166
+ return { id: entry.tabId, type, title: '', meta: entry.readMeta() }
167
+ }
168
+
169
+ /**
170
+ * 聚焦一个 native tab:better-sidebar >= 0.19 的 `surface.activate` 是空操作
171
+ * (只返回记录存在性——authority: dsh-better-sidebar src/client/native/surface.ts),
172
+ * 真实聚焦面是 dsh 0.1.5 的 `ISidebarRight.focus`(authority:
173
+ * dsh-client-ui-sidebar-right lib/types/client/service.d.ts `focus(tabId)`)。
174
+ * focus 只标记活动 tab/pane,不展开折叠的栏——聚焦的语义包含「让用户看见」,
175
+ * 故折叠时补 toggleExpanded(authority: 同文件 `isExpanded/toggleExpanded`)。
176
+ * @returns true = 已通过 focus 聚焦;false = 面缺席(调用方回退 activateTab)。
177
+ */
178
+ export function focusNativeTab(ctx: Context, tabId: string): boolean {
179
+ try {
180
+ const face = ctx.get('sidebarRight') as {
181
+ focus?: (id: string) => void
182
+ isExpanded?: () => boolean
183
+ toggleExpanded?: () => void
184
+ } | undefined
185
+ if (typeof face?.focus !== 'function') return false
186
+ face.focus(tabId)
187
+ if (face.isExpanded?.() === false) face.toggleExpanded?.()
188
+ return true
189
+ } catch {
190
+ return false
191
+ }
192
+ }
@@ -16,10 +16,16 @@ import {
16
16
  sideTabTitle,
17
17
  } from './model.ts'
18
18
  import { readInputDraft, resolveSessionInput } from './composer.ts'
19
+ import { focusNativeTab, lastLiveSideChat, liveSideChatsOf, markSideChatOpening, nativeSidebarHost, nativeTabShell, sideChatOpening } from './native.ts'
19
20
 
20
- /** 从最新快照读一个 Tab(meta 合并写入前的读取面;布局即注册表)。 */
21
+ /**
22
+ * 从最新快照读一个 Tab(meta 合并写入前的读取面;布局即注册表)。
23
+ * better-sidebar >= 0.19: the open tab lives in the native right sidebar, not
24
+ * in the layout snapshot — fall back to the live-panel registry.
25
+ */
21
26
  export function readTab(ctx: Context, tabId: string): SidebarTab | undefined {
22
- return collectTabs(ctx.betterSidebar.getSnapshot().state).find(tab => tab.id === tabId)
27
+ const tab = collectTabs(ctx.betterSidebar.getSnapshot().state).find(candidate => candidate.id === tabId)
28
+ return tab ?? nativeTabShell(tabId, SIDE_TAB_TYPE)
23
29
  }
24
30
 
25
31
  /**
@@ -33,6 +39,17 @@ export function openOrFocusSideChat(ctx: Context, sessionId: string, draftText?:
33
39
  if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return false
34
40
  const existing = collectSideTabs(snapshot.state)
35
41
 
42
+ // Native right sidebar (better-sidebar >= 0.19): the tab is invisible to the
43
+ // layout snapshot, so focus the live panel and hand it the draft directly.
44
+ const live = lastLiveSideChat(sessionId)
45
+ if (live !== undefined) {
46
+ if (draftText !== undefined && draftText !== '') live.seedDraft(draftText)
47
+ // native 宿主的 activateTab 是空操作(surface.activate 只查记录存在性),
48
+ // 聚焦走 ISidebarRight.focus 探测;面缺席(legacy)回退 activateTab。
49
+ if (!focusNativeTab(ctx, live.tabId)) ctx.betterSidebar.activateTab(live.tabId, { sessionId })
50
+ return true
51
+ }
52
+
36
53
  if (existing.length > 0) {
37
54
  const target = existing[existing.length - 1]!
38
55
  if (draftText !== undefined && draftText !== '') {
@@ -50,6 +67,15 @@ export function openOrFocusSideChat(ctx: Context, sessionId: string, draftText?:
50
67
  return true
51
68
  }
52
69
 
70
+ // openTab→面板挂载窗口(native 特有):tab 已铸但对快照与 registry 均不可见,
71
+ // 重复触发落进 in-flight 标记(草稿暂存、注册时回放)而非再开一个
72
+ // (双开 = 双 fork 出孤儿会话)。legacy 无此窗口(openTab 同步落快照)。
73
+ const opening = sideChatOpening(sessionId)
74
+ if (opening !== undefined) {
75
+ if (draftText !== undefined && draftText !== '') opening.drafts.push(draftText)
76
+ return true
77
+ }
78
+
53
79
  return createSideChat(ctx, sessionId, draftText)
54
80
  } catch (error) {
55
81
  console.warn('[dsh-sidenote] 打开侧边聊天失败:', error)
@@ -66,12 +92,26 @@ export function createSideChat(ctx: Context, sessionId: string, draftText?: stri
66
92
  try {
67
93
  const snapshot = ctx.betterSidebar.getSnapshot()
68
94
  if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return false
95
+ // openTab 的已知静默失败面先排除(类型在设置里被禁用 = 宿主 warn 后 return,
96
+ // 不抛错)——否则 native 分支的乐观返回会把失败报成成功。
97
+ if (!ctx.betterSidebar.isTabEnabled(SIDE_TAB_TYPE)) return false
69
98
  // 新建:openTab 走 createTab 铸造(seed.meta 会被忽略),所以先记下既有
70
99
  // id 集,openTab 同步落状态后找出新 Tab,再把 pendingDraft 写进它的 meta。
71
100
  const before = new Set(collectTabs(snapshot.state).map(tab => tab.id))
72
- ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE }, { sessionId })
101
+ // Native tabs (better-sidebar >= 0.19) take meta from the seed: the minted id
102
+ // only becomes known once the panel mounts and publishes itself.
103
+ const seed = draftText !== undefined && draftText !== ''
104
+ ? { type: SIDE_TAB_TYPE, meta: { pendingDraft: draftText } }
105
+ : { type: SIDE_TAB_TYPE }
106
+ ctx.betterSidebar.openTab(seed, { sessionId })
73
107
  const created = collectSideTabs(ctx.betterSidebar.getSnapshot().state).find(tab => !before.has(tab.id))
74
- if (created === undefined) return false
108
+ if (created === undefined) {
109
+ if (!nativeSidebarHost(ctx)) return false
110
+ // native:tab 不进快照属预期——openTab 不抛错即视为成功;标记 in-flight
111
+ // 窗口供并发触发去重(窗口语意见 native.ts)。
112
+ markSideChatOpening(sessionId)
113
+ return true
114
+ }
75
115
  if (draftText !== undefined && draftText !== '') {
76
116
  ctx.betterSidebar.updateTab(created.id, { meta: { pendingDraft: draftText } })
77
117
  }
@@ -93,6 +133,9 @@ export function sideChatTargetTitle(ctx: Context, sessionId: string): string | u
93
133
  if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return undefined
94
134
  const existing = collectSideTabs(snapshot.state)
95
135
  if (existing.length > 0) return existing[existing.length - 1]!.title
136
+ // native:布局快照看不到 tab,改读 live registry(最近打开者)。
137
+ const live = lastLiveSideChat(sessionId)
138
+ if (live !== undefined) return live.readTitle()
96
139
  return sideTabTitle([])
97
140
  } catch {
98
141
  return undefined
@@ -109,13 +152,26 @@ export function reopenSideChat(ctx: Context, sessionId: string, childId: string,
109
152
  try {
110
153
  const snapshot = ctx.betterSidebar.getSnapshot()
111
154
  if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return false
155
+ if (!ctx.betterSidebar.isTabEnabled(SIDE_TAB_TYPE)) return false
156
+ // native 下同 kind 每 pane 单实例(dsh sidebar-right held 规则):有存活
157
+ // 实例时 openTab 必折叠为聚焦既有 tab 且 seed.meta 被丢弃——拒绝(弹层
158
+ // 侧此时本就不列重开项,这里是双保险)。
159
+ if (nativeSidebarHost(ctx) && liveSideChatsOf(sessionId).length > 0) return false
112
160
  const before = new Set(collectTabs(snapshot.state).map(tab => tab.id))
113
- ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE }, { sessionId })
161
+ // native(>= 0.19):seed.meta 携带恢复信息(native 面采纳 seed.meta;
162
+ // 面板挂载即走绑定恢复路径)。legacy 的 createTab 铸造忽略 seed.meta,
163
+ // 落快照后补写。
164
+ const meta = { childId, parentSessionId: sessionId }
165
+ ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE, ...(title !== undefined ? { title } : {}), meta }, { sessionId })
114
166
  const created = collectSideTabs(ctx.betterSidebar.getSnapshot().state).find(tab => !before.has(tab.id))
115
- if (created === undefined) return false
167
+ if (created === undefined) {
168
+ if (!nativeSidebarHost(ctx)) return false
169
+ markSideChatOpening(sessionId)
170
+ return true
171
+ }
116
172
  ctx.betterSidebar.updateTab(created.id, {
117
173
  ...(title !== undefined ? { title } : {}),
118
- meta: { childId, parentSessionId: sessionId },
174
+ meta,
119
175
  })
120
176
  return true
121
177
  } catch (error) {
@@ -2,7 +2,9 @@
2
2
  * /side 斜杠命令(spike 落点):经 client 侧 `ctx.commandUi.register`
3
3
  * (dsh-client-ui-commands 的 CommandContribution,ui 形态 popupSelect)
4
4
  * 注册。popupSelect 是该注册面唯一的 UI 形态 —— 菜单行被选中后弹一个
5
- * 选项壳,选项 = 新建 + 当前会话已并存的侧边聊天(聚焦)。
5
+ * 选项壳,选项 = 新建 + 当前会话已并存的侧边聊天(聚焦)+ 最近关闭(重开)。
6
+ * native 宿主(better-sidebar >= 0.19)受 dsh sidebar-right 的 held 规则约束
7
+ * (page kind 每 pane 单实例):有存活实例时不列「新建」与「重开」。
6
8
  *
7
9
  * 服务经 ctx.get 惰性解析(commandUi 不在 inject 清单里):服务缺失、
8
10
  * 注册抛错(如与 host 命令撞名)都降级为「只有 Tab 入口」,绝不影响面板。
@@ -12,6 +14,7 @@
12
14
  */
13
15
  import type { Context } from '../host/contracts.ts'
14
16
  import { canForkFrom, collectSideTabs } from './model.ts'
17
+ import { focusNativeTab, liveSideChatsOf, nativeSidebarHost } from './native.ts'
15
18
  import { createSideChat, reopenSideChat } from './open.ts'
16
19
  import { dropClosedSideChat, listClosedSideChats } from './recentClosed.ts'
17
20
  import { t } from '../locales.ts'
@@ -32,7 +35,11 @@ interface SelectOption {
32
35
  interface CommandUiService {
33
36
  register(contribution: {
34
37
  readonly name: string
35
- readonly description: string
38
+ // 契约在 0.1.5 从 string 改为 () => string(authority:
39
+ // dsh-client-ui-commands 0.1.5-rc.2 contract.d.ts `readonly description: () => string`;
40
+ // 0.1.1~0.1.3 为 string)。传错形状会在候选合成时炸掉整个斜杠菜单
41
+ // (ui-input-trigger 的 contribution.description() 对 string 抛 TypeError)。
42
+ readonly description: string | (() => string)
36
43
  available(session: CommandSession): boolean
37
44
  readonly ui: {
38
45
  readonly kind: 'popupSelect'
@@ -42,28 +49,52 @@ interface CommandUiService {
42
49
  }): () => void
43
50
  }
44
51
 
52
+ /** 0.1.5 信号:`connection.api.sessions` 面在 0.1.5 被删(与模型面迁移同判据)。 */
53
+ function isDsh015Plus(ctx: Context): boolean {
54
+ const legacy = (ctx as { connection?: { api?: { sessions?: unknown } } }).connection?.api?.sessions
55
+ return legacy === undefined || legacy === null
56
+ }
57
+
45
58
  /** 单个命令名对应的完整贡献(side / 侧边 共用)。 */
46
59
  function makeContribution(ctx: Context, name: string) {
47
60
  return {
48
61
  name,
49
- description: t('cmdDesc'),
62
+ description: isDsh015Plus(ctx) ? () => t('cmdDesc') : t('cmdDesc'),
50
63
  available: (session: CommandSession) => canForkFrom(ctx, session.sessionId),
51
64
  ui: {
52
65
  kind: 'popupSelect' as const,
53
66
  options: (session: CommandSession) => {
54
- const options: SelectOption[] = [
55
- { id: 'new', label: t('cmdNew'), detail: t('cmdNewDetail') },
56
- ]
67
+ const options: SelectOption[] = []
68
+ const native = nativeSidebarHost(ctx)
69
+ const lives = native ? liveSideChatsOf(session.sessionId) : []
70
+ // native 下同 kind 每 pane 单实例(dsh-client-ui-sidebar-right 的 held
71
+ // 规则:page kind 的 contentId 恒为 sidebar://<kind>,openTab 必折叠为
72
+ // 聚焦既有 tab)——已有存活实例时「新建」名不副实,不列。
73
+ if (!native || lives.length === 0) {
74
+ options.push({ id: 'new', label: t('cmdNew'), detail: t('cmdNewDetail') })
75
+ }
57
76
  // 已并存的侧边聊天列为聚焦项(命令弹层即多实例管理入口)。
58
- const snapshot = ctx.betterSidebar.getSnapshot()
59
- if (snapshot.sessionId === session.sessionId && snapshot.state !== undefined) {
60
- for (const tab of collectSideTabs(snapshot.state)) {
61
- options.push({ id: `focus:${tab.id}`, label: t('cmdFocus', { title: tab.title }), detail: t('cmdFocusDetail') })
77
+ // native(>= 0.19):tab 不进布局快照,枚举改读 live registry;
78
+ // legacy:读布局快照(现状)。
79
+ if (native) {
80
+ for (const live of lives) {
81
+ options.push({ id: `focus:${live.tabId}`, label: t('cmdFocus', { title: live.readTitle() }), detail: t('cmdFocusDetail') })
82
+ }
83
+ } else {
84
+ const snapshot = ctx.betterSidebar.getSnapshot()
85
+ if (snapshot.sessionId === session.sessionId && snapshot.state !== undefined) {
86
+ for (const tab of collectSideTabs(snapshot.state)) {
87
+ options.push({ id: `focus:${tab.id}`, label: t('cmdFocus', { title: tab.title }), detail: t('cmdFocusDetail') })
88
+ }
62
89
  }
63
90
  }
64
91
  // D3 后悔药:最近关闭的可重开(Cmd+Shift+T 心智)。
65
- for (const entry of listClosedSideChats(session.sessionId)) {
66
- options.push({ id: `reopen:${entry.childId}`, label: t('cmdReopen', { title: entry.title }), detail: t('cmdReopenDetail') })
92
+ // native 单实例约束下,有存活实例时 reopen 必被 held 折叠(seed.meta
93
+ // 丢弃且聚焦错对象)——此时不列重开项。
94
+ if (!(native && lives.length > 0)) {
95
+ for (const entry of listClosedSideChats(session.sessionId)) {
96
+ options.push({ id: `reopen:${entry.childId}`, label: t('cmdReopen', { title: entry.title }), detail: t('cmdReopenDetail') })
97
+ }
67
98
  }
68
99
  return Promise.resolve(options)
69
100
  },
@@ -75,7 +106,9 @@ function makeContribution(ctx: Context, name: string) {
75
106
  return
76
107
  }
77
108
  if (option.id.startsWith('focus:')) {
78
- ctx.betterSidebar.activateTab(option.id.slice('focus:'.length), { sessionId: session.sessionId })
109
+ const tabId = option.id.slice('focus:'.length)
110
+ // native 宿主的 activateTab 是空操作——聚焦走 ISidebarRight.focus 探测。
111
+ if (!focusNativeTab(ctx, tabId)) ctx.betterSidebar.activateTab(tabId, { sessionId: session.sessionId })
79
112
  return
80
113
  }
81
114
  if (option.id.startsWith('reopen:')) {