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.
@@ -216,6 +216,51 @@ export interface ModelSelection {
216
216
  model: string;
217
217
  reasoningEffort?: string;
218
218
  }
219
+ /**
220
+ * Remote session face(dsh >= 0.1.5 的模型读写照面;0.1.5 删除了
221
+ * `connection.api.sessions.*` 的 client 面后由它接管)。
222
+ * `remote`/`remote.session` 在宿主侧是可注入服务,但本插件不声明 inject
223
+ * (0.1.2 及更早无此服务,声明会让插件在旧宿主上永不激活)——调用点经
224
+ * `ctx.get` 惰性探测(cordis 的 get 不受 inject 门禁限制)。
225
+ * Authority:`@deepseek-ai/dsh-api-session-controller/lib/typert.remote-client.d.ts`
226
+ * (RemoteResult = `{ok: true, value} | {ok: false, error}`;error 实为
227
+ * RemoteError 实例,code 必有、message 继承 Error,这里弱化为可读取切片)。
228
+ */
229
+ export interface RemoteSessionModelFace {
230
+ selectModel(request: {
231
+ sessionId: SessionId;
232
+ provider: string;
233
+ model: string;
234
+ reasoningEffort?: string;
235
+ }): Promise<{
236
+ ok: true;
237
+ value: unknown;
238
+ } | {
239
+ ok: false;
240
+ error: {
241
+ code?: string;
242
+ message?: string;
243
+ };
244
+ }>;
245
+ /** 宿主级模型目录(authority 同上,`ModelCatalog`)。 */
246
+ modelCatalog?(): Promise<{
247
+ ok: true;
248
+ value: ModelCatalogFace;
249
+ } | {
250
+ ok: false;
251
+ error: {
252
+ code?: string;
253
+ message?: string;
254
+ };
255
+ }>;
256
+ }
257
+ /** `remote.session.modelCatalog()` 的返回切片(authority: 同上 `ModelCatalog`)。 */
258
+ export interface ModelCatalogFace {
259
+ readonly default: ModelSelection;
260
+ readonly routableProviders: readonly string[];
261
+ readonly groups: SessionModelsResult['groups'];
262
+ readonly failures?: readonly unknown[];
263
+ }
219
264
  export interface SessionModelsResult {
220
265
  current: ModelSelection;
221
266
  routable: boolean;
@@ -6,7 +6,7 @@
6
6
  * off-face 探测纪律:session.open()、store.update 均为运行时可达但契约不
7
7
  * 保证的面——就地 feature-check + 吞错降级,并登记进 host/probes.ts。
8
8
  */
9
- import type { Context, ConversationSnapshot, SessionBinding, SessionFace, SessionModelsResult } from '../host/contracts.ts';
9
+ import type { Context, ConversationSnapshot, ModelSelection, RemoteSessionModelFace, SessionBinding, SessionFace, SessionModelsResult } from '../host/contracts.ts';
10
10
  import { type SideChatMeta } from './model.ts';
11
11
  /**
12
12
  * 首开编排:fork(全量历史快照)→ 登记 meta(先行,绝不丢登记)→
@@ -30,6 +30,36 @@ export declare function ensurePanelOpen(store: unknown): void;
30
30
  * 运行时自动 open)。feature-check + 吞错,运行时若移除则降级为只发不收。
31
31
  */
32
32
  export declare function openSessionWindow(session: SessionFace | undefined): void;
33
+ /**
34
+ * Current model selection of a session, read from its `modelSelection`
35
+ * projection (dsh >= 0.1.2): the durable selection is projected state, so no RPC
36
+ * is needed and the read is safe for any session id. Snapshot shape authority:
37
+ * `@deepseek-ai/dsh-api-session-controller/lib/types/types.d.ts`
38
+ * (`ModelSelectionProjection = { lastUsed, next }`,两者均可为 null——
39
+ * `next` 缺省时按投影文档回落 `lastUsed`)。
40
+ */
41
+ export declare function projectedModelSelection(ctx: Context, sessionId: string): ModelSelection | undefined;
42
+ /**
43
+ * Remote session face carrying `selectModel`/`modelCatalog`(dsh 0.1.5 删除
44
+ * `connection.api.sessions.*` client 面后的模型 RPC 面;事实上 0.1.2-rc.1
45
+ * 起存在——authority: dsh-api-session-controller lib/typert.remote-client.d.ts)。
46
+ * 探测走 `ctx.get`(cordis 的 get 不受 inject 门禁限制,本插件为兼容旧宿主
47
+ * 不声明 inject 'remote.session';机制见 contracts.ts RemoteSessionModelFace)。
48
+ * `ctx.get('remote')` 返回命名空间容器时下钻其 `.session` 成员。
49
+ */
50
+ export declare function remoteSessionFace(ctx: Context): RemoteSessionModelFace | undefined;
51
+ /**
52
+ * 读会话当前模型选择:投影面(0.1.2+,同步无 RPC)优先;旧
53
+ * `connection.api.sessions.models` RPC(<= 0.1.2 的 client 面,0.1.5 已删)
54
+ * 回退(双版本链,同 chatSourceOf 先例)。永不抛错。
55
+ */
56
+ export declare function readModelSelection(ctx: Context, sessionId: string): Promise<ModelSelection | undefined>;
57
+ /**
58
+ * 写会话模型选择:`remote.session.selectModel`(新面)优先,旧
59
+ * `connection.api.sessions.selectModel` 回退。面在而调用失败(模型不可路由等)
60
+ * 属真实失败,不再落旧面重试。
61
+ */
62
+ export declare function writeModelSelection(ctx: Context, sessionId: string, selection: ModelSelection): Promise<boolean>;
33
63
  /** 读子会话当前模型名(composer 模型标签用);任何失败回退 null(默认文案)。 */
34
64
  export declare function readModelName(ctx: Context, sessionId: string): Promise<string | null>;
35
65
  /** 会话内容读取源(0.1.1/0.1.2 双兼容,feature-check 优先链)。 */
@@ -46,7 +76,11 @@ export interface ChatSource {
46
76
  * 既有降级,不崩)。
47
77
  */
48
78
  export declare function chatSourceOf(ctx: Context, binding: SessionBinding | undefined): ChatSource | undefined;
49
- /** 拉会话模型目录(models RPC 容错;失败 null → 菜单保持只读标签态)。 */
79
+ /**
80
+ * 拉会话模型目录:0.1.5 走 `remote.session.modelCatalog()`(目录是宿主级,
81
+ * 菜单选中项所需的 current 由投影/旧面补上);旧面回退
82
+ * `connection.api.sessions.models`。失败 null → 菜单保持只读标签态。
83
+ */
50
84
  export declare function listModels(ctx: Context, sessionId: string): Promise<SessionModelsResult | null>;
51
85
  /** 切换子会话模型;成功返回新模型展示名,失败 null(best-effort)。 */
52
86
  export declare function switchModel(ctx: Context, sessionId: string, provider: string, model: string): Promise<string | null>;
@@ -0,0 +1,87 @@
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
+ /** Remember the plugin root context at activation (see module docstring). */
25
+ export declare function setRootContext(ctx: Context): void;
26
+ /** Plugin root context; falls back to the caller's context (legacy behaviour). */
27
+ export declare function rootContext(fallback: Context): Context;
28
+ /**
29
+ * True when the host keeps sidebar tabs outside the legacy layout store
30
+ * (better-sidebar >= 0.19). Version parse is best-effort: an unparsable
31
+ * version reports false, which keeps the pre-0.19 behaviour.
32
+ */
33
+ export declare function nativeSidebarHost(ctx: Context): boolean;
34
+ interface LiveSideChat {
35
+ readonly tabId: string;
36
+ readonly sessionId: string;
37
+ /** Write text into the panel composer (draft delivery). */
38
+ seedDraft(text: string): void;
39
+ /** Current tab meta, read at call time (the panel re-renders on meta change). */
40
+ readMeta(): unknown;
41
+ /** Current tab title(聚焦列表/编号数据源的读取面)。 */
42
+ readTitle(): string;
43
+ }
44
+ /**
45
+ * Publish a mounted panel so programmatic entry points can focus it and seed
46
+ * its draft. Returns the disposer for the panel's effect cleanup.
47
+ */
48
+ export declare function registerLiveSideChat(sessionId: string, tabId: string, seedDraft: (text: string) => void, readMeta: () => unknown, readTitle: () => string): () => void;
49
+ interface OpeningMark {
50
+ readonly at: number;
51
+ /** 窗口内到达的待投递草稿(面板注册时经 seedDraft 回放)。 */
52
+ readonly drafts: string[];
53
+ }
54
+ /** 标记一次已发起、面板尚未注册的 native 打开(create/reopen 铸造后调用)。 */
55
+ export declare function markSideChatOpening(sessionId: string): void;
56
+ /** 进行中的打开;TTL 外视为 openTab 被拒/面板未挂载,清除并返回 undefined。 */
57
+ export declare function sideChatOpening(sessionId: string): OpeningMark | undefined;
58
+ /** Live panel for a native tab id (undefined for legacy layout tabs). */
59
+ export declare function liveSideChat(tabId: string): LiveSideChat | undefined;
60
+ /** 一个会话的全部存活面板(按打开序升序;native 下枚举 side tab 的唯一数据源)。 */
61
+ export declare function liveSideChatsOf(sessionId: string): LiveSideChat[];
62
+ /**
63
+ * Most recently OPENED live panel of a session — the open-or-focus target
64
+ * when the legacy snapshot cannot see the tab (mirrors "last opened wins").
65
+ * 排序锚在首次注册序号上:面板重挂载不翻序(见 birthOrder)。
66
+ */
67
+ export declare function lastLiveSideChat(sessionId: string): LiveSideChat | undefined;
68
+ /** 插件卸载/重载时清场(apply 的 ctx.effect disposer 调用)。 */
69
+ export declare function clearNativeRuntime(): void;
70
+ /** Native tab shell for reads that go through the layout snapshot (`readTab`). */
71
+ export declare function nativeTabShell(tabId: string, type: string): {
72
+ id: string;
73
+ type: string;
74
+ title: string;
75
+ meta: unknown;
76
+ } | undefined;
77
+ /**
78
+ * 聚焦一个 native tab:better-sidebar >= 0.19 的 `surface.activate` 是空操作
79
+ * (只返回记录存在性——authority: dsh-better-sidebar src/client/native/surface.ts),
80
+ * 真实聚焦面是 dsh 0.1.5 的 `ISidebarRight.focus`(authority:
81
+ * dsh-client-ui-sidebar-right lib/types/client/service.d.ts `focus(tabId)`)。
82
+ * focus 只标记活动 tab/pane,不展开折叠的栏——聚焦的语义包含「让用户看见」,
83
+ * 故折叠时补 toggleExpanded(authority: 同文件 `isExpanded/toggleExpanded`)。
84
+ * @returns true = 已通过 focus 聚焦;false = 面缺席(调用方回退 activateTab)。
85
+ */
86
+ export declare function focusNativeTab(ctx: Context, tabId: string): boolean;
87
+ export {};
@@ -7,7 +7,11 @@
7
7
  * 否则写 meta.pendingDraft 移交面板(面板在 composer 就绪后应用并清除)。
8
8
  */
9
9
  import type { Context, SidebarTab } from '../host/contracts.ts';
10
- /** 从最新快照读一个 Tab(meta 合并写入前的读取面;布局即注册表)。 */
10
+ /**
11
+ * 从最新快照读一个 Tab(meta 合并写入前的读取面;布局即注册表)。
12
+ * better-sidebar >= 0.19: the open tab lives in the native right sidebar, not
13
+ * in the layout snapshot — fall back to the live-panel registry.
14
+ */
11
15
  export declare function readTab(ctx: Context, tabId: string): SidebarTab | undefined;
12
16
  /**
13
17
  * 为 sessionId 新建或聚焦一个侧边聊天 Tab;draftText 给出时写入其草稿。
@@ -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 入口」,绝不影响面板。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidenote",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "DSH web plugin: Codex-style side chat (fork the current session into a persistent side panel) + selection annotations with numbered badges and a composer chip. A thin consumer of dsh-better-sidebar. / Codex 风格侧边聊天 + 划选注释的 DSH 插件。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -227,6 +227,35 @@ export interface ModelSelection {
227
227
  reasoningEffort?: string
228
228
  }
229
229
 
230
+ /**
231
+ * Remote session face(dsh >= 0.1.5 的模型读写照面;0.1.5 删除了
232
+ * `connection.api.sessions.*` 的 client 面后由它接管)。
233
+ * `remote`/`remote.session` 在宿主侧是可注入服务,但本插件不声明 inject
234
+ * (0.1.2 及更早无此服务,声明会让插件在旧宿主上永不激活)——调用点经
235
+ * `ctx.get` 惰性探测(cordis 的 get 不受 inject 门禁限制)。
236
+ * Authority:`@deepseek-ai/dsh-api-session-controller/lib/typert.remote-client.d.ts`
237
+ * (RemoteResult = `{ok: true, value} | {ok: false, error}`;error 实为
238
+ * RemoteError 实例,code 必有、message 继承 Error,这里弱化为可读取切片)。
239
+ */
240
+ export interface RemoteSessionModelFace {
241
+ selectModel(request: {
242
+ sessionId: SessionId
243
+ provider: string
244
+ model: string
245
+ reasoningEffort?: string
246
+ }): Promise<{ ok: true; value: unknown } | { ok: false; error: { code?: string; message?: string } }>
247
+ /** 宿主级模型目录(authority 同上,`ModelCatalog`)。 */
248
+ modelCatalog?(): Promise<{ ok: true; value: ModelCatalogFace } | { ok: false; error: { code?: string; message?: string } }>
249
+ }
250
+
251
+ /** `remote.session.modelCatalog()` 的返回切片(authority: 同上 `ModelCatalog`)。 */
252
+ export interface ModelCatalogFace {
253
+ readonly default: ModelSelection
254
+ readonly routableProviders: readonly string[]
255
+ readonly groups: SessionModelsResult['groups']
256
+ readonly failures?: readonly unknown[]
257
+ }
258
+
230
259
  export interface SessionModelsResult {
231
260
  current: ModelSelection
232
261
  routable: boolean
@@ -9,6 +9,8 @@
9
9
  * 新增 off-face 依赖时:调用点照写就地探测,同时把条目加进 PROBES。
10
10
  */
11
11
  import type { Context } from './contracts.ts'
12
+ import { nativeSidebarHost } from '../sidechat/native.ts'
13
+ import { remoteSessionFace } from '../sidechat/lifecycle.ts'
12
14
 
13
15
  interface Probe {
14
16
  /** 探测项名称(告警文案用)。 */
@@ -57,6 +59,31 @@ const PROBES: readonly Probe[] = [
57
59
  },
58
60
  fallback: 'ProducedFiles 等动态复用件降级为自绘',
59
61
  },
62
+ {
63
+ name: 'sidebarRight.focus(native tab 程序化聚焦)',
64
+ check: (ctx) => {
65
+ // 仅 better-sidebar >= 0.19(native 右栏)需要的面;老宿主的
66
+ // activateTab 本就可用,缺失不算降级。
67
+ if (!nativeSidebarHost(ctx)) return true
68
+ const face = (ctx as { get?: (k: string) => unknown }).get?.('sidebarRight') as
69
+ | { focus?: unknown }
70
+ | undefined
71
+ return typeof face?.focus === 'function'
72
+ },
73
+ fallback: '聚焦既有侧边聊天 tab 不可用(activateTab 在 native 面是空操作)',
74
+ },
75
+ {
76
+ name: '会话模型面(remote.session / connection.api.sessions 双版本链)',
77
+ check: (ctx) => {
78
+ // 新面(0.1.5 的 remote.session)与旧面(<= 0.1.2 的 connection.api)
79
+ // 任一在列即能力在——双版本链的探测与调用点同序(lifecycle.ts)。
80
+ if (remoteSessionFace(ctx) !== undefined) return true
81
+ const legacy = (ctx as { connection?: { api?: { sessions?: { models?: unknown } } } })
82
+ .connection?.api?.sessions?.models
83
+ return typeof legacy === 'function'
84
+ },
85
+ fallback: 'fork 模型同步、模型标签与模型切换不可用(子会话用宿主默认模型)',
86
+ },
60
87
  ]
61
88
 
62
89
  /** 激活时跑一遍探测清单;缺失项逐项 warn-once。返回缺失项数(测试可断言)。 */
@@ -13,10 +13,18 @@ import { attachLocale, type LocaleServiceLike } from './locales.ts'
13
13
  import { createReflowStore } from './reflow.ts'
14
14
  import { registerSideChat } from './sidechat/index.tsx'
15
15
  import { registerAnnotations } from './annotate/index.tsx'
16
+ import { clearNativeRuntime, setRootContext } from './sidechat/native.ts'
16
17
 
17
18
  export const inject = ['betterSidebar', 'sessions', 'workspaces', 'slots', 'connection', 'locale']
18
19
 
19
20
  export function apply(ctx: Context): void {
21
+ // 侧栏 Tab 的原生面(better-sidebar >= 0.19)与宿主调用都要用插件根 ctx:
22
+ // 面板拿到的是 slot 注入的收缩 ctx(inject 里没有 workspaces),见 native.ts。
23
+ // fiber 撤销即清场(registry/openings/birthOrder 全模块级,重激活不得带病存活)。
24
+ ctx.effect(() => {
25
+ setRootContext(ctx)
26
+ return () => { clearNativeRuntime() }
27
+ }, 'dsh-sidenote: native bridge')
20
28
  // 跟随 DSH 通用设置里的语言(locale.preference,Host-backed,实时切换)。
21
29
  attachLocale(ctx.locale as LocaleServiceLike | undefined)
22
30
  // T2 off-face 全景探测:宿主升级后哪些能力降级了,开发者工具里一眼可见。
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 模型选择器(WI-02):composer 脚部的模型 chip → 两级 Menu(provider 分组
3
- * 标签 + 型号行)。数据走 wire 面(sessions.models/selectModel——fork 时
4
- * 同步主会话的同款通道);切的是子会话自己的选择,不回写主会话。
3
+ * 标签 + 型号行)。数据面走 lifecycle.ts 的双版本链(0.1.5 的
4
+ * remote.session.modelCatalog/selectModel 优先,<= 0.1.2 的
5
+ * connection.api.sessions 回退);切的是子会话自己的选择,不回写主会话。
5
6
  *
6
- * 懒加载:首次打开才拉目录(models RPC 有成本);切换后本地标签立即更新
7
- * (selectModel 返回的 selected 为准)。
7
+ * 懒加载:首次打开才拉目录(catalog RPC 有成本);切换后本地标签立即更新。
8
8
  */
9
9
  import { useState } from 'react'
10
10
  import { IconCheckOutline16, IconChevronDownOutline14, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -16,10 +16,12 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExterna
16
16
  import { IconNewChatOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
17
17
  import type { Context, SessionFace, TabComponentProps } from '../host/contracts.ts'
18
18
  import { useComposer, type Composer } from './composer.ts'
19
- import { clearPendingDraft, parseSideChatMeta, phaseOf } from './model.ts'
19
+ import { appendDraftText, clearPendingDraft, parseSideChatMeta, phaseOf } from './model.ts'
20
20
  import { transcriptOf } from '../chat/transcript.ts'
21
21
  import { EmptyState, MessageList, StateScreen } from './rows.tsx'
22
22
  import { chatSourceOf, ensurePanelOpen, forkAndRegister, openSessionWindow, readModelName, updateTabMeta } from './lifecycle.ts'
23
+ import { nativeSidebarHost, registerLiveSideChat } from './native.ts'
24
+ import { dropClosedSideChat, recordClosedSideChat } from './recentClosed.ts'
23
25
  import { ToolCard } from '../chat/ToolCard.tsx'
24
26
  import { ComposerBar } from './ComposerBar.tsx'
25
27
  import { ReasoningRow } from '../chat/ReasoningRow.tsx'
@@ -187,6 +189,60 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
187
189
  // pendingDraft/相位变化时真正动作(清除后 pendingDraft 为 undefined,幂等)。
188
190
  }, [pendingDraft, phase, ctx, tab.id])
189
191
 
192
+ // Native right sidebar (better-sidebar >= 0.19): publish this panel so the
193
+ // programmatic entry points (/side, selection bridge) can reach its tab id and
194
+ // seed the draft — the layout snapshot cannot see native tabs (see native.ts).
195
+ // latest-ref:composer/meta/phase/title 每渲新引用,进 deps 会高频重注册。
196
+ const composerRef = useRef(composer)
197
+ composerRef.current = composer
198
+ const metaRef = useRef(tab.meta)
199
+ metaRef.current = tab.meta
200
+ const phaseRef = useRef(phase)
201
+ phaseRef.current = phase
202
+ const titleRef = useRef(tab.title)
203
+ titleRef.current = tab.title
204
+ useEffect(() => {
205
+ // 后悔药自愈(native):重挂载说明上次卸载是切会话/HMR 而非关闭,清掉误记。
206
+ const metaNow = parseSideChatMeta(metaRef.current)
207
+ if (nativeSidebarHost(ctx) && metaNow.childId !== undefined) {
208
+ dropClosedSideChat(scope.sessionId, metaNow.childId)
209
+ }
210
+ const dispose = registerLiveSideChat(
211
+ scope.sessionId,
212
+ tab.id,
213
+ (text) => {
214
+ // 相位门:未到 chat(forking/loading)时 composer 还是本地草稿,机器
215
+ // 绑定后本地草稿被丢弃——改走 meta.pendingDraft,由既有 pendingDraft
216
+ // effect 在相位就绪后应用(与 legacy 路径同机制)。
217
+ if (phaseRef.current !== 'chat') {
218
+ updateTabMeta(ctx, tab.id, (m) => ({ ...m, pendingDraft: appendDraftText(m.pendingDraft ?? '', text) }))
219
+ return
220
+ }
221
+ composerRef.current.appendDraft(text)
222
+ requestAnimationFrame(() => { rootRef.current?.querySelector('textarea')?.focus() })
223
+ },
224
+ () => metaRef.current,
225
+ () => titleRef.current,
226
+ )
227
+ return () => {
228
+ dispose()
229
+ // 后悔药数据源(native):用户 × 关 native tab 不经 descriptor.onClose
230
+ // (tab-adapter 卸载只 records.drop),以面板卸载补记;误记由挂载时的
231
+ // drop 自愈。legacy 由 descriptor.onClose 负责,不双写。
232
+ if (nativeSidebarHost(ctx)) {
233
+ const m = parseSideChatMeta(metaRef.current)
234
+ if (m.childId !== undefined && m.parentSessionId !== undefined) {
235
+ recordClosedSideChat(m.parentSessionId, {
236
+ childId: m.childId,
237
+ parentSessionId: m.parentSessionId,
238
+ title: titleRef.current,
239
+ closedAt: Date.now(),
240
+ })
241
+ }
242
+ }
243
+ }
244
+ }, [scope.sessionId, tab.id, ctx])
245
+
190
246
  // D3a 保存为正式会话:fork 子会话为独立主会话(无 unarchive API,
191
247
  // fork 即转正——内置侧边对话同款路径)→ 主视图打开 → 关本 Tab →
192
248
  // 全局 toast 确认去向(P4:Tab 关闭后面板树卸载,toast 挂在 body 级
@@ -23,6 +23,7 @@ import { SideChatPanel } from './SideChatPanel.tsx'
23
23
  import { SIDE_TAB_TYPE, canForkFrom, collectSideTabs, mintSideTabId, sideTabTitle } from './model.ts'
24
24
  import { parseSideChatMeta } from './model.ts'
25
25
  import { recordClosedSideChat } from './recentClosed.ts'
26
+ import { liveSideChatsOf, nativeSidebarHost } from './native.ts'
26
27
  import { openOrFocusSideChat, sideChatTargetTitle } from './open.ts'
27
28
  import { t } from '../locales.ts'
28
29
  import { registerHeaderEntry } from './header.tsx'
@@ -39,9 +40,18 @@ export function registerSideChat(ctx: Context, reflow: ReflowStore): void {
39
40
  icon: (size: number) => <IconNewChatOutline16 size={size} />,
40
41
  order: 60,
41
42
  available: (availableCtx, scope) => canForkFrom(availableCtx, scope.sessionId),
42
- createTab: (state) => ({
43
- tab: { id: mintSideTabId(), type: SIDE_TAB_TYPE, title: sideTabTitle(collectSideTabs(state).map(tab => tab.title)) },
44
- }),
43
+ createTab: (state) => {
44
+ // 标题编号数据源:legacy 读布局快照;native 的 tab 不进快照,并入
45
+ // live registry(当前会话——openTab 的会话守卫保证 state 属在屏会话)。
46
+ const titles = collectSideTabs(state).map(tab => tab.title)
47
+ if (nativeSidebarHost(ctx)) {
48
+ const current = ctx.sessions.list.getSnapshot().current
49
+ if (current !== undefined) {
50
+ for (const live of liveSideChatsOf(current)) titles.push(live.readTitle())
51
+ }
52
+ }
53
+ return { tab: { id: mintSideTabId(), type: SIDE_TAB_TYPE, title: sideTabTitle(titles) } }
54
+ },
45
55
  // × 即焚观感 + 后悔药:关 Tab 时登记「最近关闭」(会话本体仍归档在盘)。
46
56
  onClose: (closedTab) => {
47
57
  const meta = parseSideChatMeta(closedTab.meta)
@@ -6,9 +6,10 @@
6
6
  * off-face 探测纪律:session.open()、store.update 均为运行时可达但契约不
7
7
  * 保证的面——就地 feature-check + 吞错降级,并登记进 host/probes.ts。
8
8
  */
9
- import type { Context, ConversationSnapshot, SessionBinding, SessionFace, SessionModelsResult, UiConversationLike } from '../host/contracts.ts'
9
+ import type { Context, ConversationSnapshot, ModelSelection, RemoteSessionModelFace, SessionBinding, SessionFace, SessionModelsResult, UiConversationLike } from '../host/contracts.ts'
10
10
  import { parseSideChatMeta, type SideChatMeta } from './model.ts'
11
11
  import { readTab } from './open.ts'
12
+ import { rootContext } from './native.ts'
12
13
 
13
14
  /**
14
15
  * 首开编排:fork(全量历史快照)→ 登记 meta(先行,绝不丢登记)→
@@ -34,6 +35,9 @@ export async function forkAndRegister(ctx: Context, parentSessionId: string, tab
34
35
  // 边界缺失不阻断 fork。
35
36
  }
36
37
  const forked = await ctx.sessions.fork({ sessionId: parentSessionId })
38
+ // The slot-provided context a panel receives has a narrower inject list than the
39
+ // plugin root context, so host calls go through the root context (see native.ts).
40
+ const owner = rootContext(ctx)
37
41
  // meta 先行:fork resolve 后立即登记 childId(会话切换导致组件卸载时
38
42
  // updateTab 找不到 tab 也只是 no-op——否则 Tab 永远停在 forking 且下次
39
43
  // 挂载重复 fork 出孤儿会话)。
@@ -45,22 +49,20 @@ export async function forkAndRegister(ctx: Context, parentSessionId: string, tab
45
49
  }))
46
50
  // 归档失败残留可见(无 unarchive API),不阻断面板。
47
51
  try {
48
- await ctx.workspaces.archiveSession(forked)
52
+ await owner.workspaces.archiveSession(forked)
49
53
  } catch (error) {
50
54
  console.warn('[dsh-sidenote] 归档侧边会话失败(会话列表可能短暂可见):', error)
51
55
  }
52
56
  // fork 继承 agent preset 但不继承模型选择——读主会话当前模型并同步到
53
57
  // 子会话(best-effort,失败则子会话用宿主默认模型,面板标签如实回退)。
58
+ // 双版本链:投影 + remote.session(0.1.5)优先,connection.api(<= 0.1.2
59
+ // client 面)回退——面迁移不弃旧档(chatSourceOf 先例)。
54
60
  try {
55
- const parentModels = await ctx.connection.api.sessions.models({ sessionId: parentSessionId })
56
- if (parentModels.result.ok) {
57
- const current = parentModels.result.value.current
58
- await ctx.connection.api.sessions.selectModel({
59
- sessionId: forked,
60
- provider: current.provider,
61
- model: current.model,
62
- ...(current.reasoningEffort !== undefined ? { reasoningEffort: current.reasoningEffort } : {}),
63
- })
61
+ const selection = await readModelSelection(owner, parentSessionId)
62
+ if (selection === undefined) {
63
+ console.warn('[dsh-sidenote] 同步主会话模型跳过:主机模型 API 不可用')
64
+ } else if (!(await writeModelSelection(owner, forked, selection))) {
65
+ console.warn('[dsh-sidenote] 同步主会话模型失败(子会话用默认模型)')
64
66
  }
65
67
  } catch (error) {
66
68
  console.warn('[dsh-sidenote] 同步主会话模型失败(子会话用默认模型):', error)
@@ -104,14 +106,100 @@ export function openSessionWindow(session: SessionFace | undefined): void {
104
106
  })
105
107
  }
106
108
 
107
- /** 读子会话当前模型名(composer 模型标签用);任何失败回退 null(默认文案)。 */
108
- export async function readModelName(ctx: Context, sessionId: string): Promise<string | null> {
109
+ /**
110
+ * Current model selection of a session, read from its `modelSelection`
111
+ * projection (dsh >= 0.1.2): the durable selection is projected state, so no RPC
112
+ * is needed and the read is safe for any session id. Snapshot shape authority:
113
+ * `@deepseek-ai/dsh-api-session-controller/lib/types/types.d.ts`
114
+ * (`ModelSelectionProjection = { lastUsed, next }`,两者均可为 null——
115
+ * `next` 缺省时按投影文档回落 `lastUsed`)。
116
+ */
117
+ export function projectedModelSelection(ctx: Context, sessionId: string): ModelSelection | undefined {
118
+ try {
119
+ const session = ctx.sessions.binding(sessionId)?.session as
120
+ | { projections?: { faceOf?: (name: string) => { getSnapshot?: () => unknown } | undefined } }
121
+ | undefined
122
+ const snapshot = session?.projections?.faceOf?.('modelSelection')?.getSnapshot?.() as
123
+ | { next?: ModelSelection | null; lastUsed?: ModelSelection | null }
124
+ | undefined
125
+ return snapshot?.next ?? snapshot?.lastUsed ?? undefined
126
+ } catch {
127
+ return undefined
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Remote session face carrying `selectModel`/`modelCatalog`(dsh 0.1.5 删除
133
+ * `connection.api.sessions.*` client 面后的模型 RPC 面;事实上 0.1.2-rc.1
134
+ * 起存在——authority: dsh-api-session-controller lib/typert.remote-client.d.ts)。
135
+ * 探测走 `ctx.get`(cordis 的 get 不受 inject 门禁限制,本插件为兼容旧宿主
136
+ * 不声明 inject 'remote.session';机制见 contracts.ts RemoteSessionModelFace)。
137
+ * `ctx.get('remote')` 返回命名空间容器时下钻其 `.session` 成员。
138
+ */
139
+ export function remoteSessionFace(ctx: Context): RemoteSessionModelFace | undefined {
140
+ for (const name of ['remote.session', 'remote']) {
141
+ let candidate: unknown
142
+ try {
143
+ candidate = ctx.get(name)
144
+ } catch {
145
+ continue // service absent
146
+ }
147
+ const face = candidate as { selectModel?: unknown; session?: { selectModel?: unknown } } | undefined
148
+ if (typeof face?.selectModel === 'function') return face as RemoteSessionModelFace
149
+ if (typeof face?.session?.selectModel === 'function') return face.session as RemoteSessionModelFace
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ /**
155
+ * 读会话当前模型选择:投影面(0.1.2+,同步无 RPC)优先;旧
156
+ * `connection.api.sessions.models` RPC(<= 0.1.2 的 client 面,0.1.5 已删)
157
+ * 回退(双版本链,同 chatSourceOf 先例)。永不抛错。
158
+ */
159
+ export async function readModelSelection(ctx: Context, sessionId: string): Promise<ModelSelection | undefined> {
160
+ const projected = projectedModelSelection(ctx, sessionId)
161
+ if (projected !== undefined) return projected
109
162
  try {
110
163
  const res = await ctx.connection.api.sessions.models({ sessionId })
111
- return res.result.ok ? res.result.value.current.model : null
164
+ if (res.result.ok) return res.result.value.current
112
165
  } catch {
113
- return null
166
+ // 旧面缺席(0.1.5)→ undefined。
114
167
  }
168
+ return undefined
169
+ }
170
+
171
+ /**
172
+ * 写会话模型选择:`remote.session.selectModel`(新面)优先,旧
173
+ * `connection.api.sessions.selectModel` 回退。面在而调用失败(模型不可路由等)
174
+ * 属真实失败,不再落旧面重试。
175
+ */
176
+ export async function writeModelSelection(ctx: Context, sessionId: string, selection: ModelSelection): Promise<boolean> {
177
+ const request = {
178
+ sessionId,
179
+ provider: selection.provider,
180
+ model: selection.model,
181
+ ...(selection.reasoningEffort !== undefined ? { reasoningEffort: selection.reasoningEffort } : {}),
182
+ }
183
+ const face = remoteSessionFace(ctx)
184
+ if (face !== undefined) {
185
+ try {
186
+ return (await face.selectModel(request)).ok
187
+ } catch {
188
+ return false
189
+ }
190
+ }
191
+ try {
192
+ const res = await ctx.connection.api.sessions.selectModel(request)
193
+ return res.result.ok
194
+ } catch {
195
+ return false
196
+ }
197
+ }
198
+
199
+ /** 读子会话当前模型名(composer 模型标签用);任何失败回退 null(默认文案)。 */
200
+ export async function readModelName(ctx: Context, sessionId: string): Promise<string | null> {
201
+ const selection = await readModelSelection(ctx, sessionId)
202
+ return selection?.model ?? null
115
203
  }
116
204
 
117
205
  /** 会话内容读取源(0.1.1/0.1.2 双兼容,feature-check 优先链)。 */
@@ -155,8 +243,30 @@ export function chatSourceOf(ctx: Context, binding: SessionBinding | undefined):
155
243
  return undefined
156
244
  }
157
245
 
158
- /** 拉会话模型目录(models RPC 容错;失败 null → 菜单保持只读标签态)。 */
246
+ /**
247
+ * 拉会话模型目录:0.1.5 走 `remote.session.modelCatalog()`(目录是宿主级,
248
+ * 菜单选中项所需的 current 由投影/旧面补上);旧面回退
249
+ * `connection.api.sessions.models`。失败 null → 菜单保持只读标签态。
250
+ */
159
251
  export async function listModels(ctx: Context, sessionId: string): Promise<SessionModelsResult | null> {
252
+ const face = remoteSessionFace(ctx)
253
+ if (typeof face?.modelCatalog === 'function') {
254
+ try {
255
+ const result = await face.modelCatalog()
256
+ if (result.ok) {
257
+ const catalog = result.value
258
+ const current = (await readModelSelection(ctx, sessionId)) ?? catalog.default
259
+ return {
260
+ current,
261
+ routable: catalog.routableProviders.length > 0,
262
+ groups: catalog.groups,
263
+ ...(catalog.failures !== undefined ? { failures: catalog.failures } : {}),
264
+ }
265
+ }
266
+ } catch {
267
+ // 落旧面。
268
+ }
269
+ }
160
270
  try {
161
271
  const res = await ctx.connection.api.sessions.models({ sessionId })
162
272
  return res.result.ok ? res.result.value : null
@@ -167,10 +277,6 @@ export async function listModels(ctx: Context, sessionId: string): Promise<Sessi
167
277
 
168
278
  /** 切换子会话模型;成功返回新模型展示名,失败 null(best-effort)。 */
169
279
  export async function switchModel(ctx: Context, sessionId: string, provider: string, model: string): Promise<string | null> {
170
- try {
171
- const res = await ctx.connection.api.sessions.selectModel({ sessionId, provider, model })
172
- return res.result.ok ? model : null
173
- } catch {
174
- return null
175
- }
280
+ const ok = await writeModelSelection(ctx, sessionId, { provider, model })
281
+ return ok ? model : null
176
282
  }