@xmanrui/dsh-im 0.6.0 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,7 +17,7 @@ const result = await build({
17
17
  format: 'cjs',
18
18
  platform: 'browser',
19
19
  target: ['chrome100'],
20
- external: ['react'],
20
+ external: ['react', 'react-dom'],
21
21
  define: {
22
22
  'globalThis.__DSH_IM_LOGO_DATA_URL__': JSON.stringify(logoDataUrl),
23
23
  },
@@ -45,6 +45,20 @@ const EN = Object.freeze({
45
45
  '消息通道': 'Message channel',
46
46
  '最近检查': 'Last checked',
47
47
  '当前工作区': 'Current workspace',
48
+ '选择目录': 'Choose folder',
49
+ '选择机器人工作区目录': 'Select bot workspace folder',
50
+ '当前目录': 'Current folder',
51
+ '主目录': 'Home',
52
+ '正在准备目录选择器…': 'Preparing folder picker…',
53
+ '正在读取目录…': 'Loading folders…',
54
+ '这个目录中没有子文件夹。': 'This folder has no subfolders.',
55
+ '此目录的子文件夹过多,仅显示前一部分。': 'This folder has too many subfolders; only the first group is shown.',
56
+ '无法读取目录,请重试。': 'Could not load the folder. Try again.',
57
+ '重试': 'Retry',
58
+ '显示隐藏文件夹': 'Show hidden folders',
59
+ '切换后会清除这个机器人的旧会话映射。': 'Switching clears this bot’s previous session mappings.',
60
+ '切换中…': 'Switching…',
61
+ '选择此目录': 'Select this folder',
48
62
  '工作区绝对路径': 'Absolute workspace path',
49
63
  '/绝对路径/到/工作区': '/absolute/path/to/workspace',
50
64
  '修改': 'Change',
@@ -39,9 +39,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
39
39
  import { installWhatsappStyles } from './channels/whatsapp/styles.js';
40
40
  import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
41
41
  import { installImStyles } from './styles.js';
42
+ import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
42
43
 
43
44
  export const name = 'im-settings';
44
- export const inject = ['slots', 'connection', 'locale'];
45
+ export const inject = ['slots', 'connection', 'locale', 'workspaces'];
45
46
 
46
47
  const IM_PLUGIN_LOGO_URL = globalThis.__DSH_IM_LOGO_DATA_URL__ ?? 'assets/logo-icon.png';
47
48
 
@@ -122,11 +123,13 @@ export function IMSettingsTab({
122
123
  wecomRpcCall,
123
124
  weixinRpcCall,
124
125
  whatsappRpcCall,
126
+ workspaceDirectoryPicker,
125
127
  }) {
126
128
  const [selected, setSelected] = React.useState('weixin');
127
129
  const githubTooltipId = React.useId();
128
130
  const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
129
- return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
131
+ return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
132
+ h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
130
133
  h('header', { className: 'dim-title' },
131
134
  h('div', { className: 'dim-brand' },
132
135
  h('img', {
@@ -194,7 +197,7 @@ export function IMSettingsTab({
194
197
  ? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
195
198
  : h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
196
199
  ),
197
- );
200
+ ));
198
201
  }
199
202
 
200
203
  export function apply(ctx) {
@@ -240,6 +243,10 @@ export function apply(ctx) {
240
243
  ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
241
244
  const slackRpcCall = (endpoint, payload, signal) =>
242
245
  ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
246
+ const workspaceDirectoryPicker = Object.freeze({
247
+ listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
248
+ pickDirectory: () => ctx.workspaces.pickDirectory(),
249
+ });
243
250
 
244
251
  ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
245
252
  name: 'settings.plugins.tab',
@@ -257,6 +264,7 @@ export function apply(ctx) {
257
264
  wecomRpcCall,
258
265
  weixinRpcCall,
259
266
  whatsappRpcCall,
267
+ workspaceDirectoryPicker,
260
268
  }),
261
269
  }, IMSettingsTab));
262
270
  }
@@ -138,14 +138,47 @@ const CSS = String.raw`
138
138
  .dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; }
139
139
  .dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; }
140
140
  .dim-panel .dim-workspacePath { min-width: 0; display: block; overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
141
- .dim-panel .dim-workspaceForm { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; }
142
- .dim-panel .dim-workspaceForm input { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
143
- .dim-panel .dim-workspaceForm input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 10%); }
144
- .dim-panel .dim-workspaceActions { display: flex; gap: 6px; }
145
- .dim-panel .dim-workspaceActions button { min-height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
146
- .dim-panel .dim-workspaceActions button[type="submit"] { border-color: #1677ff; color: white; background: #1677ff; }
147
- .dim-panel .dim-workspaceActions button:disabled { cursor: not-allowed; opacity: .55; }
148
- .dim-panel .dim-workspaceError { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; }
141
+ .dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); }
142
+ .dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; }
143
+ .dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); }
144
+ .dim-directoryPickerHeader { min-width: 0; padding: 22px 24px 17px; border-bottom: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
145
+ .dim-directoryPickerHeader h3 { margin: 0 0 14px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 1.35; font-weight: 680; }
146
+ .dim-directoryPickerHeader > p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; }
147
+ .dim-directoryCrumbs { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); }
148
+ .dim-directoryCrumbs button { max-width: 210px; overflow: hidden; padding: 3px 5px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
149
+ .dim-directoryCrumbs button:hover:not(:disabled) { color: var(--dim-blue); background: var(--dim-blue-soft); }
150
+ .dim-directoryCrumbs button[aria-current="page"] { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 650; }
151
+ .dim-directoryCrumbs button:focus-visible, .dim-directoryList button:focus-visible, .dim-directoryPickerActions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 1px; }
152
+ .dim-directoryCrumbSeparator { flex: none; font-size: 12px; }
153
+ .dim-directoryPickerBody { min-height: 0; overflow-y: auto; padding: 14px 16px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; }
154
+ .dim-directoryList { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
155
+ .dim-directoryList button { width: 100%; min-height: 46px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 7px 11px; border: 0; border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: inherit; text-align: left; cursor: pointer; }
156
+ .dim-directoryList button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
157
+ .dim-directoryList button:disabled, .dim-directoryCrumbs button:disabled { cursor: wait; opacity: .55; }
158
+ .dim-directoryFolder { width: 24px; height: 24px; display: grid; place-items: center; color: var(--dsw-alias-label-secondary, #646a73); }
159
+ .dim-directoryFolder svg { width: 22px; height: 22px; }
160
+ .dim-directoryName { min-width: 0; overflow: hidden; font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
161
+ .dim-directoryChevron { width: 18px; height: 18px; display: grid; place-items: center; color: var(--dsw-alias-label-tertiary, #8f959e); }
162
+ .dim-directoryChevron svg { width: 17px; height: 17px; }
163
+ .dim-directoryPickerState { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
164
+ .dim-directoryPickerState p { margin: 0; font-size: 13px; line-height: 1.6; }
165
+ .dim-directoryPickerSpinner { width: 24px; height: 24px; border: 3px solid var(--dsw-alias-border-l2, #e6e8eb); border-top-color: var(--dim-blue); border-radius: 50%; animation: dim-spin .8s linear infinite; }
166
+ .dim-directoryPickerError { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 22%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 8px; color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 1.5; }
167
+ .dim-directoryPickerError button { flex: none; padding: 4px 8px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 650; cursor: pointer; }
168
+ .dim-directoryPickerTruncated { margin: 10px 4px 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 1.5; }
169
+ .dim-directoryPickerFooter { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 14px; padding: 16px 20px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); background: var(--dsw-alias-bg-layer-1, #fff); }
170
+ .dim-directoryHidden { display: inline-flex; align-items: center; gap: 7px; padding: 2px 0; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; }
171
+ .dim-directoryHidden:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 2px; }
172
+ .dim-directoryHidden:disabled { cursor: not-allowed; opacity: .52; }
173
+ .dim-directoryHiddenBox { position: relative; width: 15px; height: 15px; flex: 0 0 15px; border: 1px solid var(--dsw-alias-border-l2, #c9cdd4); border-radius: 4px; background: var(--dsw-alias-bg-layer-1, #fff); }
174
+ .dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox { border-color: var(--dim-blue); background: var(--dim-blue); }
175
+ .dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); }
176
+ .dim-directoryPickerNotice { min-width: 0; margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; text-align: right; }
177
+ .dim-directoryPickerActions { display: flex; gap: 8px; }
178
+ .dim-directoryPickerActions button { min-height: 36px; padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
179
+ .dim-directoryPickerActions .dim-directoryPickerPrimary { border-color: var(--dim-blue); color: #fff; background: var(--dim-blue); }
180
+ .dim-directoryPickerActions button:hover:not(:disabled) { filter: brightness(.97); }
181
+ .dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; }
149
182
  .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
150
183
  .dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
151
184
  .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
@@ -206,9 +239,17 @@ const CSS = String.raw`
206
239
  .dim-title p { white-space: normal; }
207
240
  .dim-githubTooltip { right: auto; left: 0; }
208
241
  .dim-rail { grid-template-columns: minmax(0, 1fr); }
242
+ .dim-directoryPickerBackdrop { padding: 10px; }
243
+ .dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
244
+ .dim-directoryPickerHeader { padding: 18px 17px 14px; }
245
+ .dim-directoryPickerHeader h3 { font-size: 18px; }
246
+ .dim-directoryPickerBody { padding: 10px; }
247
+ .dim-directoryPickerFooter { grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; padding: 13px 14px; }
248
+ .dim-directoryPickerNotice { grid-column: 1 / -1; grid-row: 1; text-align: left; }
209
249
  }
210
250
  @media (prefers-reduced-motion: reduce) {
211
251
  .dim-page * { transition-duration: .01ms !important; }
252
+ .dim-directoryPickerSpinner { animation-duration: 1.8s; }
212
253
  }
213
254
  `;
214
255
 
@@ -0,0 +1,230 @@
1
+ import * as React from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ import { h } from './i18n.js';
5
+
6
+ function pickerErrorCode(error) {
7
+ return error?.rpcError?.code ?? error?.code;
8
+ }
9
+
10
+ function pickerErrorDetails(error) {
11
+ return error?.rpcError?.details ?? error?.details;
12
+ }
13
+
14
+ function pickerErrorMessage(error) {
15
+ return error?.rpcError?.message ?? error?.message ?? '无法读取目录,请重试。';
16
+ }
17
+
18
+ function FolderIcon() {
19
+ return React.createElement('svg', {
20
+ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8,
21
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
22
+ },
23
+ React.createElement('path', { d: 'M3.5 7.25A2.25 2.25 0 0 1 5.75 5h4.1l1.8 2h6.6a2.25 2.25 0 0 1 2.25 2.25v7A2.75 2.75 0 0 1 17.75 19h-12A2.25 2.25 0 0 1 3.5 16.75v-9.5Z' }));
24
+ }
25
+
26
+ function ChevronIcon() {
27
+ return React.createElement('svg', {
28
+ viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7,
29
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
30
+ }, React.createElement('path', { d: 'm7.5 4.5 5 5.5-5 5.5' }));
31
+ }
32
+
33
+ function displayCrumbs(listing) {
34
+ const homeIndex = listing.crumbs.findIndex((crumb) => crumb.path === listing.home);
35
+ if (homeIndex < 0) return listing.crumbs;
36
+ return listing.crumbs.slice(homeIndex);
37
+ }
38
+
39
+ export function WorkspaceDirectoryPicker({
40
+ open,
41
+ startPath,
42
+ picker,
43
+ busy = false,
44
+ saveError = null,
45
+ onPicked,
46
+ onCancel,
47
+ }) {
48
+ const [listing, setListing] = React.useState(null);
49
+ const [loading, setLoading] = React.useState(false);
50
+ const [error, setError] = React.useState(null);
51
+ const [showHidden, setShowHidden] = React.useState(false);
52
+ const [retryKey, setRetryKey] = React.useState(0);
53
+ const requestRef = React.useRef(0);
54
+ const controllerRef = React.useRef(null);
55
+ const dialogRef = React.useRef(null);
56
+ const bodyRef = React.useRef(null);
57
+ const titleId = React.useId();
58
+ const noticeId = React.useId();
59
+ const initialPathRef = React.useRef(startPath);
60
+ const onPickedRef = React.useRef(onPicked);
61
+ const onCancelRef = React.useRef(onCancel);
62
+ const busyRef = React.useRef(busy);
63
+
64
+ onPickedRef.current = onPicked;
65
+ onCancelRef.current = onCancel;
66
+ busyRef.current = busy;
67
+
68
+ const loadDirectory = React.useCallback(async (path, { reportError = true } = {}) => {
69
+ const request = requestRef.current + 1;
70
+ requestRef.current = request;
71
+ controllerRef.current?.abort();
72
+ const controller = new AbortController();
73
+ controllerRef.current = controller;
74
+ setLoading(true);
75
+ if (reportError) setError(null);
76
+ try {
77
+ const next = await picker.listDirectory(path, controller.signal);
78
+ if (request !== requestRef.current || controller.signal.aborted) return { aborted: true };
79
+ if (bodyRef.current) bodyRef.current.scrollTop = 0;
80
+ setListing(next);
81
+ setError(null);
82
+ return { value: next };
83
+ } catch (cause) {
84
+ if (request !== requestRef.current || controller.signal.aborted) return { aborted: true };
85
+ if (reportError) setError(pickerErrorMessage(cause));
86
+ return { error: cause };
87
+ } finally {
88
+ if (request === requestRef.current) setLoading(false);
89
+ }
90
+ }, [picker]);
91
+
92
+ React.useEffect(() => {
93
+ if (!open) return undefined;
94
+ let active = true;
95
+ setListing(null);
96
+ setError(null);
97
+ setShowHidden(false);
98
+ dialogRef.current?.focus?.();
99
+ const handleKeyDown = (event) => {
100
+ if (event.key === 'Escape' && !busyRef.current) onCancelRef.current?.();
101
+ };
102
+ if (typeof document !== 'undefined') document.addEventListener('keydown', handleKeyDown);
103
+
104
+ const start = async () => {
105
+ const initialPath = initialPathRef.current;
106
+ const initial = await loadDirectory(initialPath || undefined, { reportError: false });
107
+ if (!active || initial.aborted || initial.value) return;
108
+ const code = pickerErrorCode(initial.error);
109
+ const details = pickerErrorDetails(initial.error);
110
+ if (code === 'directory-picker-unavailable'
111
+ && details?.capability === 'native'
112
+ && typeof picker.pickDirectory === 'function') {
113
+ setLoading(true);
114
+ try {
115
+ const selected = await picker.pickDirectory();
116
+ if (!active) return;
117
+ if (selected !== null) await onPickedRef.current?.(selected);
118
+ else onCancelRef.current?.();
119
+ } catch (cause) {
120
+ if (active) setError(pickerErrorMessage(cause));
121
+ } finally {
122
+ if (active) setLoading(false);
123
+ }
124
+ return;
125
+ }
126
+ if (initialPath && code === 'directory-unreadable') {
127
+ const home = await loadDirectory(undefined, { reportError: false });
128
+ if (!active || home.aborted || home.value) return;
129
+ setError(pickerErrorMessage(home.error));
130
+ return;
131
+ }
132
+ setError(pickerErrorMessage(initial.error));
133
+ };
134
+
135
+ void start();
136
+ return () => {
137
+ active = false;
138
+ if (typeof document !== 'undefined') document.removeEventListener('keydown', handleKeyDown);
139
+ requestRef.current += 1;
140
+ controllerRef.current?.abort();
141
+ };
142
+ }, [loadDirectory, open, picker, retryKey]);
143
+
144
+ if (!open) return null;
145
+ const entries = (listing?.entries ?? []).filter((entry) => showHidden || !entry.hidden);
146
+ const crumbs = listing ? displayCrumbs(listing) : [];
147
+ const presentedError = saveError ?? error;
148
+
149
+ const content = h('div', {
150
+ className: 'dim-directoryPickerBackdrop',
151
+ onMouseDown: (event) => {
152
+ if (event.target === event.currentTarget && !busy) onCancel();
153
+ },
154
+ },
155
+ h('section', {
156
+ ref: dialogRef,
157
+ className: 'dim-directoryPicker',
158
+ role: 'dialog',
159
+ 'aria-modal': 'true',
160
+ 'aria-labelledby': titleId,
161
+ 'aria-describedby': noticeId,
162
+ tabIndex: -1,
163
+ },
164
+ h('header', { className: 'dim-directoryPickerHeader' },
165
+ h('h3', { id: titleId }, '选择机器人工作区目录'),
166
+ listing
167
+ ? h('nav', { className: 'dim-directoryCrumbs', 'aria-label': '当前目录' },
168
+ crumbs.map((crumb, index) => h(React.Fragment, { key: crumb.path },
169
+ index > 0 ? h('span', { className: 'dim-directoryCrumbSeparator', 'aria-hidden': 'true' }, '›') : null,
170
+ React.createElement('button', {
171
+ type: 'button',
172
+ title: crumb.path,
173
+ disabled: loading || busy,
174
+ 'aria-current': index === crumbs.length - 1 ? 'page' : undefined,
175
+ onClick: () => void loadDirectory(crumb.path),
176
+ }, crumb.path === listing.home
177
+ ? h('span', null, '主目录')
178
+ : (crumb.name || crumb.path)))),
179
+ )
180
+ : h('p', null, '正在准备目录选择器…')),
181
+ h('div', { ref: bodyRef, className: 'dim-directoryPickerBody', 'aria-busy': loading },
182
+ loading && !listing
183
+ ? h('div', { className: 'dim-directoryPickerState' },
184
+ h('span', { className: 'dim-directoryPickerSpinner', 'aria-hidden': 'true' }),
185
+ h('p', null, '正在读取目录…'))
186
+ : listing
187
+ ? entries.length > 0
188
+ ? h('ul', { className: 'dim-directoryList' }, entries.map((entry) => h('li', { key: entry.path },
189
+ React.createElement('button', {
190
+ type: 'button',
191
+ title: entry.path,
192
+ disabled: loading || busy,
193
+ onClick: () => void loadDirectory(entry.path),
194
+ },
195
+ h('span', { className: 'dim-directoryFolder' }, h(FolderIcon)),
196
+ React.createElement('span', { className: 'dim-directoryName' }, entry.name),
197
+ h('span', { className: 'dim-directoryChevron' }, h(ChevronIcon))))))
198
+ : h('div', { className: 'dim-directoryPickerState' },
199
+ h('p', null, '这个目录中没有子文件夹。'))
200
+ : null,
201
+ listing?.truncated
202
+ ? h('p', { className: 'dim-directoryPickerTruncated' }, '此目录的子文件夹过多,仅显示前一部分。')
203
+ : null,
204
+ presentedError ? h('div', { className: 'dim-directoryPickerError', role: 'alert' },
205
+ h('span', null, presentedError),
206
+ !listing && !busy ? h('button', {
207
+ type: 'button', onClick: () => setRetryKey((value) => value + 1),
208
+ }, '重试') : null) : null),
209
+ h('footer', { className: 'dim-directoryPickerFooter' },
210
+ h('button', {
211
+ type: 'button',
212
+ className: 'dim-directoryHidden',
213
+ 'aria-pressed': showHidden,
214
+ onClick: () => setShowHidden((value) => !value),
215
+ disabled: busy || !listing,
216
+ },
217
+ h('span', { className: 'dim-directoryHiddenBox', 'aria-hidden': 'true' }),
218
+ h('span', null, '显示隐藏文件夹')),
219
+ h('p', { id: noticeId, className: 'dim-directoryPickerNotice' }, '切换后会清除这个机器人的旧会话映射。'),
220
+ h('div', { className: 'dim-directoryPickerActions' },
221
+ h('button', { type: 'button', onClick: onCancel, disabled: busy }, '取消'),
222
+ h('button', {
223
+ type: 'button',
224
+ className: 'dim-directoryPickerPrimary',
225
+ disabled: busy || loading || !listing,
226
+ onClick: () => listing && void onPicked(listing.path),
227
+ }, busy ? '切换中…' : '选择此目录')))));
228
+
229
+ return typeof document === 'undefined' ? content : createPortal(content, document.body);
230
+ }
@@ -1,96 +1,69 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { h } from './i18n.js';
4
+ import { WorkspaceDirectoryPicker } from './workspace-directory-picker.js';
4
5
 
5
- function looksLikeAbsolutePath(value) {
6
- return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
7
- }
6
+ export const WorkspaceDirectoryPickerContext = React.createContext(null);
8
7
 
9
- export function WorkspaceEditor({ workspace, disabled = false, onSave }) {
10
- const [editing, setEditing] = React.useState(false);
11
- const [draft, setDraft] = React.useState(workspace ?? '');
8
+ export function WorkspaceEditor({ workspace, directoryPicker, disabled = false, onSave }) {
9
+ const sharedDirectoryPicker = React.useContext(WorkspaceDirectoryPickerContext);
10
+ const activeDirectoryPicker = directoryPicker ?? sharedDirectoryPicker;
11
+ const [open, setOpen] = React.useState(false);
12
12
  const [saving, setSaving] = React.useState(false);
13
13
  const [error, setError] = React.useState(null);
14
- const inputRef = React.useRef(null);
15
14
  const editButtonRef = React.useRef(null);
16
- const restoreFocus = React.useRef(false);
17
-
18
- React.useEffect(() => {
19
- if (!editing) setDraft(workspace ?? '');
20
- }, [workspace, editing]);
21
-
22
- React.useEffect(() => {
23
- if (editing) inputRef.current?.focus();
24
- else if (restoreFocus.current) {
25
- restoreFocus.current = false;
26
- editButtonRef.current?.focus();
27
- }
28
- }, [editing]);
15
+ const savingRef = React.useRef(false);
29
16
 
30
- const cancel = () => {
31
- setDraft(workspace ?? '');
17
+ const close = React.useCallback(() => {
18
+ setOpen(false);
32
19
  setError(null);
33
- restoreFocus.current = true;
34
- setEditing(false);
35
- };
20
+ queueMicrotask(() => editButtonRef.current?.focus?.());
21
+ }, []);
36
22
 
37
- const submit = async (event) => {
38
- event.preventDefault();
39
- const value = draft.trim();
40
- if (!value || saving || disabled) return;
41
- if (!looksLikeAbsolutePath(value)) {
42
- setError('工作区必须是绝对路径。');
23
+ const pick = React.useCallback(async (value) => {
24
+ if (!value || savingRef.current || disabled) return;
25
+ if (value === workspace) {
26
+ close();
43
27
  return;
44
28
  }
29
+ savingRef.current = true;
45
30
  setSaving(true);
46
31
  setError(null);
47
32
  try {
48
33
  await onSave?.(value);
49
- restoreFocus.current = true;
50
- setEditing(false);
34
+ close();
51
35
  } catch (cause) {
52
36
  setError(cause?.message ?? '工作区修改失败,请重试。');
53
37
  } finally {
38
+ savingRef.current = false;
54
39
  setSaving(false);
55
40
  }
56
- };
41
+ }, [close, disabled, onSave, workspace]);
57
42
 
58
43
  return h('div', { className: 'dim-workspace' },
59
44
  h('div', { className: 'dim-workspaceHeader' },
60
45
  h('span', null, '当前工作区'),
61
- editing ? null : h('button', {
46
+ h('button', {
62
47
  type: 'button',
63
48
  ref: editButtonRef,
64
49
  className: 'dim-workspaceEdit',
65
- onClick: () => { setEditing(true); setError(null); },
66
- disabled,
67
- }, '修改')),
68
- editing
69
- ? h('form', { className: 'dim-workspaceForm', onSubmit: submit },
70
- h('input', {
71
- ref: inputRef,
72
- value: draft,
73
- onChange: (event) => setDraft(event.target.value),
74
- placeholder: '/绝对路径/到/工作区',
75
- 'aria-label': '工作区绝对路径',
76
- autoCapitalize: 'none',
77
- autoCorrect: 'off',
78
- spellCheck: false,
79
- maxLength: 4_096,
80
- required: true,
81
- disabled: saving || disabled,
82
- }),
83
- h('div', { className: 'dim-workspaceActions' },
84
- h('button', {
85
- type: 'submit', disabled: saving || disabled || !draft.trim(),
86
- }, saving ? '保存中…' : '保存'),
87
- h('button', { type: 'button', onClick: cancel, disabled: saving || disabled }, '取消')),
88
- error ? h('p', { className: 'dim-workspaceError', role: 'alert' }, error) : null)
89
- : workspace
90
- ? React.createElement('code', {
91
- className: 'dim-workspacePath',
92
- title: workspace,
93
- }, workspace)
94
- : h('code', { className: 'dim-workspacePath' }, '未设置'),
50
+ onClick: () => { setOpen(true); setError(null); },
51
+ disabled: disabled || !activeDirectoryPicker,
52
+ }, '选择目录')),
53
+ workspace
54
+ ? React.createElement('code', {
55
+ className: 'dim-workspacePath',
56
+ title: workspace,
57
+ }, workspace)
58
+ : h('code', { className: 'dim-workspacePath' }, '未设置'),
59
+ open ? h(WorkspaceDirectoryPicker, {
60
+ open,
61
+ startPath: workspace,
62
+ picker: activeDirectoryPicker,
63
+ busy: saving || disabled,
64
+ saveError: error,
65
+ onPicked: pick,
66
+ onCancel: close,
67
+ }) : null,
95
68
  );
96
69
  }
@@ -72,6 +72,11 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
72
72
  throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
73
73
  }
74
74
  }
75
+ for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
76
+ if (!host.includes(marker)) {
77
+ throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);
78
+ }
79
+ }
75
80
  if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) {
76
81
  throw new Error('host bundle still imports an external channel plugin');
77
82
  }
@@ -16,6 +16,8 @@ const HELP_TEXT = [
16
16
  '/new 开启一个全新会话',
17
17
  '/workspace 工作区绝对路径 切换工作区',
18
18
  '/workspacelist 列出工作区绝对路径',
19
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
20
+ '/session Session ID 将当前聊天绑定到指定会话',
19
21
  '/status 检查连接状态',
20
22
  '/help 显示本帮助',
21
23
  ].join('\n');
@@ -217,7 +219,7 @@ export class DingtalkHarnessBridge {
217
219
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
218
220
  return;
219
221
  }
220
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
222
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
221
223
  if (workspaceCommand) {
222
224
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
223
225
  await this.#send(sessionWebhook, reply);
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
+ import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
6
+
5
7
  function workspacePaths(value) {
6
8
  if (!Array.isArray(value?.items)) return [];
7
9
  return value.items.flatMap((item) => (
@@ -9,6 +11,47 @@ function workspacePaths(value) {
9
11
  ));
10
12
  }
11
13
 
14
+ function workspaceFromList(workspacePath, workspaceList) {
15
+ if (!Array.isArray(workspaceList?.items)
16
+ || !Array.isArray(workspaceList?.archivedSessionIds)) {
17
+ throw new Error('Harness returned an invalid response for workspace.list');
18
+ }
19
+
20
+ const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
21
+ if (!workspace) return null;
22
+ if (!Array.isArray(workspace.sessionIds)
23
+ || workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
24
+ throw new Error('Harness returned invalid session IDs for workspace.list');
25
+ }
26
+ return workspace;
27
+ }
28
+
29
+ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
30
+ if (!Array.isArray(sessionList?.items)) {
31
+ throw new Error('Harness returned an invalid response for session.list');
32
+ }
33
+
34
+ const archived = new Set(archivedSessionIds);
35
+ const summaries = new Map(sessionList.items.flatMap((item) => (
36
+ typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
37
+ )));
38
+ return {
39
+ workspace: workspace.path,
40
+ sessions: workspace.sessionIds.map((sessionId) => {
41
+ const summary = summaries.get(sessionId);
42
+ const title = summary?.projections?.values?.title;
43
+ return {
44
+ sessionId,
45
+ title: typeof title === 'string' ? title : null,
46
+ archived: archived.has(sessionId),
47
+ blank: summary?.blank === true,
48
+ origin: summary?.origin === 'subagent' ? 'subagent' : null,
49
+ summaryAvailable: summary !== undefined,
50
+ };
51
+ }),
52
+ };
53
+ }
54
+
12
55
  function sleep(ms, signal) {
13
56
  return new Promise((resolve, reject) => {
14
57
  if (signal?.aborted) {
@@ -229,6 +272,19 @@ export class HarnessClient {
229
272
  return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
230
273
  }
231
274
 
275
+ async listWorkspaceSessions(workspacePath, options = {}) {
276
+ await this.ensureRunning(options);
277
+ const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
278
+ const workspace = workspaceFromList(workspacePath, workspaceList);
279
+ if (!workspace) return { workspace: workspacePath, sessions: [] };
280
+ const sessionList = await this.rpc('session.list', {}, 30_000, options);
281
+ return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
282
+ }
283
+
284
+ async adoptWorkspaceSession(value, options = {}) {
285
+ return adoptRegisteredWorkspaceSession(this, value, options);
286
+ }
287
+
232
288
  async workspaceId(options = {}) {
233
289
  const { workspace = this.#workspace, ...rpcOptions } = options;
234
290
  const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);