dsh-jiuling-remote-mobile 1.0.5 → 1.0.6

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.
Files changed (3) hide show
  1. package/lib/client.js +295 -150
  2. package/lib/index.js +62 -32
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -38,54 +38,91 @@ window.__ModuleLoader__.load({
38
38
  var react = require('react');
39
39
  var h = react.createElement;
40
40
 
41
+ /**
42
+ * 官方 UI 原子组件(对话框、按钮、分段页签、图标)。
43
+ *
44
+ * ⚠️ **这是「长得像官方」的唯一正解**:官方目录选择器
45
+ * (`dsh-client-ui-directory-picker-browse`)自己也是这么 `require` 的,
46
+ * 该模块已在页面模块注册表里,手机与桌面两端都有。
47
+ *
48
+ * 自己手搓 `div` + `style` 的代价是实打实的:先前那版写死了
49
+ * `background:var(--dsw-alias-bg-primary,#1b1b1f)` —— 而
50
+ * **`--dsw-alias-bg-primary` 这个令牌根本不存在**(官方真实名字是
51
+ * `--dsw-alias-bg-layer-1` / `bg-l1` / `bg-base`),于是整块卡片永远走
52
+ * 那个深色 fallback;`--dsw-alias-border-secondary` 同样不存在。
53
+ * 表现就是手机上「两大坨黑色、跟官方 UI 完全脱钩」。
54
+ */
55
+ var primitives = require('@deepseek-ai/dsh-client-ui-primitives');
56
+ var Modal = primitives.Modal;
57
+ var Button = primitives.Button;
58
+ var SegmentedTabs = primitives.SegmentedTabs;
59
+ var IconFolderCloseRegular = primitives.IconFolderCloseRegular;
60
+ var IconChevronRightOutlineRegular = primitives.IconChevronRightOutlineRegular;
61
+ var IconPlusOutlineRegular = primitives.IconPlusOutlineRegular;
62
+
41
63
  /** 宿主桥的路由(同源,直接相对路径)。 */
42
64
  var ROUTE_RPC = '/api/dsh-remote/rpc';
43
65
  var ROUTE_STREAM = '/api/dsh-remote/stream';
44
66
 
45
- /** 配色取自官方设计令牌,拿不到时回退到中性值,避免与官方 UI 撞色。 */
67
+ /**
68
+ * 对话框卡片的 className(Modal 的 `className` 收字符串、不是 style 对象)。
69
+ *
70
+ * 尺寸与留白**逐条照抄**官方目录选择器对 Modal 的覆盖
71
+ * (`.../dsh-client-ui-directory-picker-browse/lib/client.js` 里
72
+ * `dialog{...gap:0;width:min(680px,100%);height:min(500px,100dvh - 32px);padding:0}`),
73
+ * 只把宽度收窄到手机合适的 560px。背景、圆角、阴影、遮罩一律由官方
74
+ * Modal 自己的 CSS 提供 —— 本插件**不再声明任何颜色**。
75
+ */
76
+ var DIALOG_CLASS = 'jlm_dialog';
77
+
78
+ /**
79
+ * 布局样式:取值全部照抄官方目录选择器的同义规则,令牌全部是
80
+ * **真实存在**的官方设计令牌(`--dsw-alias-border-l3`、`bg-layer-*`、
81
+ * `interactive-bg-hover`、`label-*` …),不写死任何颜色值。
82
+ */
46
83
  var CSS = {
47
- overlay:
48
- 'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;' +
49
- 'background:rgba(0,0,0,.42);padding:16px;',
50
- panel:
51
- 'width:100%;max-width:520px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;' +
52
- 'border-radius:14px;background:var(--dsw-alias-bg-primary,#1b1b1f);' +
53
- 'color:var(--dsw-alias-label-primary,#e8e8ea);' +
54
- 'box-shadow:0 18px 48px rgba(0,0,0,.38);' +
55
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.1));',
56
- head: 'display:flex;align-items:center;gap:10px;padding:14px 16px 10px;flex:none;',
57
- title: 'font-size:15px;font-weight:600;letter-spacing:.01em;flex:1;min-width:0;',
58
- tabs: 'display:flex;gap:6px;padding:0 16px 10px;flex:none;',
59
- tab:
60
- 'flex:1;height:32px;border-radius:8px;border:1px solid transparent;cursor:pointer;' +
61
- 'font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary,#9a9aa2);',
62
- tabOn:
63
- 'flex:1;height:32px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
64
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.14));' +
65
- 'background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07));' +
66
- 'color:var(--dsw-alias-label-primary,#e8e8ea);',
67
- path:
68
- 'display:flex;align-items:center;gap:8px;padding:8px 16px;flex:none;font-size:12px;' +
69
- 'color:var(--dsw-alias-label-tertiary,#7d7d85);' +
70
- 'border-top:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));' +
71
- 'border-bottom:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));',
84
+ dialog: 'gap:0;width:min(560px,100%);height:min(560px,100dvh - 32px);padding:0;',
85
+ // 官方用它把 header / content / footer 抬进 Modal 卡片的 flex 流里。
86
+ editorScope: 'display:contents;',
87
+ header:
88
+ 'display:flex;flex-direction:column;flex:none;gap:8px;' +
89
+ 'padding:16px 14px 8px 24px;border-bottom:.5px solid var(--dsw-alias-border-l3);',
90
+ title:
91
+ 'display:flex;align-items:flex-end;min-height:28px;margin:0;' +
92
+ 'font-size:16px;font-weight:510;line-height:24px;color:var(--dsw-alias-label-primary);',
93
+ crumbBar: 'display:flex;align-items:center;gap:8px;min-height:24px;',
72
94
  pathText:
73
- 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
74
- list: 'flex:1;min-height:120px;overflow:auto;padding:6px 8px;',
95
+ 'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
96
+ 'color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;',
97
+ content:
98
+ 'display:flex;flex-direction:column;flex:1 1 auto;min-height:0;' +
99
+ 'padding:16px 16px 16px 24px;position:relative;overflow:hidden;',
100
+ column:
101
+ 'display:flex;flex-direction:column;flex:1 1 auto;gap:2px;min-height:0;' +
102
+ 'padding-right:8px;overflow-y:auto;',
75
103
  row:
76
- 'display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 10px;' +
77
- 'border-radius:8px;border:none;background:transparent;cursor:pointer;font-size:14px;' +
78
- 'color:var(--dsw-alias-label-primary,#e8e8ea);',
79
- foot: 'display:flex;gap:8px;padding:12px 16px;flex:none;justify-content:flex-end;',
80
- btn:
81
- 'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;' +
82
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.16));' +
83
- 'background:transparent;color:var(--dsw-alias-label-primary,#e8e8ea);',
84
- btnPrimary:
85
- 'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
86
- 'border:none;background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;',
87
- note: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-label-tertiary,#7d7d85);',
88
- err: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-state-error-primary,#ff6b6b);word-break:break-all;',
104
+ 'display:flex;align-items:center;gap:4px;width:100%;height:28px;padding:4px;' +
105
+ 'border:none;border-radius:var(--dsw-radius-sm);background:0 0;' +
106
+ 'text-align:left;cursor:pointer;flex:0 0 auto;',
107
+ rowIcon: 'flex:none;color:var(--dsw-alias-label-secondary);',
108
+ rowName:
109
+ 'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
110
+ 'color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px;',
111
+ rowChevron: 'flex:none;color:var(--dsw-alias-label-tertiary);',
112
+ status:
113
+ 'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
114
+ 'color:var(--dsw-alias-label-secondary);',
115
+ error:
116
+ 'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
117
+ 'color:var(--dsw-alias-state-error-primary);word-break:break-all;',
118
+ note:
119
+ 'padding:8px 4px 0;font-size:12px;line-height:18px;' +
120
+ 'color:var(--dsw-alias-label-tertiary);',
121
+ footerBar:
122
+ 'display:flex;flex-wrap:wrap;flex:none;align-items:center;gap:8px;' +
123
+ 'padding:16px 24px;border-top:.5px solid var(--dsw-alias-border-l3);',
124
+ footerGap: 'flex:1 1 0;',
125
+ footerAction: 'min-width:72px;',
89
126
  };
90
127
 
91
128
  /**
@@ -121,9 +158,48 @@ window.__ModuleLoader__.load({
121
158
  return out;
122
159
  }
123
160
 
161
+ // ⚠️ **先把文本留一份副本再转对象**:下面那个 `<style>` 标签拼的是 CSS
162
+ // **文本**,而第 162 行会把 `CSS` 就地改写成对象 —— 顺序反了就会拼出
163
+ // `[object Object]`,卡片尺寸规则整条失效(列表被 flex 压成 0 高、只剩空白)。
164
+ var DIALOG_CSS_TEXT = CSS.dialog;
165
+
124
166
  // 就地转成对象:下游一律写 `style: CSS.xxx`,不会再碰到字符串形式。
125
167
  for (var styleKey in CSS) CSS[styleKey] = toStyle(CSS[styleKey]);
126
168
 
169
+ /**
170
+ * 注入官方风格的样式表。
171
+ *
172
+ * ⚠️ 卡片那层(背景/圆角/阴影/遮罩)**全部由官方 Modal 自己的 CSS 提供**,
173
+ * 这里只覆盖尺寸与留白 —— 与官方目录选择器覆盖
174
+ * `dialog` 类的方式完全一致。所以本插件**不声明任何颜色**,
175
+ * 亮色/暗色主题都随官方走,不会再出现「一大坨黑」。
176
+ *
177
+ * 用 `<style data-plugin-css>` 注入(官方各 client 包的标准做法),
178
+ * 而不是把样式塞进 React 的 `style` prop —— Modal 只接受 `className`。
179
+ */
180
+ var STYLE_TAG_ID = 'dsh-jiuling-remote-mobile/DirectoryTabs.css';
181
+ if (
182
+ typeof document !== 'undefined' &&
183
+ document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_TAG_ID) + ']') === null
184
+ ) {
185
+ var styleTag = document.createElement('style');
186
+ styleTag.dataset.plugin = 'dsh-jiuling-remote-mobile';
187
+ styleTag.dataset.pluginCss = STYLE_TAG_ID;
188
+ styleTag.textContent =
189
+ '.' +
190
+ DIALOG_CLASS +
191
+ '{' +
192
+ DIALOG_CSS_TEXT +
193
+ '}' +
194
+ '.' +
195
+ DIALOG_CLASS +
196
+ ' [role="tablist"]{flex:none;}' +
197
+ '.' +
198
+ DIALOG_CLASS +
199
+ ' button:hover{background:var(--dsw-alias-interactive-bg-hover);}';
200
+ document.head.appendChild(styleTag);
201
+ }
202
+
127
203
  /**
128
204
  * 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
129
205
  *
@@ -203,6 +279,10 @@ window.__ModuleLoader__.load({
203
279
  var listDirectory = props.listDirectory;
204
280
  var createDirectory = props.createDirectory;
205
281
  var openSession = props.openSession;
282
+ /** 把宿主刚建好的会话登记进本地列表(见 `settleLocal`)。 */
283
+ var registerLocal = props.registerLocal;
284
+ /** 兜底:拉一次全量会话列表(登记失败时用)。 */
285
+ var refreshSessions = props.refreshSessions;
206
286
 
207
287
  var statePair = react.useState({
208
288
  tab: 'local',
@@ -283,24 +363,75 @@ window.__ModuleLoader__.load({
283
363
  onPicked(loc.path);
284
364
  };
285
365
 
286
- /** 「电脑」页签:在电脑的这个目录上开一个新会话,并跳到它。 */
366
+ /** 「电脑」页签:在电脑的这个目录上开一个新对话,并跳到它。 */
287
367
  var openDesktop = function () {
288
368
  if (loc.path === '' || state.creating) return;
289
369
  patch({ creating: true, error: null });
290
370
  bridge('createSession', { path: loc.path }).then(
291
371
  function (result) {
292
372
  if (!aliveRef.current) return;
293
- patch({ creating: false });
294
- onCancel();
295
- if (openSession !== undefined && result && result.sessionId) {
296
- openSession(result.sessionId);
297
- }
373
+ // ⚠️ **先登记、再打开**,顺序反了就会静默失败(见 `registerLocal`)。
374
+ return settleLocal(result).then(function () {
375
+ if (!aliveRef.current) return;
376
+ patch({ creating: false });
377
+ onCancel();
378
+ if (openSession !== undefined && result && result.sessionId) {
379
+ openSession(result.sessionId);
380
+ }
381
+ });
298
382
  },
299
383
  function (reason) {
300
384
  if (!aliveRef.current) return;
301
385
  patch({ creating: false, error: String((reason && reason.message) || reason) });
302
386
  },
303
- );
387
+ ).then(null, function (reason) {
388
+ if (!aliveRef.current) return;
389
+ patch({ creating: false, error: String((reason && reason.message) || reason) });
390
+ });
391
+ };
392
+
393
+ /**
394
+ * 把刚在电脑上建好的会话**登记进本地会话列表**。
395
+ *
396
+ * ⚠️ **这一步不能省,而且必须在 `openSession` 之前完成。**
397
+ *
398
+ * `uiWorkspace.openSession(id)` 最终走
399
+ * `SessionManager.resolveTarget(id)`,它有**三道**身份检查,全不过就抛
400
+ * `sessions.retain: unknown session <id>`:
401
+ *
402
+ * 1. `sessions.has(id)` —— 已物化的客户端实例;
403
+ * 2. `summaries.some(...)` —— 客户端列表快照;
404
+ * 3. `addresses` —— 子 agent 地址。
405
+ *
406
+ * 而会话是**宿主**建的,客户端只能靠 `api-session/added` 这条**异步推送**
407
+ * 知道它 —— RPC 的响应会先到。所以直接在 RPC 回执里 `openSession()`
408
+ * 是**必然的竞态**:抛出的异常还被 `openSession()` 同步吞掉,界面
409
+ * 只表现为「点完没反应、又回到默认工作区」,控制台一句错都没有。
410
+ *
411
+ * 正解是走官方 client 自己的 `sessions.create({sessionId, cwd})`:
412
+ * `SessionManager.create()` 在 RPC 返回后立刻
413
+ * `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
414
+ * 于是 `openSession()` 当场就能解析到它。
415
+ *
416
+ * 幂等性是安全的:宿主侧已经建过这个 id,`createOrAdopt` 看到
417
+ * `agents.get(sessionId)` 活着就直接返回它,不会重复建、也不会撞 cwd。
418
+ *
419
+ * `cwd` 由宿主回执带回(手机本地的工作区路径,不是电脑路径)——
420
+ * 两端各自的 cwd 互不相通,只在 `sessionId` 上对齐。
421
+ *
422
+ * @param result - 宿主 `createSession` 的回执 `{sessionId, cwd}`。
423
+ * @returns 登记完成的 Promise(失败也 resolve,交由调用方决定下一步)。
424
+ */
425
+ var settleLocal = function (result) {
426
+ if (!result || typeof result.sessionId !== 'string') return Promise.resolve();
427
+ var local = registerLocal === undefined
428
+ ? Promise.resolve()
429
+ : Promise.resolve(registerLocal(result.sessionId, result.cwd)).then(null, function () {
430
+ // 登记失败(例如 `session/writer-held`):退一步拉全量列表,
431
+ // 宿主那一行仍然会进来,`openSession` 同样能解析。
432
+ return refreshSessions === undefined ? null : Promise.resolve(refreshSessions()).then(null, function () { return null; });
433
+ });
434
+ return local;
304
435
  };
305
436
 
306
437
  var makeFolder = function () {
@@ -327,89 +458,43 @@ window.__ModuleLoader__.load({
327
458
  key: full,
328
459
  type: 'button',
329
460
  style: CSS.row,
330
- onMouseEnter: function (event) {
331
- event.currentTarget.style.background =
332
- 'var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07))';
333
- },
334
- onMouseLeave: function (event) {
335
- event.currentTarget.style.background = 'transparent';
336
- },
337
461
  onClick: function () {
338
462
  patch({ path: full });
339
463
  },
340
464
  },
341
- h('span', { style: { opacity: 0.55, flex: 'none' } }, '\u25B8'),
342
- h(
343
- 'span',
344
- {
345
- style: {
346
- flex: '1',
347
- minWidth: 0,
348
- overflow: 'hidden',
349
- textOverflow: 'ellipsis',
350
- whiteSpace: 'nowrap',
351
- },
352
- },
353
- nameOf(entry),
354
- ),
465
+ // ⚠️ 图标组件只解构 `size/className/strokeWidth`,**不透传 `style`**
466
+ // —— 上色要靠外层 span(官方图标本来就是 currentColor 描边)。
467
+ h('span', { style: CSS.rowIcon }, h(IconFolderCloseRegular, { size: 16 })),
468
+ h('span', { style: CSS.rowName }, nameOf(entry)),
469
+ h('span', { style: CSS.rowChevron }, h(IconChevronRightOutlineRegular, { size: 12 })),
355
470
  );
356
471
  });
357
472
 
358
- return h(
473
+ var body = h(
359
474
  'div',
360
- {
361
- style: CSS.overlay,
362
- onClick: function (event) {
363
- if (event.target === event.currentTarget) onCancel();
364
- },
365
- },
475
+ { style: CSS.editorScope },
476
+ // —— 头部:标题 + 页签 + 路径行 ——
366
477
  h(
367
478
  'div',
368
- { style: CSS.panel, role: 'dialog', 'aria-label': '选择工作区目录' },
369
- h(
370
- 'div',
371
- { style: CSS.head },
372
- h('div', { style: CSS.title }, '选择工作区目录'),
373
- h(
374
- 'button',
375
- { type: 'button', 'aria-label': '关闭', style: CSS.btn, onClick: onCancel },
376
- '关闭',
377
- ),
378
- ),
479
+ { style: CSS.header },
480
+ h('h2', { style: CSS.title }, '选择工作区目录'),
481
+ h(SegmentedTabs, {
482
+ label: '目录来源',
483
+ value: state.tab,
484
+ onChange: switchTab,
485
+ items: [
486
+ { value: 'local', label: '手机' },
487
+ { value: 'desktop', label: '电脑' },
488
+ ],
489
+ }),
379
490
  h(
380
491
  'div',
381
- { style: CSS.tabs },
492
+ { style: CSS.crumbBar },
382
493
  h(
383
- 'button',
494
+ Button,
384
495
  {
385
- type: 'button',
386
- style: state.tab === 'local' ? CSS.tabOn : CSS.tab,
387
- onClick: function () {
388
- switchTab('local');
389
- },
390
- },
391
- '手机',
392
- ),
393
- h(
394
- 'button',
395
- {
396
- type: 'button',
397
- style: state.tab === 'desktop' ? CSS.tabOn : CSS.tab,
398
- onClick: function () {
399
- switchTab('desktop');
400
- },
401
- },
402
- '电脑',
403
- ),
404
- ),
405
- h(
406
- 'div',
407
- { style: CSS.path },
408
- h(
409
- 'button',
410
- {
411
- type: 'button',
412
- style: CSS.btn,
496
+ variant: 'outline',
497
+ size: 'sm',
413
498
  disabled: loc.parent === '',
414
499
  onClick: function () {
415
500
  patch({ path: loc.parent === '' ? null : loc.parent });
@@ -417,16 +502,21 @@ window.__ModuleLoader__.load({
417
502
  },
418
503
  '上一级',
419
504
  ),
420
- h('div', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
505
+ h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
421
506
  ),
422
- state.error !== null ? h('div', { style: CSS.err, role: 'alert' }, state.error) : null,
507
+ ),
508
+ // —— 主体:目录列表 ——
509
+ h(
510
+ 'div',
511
+ { style: CSS.content },
423
512
  state.loading
424
- ? h('div', { style: CSS.note }, '加载中…')
513
+ ? h('div', { style: CSS.status, role: 'status' }, '加载中…')
425
514
  : h(
426
515
  'div',
427
- { style: CSS.list },
428
- rows.length > 0 ? rows : h('div', { style: CSS.note }, '这里没有子文件夹'),
516
+ { style: CSS.column },
517
+ rows.length > 0 ? rows : h('div', { style: CSS.status }, '这里没有子文件夹'),
429
518
  ),
519
+ state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
430
520
  state.tab === 'desktop'
431
521
  ? h(
432
522
  'div',
@@ -434,36 +524,63 @@ window.__ModuleLoader__.load({
434
524
  '在电脑的这个目录上开一个新对话,手机上会同步显示它的全部过程。',
435
525
  )
436
526
  : null,
437
- h(
438
- 'div',
439
- { style: CSS.foot },
440
- state.tab === 'local' && createDirectory !== undefined
441
- ? h('button', { type: 'button', style: CSS.btn, onClick: makeFolder }, '新建文件夹')
442
- : null,
443
- state.tab === 'local'
444
- ? h(
445
- 'button',
446
- {
447
- type: 'button',
448
- style: CSS.btnPrimary,
449
- disabled: loc.path === '' || props.busy,
450
- onClick: pickLocal,
451
- },
452
- props.busy ? '处理中…' : '选择此文件夹',
453
- )
454
- : h(
455
- 'button',
456
- {
457
- type: 'button',
458
- style: CSS.btnPrimary,
459
- disabled: loc.path === '' || state.creating,
460
- onClick: openDesktop,
461
- },
462
- state.creating ? '正在电脑上新建…' : '在此新建对话',
463
- ),
464
- ),
527
+ ),
528
+ // —— 底部动作栏 ——
529
+ h(
530
+ 'div',
531
+ { style: CSS.footerBar },
532
+ state.tab === 'local' && createDirectory !== undefined
533
+ ? h(
534
+ Button,
535
+ {
536
+ variant: 'outline',
537
+ icon: h(IconPlusOutlineRegular, { size: 14 }),
538
+ onClick: makeFolder,
539
+ },
540
+ '新建文件夹',
541
+ )
542
+ : null,
543
+ h('span', { style: CSS.footerGap }),
544
+ h(Button, { variant: 'outline', style: CSS.footerAction, onClick: onCancel }, '取消'),
545
+ state.tab === 'local'
546
+ ? h(
547
+ Button,
548
+ {
549
+ variant: 'primary',
550
+ style: CSS.footerAction,
551
+ disabled: loc.path === '' || props.busy,
552
+ onClick: pickLocal,
553
+ },
554
+ props.busy ? '处理中…' : '选择此文件夹',
555
+ )
556
+ : h(
557
+ Button,
558
+ {
559
+ variant: 'primary',
560
+ style: CSS.footerAction,
561
+ disabled: loc.path === '' || state.creating,
562
+ onClick: openDesktop,
563
+ },
564
+ state.creating ? '正在电脑上新建…' : '在此新建对话',
565
+ ),
465
566
  ),
466
567
  );
568
+
569
+ // 卡片本身(背景/圆角/阴影/遮罩/关闭按钮)全部交给官方 Modal;
570
+ // 我们只覆盖尺寸与留白(见 DIALOG_CLASS)。`headless` 才不会
571
+ // 再套一层官方自带的标题栏 —— 标题由我们放进 header 里。
572
+ return h(
573
+ Modal,
574
+ {
575
+ open: open,
576
+ onClose: onCancel,
577
+ title: '选择工作区目录',
578
+ closeLabel: '关闭',
579
+ className: DIALOG_CLASS,
580
+ headless: true,
581
+ },
582
+ body,
583
+ );
467
584
  }
468
585
 
469
586
  //#region 电脑流式帧 → 本地 transient
@@ -642,6 +759,34 @@ window.__ModuleLoader__.load({
642
759
  listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal),
643
760
  createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name),
644
761
  openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
762
+ /**
763
+ * 把宿主刚建好的会话登记进本地列表。
764
+ *
765
+ * 走官方 client 的 `sessions.create({sessionId, cwd})`:
766
+ * `SessionManager.create()` 在 RPC 回执后立刻
767
+ * `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
768
+ * 于是 `openSession()` 当场就能解析到这个 id。
769
+ * **不登记就直接 `openSession()` 是竞态**,会抛
770
+ * `sessions.retain: unknown session` 并被静默吞掉
771
+ * (详见组件里 `settleLocal` 的注释)。
772
+ *
773
+ * 传 `cwd`(宿主回执里的**手机本地**路径)而不是 `workspaceId`:
774
+ * 宿主侧已经把这个会话挂进工作区了,这里只需要让客户端列表
775
+ * 认识它;带上 cwd 才能让列表把它分进正确的工作区分组。
776
+ *
777
+ * 宿主侧已建过该 id ⇒ `createOrAdopt` 看到活着的 agent 直接返回,
778
+ * 不会重复建。
779
+ *
780
+ * @param sessionId - 会话 id。
781
+ * @param cwd - 宿主回执里的本地 cwd(两端只在 id 上对齐)。
782
+ */
783
+ registerLocal: (sessionId, cwd) => {
784
+ const options = { sessionId };
785
+ if (cwd !== undefined && cwd !== '') options.cwd = cwd;
786
+ return ctx.sessions.create(options);
787
+ },
788
+ /** 兜底:拉一次全量会话列表。 */
789
+ refreshSessions: () => ctx.sessions.refresh(),
645
790
  });
646
791
 
647
792
  // 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
package/lib/index.js CHANGED
@@ -47,7 +47,7 @@
47
47
  * @module dsh-remote-mobile
48
48
  */
49
49
 
50
- import { randomUUID } from 'node:crypto';
50
+ import { createHash, randomUUID } from 'node:crypto';
51
51
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
52
52
  import { homedir } from 'node:os';
53
53
  import { join } from 'node:path';
@@ -558,44 +558,70 @@ export function apply(ctx) {
558
558
  }
559
559
 
560
560
  /**
561
- * 挑一个**手机本地**的工作区给新建的远程会话用。
561
+ * 把电脑路径压成一个**文件名安全**的稳定 slug。
562
562
  *
563
- * ## ⚠️ 为什么不能直接用 `process.cwd()`
563
+ * 只用于镜像目录的命名(用户看不到),所以要求是「同一个电脑路径永远得到
564
+ * 同一个名字,不同路径几乎不可能撞」。`createHash` 那 8 位十六进制就是防撞的:
565
+ * 纯字符替换会把 `C:\a-b` 与 `C:\a\b` 压成同一个名字。
564
566
  *
565
- * 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
566
- * (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter:
567
- * `record.sessionIds.filter(id => host.sessionPath(id) === record.path)`,
568
- * 而 `sessionPath` 是 header.cwd 的 `realpathNormalize`)。
567
+ * @param desktopPath - 电脑上的绝对路径(win32 语义)。
568
+ * @returns 形如 `c-users-xiaopan-1a2b3c4d` 的 slug。
569
+ */
570
+ function desktopSlug(desktopPath) {
571
+ const flat = desktopPath.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
572
+ const digest = createHash('sha256').update(desktopPath).digest('hex').slice(0, 8);
573
+ return `${flat === '' ? 'root' : flat}-${digest}`;
574
+ }
575
+
576
+ /**
577
+ * 取电脑路径的末段当工作区标题。
578
+ *
579
+ * ⚠️ **不能直接用 `node:path` 的 `basename`**:手机是 posix 语义,
580
+ * `basename('C:\\Users\\XIAOPAN')` 会把整串原样返回(反斜杠不是分隔符),
581
+ * 侧边栏就会显示一长条 `C:\Users\XIAOPAN`。这里两种分隔符都切。
582
+ *
583
+ * @param desktopPath - 电脑上的绝对路径。
584
+ * @returns 末段;没有末段(如 `C:\`)时退回去掉尾部斜杠的原串。
585
+ */
586
+ function desktopTitle(desktopPath) {
587
+ const trimmed = desktopPath.replace(/[/\\]+$/, '');
588
+ const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
589
+ const name = cut < 0 ? trimmed : trimmed.slice(cut + 1);
590
+ return name === '' ? desktopPath : name;
591
+ }
592
+
593
+ /**
594
+ * 为电脑上的一个目录,在手机本地建(或复用)一个**同名镜像工作区**。
595
+ *
596
+ * ## 为什么要建工作区,而不是塞进「默认工作区」
569
597
  *
570
- * 所以 cwd 落在任何已有工作区之外时,这个会话**不属于任何工作区**:
571
- * 侧边栏没有它、composer 也被判成「没选工作区」而禁用 —— 界面表现是停在
572
- * hero 页的「选择一个工作区开始」,看着像新会话没建成功,其实会话已经建好了
573
- * (磁盘上有日志、两端 sessionId 一致)。
598
+ * 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
599
+ * (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter)。
600
+ * 早先的实现把远程会话的 cwd 设成手机默认工作区的路径,于是**所有**远程会话
601
+ * 都堆在「默认工作区」底下 —— 用户在电脑页签里选了 `C:\Users\XIAOPAN`,
602
+ * 侧边栏却显示「默认工作区」,看起来就像「建完又回到默认工作去了」。
574
603
  *
575
- * 引擎的 `process.cwd()` 是 `files/workspace`,而手机默认工作区是
576
- * `files/workspace/deepseek-harness/default-workspace`(**更深一层**),
577
- * 两者不相等,正好踩中这个坑。
604
+ * 正解是给每个电脑目录配一个**独立的手机本地镜像目录**,用它注册一个独立
605
+ * 工作区,标题取电脑目录的末段(`XIAOPAN`)。镜像目录是空的、永远不会被写入
606
+ * —— 真正干活的是电脑那台机器,这里只是给前端一个可以挂载的 cwd 锚点。
578
607
  *
579
- * ⚠️ **光把 cwd 设对还不够**:`sessionPaths` 索引只在该注册表启动
580
- * (`indexHeader`)时建一次,新建的会话**不在索引里** ⇒ `sessionPath(id)`
581
- * 返回 `undefined` ≠ `record.path`,照样被 getter 过滤掉。
582
- * 所以创建完必须再 `attachSession()` 一次(见 `createSession`),
583
- * 它会 `rememberSessionPath()` 把这条补进索引。
608
+ * ⚠️ cwd 依然**不能**是电脑路径:Android 是 posix 语义,
609
+ * `path.posix.isAbsolute('C:/Users/...') === false`,而
610
+ * `createOrAdopt()` 里有 `await mkdir(cwd, {recursive:true})` —— 传电脑路径
611
+ * 会真在手机上建出一个叫 `C:\Users\...` 的怪目录。
584
612
  *
585
613
  * @param ctx - 宿主 Cordis 上下文。
586
- * @returns 工作区实体;没有可用工作区时 `undefined`。
614
+ * @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
615
+ * @returns 工作区实体(含 `.id` / `.path` / `.title`)。
587
616
  */
588
- function localWorkspaceFor(ctx) {
589
- try {
590
- const registry = ctx.get?.('workspaceRegistry');
591
- const all = registry?.list?.() ?? [];
592
- if (all.length === 0) return undefined;
593
- const preferredId = registry?.state?.defaultWorkspaceId;
594
- const chosen = all.find((entity) => entity.id === preferredId) ?? all[0];
595
- return typeof chosen?.path === 'string' && chosen.path !== '' ? chosen : undefined;
596
- } catch {
597
- return undefined;
617
+ async function ensureDesktopWorkspace(ctx, desktopPath) {
618
+ const registry = ctx.get?.('workspaceRegistry');
619
+ if (registry === undefined || typeof registry.create !== 'function') {
620
+ throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
598
621
  }
622
+ const dir = join(STATE_DIR, 'workspaces', desktopSlug(desktopPath));
623
+ mkdirSync(dir, { recursive: true });
624
+ return await registry.create(dir, desktopTitle(desktopPath));
599
625
  }
600
626
 
601
627
  /**
@@ -633,8 +659,12 @@ async function handleBridge(ctx, config, io) {
633
659
  // ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
634
660
  // (Android 是 posix 语义,`path.posix.isAbsolute('C:/...') === false`,
635
661
  // 且官方 createOrAdopt 会 `mkdir(cwd, {recursive:true})` 真去建目录)。
636
- const workspace = localWorkspaceFor(ctx);
637
- const cwd = workspace?.path ?? process.cwd();
662
+ //
663
+ // 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
664
+ // 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
665
+ // 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
666
+ const workspace = await ensureDesktopWorkspace(ctx, params.path);
667
+ const cwd = workspace.path;
638
668
  // ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
639
669
  //
640
670
  // `ctx.sessions.create()` 只把会话塞进内存 store:持久化层既没
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",