dsh-sidenote 0.3.2 → 0.3.3

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,168 @@
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
+ }
62
+
63
+ /** Native tab id → live panel. Native layouts are memory-only, so is this. */
64
+ const liveSideChats = new Map<string, LiveSideChat>()
65
+
66
+ /**
67
+ * Publish a mounted panel so programmatic entry points can focus it and seed
68
+ * its draft. Returns the disposer for the panel's effect cleanup.
69
+ */
70
+ export function registerLiveSideChat(
71
+ sessionId: string,
72
+ tabId: string,
73
+ seedDraft: (text: string) => void,
74
+ readMeta: () => unknown,
75
+ ): () => void {
76
+ const entry: LiveSideChat = { tabId, sessionId, seedDraft, readMeta }
77
+ liveSideChats.set(tabId, entry)
78
+ // 挂载即回放在 openTab→注册窗口内暂存的草稿(seedDraft 内有相位门)。
79
+ const opening = openings.get(sessionId)
80
+ if (opening !== undefined) {
81
+ openings.delete(sessionId)
82
+ for (const draft of opening.drafts) seedDraft(draft)
83
+ }
84
+ return () => {
85
+ if (liveSideChats.get(tabId) === entry) liveSideChats.delete(tabId)
86
+ }
87
+ }
88
+
89
+ // ── openTab→面板挂载窗口的 in-flight 标记 ────────────────────────────────────
90
+ // native tab 铸造后对布局快照与 registry 同时不可见,直到面板挂载登记;窗口内
91
+ // 的重复触发必须落进标记(草稿暂存、注册时回放),而不是再开一个 tab
92
+ // (双开 = 双 fork 出孤儿会话)。legacy 宿主无此窗口(openTab 同步落快照)。
93
+
94
+ /** 标记失效应力:openTab 被静默拒绝/面板未挂载时,超时后允许重试。 */
95
+ const OPENING_TTL_MS = 10_000
96
+
97
+ interface OpeningMark {
98
+ readonly at: number
99
+ /** 窗口内到达的待投递草稿(面板注册时经 seedDraft 回放)。 */
100
+ readonly drafts: string[]
101
+ }
102
+
103
+ const openings = new Map<string, OpeningMark>()
104
+
105
+ /** 标记一次已发起、面板尚未注册的 native 打开(create/reopen 铸造后调用)。 */
106
+ export function markSideChatOpening(sessionId: string): void {
107
+ openings.set(sessionId, { at: Date.now(), drafts: [] })
108
+ }
109
+
110
+ /** 进行中的打开;TTL 外视为 openTab 被拒/面板未挂载,清除并返回 undefined。 */
111
+ export function sideChatOpening(sessionId: string): OpeningMark | undefined {
112
+ const mark = openings.get(sessionId)
113
+ if (mark === undefined) return undefined
114
+ if (Date.now() - mark.at > OPENING_TTL_MS) {
115
+ openings.delete(sessionId)
116
+ return undefined
117
+ }
118
+ return mark
119
+ }
120
+
121
+ /** Live panel for a native tab id (undefined for legacy layout tabs). */
122
+ export function liveSideChat(tabId: string): LiveSideChat | undefined {
123
+ return liveSideChats.get(tabId)
124
+ }
125
+
126
+ /**
127
+ * Most recently registered live panel of a session — the open-or-focus target
128
+ * when the legacy snapshot cannot see the tab (mirrors "last opened wins").
129
+ */
130
+ export function lastLiveSideChat(sessionId: string): LiveSideChat | undefined {
131
+ let found: LiveSideChat | undefined
132
+ for (const entry of liveSideChats.values()) {
133
+ if (entry.sessionId === sessionId) found = entry
134
+ }
135
+ return found
136
+ }
137
+
138
+ /** Native tab shell for reads that go through the layout snapshot (`readTab`). */
139
+ export function nativeTabShell(tabId: string, type: string): { id: string; type: string; title: string; meta: unknown } | undefined {
140
+ const entry = liveSideChats.get(tabId)
141
+ if (entry === undefined) return undefined
142
+ return { id: entry.tabId, type, title: '', meta: entry.readMeta() }
143
+ }
144
+
145
+ /**
146
+ * 聚焦一个 native tab:better-sidebar >= 0.19 的 `surface.activate` 是空操作
147
+ * (只返回记录存在性——authority: dsh-better-sidebar src/client/native/surface.ts),
148
+ * 真实聚焦面是 dsh 0.1.5 的 `ISidebarRight.focus`(authority:
149
+ * dsh-client-ui-sidebar-right lib/types/client/service.d.ts `focus(tabId)`)。
150
+ * focus 只标记活动 tab/pane,不展开折叠的栏——聚焦的语义包含「让用户看见」,
151
+ * 故折叠时补 toggleExpanded(authority: 同文件 `isExpanded/toggleExpanded`)。
152
+ * @returns true = 已通过 focus 聚焦;false = 面缺席(调用方回退 activateTab)。
153
+ */
154
+ export function focusNativeTab(ctx: Context, tabId: string): boolean {
155
+ try {
156
+ const face = ctx.get('sidebarRight') as {
157
+ focus?: (id: string) => void
158
+ isExpanded?: () => boolean
159
+ toggleExpanded?: () => void
160
+ } | undefined
161
+ if (typeof face?.focus !== 'function') return false
162
+ face.focus(tabId)
163
+ if (face.isExpanded?.() === false) face.toggleExpanded?.()
164
+ return true
165
+ } catch {
166
+ return false
167
+ }
168
+ }
@@ -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, 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
  }
@@ -109,13 +149,22 @@ export function reopenSideChat(ctx: Context, sessionId: string, childId: string,
109
149
  try {
110
150
  const snapshot = ctx.betterSidebar.getSnapshot()
111
151
  if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return false
152
+ if (!ctx.betterSidebar.isTabEnabled(SIDE_TAB_TYPE)) return false
112
153
  const before = new Set(collectTabs(snapshot.state).map(tab => tab.id))
113
- ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE }, { sessionId })
154
+ // native(>= 0.19):seed.meta 携带恢复信息(native 面采纳 seed.meta;
155
+ // 面板挂载即走绑定恢复路径)。legacy 的 createTab 铸造忽略 seed.meta,
156
+ // 落快照后补写。
157
+ const meta = { childId, parentSessionId: sessionId }
158
+ ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE, ...(title !== undefined ? { title } : {}), meta }, { sessionId })
114
159
  const created = collectSideTabs(ctx.betterSidebar.getSnapshot().state).find(tab => !before.has(tab.id))
115
- if (created === undefined) return false
160
+ if (created === undefined) {
161
+ if (!nativeSidebarHost(ctx)) return false
162
+ markSideChatOpening(sessionId)
163
+ return true
164
+ }
116
165
  ctx.betterSidebar.updateTab(created.id, {
117
166
  ...(title !== undefined ? { title } : {}),
118
- meta: { childId, parentSessionId: sessionId },
167
+ meta,
119
168
  })
120
169
  return true
121
170
  } catch (error) {
@@ -32,7 +32,11 @@ interface SelectOption {
32
32
  interface CommandUiService {
33
33
  register(contribution: {
34
34
  readonly name: string
35
- readonly description: string
35
+ // 契约在 0.1.5 从 string 改为 () => string(authority:
36
+ // dsh-client-ui-commands 0.1.5-rc.2 contract.d.ts `readonly description: () => string`;
37
+ // 0.1.1~0.1.3 为 string)。传错形状会在候选合成时炸掉整个斜杠菜单
38
+ // (ui-input-trigger 的 contribution.description() 对 string 抛 TypeError)。
39
+ readonly description: string | (() => string)
36
40
  available(session: CommandSession): boolean
37
41
  readonly ui: {
38
42
  readonly kind: 'popupSelect'
@@ -42,11 +46,17 @@ interface CommandUiService {
42
46
  }): () => void
43
47
  }
44
48
 
49
+ /** 0.1.5 信号:`connection.api.sessions` 面在 0.1.5 被删(与模型面迁移同判据)。 */
50
+ function isDsh015Plus(ctx: Context): boolean {
51
+ const legacy = (ctx as { connection?: { api?: { sessions?: unknown } } }).connection?.api?.sessions
52
+ return legacy === undefined || legacy === null
53
+ }
54
+
45
55
  /** 单个命令名对应的完整贡献(side / 侧边 共用)。 */
46
56
  function makeContribution(ctx: Context, name: string) {
47
57
  return {
48
58
  name,
49
- description: t('cmdDesc'),
59
+ description: isDsh015Plus(ctx) ? () => t('cmdDesc') : t('cmdDesc'),
50
60
  available: (session: CommandSession) => canForkFrom(ctx, session.sessionId),
51
61
  ui: {
52
62
  kind: 'popupSelect' as const,