dsh-codex-handoff 0.1.1 → 0.1.2

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
@@ -10,19 +10,35 @@
10
10
  需要 DSH(DeepSeek Harness)与**本机已登录、可正常调用的 Codex CLI**(`codex` 可执行文件会自动在
11
11
  `%LOCALAPPDATA%\OpenAI\Codex\bin\*\codex.exe`、`CODEX_BIN`、PATH 里找)。
12
12
 
13
+ ### 从 npm(推荐)
14
+
15
+ ```
16
+ dsh-codex-handoff@0.1.1
17
+ ```
18
+
19
+ - 界面:**插件** 页 → 添加 → 输入 `dsh-codex-handoff`
20
+ - 工具:`plugin_manager` → `install_bundle`,`target` 填 `dsh-codex-handoff@0.1.1`
21
+
22
+ ### 从源码
23
+
13
24
  ```powershell
14
25
  git clone https://github.com/HATO-24/dsh-codex-handoff.git
15
26
  ```
16
27
 
17
- 然后在 DSH 里装进当前 profile,两种方式任选:
18
-
19
- - 界面:**插件** 页 → 添加 → 指向克隆下来的目录;
20
- - 工具:`plugin_manager` → `install_bundle`,`target` 填该目录的绝对路径。
28
+ 然后在 DSH 里装进当前 profile:界面里 **插件** 页 → 添加 → 指向克隆下来的目录,
29
+ 或用 `plugin_manager` → `install_bundle`,`target` 填该目录的绝对路径。
21
30
 
22
31
  装好后:
23
32
 
24
- - 左侧栏面板列表多出 **GPT 对话**(点开在中间整页查看)
25
- - 会话标题栏最右侧多一个对话气泡按钮(点开是右侧抽屉)
33
+ - **左下角**(Settings 旁、催更姬旁边)多一个 **GPT** 按钮:点它在 **DSH 右侧栏** 打开对话面板
34
+ - 右侧栏顶部出现 **GPT 对话** 标签(和「开始」并列)
35
+ - 会话标题栏最右侧还有一个对话气泡按钮,作为快捷入口
36
+
37
+ > 为什么不用左侧栏图标?点它会切走 conversation,`sidebarRight.mounted` 变空,
38
+ > `openResource` 直接抛 `sidebarRight: no session surface is mounted`。
39
+ > 这是 DSH 的机制限制,所以入口必须放在**不切面板**的位置(左下角 / 会话头部)。
40
+
41
+ > 注意:Client 插件改了 `inject` 后**要重启 DSH** 才会重新解析。
26
42
 
27
43
  > 本地改代码后,在插件页把该插件 **禁用 → 启用** 即可重载新代码,不用重启 DSH。
28
44
  > 首次调用会消耗 Codex 额度;想省上下文就把 `DSH_CODEX_BRIDGE_ISOLATE=1` 打开。
@@ -91,12 +107,27 @@ codex exec -c 'mcp_servers={}' ...
91
107
 
92
108
  ## 三、用
93
109
 
94
- ### 1. 侧栏查看记录
110
+ ### 1. 在右侧栏查看记录
111
+
112
+ 对话面板**住在 DSH 右侧栏**里(和催更姬用的是同一个扩展点):
113
+
114
+ - 点**左下角的 GPT 按钮**(或会话标题栏的气泡按钮)→ 右侧栏自动展开「GPT 对话」标签
115
+ - 右侧栏本身可以拖动分隔条调整宽度
116
+ - 主面板不受影响,对话照常进行
95
117
 
96
- 两个入口,同一份数据:
118
+ 实现用的是 `ctx.sidebarRightTabs.register()` + `sidebar.right.pane.tab` slot,来自 DSH 内置的
119
+ `@deepseek-ai/dsh-client-ui-sidebar-right`。**关键点:Client 插件的 `inject` 必须显式声明
120
+ `sidebarRight` / `sidebarRightTabs`**,否则 `ctx` 里根本看不到这两个服务(这是踩过的坑,
121
+ 表现为 tab 静默不注册、甚至把整个 apply 打断导致界面元素消失)。
97
122
 
98
- - **侧栏面板**:DSH 左侧栏面板图标里多一个 **GPT 对话**(order 30,在「写作助手」之后、「ZAI Usage」之前),点开在中间整页查看。
99
- - **右侧抽屉**:会话标题栏最右侧(`…` 左边)多一个对话气泡按钮,点开从右边滑出 430px 抽屉,**贴着对话看不打断主界面**;`Esc` 或右上角 × 关闭。抽屉从标题栏下方(`top: 48px`)开始——窗口控制按钮(− □ ×)在页面之上,任何 z-index 都盖不过去,只能避开。
123
+ **入口位置也踩过坑**:`sidebarRight.openResource()` 内部调 `this.require()`,它要求
124
+ `this.mounted.getSnapshot()` 有值(屏幕上必须挂着一个会话)。所以入口**不能放在会切走
125
+ conversation 的地方**——左侧栏图标会切主面板,conversation 卸载后 `mounted` 为空,
126
+ 直接抛 `sidebarRight: no session surface is mounted`。只能用左下角按钮或会话头部按钮。
127
+
128
+ 样式全部走 DSH 的 theme token(`--dsw-alias-bg-base` / `--dsw-alias-label-primary` /
129
+ `--dsw-alias-brand-primary` 等),明暗主题与换肤自动跟随。注意 `--dsw-alias-bg` / `--dsw-alias-text`
130
+ 这类名字**不存在**,写了会静默回退到硬编码色值——这也是踩过的坑。
100
131
 
101
132
  两个入口都显示每段会话的**模型与推理强度**:
102
133
 
@@ -114,7 +145,7 @@ codex exec -c 'mcp_servers={}' ...
114
145
 
115
146
  ### 1.5 在面板里直接发消息、切换模型
116
147
 
117
- 侧栏面板和右侧抽屉底部都有发送区:
148
+ 右侧栏底部有发送区:
118
149
 
119
150
  - 输入框:`Enter` 发送、`Shift+Enter` 换行
120
151
  - **模型下拉**:来自 `/models`,列出 `codex debug models` 里的全部 10 个模型
@@ -180,6 +211,7 @@ codex_gpt({ threadId: "01a1ac8e-01a4-79a2-a70d-855254e70751", message: "继续
180
211
  | GET | `/status` | 工作区、codex 路径、隔离开关 |
181
212
  | GET | `/models` | 可用模型目录(slug / 显示名 / 支持的推理档位) |
182
213
  | POST | `/send` | 发一轮真实对话,body:`{message, conversationId?, threadId?, model?, reasoning?}` |
214
+ | POST | `/promote` | 把一段会话标记成 Codex App 可见,body:`{threadId}` 或 `{conversationId}` |
183
215
 
184
216
  ```powershell
185
217
  $body = @{ message = "只回复两个字:收到"; reasoning = "low" } | ConvertTo-Json -Compress
@@ -191,13 +223,21 @@ Invoke-WebRequest -Uri "http://127.0.0.1:12530/api/codex-bridge/send" -Method PO
191
223
  > `POST /send` 会**真实消耗 Codex 额度**,且同步等待(一次 `low` 推理实测约 58 秒)。
192
224
  > 路由用 Node 原生 handler 而非 `resource`:`resource` 路由只放行 GET/HEAD,POST 会被平台层直接 405。
193
225
 
194
- ### 3. 在 Codex 软件里做第二轮
226
+ ### 3. 在 Codex 软件里做第二轮(按需转换)
227
+
228
+ 会话**默认持久化**(不再 `--ephemeral`),会写进 `~/.codex/sessions/` 并注册到 Codex 的
229
+ state DB,命令行随时可以 `codex exec resume <thread_id> -` 继续。
230
+
231
+ 但 **Codex 桌面版的会话列表只显示 `source = "vscode"` 的线程**;`codex exec` 写的是 `exec`,
232
+ 所以默认在 App 列表里看不到(实测:数据三处齐全——threads、projection state、turns——仍然不显示)。
195
233
 
196
- 会话**默认持久化**(不再 `--ephemeral`),所以:
234
+ 因此提供了**按需转换**:在会话详情里点 **「转移到 Codex」**,插件会把该 thread 的 `source`
235
+ 改成 `vscode`,之后**重启 Codex App** 就能在列表里找到并继续。
197
236
 
198
- - 桥发起的对话会出现在 `~/.codex/sessions/` 并注册进 Codex 的 state DB;
199
- - 在 Codex 桌面应用的会话列表里可以直接找到、继续;
200
- - 命令行也能续:`codex exec resume <thread_id> -`。
237
+ - 只改 `source` 一个字段(用 Node 内置 `node:sqlite`)
238
+ - 不想每轮都改,所以做成了手动按钮,不是自动行为
239
+ - `DSH_CODEX_BRIDGE_APP_VISIBLE=0` 可以整个关掉这个能力
240
+ - 试过官方的 `codex exec --thread-source vscode`:**无效**,写进去仍是 `exec`
201
241
 
202
242
  ### 4. Codex 侧回读(两边都能继续)
203
243
 
package/lib/client.js CHANGED
@@ -16,6 +16,21 @@ window.__ModuleLoader__.load({
16
16
 
17
17
  const ROUTE = "/api/codex-bridge";
18
18
  const DRAWER_WIDTH = 430;
19
+ const DRAWER_WIDTH_KEY = "dsh-codex-handoff:drawer-width";
20
+ /** 右侧栏 tab 的 id(也是 sidebar.right.pane.tab 的 key)。 */
21
+ const TAB_ID = "dsh-codex-handoff";
22
+ const TAB_RESOURCE = "dsh-resource://codex-bridge/main";
23
+
24
+ /**
25
+ * 打开入口:激活右侧栏 tab。apply 里会按实际服务覆盖;
26
+ * 服务不可用时只记一条日志(抽屉已移除,不再有回退界面)。
27
+ */
28
+ const bridgeOpeners = {
29
+ open: () => console.error("[codex-bridge] 右侧栏服务不可用,无法打开对话面板"),
30
+ };
31
+
32
+ /** apply 时填入 ctx.layout,供主面板挂载后切回对话页。 */
33
+ let bridgeLayout = null;
19
34
 
20
35
  // ── 抽屉开关:模块级小 store,按钮与抽屉共享 ──────────────────────
21
36
  const drawerStore = { open: false, listeners: new Set() };
@@ -41,13 +56,23 @@ window.__ModuleLoader__.load({
41
56
  }
42
57
 
43
58
  // ── 样式 ─────────────────────────────────────────────────────────
59
+ // 全部使用 DSH 的 alias token(cordis_inspect Theme.listTokens 列出的名字),
60
+ // 这样明暗主题、用户换肤都会自动跟随,外观也和本体一致。
44
61
  const colors = {
45
- bg: "var(--dsw-alias-bg, #16181d)",
46
- panel: "var(--dsw-alias-surface, rgba(127,127,127,.06))",
47
- border: "var(--dsw-alias-border, rgba(127,127,127,.24))",
48
- accent: "var(--dsw-alias-accent, #4f8ef7)",
49
- text: "var(--dsw-alias-text, #e6edf3)",
50
- code: "var(--dsw-alias-code-bg, rgba(127,127,127,.10))",
62
+ bg: "var(--dsw-alias-bg-base, #16181d)",
63
+ panel: "var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))",
64
+ layer2: "var(--dsw-alias-bg-layer-2, rgba(127,127,127,.10))",
65
+ overlay: "var(--dsw-alias-bg-overlay, #1c1f26)",
66
+ sidebar: "var(--dsw-specific-sidebar-fill, #16181d)",
67
+ border: "var(--dsw-alias-border-l1, rgba(127,127,127,.24))",
68
+ borderStrong: "var(--dsw-alias-border-l2, rgba(127,127,127,.36))",
69
+ accent: "var(--dsw-alias-brand-primary, #4f8ef7)",
70
+ text: "var(--dsw-alias-label-primary, #e6edf3)",
71
+ textSecondary: "var(--dsw-alias-label-secondary, #9aa4b2)",
72
+ idle: "var(--dsw-alias-state-idle-primary, #8b949e)",
73
+ error: "var(--dsw-alias-state-error-primary, #d06a5a)",
74
+ success: "var(--dsw-alias-state-success-primary, #3fb950)",
75
+ warn: "var(--dsw-alias-state-warn-primary, #d29922)",
51
76
  };
52
77
 
53
78
  const styles = {
@@ -60,6 +85,15 @@ window.__ModuleLoader__.load({
60
85
  color: colors.text,
61
86
  fontFamily: "inherit",
62
87
  },
88
+ content: {
89
+ display: "flex",
90
+ flexDirection: "column",
91
+ height: "100%",
92
+ minHeight: 0,
93
+ background: colors.bg,
94
+ color: colors.text,
95
+ fontFamily: "inherit",
96
+ },
63
97
  head: {
64
98
  display: "flex",
65
99
  alignItems: "center",
@@ -104,9 +138,43 @@ window.__ModuleLoader__.load({
104
138
  border: `1px solid ${colors.border}`,
105
139
  borderRadius: 8,
106
140
  padding: "9px 11px",
107
- cursor: "pointer",
108
141
  background: colors.panel,
109
142
  },
143
+ cardHead: {
144
+ position: "relative",
145
+ cursor: "pointer",
146
+ paddingRight: 16,
147
+ },
148
+ cardBody: { marginTop: 10 },
149
+ chevron: {
150
+ position: "absolute",
151
+ right: 0,
152
+ top: 0,
153
+ fontSize: 10,
154
+ opacity: 0.45,
155
+ lineHeight: "18px",
156
+ },
157
+ threadRow: {
158
+ display: "flex",
159
+ alignItems: "center",
160
+ justifyContent: "space-between",
161
+ gap: 8,
162
+ marginBottom: 2,
163
+ },
164
+ threadText: {
165
+ fontSize: 10.5,
166
+ color: colors.textSecondary,
167
+ wordBreak: "break-all",
168
+ },
169
+ resizeHandle: {
170
+ position: "absolute",
171
+ left: -3,
172
+ top: 0,
173
+ bottom: 0,
174
+ width: 7,
175
+ cursor: "col-resize",
176
+ zIndex: 2,
177
+ },
110
178
  cardActive: {
111
179
  borderColor: colors.accent,
112
180
  boxShadow: `0 0 0 1px ${colors.accent} inset`,
@@ -123,8 +191,8 @@ window.__ModuleLoader__.load({
123
191
  empty: { fontSize: 12, opacity: 0.6, lineHeight: 1.75, padding: "8px 4px" },
124
192
  turn: {
125
193
  borderTop: `1px dashed ${colors.border}`,
126
- paddingTop: 10,
127
- marginTop: 10,
194
+ paddingTop: 12,
195
+ marginTop: 12,
128
196
  },
129
197
  label: { fontSize: 10.5, fontWeight: 600, opacity: 0.7, marginBottom: 4 },
130
198
  rowBetween: {
@@ -133,20 +201,75 @@ window.__ModuleLoader__.load({
133
201
  justifyContent: "space-between",
134
202
  gap: 8,
135
203
  },
204
+ turnHead: {
205
+ display: "flex",
206
+ alignItems: "center",
207
+ gap: 6,
208
+ flexWrap: "wrap",
209
+ marginBottom: 8,
210
+ },
211
+ turnIndex: { fontSize: 11, fontWeight: 700, opacity: 0.5 },
212
+ turnTime: { fontSize: 11, opacity: 0.5 },
213
+ pill: {
214
+ fontSize: 10,
215
+ padding: "0 6px",
216
+ lineHeight: "16px",
217
+ borderRadius: 999,
218
+ border: `1px solid ${colors.border}`,
219
+ opacity: 0.85,
220
+ whiteSpace: "nowrap",
221
+ },
222
+ pillMuted: { fontSize: 10, opacity: 0.45, whiteSpace: "nowrap" },
223
+ messageAsk: {
224
+ borderRadius: 8,
225
+ background: colors.layer2,
226
+ padding: "8px 10px",
227
+ marginBottom: 8,
228
+ },
229
+ messageReply: {
230
+ borderRadius: 8,
231
+ border: `1px solid ${colors.border}`,
232
+ padding: "8px 10px",
233
+ },
234
+ messageLabel: { fontSize: 10.5, fontWeight: 600, opacity: 0.6, marginBottom: 4 },
235
+ messageText: {
236
+ fontSize: 12,
237
+ lineHeight: 1.8,
238
+ whiteSpace: "pre-wrap",
239
+ wordBreak: "break-word",
240
+ fontFamily: "inherit",
241
+ },
242
+ linkBtn: {
243
+ appearance: "none",
244
+ border: "none",
245
+ background: "transparent",
246
+ color: colors.accent,
247
+ fontSize: 11,
248
+ padding: "4px 0",
249
+ cursor: "pointer",
250
+ fontFamily: "inherit",
251
+ },
252
+ externalBlock: {
253
+ marginTop: 14,
254
+ paddingTop: 12,
255
+ borderTop: `1px dashed ${colors.border}`,
256
+ },
257
+ externalTitle: { fontSize: 11, fontWeight: 600, color: colors.accent },
258
+ externalItem: { marginTop: 8 },
136
259
  pre: {
260
+ /* 兜底:不再限制高度,内层滚动会截走滚轮,长文交给外层容器统一滚动 */
137
261
  margin: 0,
138
262
  padding: "8px 10px",
139
263
  borderRadius: 6,
140
- background: colors.code,
264
+ background: colors.layer2,
141
265
  fontSize: 11.5,
142
- lineHeight: 1.7,
266
+ lineHeight: 1.75,
143
267
  whiteSpace: "pre-wrap",
144
268
  wordBreak: "break-word",
145
- maxHeight: 320,
146
- overflowY: "auto",
147
269
  fontFamily: "inherit",
148
270
  },
149
- error: { color: "#d06a5a", fontSize: 12, padding: "8px 4px", lineHeight: 1.7 },
271
+ error: { color: colors.error, fontSize: 12, padding: "8px 4px", lineHeight: 1.7 },
272
+ okNote: { color: colors.success, fontSize: 11, lineHeight: 1.6 },
150
273
  /* 底部发送区 */
151
274
  composer: {
152
275
  flex: "0 0 auto",
@@ -163,7 +286,7 @@ window.__ModuleLoader__.load({
163
286
  resize: "none",
164
287
  border: `1px solid ${colors.border}`,
165
288
  borderRadius: 6,
166
- background: colors.code,
289
+ background: colors.layer2,
167
290
  color: "inherit",
168
291
  fontFamily: "inherit",
169
292
  fontSize: 12,
@@ -198,7 +321,7 @@ window.__ModuleLoader__.load({
198
321
  gap: 5,
199
322
  border: `1px solid ${colors.border}`,
200
323
  borderRadius: 6,
201
- background: colors.code,
324
+ background: colors.layer2,
202
325
  color: colors.text,
203
326
  fontFamily: "inherit",
204
327
  fontSize: 11,
@@ -233,7 +356,7 @@ window.__ModuleLoader__.load({
233
356
  color: colors.text,
234
357
  },
235
358
  dropdownItemActive: {
236
- background: colors.code,
359
+ background: colors.layer2,
237
360
  color: colors.accent,
238
361
  fontWeight: 600,
239
362
  },
@@ -356,73 +479,100 @@ window.__ModuleLoader__.load({
356
479
  }
357
480
 
358
481
  // ── 展示件 ───────────────────────────────────────────────────────
482
+ const COLLAPSE_CHARS = 900;
483
+
484
+ /** 一条消息正文:过长时默认收起。内层不滚动,滚轮交给外层容器统一处理。 */
485
+ function MessageBody({ text }) {
486
+ const [expanded, setExpanded] = useState(false);
487
+ const value = String(text || "");
488
+ const isLong = value.length > COLLAPSE_CHARS;
489
+ const shown = expanded || !isLong ? value : `${value.slice(0, COLLAPSE_CHARS)}…`;
490
+
491
+ return h(
492
+ "div",
493
+ null,
494
+ h("div", { style: styles.messageText }, shown),
495
+ isLong
496
+ ? h(
497
+ "button",
498
+ {
499
+ type: "button",
500
+ style: styles.linkBtn,
501
+ onClick: () => setExpanded(!expanded),
502
+ },
503
+ expanded ? "收起" : `展开全文(${value.length} 字)`,
504
+ )
505
+ : null,
506
+ );
507
+ }
508
+
359
509
  function TurnBlock({ turn, index }) {
360
510
  const meta = turn.meta || {};
361
511
  const usage = meta.usage;
512
+ const model = meta.actualModel || meta.model || "";
513
+ const effort = meta.actualEffort || meta.reasoning || "";
514
+
362
515
  return h(
363
516
  "div",
364
517
  { style: styles.turn },
365
518
  h(
366
519
  "div",
367
- { style: styles.rowBetween },
368
- h("div", { style: styles.label }, `第 ${index + 1} 轮 · ${formatTime(turn.at)}`),
369
- h(
370
- "div",
371
- { style: styles.meta },
372
- (meta.actualModel || meta.model) ? h("span", null, meta.actualModel || meta.model) : null,
373
- (meta.actualEffort || meta.reasoning)
374
- ? h("span", null, `推理 ${meta.actualEffort || meta.reasoning}`)
375
- : null,
376
- usage
377
- ? h(
378
- "span",
379
- { title: `input ${usage.input_tokens} / cached ${usage.cached_input_tokens ?? 0}` },
380
- `${Math.round((usage.input_tokens || 0) / 1000)}k in`,
381
- )
382
- : null,
383
- ),
520
+ { style: styles.turnHead },
521
+ h("span", { style: styles.turnIndex }, `#${index + 1}`),
522
+ h("span", { style: styles.turnTime }, formatTime(turn.at)),
523
+ model ? h("span", { style: styles.pill }, model) : null,
524
+ effort ? h("span", { style: styles.pill }, `推理 ${effort}`) : null,
525
+ usage
526
+ ? h(
527
+ "span",
528
+ {
529
+ style: styles.pillMuted,
530
+ title: `input ${usage.input_tokens} / cached ${usage.cached_input_tokens ?? 0}`,
531
+ },
532
+ `${Math.round((usage.input_tokens || 0) / 1000)}k in`,
533
+ )
534
+ : null,
535
+ ),
536
+ h(
537
+ "div",
538
+ { style: styles.messageAsk },
539
+ h("div", { style: styles.messageLabel }, "我 → GPT"),
540
+ h(MessageBody, { text: turn.prompt || "(空)" }),
541
+ ),
542
+ h(
543
+ "div",
544
+ { style: styles.messageReply },
545
+ h("div", { style: styles.messageLabel }, "GPT → 我"),
546
+ h(MessageBody, { text: turn.reply || "(空)" }),
384
547
  ),
385
- h("div", { style: styles.label }, "提问"),
386
- h("pre", { style: styles.pre }, turn.prompt || "(空)"),
387
- h("div", { style: { ...styles.label, marginTop: 8 } }, "GPT 回复"),
388
- h("pre", { style: styles.pre }, turn.reply || "(空)"),
389
548
  );
390
549
  }
391
550
 
392
551
  /**
393
- * Codex 会话库回读:rollout 里可能包含用户在 Codex 软件里继续的轮次,
394
- * 那些不在桥的记录里,标成「Codex 侧新增」。
552
+ * 只列「在 Codex 里继续、桥不知道」的那些轮次。
553
+ * 桥已经记过的问答不再重复展示——否则同一段内容会在这里出现第二遍。
395
554
  */
396
555
  function CodexTimeline({ timeline }) {
397
556
  const [expanded, setExpanded] = useState(false);
398
- const messages = timeline?.messages ?? [];
399
- if (messages.length === 0) return null;
400
- const COLLAPSED = 3;
401
- const shown = expanded ? messages : messages.slice(-COLLAPSED);
402
- const externalCount = messages.filter((m) => m.external).length;
557
+ const external = (timeline && timeline.messages ? timeline.messages : []).filter(
558
+ (message) => message.external,
559
+ );
560
+ if (external.length === 0) return null;
561
+
562
+ const COLLAPSED = 2;
563
+ const shown = expanded ? external : external.slice(-COLLAPSED);
403
564
 
404
565
  return h(
405
566
  "div",
406
- {
407
- style: {
408
- marginTop: 12,
409
- borderTop: `1px dashed ${colors.border}`,
410
- paddingTop: 10,
411
- },
412
- },
567
+ { style: styles.externalBlock },
413
568
  h(
414
569
  "div",
415
570
  { style: styles.rowBetween },
416
- h(
417
- "div",
418
- { style: styles.label },
419
- `Codex 会话原文 · ${messages.length} 条`,
420
- externalCount > 0 ? `(${externalCount} 条 Codex 侧新增)` : "",
421
- ),
422
- messages.length > COLLAPSED
571
+ h("div", { style: styles.externalTitle }, `在 Codex 里继续的 ${external.length} 条`),
572
+ external.length > COLLAPSED
423
573
  ? h(
424
574
  "button",
425
- { type: "button", style: styles.btn, onClick: () => setExpanded(!expanded) },
575
+ { type: "button", style: styles.linkBtn, onClick: () => setExpanded(!expanded) },
426
576
  expanded ? "收起" : "展开全部",
427
577
  )
428
578
  : null,
@@ -430,19 +580,13 @@ window.__ModuleLoader__.load({
430
580
  shown.map((message, index) =>
431
581
  h(
432
582
  "div",
433
- { key: index, style: { marginTop: 8 } },
583
+ { key: index, style: styles.externalItem },
434
584
  h(
435
585
  "div",
436
- {
437
- style: {
438
- ...styles.label,
439
- color: message.external ? colors.accent : undefined,
440
- },
441
- },
442
- message.role === "user" ? "用户" : "GPT",
443
- message.external ? " · Codex 侧新增" : "",
586
+ { style: styles.messageLabel },
587
+ message.role === "user" ? "你(Codex 侧)" : "GPT(Codex 侧)",
444
588
  ),
445
- h("pre", { style: styles.pre }, message.text || "(空)"),
589
+ h(MessageBody, { text: message.text || "(空)" }),
446
590
  ),
447
591
  ),
448
592
  );
@@ -673,40 +817,80 @@ window.__ModuleLoader__.load({
673
817
  }
674
818
 
675
819
  function ConversationList({ conversations, selected, onSelect, detail, detailError, detailLoading }) {
820
+ const [promoteNote, setPromoteNote] = useState({});
821
+
822
+ /** 按需把某段会话标记成 Codex App 可见(改完要重启 App 才会出现)。 */
823
+ const promoteToApp = async (item, threadId) => {
824
+ try {
825
+ const response = await fetch(`${ROUTE}/promote`, {
826
+ method: "POST",
827
+ headers: { "content-type": "application/json" },
828
+ body: JSON.stringify({ threadId }),
829
+ });
830
+ let body = null;
831
+ try {
832
+ body = await response.json();
833
+ } catch {
834
+ body = null;
835
+ }
836
+ if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
837
+ setPromoteNote((prev) => ({
838
+ ...prev,
839
+ [item.id]: "已标记为 Codex 可见,重启 Codex App 后可在那边继续这条对话",
840
+ }));
841
+ } catch (err) {
842
+ setPromoteNote((prev) => ({
843
+ ...prev,
844
+ [item.id]: `标记失败:${String(err?.message ?? err)}`,
845
+ }));
846
+ }
847
+ };
848
+
676
849
  return h(
677
850
  "div",
678
851
  { style: styles.list },
679
- conversations.map((item) =>
680
- h(
852
+ conversations.map((item) => {
853
+ const isOpen = item.id === selected;
854
+ return h(
681
855
  "div",
682
856
  {
683
857
  key: item.id,
684
- style: { ...styles.card, ...(item.id === selected ? styles.cardActive : null) },
685
- onClick: () => onSelect(item.id === selected ? "" : item.id),
686
- role: "button",
687
- tabIndex: 0,
688
- onKeyDown: (event) => {
689
- if (event.key === "Enter" || event.key === " ") {
690
- event.preventDefault();
691
- onSelect(item.id === selected ? "" : item.id);
692
- }
693
- },
858
+ className: "cbh-card",
859
+ style: { ...styles.card, ...(isOpen ? styles.cardActive : null) },
694
860
  },
695
- h("div", { style: styles.cardTitle }, item.title),
861
+ /* 只有头部负责展开/收起。详情里的按钮不冒泡到这里,否则点「展开全文」会把卡片收起来。 */
696
862
  h(
697
863
  "div",
698
- { style: styles.meta },
699
- h("span", null, formatTime(item.updatedAt)),
700
- h("span", null, `${item.turnCount} 轮`),
701
- item.model ? h("span", null, item.model) : null,
702
- item.reasoning ? h("span", null, `推理 ${item.reasoning}`) : null,
703
- item.threadId ? h("span", null, `#${item.threadId.slice(0, 6)}`) : null,
704
- item.legacy ? h("span", null, "旧记录") : null,
864
+ {
865
+ style: styles.cardHead,
866
+ role: "button",
867
+ tabIndex: 0,
868
+ "aria-expanded": isOpen,
869
+ onClick: () => onSelect(isOpen ? "" : item.id),
870
+ onKeyDown: (event) => {
871
+ if (event.key === "Enter" || event.key === " ") {
872
+ event.preventDefault();
873
+ onSelect(isOpen ? "" : item.id);
874
+ }
875
+ },
876
+ },
877
+ h("div", { style: styles.cardTitle }, item.title),
878
+ h(
879
+ "div",
880
+ { style: styles.meta },
881
+ h("span", null, formatTime(item.updatedAt)),
882
+ h("span", null, `${item.turnCount} 轮`),
883
+ item.model ? h("span", null, item.model) : null,
884
+ item.reasoning ? h("span", null, `推理 ${item.reasoning}`) : null,
885
+ item.threadId ? h("span", null, `#${item.threadId.slice(0, 6)}`) : null,
886
+ item.legacy ? h("span", null, "旧记录") : null,
887
+ ),
888
+ h("span", { style: styles.chevron }, isOpen ? "▾" : "▸"),
705
889
  ),
706
- item.id === selected
890
+ isOpen
707
891
  ? h(
708
892
  "div",
709
- null,
893
+ { style: styles.cardBody },
710
894
  detailLoading ? h("div", { style: styles.empty }, "载入中…") : null,
711
895
  detailError ? h("div", { style: styles.error }, detailError) : null,
712
896
  detail
@@ -716,10 +900,26 @@ window.__ModuleLoader__.load({
716
900
  detail.threadId
717
901
  ? h(
718
902
  "div",
719
- { style: { ...styles.meta, marginTop: 8 } },
720
- h("span", null, `Codex 会话 ${detail.threadId}`),
903
+ { style: styles.threadRow },
904
+ h("span", { style: styles.threadText }, `Codex 会话 ${detail.threadId}`),
905
+ h(
906
+ "button",
907
+ {
908
+ type: "button",
909
+ style: styles.linkBtn,
910
+ title: "把这条会话标记成 Codex App 可见(之后重启 App 就能在那边继续)",
911
+ onClick: (event) => {
912
+ event.stopPropagation();
913
+ void promoteToApp(item, detail.threadId);
914
+ },
915
+ },
916
+ "转移到 Codex",
917
+ ),
721
918
  )
722
919
  : null,
920
+ promoteNote[item.id]
921
+ ? h("div", { style: styles.okNote }, promoteNote[item.id])
922
+ : null,
723
923
  (detail.turns || []).map((turn, index) =>
724
924
  h(TurnBlock, { key: turn.id, turn, index }),
725
925
  ),
@@ -728,8 +928,8 @@ window.__ModuleLoader__.load({
728
928
  : null,
729
929
  )
730
930
  : null,
731
- ),
732
- ),
931
+ );
932
+ }),
733
933
  );
734
934
  }
735
935
 
@@ -745,7 +945,36 @@ window.__ModuleLoader__.load({
745
945
  }
746
946
 
747
947
  // ── 侧栏主面板 ───────────────────────────────────────────────────
948
+ /**
949
+ * 左侧栏「GPT 对话」图标对应的主面板。
950
+ *
951
+ * DSH 点这个图标一定会切主面板,所以这里挂载后:
952
+ * 1. 先 openResource —— 它自己会 "reveal the column"(展开右侧栏);
953
+ * 2. 再延迟一点把主面板切回对话。
954
+ * 不能反过来:selectPanel 会重置布局,把刚展开的右栏又关掉。
955
+ */
748
956
  function BridgePanel() {
957
+ useEffect(() => {
958
+ bridgeOpeners.open();
959
+
960
+ const timer = setTimeout(() => {
961
+ if (!bridgeLayout) return;
962
+ try {
963
+ bridgeLayout.selectPanel(null);
964
+ } catch (error) {
965
+ console.error("[codex-bridge] 切回对话面板失败", error);
966
+ }
967
+ }, 150);
968
+
969
+ return () => clearTimeout(timer);
970
+ }, []);
971
+
972
+ return h("div", { style: styles.root });
973
+ }
974
+
975
+ // ── 右侧抽屉 ─────────────────────────────────────────────────────
976
+ /** 侧栏 tab 与抽屉共用的内容体。onClose 为空表示宿主自己有关闭控件(右侧栏 tab)。 */
977
+ function BridgeContent({ onClose }) {
749
978
  const { data, error, reload } = useConversationList(15000);
750
979
  const [selected, setSelected] = useState("");
751
980
  const { detail, error: detailError, loading } = useConversationDetail(selected);
@@ -754,25 +983,51 @@ window.__ModuleLoader__.load({
754
983
 
755
984
  return h(
756
985
  "div",
757
- { style: styles.root },
986
+ { style: styles.content },
758
987
  h(
759
988
  "div",
760
989
  { style: styles.head },
761
990
  h(
762
991
  "div",
763
992
  null,
764
- h("h2", { style: styles.headTitle }, "GPT 对话"),
993
+ h("div", { style: styles.headTitle }, "GPT 对话"),
765
994
  h(
766
995
  "div",
767
996
  { style: styles.headSub },
768
997
  `Codex 桥 · ${conversations.length} 段会话${defaultsLabel(data?.defaults)}`,
769
998
  ),
770
999
  ),
771
- h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
1000
+ h(
1001
+ "div",
1002
+ { style: styles.headActions },
1003
+ h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
1004
+ onClose
1005
+ ? h(
1006
+ "button",
1007
+ {
1008
+ type: "button",
1009
+ style: styles.iconBtn,
1010
+ title: "关闭 (Esc)",
1011
+ "aria-label": "关闭",
1012
+ onClick: onClose,
1013
+ },
1014
+ h(
1015
+ "svg",
1016
+ { viewBox: "0 0 16 16", width: 14, height: 14, fill: "none", "aria-hidden": true },
1017
+ h("path", {
1018
+ d: "M4 4l8 8M12 4l-8 8",
1019
+ stroke: "currentColor",
1020
+ strokeWidth: 1.6,
1021
+ strokeLinecap: "round",
1022
+ }),
1023
+ ),
1024
+ )
1025
+ : null,
1026
+ ),
772
1027
  ),
773
1028
  h(
774
1029
  "div",
775
- { style: styles.body },
1030
+ { style: styles.body, className: "cbh-scroll" },
776
1031
  error ? h("div", { style: styles.error }, error) : null,
777
1032
  data && conversations.length === 0 ? h(EmptyState, { hasList: true }) : null,
778
1033
  !data && !error ? h(EmptyState, { hasList: false }) : null,
@@ -797,15 +1052,32 @@ window.__ModuleLoader__.load({
797
1052
  );
798
1053
  }
799
1054
 
800
- // ── 右侧抽屉 ─────────────────────────────────────────────────────
1055
+ /** 右侧栏 tab 的 body。宿主提供标题与关闭,这里只画内容。 */
1056
+ function BridgeTab() {
1057
+ return h(BridgeContent, {});
1058
+ }
1059
+
801
1060
  function BridgeDrawer() {
802
1061
  const open = useDrawerOpen();
803
- // 抽屉常驻挂载(关闭时只是隐藏),避免每次打开都重新拉数据、也方便保留展开状态。
804
- const { data, error, reload } = useConversationList(open ? 15000 : 0);
805
- const [selected, setSelected] = useState("");
806
- const { detail, error: detailError, loading } = useConversationDetail(selected);
807
- const conversations = data?.conversations ?? [];
808
- const activeConversation = conversations.find((item) => item.id === selected) || null;
1062
+
1063
+ // 宽度可拖拽,记在 localStorage,下次打开沿用。
1064
+ const [width, setWidth] = useState(() => {
1065
+ try {
1066
+ const saved = Number(window.localStorage.getItem(DRAWER_WIDTH_KEY));
1067
+ if (Number.isFinite(saved) && saved >= 320 && saved <= 960) return saved;
1068
+ } catch {
1069
+ /* 隐私模式读不到 localStorage */
1070
+ }
1071
+ return DRAWER_WIDTH;
1072
+ });
1073
+
1074
+ useEffect(() => {
1075
+ try {
1076
+ window.localStorage.setItem(DRAWER_WIDTH_KEY, String(width));
1077
+ } catch {
1078
+ /* 忽略写入失败 */
1079
+ }
1080
+ }, [width]);
809
1081
 
810
1082
  useEffect(() => {
811
1083
  if (!open) return undefined;
@@ -820,70 +1092,29 @@ window.__ModuleLoader__.load({
820
1092
 
821
1093
  return h(
822
1094
  "div",
823
- { style: styles.drawer, role: "dialog", "aria-label": "GPT 对话" },
824
- h(
825
- "div",
826
- { style: styles.head },
827
- h(
828
- "div",
829
- null,
830
- h("h2", { style: styles.headTitle }, "GPT 对话"),
831
- h(
832
- "div",
833
- { style: styles.headSub },
834
- `Codex 桥 · ${conversations.length} 段会话${defaultsLabel(data?.defaults)}`,
835
- ),
836
- ),
837
- h(
838
- "div",
839
- { style: styles.headActions },
840
- h("button", { type: "button", style: styles.btn, onClick: () => void reload() }, "刷新"),
841
- h(
842
- "button",
843
- {
844
- type: "button",
845
- style: styles.iconBtn,
846
- title: "关闭 (Esc)",
847
- "aria-label": "关闭",
848
- onClick: () => setDrawerOpen(false),
849
- },
850
- h(
851
- "svg",
852
- { viewBox: "0 0 16 16", width: 14, height: 14, fill: "none", "aria-hidden": true },
853
- h("path", {
854
- d: "M4 4l8 8M12 4l-8 8",
855
- stroke: "currentColor",
856
- strokeWidth: 1.6,
857
- strokeLinecap: "round",
858
- }),
859
- ),
860
- ),
861
- ),
862
- ),
863
- h(
864
- "div",
865
- { style: styles.body },
866
- error ? h("div", { style: styles.error }, error) : null,
867
- data && conversations.length === 0 ? h(EmptyState, { hasList: true }) : null,
868
- !data && !error ? h(EmptyState, { hasList: false }) : null,
869
- conversations.length > 0
870
- ? h(ConversationList, {
871
- conversations,
872
- selected,
873
- onSelect: setSelected,
874
- detail,
875
- detailError,
876
- detailLoading: loading,
877
- })
878
- : null,
879
- ),
880
- h(Composer, {
881
- conversation: activeConversation,
882
- onSent: (result) => {
883
- if (result && result.conversationId) setSelected(result.conversationId);
884
- void reload();
1095
+ { style: { ...styles.drawer, width }, role: "dialog", "aria-label": "GPT 对话" },
1096
+ h("div", {
1097
+ style: styles.resizeHandle,
1098
+ title: "拖动调整宽度",
1099
+ onMouseDown: (event) => {
1100
+ event.preventDefault();
1101
+ const startX = event.clientX;
1102
+ const startWidth = width;
1103
+ const onMove = (moveEvent) => {
1104
+ const next = Math.min(960, Math.max(320, startWidth + (startX - moveEvent.clientX)));
1105
+ setWidth(next);
1106
+ };
1107
+ const onUp = () => {
1108
+ document.removeEventListener("mousemove", onMove);
1109
+ document.removeEventListener("mouseup", onUp);
1110
+ document.body.style.cursor = "";
1111
+ };
1112
+ document.body.style.cursor = "col-resize";
1113
+ document.addEventListener("mousemove", onMove);
1114
+ document.addEventListener("mouseup", onUp);
885
1115
  },
886
1116
  }),
1117
+ h(BridgeContent, { onClose: () => setDrawerOpen(false) }),
887
1118
  );
888
1119
  }
889
1120
 
@@ -912,7 +1143,47 @@ window.__ModuleLoader__.load({
912
1143
  return h(BridgeGlyph, { size: props?.size ?? 18 });
913
1144
  }
914
1145
 
915
- /** 会话头部按钮:切换右侧抽屉。 */
1146
+
1147
+ /**
1148
+ * 左下角按钮(Settings 旁,和催更姬同一位置)。
1149
+ * 这里不切主面板,所以 sidebarRight.mounted 仍然指向当前会话,
1150
+ * openResource 才拿得到 session surface(左侧栏图标做不到这点,见 README)。
1151
+ * props.wide 由宿主给出:false 时侧栏是 56px 轨道,只画图标。
1152
+ */
1153
+ function BridgeFooterAction(props) {
1154
+ const wide = props && props.wide !== false;
1155
+ return h(
1156
+ "button",
1157
+ {
1158
+ type: "button",
1159
+ title: "打开 GPT 对话(右侧栏)",
1160
+ "aria-label": "GPT 对话",
1161
+ onClick: () => bridgeOpeners.open(),
1162
+ style: {
1163
+ appearance: "none",
1164
+ border: "none",
1165
+ background: "transparent",
1166
+ color: "inherit",
1167
+ cursor: "pointer",
1168
+ display: "flex",
1169
+ alignItems: "center",
1170
+ justifyContent: wide ? "flex-start" : "center",
1171
+ gap: 8,
1172
+ padding: "6px 8px",
1173
+ borderRadius: 6,
1174
+ width: "100%",
1175
+ fontFamily: "inherit",
1176
+ fontSize: 12,
1177
+ lineHeight: "18px",
1178
+ opacity: 0.82,
1179
+ },
1180
+ },
1181
+ h(BridgeGlyph, { size: 16 }),
1182
+ wide ? h("span", null, "GPT") : null,
1183
+ );
1184
+ }
1185
+
1186
+ /** 会话头部按钮:打开 GPT 对话(优先激活右侧栏 tab)。 */
916
1187
  function BridgeToggle() {
917
1188
  const open = useDrawerOpen();
918
1189
  return h(
@@ -933,39 +1204,125 @@ window.__ModuleLoader__.load({
933
1204
  opacity: open ? 1 : 0.78,
934
1205
  padding: 0,
935
1206
  },
936
- title: open ? "关闭 GPT 对话" : "打开 GPT 对话",
1207
+ title: "打开 GPT 对话",
937
1208
  "aria-label": "GPT 对话",
938
- "aria-pressed": open,
939
- onClick: () => setDrawerOpen(!open),
1209
+ onClick: () => bridgeOpeners.open(),
940
1210
  },
941
1211
  h(BridgeGlyph, { size: 16 }),
942
1212
  );
943
1213
  }
944
1214
 
945
1215
  return {
946
- inject: ["slots"],
1216
+ // 注意:Cordis 的 Client 插件必须在这里声明要用的服务,否则 ctx.<service> 不可见。
1217
+ // sidebarRightTabs / sidebarRight 由 @deepseek-ai/dsh-client-ui-sidebar-right 提供。
1218
+ inject: ["slots", "sidebarRight", "sidebarRightTabs", "layout"],
947
1219
  apply(ctx) {
948
- // 1) 侧栏面板入口 + 主面板
949
- ctx.slots.inject("sidebar.panellist", () =>
1220
+ bridgeLayout = ctx.layout || null;
1221
+
1222
+ // 局部样式:hover 与滚动条是伪类,内联样式写不了。
1223
+ ctx.effect(() => {
1224
+ const style = document.createElement("style");
1225
+ style.dataset.plugin = "dsh-codex-handoff";
1226
+ style.textContent = [
1227
+ ".cbh-card{transition:background .15s ease,border-color .15s ease}",
1228
+ ".cbh-card:hover{background:rgba(127,127,127,.10)}",
1229
+ ".cbh-scroll{scrollbar-width:thin;scrollbar-color:rgba(127,127,127,.35) transparent}",
1230
+ ".cbh-scroll::-webkit-scrollbar{width:9px;height:9px}",
1231
+ ".cbh-scroll::-webkit-scrollbar-thumb{background:rgba(127,127,127,.32);border-radius:5px}",
1232
+ ".cbh-scroll::-webkit-scrollbar-thumb:hover{background:rgba(127,127,127,.5)}",
1233
+ ".cbh-scroll::-webkit-scrollbar-track{background:transparent}",
1234
+ ].join("\n");
1235
+ document.head.append(style);
1236
+ return () => style.remove();
1237
+ }, "codex-bridge: local styles");
1238
+
1239
+ // 1) 左下角按钮(Settings 旁):不切主面板,
1240
+ // sidebarRight.mounted 仍指向当前会话,openResource 才能拿到 session surface。
1241
+ ctx.slots.inject("sidebar.footer.action", () =>
950
1242
  ctx.slots.register(
951
- { name: "sidebar.panellist", id: "codex-bridge", order: 30, label: "GPT 对话" },
952
- BridgePanelIcon,
1243
+ { name: "sidebar.footer.action", id: "codex-bridge", order: 42, label: "GPT 对话" },
1244
+ BridgeFooterAction,
953
1245
  ),
954
1246
  );
955
- ctx.slots.inject("main", () =>
956
- ctx.slots.register({ name: "main", key: "codex-bridge" }, BridgePanel),
957
- );
958
1247
 
959
- // 2) 会话头部按钮 + 右侧抽屉
1248
+ // 2) 会话头部按钮(快捷入口,同样不切面板)
960
1249
  ctx.slots.inject("conversation.session.header.utilities", () =>
961
1250
  ctx.slots.register(
962
1251
  { name: "conversation.session.header.utilities", id: "codex-bridge-toggle", order: 2 },
963
1252
  BridgeToggle,
964
1253
  ),
965
1254
  );
966
- ctx.slots.inject("shell.overlay", () =>
967
- ctx.slots.register({ name: "shell.overlay", id: "codex-bridge.drawer", order: 40 }, BridgeDrawer),
968
- );
1255
+ // 左侧栏图标 + main 面板已移除:点图标必然切走 conversation,
1256
+ // 导致 sidebarRight.mounted 为空,openResource 直接抛
1257
+ // "no session surface is mounted",那个入口在 DSH 里走不通。
1258
+
1259
+ // 3) 右侧栏 tab。宿主服务未就绪或抛错时,上面的入口仍然可用。
1260
+ // sidebarRightTabs / sidebarRight 由 @deepseek-ai/dsh-client-ui-sidebar-right 提供,
1261
+ // 激活顺序不保证,所以短轮询等待;访问未知服务可能抛错,全部包在 try 里。
1262
+ const registerSidebarTab = (remaining) => {
1263
+ let rightTabs;
1264
+ let rightPane;
1265
+ try {
1266
+ rightTabs =
1267
+ ctx.sidebarRightTabs ||
1268
+ (typeof ctx.get === "function" ? ctx.get("sidebarRightTabs") : undefined);
1269
+ rightPane =
1270
+ ctx.sidebarRight || (typeof ctx.get === "function" ? ctx.get("sidebarRight") : undefined);
1271
+ } catch {
1272
+ rightTabs = undefined;
1273
+ rightPane = undefined;
1274
+ }
1275
+
1276
+ if (rightTabs && rightPane) {
1277
+ try {
1278
+ ctx.effect(
1279
+ () =>
1280
+ rightTabs.register({
1281
+ id: TAB_ID,
1282
+ kind: "codex-bridge",
1283
+ keepMounted: true,
1284
+ patterns: ["dsh-resource://codex-bridge/**"],
1285
+ title: () => "GPT 对话",
1286
+ }),
1287
+ "codex-bridge: sidebar-right tab",
1288
+ );
1289
+ ctx.slots.inject("sidebar.right.pane.tab", () =>
1290
+ ctx.slots.register({ name: "sidebar.right.pane.tab", key: TAB_ID }, BridgeTab),
1291
+ );
1292
+ bridgeOpeners.open = () => {
1293
+ // 先 openResource:催更姬只调这一个就能把右栏带出来,是最可靠的一步。
1294
+ try {
1295
+ rightPane.openResource(TAB_RESOURCE);
1296
+ } catch (error) {
1297
+ console.error("[codex-bridge] openResource 失败", error);
1298
+ }
1299
+ // 再尝试显式展开右栏;它若抛错不能影响上面那一步。
1300
+ try {
1301
+ if (typeof ctx.layout?.openRightbar === "function") {
1302
+ ctx.layout.openRightbar(true, false);
1303
+ }
1304
+ } catch (error) {
1305
+ console.error("[codex-bridge] openRightbar 失败", error);
1306
+ }
1307
+ };
1308
+ console.info("[codex-bridge] 已注册右侧栏 tab");
1309
+ } catch (error) {
1310
+ console.error("[codex-bridge] 注册右侧栏 tab 失败,保留浮层抽屉", error);
1311
+ }
1312
+ return;
1313
+ }
1314
+
1315
+ if (remaining > 0) {
1316
+ setTimeout(() => registerSidebarTab(remaining - 1), 250);
1317
+ } else {
1318
+ console.warn("[codex-bridge] 右侧栏服务不可用,使用浮层抽屉作为回退");
1319
+ }
1320
+ };
1321
+ try {
1322
+ registerSidebarTab(24);
1323
+ } catch (error) {
1324
+ console.error("[codex-bridge] 注册右侧栏 tab 失败,保留浮层抽屉", error);
1325
+ }
969
1326
  },
970
1327
  };
971
1328
  },
package/lib/index.js CHANGED
@@ -221,6 +221,68 @@ function codexHomeDir() {
221
221
  return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
222
222
  }
223
223
 
224
+ // ── 让 exec 会话在 Codex App 里可见 ──────────────────────────────────
225
+ //
226
+ // Codex 桌面版的会话列表只列出 `source = "vscode"` 的线程(实测:App 里能看到的
227
+ // 全是 vscode,CLI 产生的是 exec,即使 rollout、projection、turns 都齐全也不显示)。
228
+ // `codex exec --thread-source vscode` 实测无效(写入的仍是 exec),所以只能事后把
229
+ // `state_N.sqlite` 里这一条的 source 改成 vscode。只动这一个字段,失败不影响主流程。
230
+ // 用 Node 内置的 node:sqlite(Node >= 22.5),不可用时静默跳过。
231
+
232
+ let sqliteModule;
233
+ async function loadSqlite() {
234
+ if (sqliteModule !== undefined) return sqliteModule;
235
+ try {
236
+ sqliteModule = await import("node:sqlite");
237
+ } catch {
238
+ sqliteModule = null;
239
+ }
240
+ return sqliteModule;
241
+ }
242
+
243
+ /** 找版本号最大的 state_N.sqlite(Codex 升级会换文件名)。 */
244
+ function findStateDb() {
245
+ const dir = codexHomeDir();
246
+ try {
247
+ const names = fs
248
+ .readdirSync(dir)
249
+ .filter((name) => /^state_\d+\.sqlite$/.test(name))
250
+ .sort((a, b) => {
251
+ const na = Number((a.match(/\d+/) || [0])[0]);
252
+ const nb = Number((b.match(/\d+/) || [0])[0]);
253
+ return nb - na;
254
+ });
255
+ return names.length > 0 ? path.join(dir, names[0]) : "";
256
+ } catch {
257
+ return "";
258
+ }
259
+ }
260
+
261
+ async function promoteThreadForApp(threadId) {
262
+ if (!threadId) return false;
263
+ if (process.env.DSH_CODEX_BRIDGE_APP_VISIBLE === "0") return false;
264
+
265
+ const sqlite = await loadSqlite();
266
+ if (!sqlite || typeof sqlite.DatabaseSync !== "function") return false;
267
+
268
+ const dbPath = findStateDb();
269
+ if (!dbPath) return false;
270
+
271
+ try {
272
+ const db = new sqlite.DatabaseSync(dbPath);
273
+ try {
274
+ db.exec("PRAGMA busy_timeout = 3000");
275
+ db.prepare("UPDATE threads SET source = 'vscode' WHERE id = ? AND source LIKE 'exec%'").run(threadId);
276
+ } finally {
277
+ db.close();
278
+ }
279
+ return true;
280
+ } catch (error) {
281
+ console.error(`[codex-bridge] 无法把会话标记为 Codex App 可见: ${String(error)}`);
282
+ return false;
283
+ }
284
+ }
285
+
224
286
  function listRolloutFiles() {
225
287
  if (Date.now() - rolloutIndexCache.at < 30000 && rolloutIndexCache.files.length > 0) {
226
288
  return rolloutIndexCache.files;
@@ -867,13 +929,14 @@ function makeHandler(ctx, resolveWorkspace) {
867
929
  const rest = (url.pathname || "").slice(ROUTE_PREFIX.length).replace(/^\/+/, "");
868
930
 
869
931
  try {
870
- // POST /api/codex-bridge/send —— 发一轮真实对话(会消耗 Codex 额度)。
871
- // 注意:resource 路由只能 GET/HEAD,所以这里用 handler 收 POST。
932
+ // POST /api/codex-bridge/send —— 发一轮真实对话(会消耗 Codex 额度)
933
+ // POST /api/codex-bridge/promote —— 把某段会话标记成 Codex App 可见(按需触发,改完需重启 App)
872
934
  if (req.method === "POST") {
873
- if (rest !== "send") {
935
+ if (rest !== "send" && rest !== "promote") {
874
936
  send(404, { error: "not found" });
875
937
  return;
876
938
  }
939
+
877
940
  const chunks = [];
878
941
  for await (const chunk of req) chunks.push(chunk);
879
942
  let body = {};
@@ -882,6 +945,25 @@ function makeHandler(ctx, resolveWorkspace) {
882
945
  } catch {
883
946
  body = {};
884
947
  }
948
+
949
+ if (rest === "promote") {
950
+ const conversationId = String(body?.conversationId || "");
951
+ const threadId = String(body?.threadId || "");
952
+ let targetThread = threadId;
953
+ if (!targetThread && conversationId) {
954
+ const index = readIndex(root);
955
+ const found = findConversation(index, conversationId);
956
+ targetThread = found?.threadId || "";
957
+ }
958
+ if (!targetThread) {
959
+ send(400, { error: "找不到该对话的 Codex thread" });
960
+ return;
961
+ }
962
+ const ok = await promoteThreadForApp(targetThread);
963
+ send(200, { ok, threadId: targetThread });
964
+ return;
965
+ }
966
+
885
967
  const result = await sendTurn(undefined, {
886
968
  workspace,
887
969
  conversationId: String(body?.conversationId || ""),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codex-handoff",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "GPT/Codex 对话桥:把本机 Codex CLI 的多轮会话接进 DSH,侧栏与右侧抽屉可查看往来,会话落进 Codex 会话库并可 resume 续接;提供 codex_gpt 工具与模型/推理强度切换。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -24,7 +24,11 @@
24
24
  },
25
25
  "client": {
26
26
  "platform": "web",
27
- "immediately": true
27
+ "immediately": true,
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
30
+ "@deepseek-ai/dsh-client-ui-session"
31
+ ]
28
32
  }
29
33
  },
30
34
  "license": "MIT",