dsh-remote 0.8.41 → 0.8.44

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/lib/client.js CHANGED
@@ -49,6 +49,15 @@ window.__ModuleLoader__.load({
49
49
  'settings.addMachine': '添加机器',
50
50
  'settings.editMachine': '编辑机器',
51
51
  'settings.badgeSshConfig': 'ssh cfg',
52
+ // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
53
+ 'settings.badgeOpenssh': 'openssh',
54
+ 'settings.backend': '连接后端',
55
+ 'settings.backendNative': 'native(内置 ssh2:密码 / 私钥 / agent / 动态码)',
56
+ 'settings.backendOpenssh': 'openssh(调用本机 ssh 命令)',
57
+ 'settings.backendOpensshHint': '调用本机 ssh,因此完全遵循 ~/.ssh/config:支持 GSSAPI/Kerberos(kinit 后免密登录)、ProxyCommand、PKCS11Provider、Match 等 OpenSSH 专有特性;主机密钥由 ssh 用你自己的 known_hosts 校验。代价是每条命令都要新建一次连接(比 native 慢),文件浏览(SFTP)仍保持长连接。',
58
+ 'settings.backendOpensshAliasOk': '已勾选「使用 ssh_config 别名」:主机名原样交给 ssh,端口/用户/私钥/跳板机一律由 ~/.ssh/config 的 Host 块决定,对应的输入框在 openssh 后端下填了也不生效(这就是「配置唯一来源」,issue #50)。密码/Passphrase 仍会通过 askpass 交给 ssh,仅当 Host 块里没有可用认证时才用得上。',
59
+ 'settings.sshBinary': 'ssh 路径',
60
+ 'settings.sshBinaryPlaceholder': '留空 = 用 PATH 里的 ssh',
52
61
  'settings.sshImport': '从 ~/.ssh/config 导入',
53
62
  'settings.sshCollapse': '收起',
54
63
  'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
@@ -134,6 +143,14 @@ window.__ModuleLoader__.load({
134
143
  'settings.deployUseCommand': '将使用的 dsh',
135
144
  'settings.deploySteps': '安装步骤',
136
145
  'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
146
+ 'settings.deployFailed': '部署失败',
147
+ 'settings.deployResumed': '已有部署任务在运行,已恢复显示其进度',
148
+ 'settings.deployTaskTitle': '部署任务',
149
+ 'settings.deployTaskRunning': '进行中',
150
+ 'settings.deployTaskDone': '已完成',
151
+ 'settings.deployTaskFailed': '失败',
152
+ 'settings.deployTaskCancelled': '已取消',
153
+ 'settings.deployCancel': '取消',
137
154
  'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
138
155
  'settings.badgePwd': '密码',
139
156
  'settings.badgeKeychain': '钥匙串',
@@ -249,6 +266,9 @@ window.__ModuleLoader__.load({
249
266
  'explorer.noRemoteWs': '未设置远程工作区',
250
267
  'explorer.sessionLocal': '当前会话未使用远程工作区',
251
268
  'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
269
+ // issue #49:会话列表里标识「这是远程会话,连的是哪台机」
270
+ 'session.remoteBadge': '远程会话:{target}',
271
+ 'session.remoteHover': '远程主机:{target}',
252
272
  'explorer.loading': '加载中…',
253
273
  'explorer.dirMissing': '目录不存在或已被删除',
254
274
  'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
@@ -307,6 +327,15 @@ window.__ModuleLoader__.load({
307
327
  'settings.addMachine': 'Add machine',
308
328
  'settings.editMachine': 'Edit machine',
309
329
  'settings.badgeSshConfig': 'ssh cfg',
330
+ // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
331
+ 'settings.badgeOpenssh': 'openssh',
332
+ 'settings.backend': 'Backend',
333
+ 'settings.backendNative': 'native (built-in ssh2: password / key / agent / OTP)',
334
+ 'settings.backendOpenssh': 'openssh (drive the local ssh command)',
335
+ 'settings.backendOpensshHint': 'Delegates the transport to the local `ssh`, so the whole of ~/.ssh/config applies: GSSAPI/Kerberos (log in once with kinit and no secret is needed), ProxyCommand, PKCS11Provider, Match blocks… Host keys are checked by ssh against your own known_hosts. The cost is a fresh connection per command (slower than native); file browsing over SFTP still reuses one session.',
336
+ 'settings.backendOpensshAliasOk': '"Use a ~/.ssh/config alias" is on: the name is handed to ssh verbatim, and Port/User/IdentityFile/ProxyJump come from that Host block — the matching fields below have no effect (this is the "one source of truth" case, issue #50). A password/passphrase is still offered to ssh through askpass, and is only used if the Host block has no usable authentication.',
337
+ 'settings.sshBinary': 'ssh path',
338
+ 'settings.sshBinaryPlaceholder': 'Empty = the ssh on PATH',
310
339
  'settings.sshImport': 'Import from ~/.ssh/config',
311
340
  'settings.sshCollapse': 'Collapse',
312
341
  'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
@@ -392,6 +421,14 @@ window.__ModuleLoader__.load({
392
421
  'settings.deployUseCommand': 'dsh that will be used',
393
422
  'settings.deploySteps': 'Install steps',
394
423
  'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
424
+ 'settings.deployFailed': 'Deploy failed',
425
+ 'settings.deployResumed': 'A deploy is already running — re-attached to its progress',
426
+ 'settings.deployTaskTitle': 'Deploy task',
427
+ 'settings.deployTaskRunning': 'running',
428
+ 'settings.deployTaskDone': 'done',
429
+ 'settings.deployTaskFailed': 'failed',
430
+ 'settings.deployTaskCancelled': 'cancelled',
431
+ 'settings.deployCancel': 'Cancel',
395
432
  'settings.deployInvestigateStarted': 'A session was created — follow up there.',
396
433
  'settings.badgePwd': 'password',
397
434
  'settings.badgeKeychain': 'keychain',
@@ -507,6 +544,9 @@ window.__ModuleLoader__.load({
507
544
  'explorer.noRemoteWs': 'No remote workspace set',
508
545
  'explorer.sessionLocal': 'This session does not use a remote workspace',
509
546
  'explorer.localSession': 'This session is a local workspace and does not use remote files.\n\nTo access remote, set a machine as "current" in Settings, then pick a remote workspace with the "…" button above.',
547
+ // issue #49: tell a remote Session apart in the Session list, and name its host.
548
+ 'session.remoteBadge': 'Remote session: {target}',
549
+ 'session.remoteHover': 'Remote host: {target}',
510
550
  'explorer.loading': 'Loading…',
511
551
  'explorer.dirMissing': 'Directory does not exist or was deleted',
512
552
  'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
@@ -747,8 +787,40 @@ window.__ModuleLoader__.load({
747
787
  document.head.appendChild(el)
748
788
  } catch { /* vm tests / no DOM */ }
749
789
  }
790
+ /**
791
+ * Issue #52 — a backdrop may close its dialog only when the PRESS began on
792
+ * the backdrop itself.
793
+ *
794
+ * `click` is dispatched on the nearest common ancestor of the mousedown and
795
+ * mouseup targets. A gesture that pressed an element INSIDE the panel and
796
+ * released on the backdrop — because the panel re-laid-out mid-gesture (the
797
+ * remote autocomplete list used to sit in document flow, so expanding it
798
+ * pushed a centred panel's edges out from under the pointer) — is therefore
799
+ * reported as a click ON THE BACKDROP, and a bare `onClick` closed the whole
800
+ * dialog. Selecting a suggestion kicked the user out of the picker.
801
+ *
802
+ * Guarding on the press origin keeps "click outside to dismiss" while a
803
+ * drag that merely ENDS outside can no longer dismiss anything. The panel's
804
+ * own `stopPropagation` stays as a second line of defence.
805
+ *
806
+ * @returns {{onMouseDown: Function, onClick: (e: object) => boolean}} props to
807
+ * spread on the backdrop; `onClick` reports whether the press started there.
808
+ */
809
+ function useBackdropGuard() {
810
+ const pressedOnBackdrop = React.useRef(false)
811
+ return {
812
+ onMouseDown: (e) => { pressedOnBackdrop.current = e.target === e.currentTarget },
813
+ onClick: (e) => {
814
+ const startedOnBackdrop = pressedOnBackdrop.current
815
+ pressedOnBackdrop.current = false
816
+ return startedOnBackdrop && e.target === e.currentTarget
817
+ },
818
+ }
819
+ }
820
+
750
821
  function PromptDialog(props) {
751
822
  const { title, initial, placeholder, onSubmit, onCancel } = props
823
+ const backdrop = useBackdropGuard()
752
824
  const [val, setVal] = React.useState(initial || '')
753
825
  React.useEffect(() => {
754
826
  try {
@@ -763,7 +835,10 @@ window.__ModuleLoader__.load({
763
835
  }, [val, onSubmit, onCancel])
764
836
  return React.createElement('div', {
765
837
  style: { position: 'fixed', inset: 0, zIndex: 1000000, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
766
- onClick: onCancel,
838
+ onMouseDown: backdrop.onMouseDown,
839
+ // Issue #52: only a press that STARTED here may dismiss (a gesture that
840
+ // began inside the panel and ended on the backdrop must not).
841
+ onClick: (e) => { if (backdrop.onClick(e)) onCancel() },
767
842
  },
768
843
  React.createElement('div', {
769
844
  className: 'dsh-rw-modal-panel',
@@ -860,6 +935,10 @@ window.__ModuleLoader__.load({
860
935
  const [deployBusy, setDeployBusy] = React.useState('')
861
936
  const [deployErr, setDeployErr] = React.useState('')
862
937
  const [deployOpen, setDeployOpen] = React.useState(false)
938
+ // The HOST-SIDE deploy task (resumable across window close/reopen). This
939
+ // is the object the poll keeps fresh; it survives because the work runs
940
+ // on the host, not in this tab.
941
+ const [deployTask, setDeployTask] = React.useState(null)
863
942
  // session's machine", which the host resolves from the session binding.
864
943
  React.useEffect(() => { ensureChromeCss() }, [])
865
944
 
@@ -870,6 +949,9 @@ window.__ModuleLoader__.load({
870
949
  host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
871
950
  password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
872
951
  useSshConfig: !!form.useSshConfig,
952
+ // Issue #51: the probe must exercise the SAME backend the machine will
953
+ // use — a host reachable only via GSSAPI fails the native probe.
954
+ backend: form.backend || 'native',
873
955
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
874
956
  })
875
957
  .then((r) => {
@@ -953,10 +1035,31 @@ window.__ModuleLoader__.load({
953
1035
  // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
954
1036
  // The remote never opens an inbound port; we SSH out and carry the remote
955
1037
  // loopback web UI back to a local port. One tunnel per machine.
1038
+ //
1039
+ // This SAME poll carries the deploy-task state, so a REOPENED window picks
1040
+ // up an in-flight install on the first tick (15s worst case — plus the
1041
+ // install button itself resumes). When a deploy is running the interval
1042
+ // tightens so step progress looks live.
956
1043
  const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
957
- .then((r) => setWebAttaches(r.attaches || []))
1044
+ .then((r) => {
1045
+ setWebAttaches(r.attaches || [])
1046
+ const t = (r.deploys || []).find((d) => d.machineId === webAttachMachine) || r.deploy || null
1047
+ if (t && (t.status === 'running' || t.status === 'queued')) {
1048
+ setDeployTask(t)
1049
+ setDeployBusy('install')
1050
+ } else if (t) {
1051
+ setDeployTask(t)
1052
+ }
1053
+ return t
1054
+ })
958
1055
  .catch(() => {})
959
- React.useEffect(() => { refreshWebAttaches(); const t = setInterval(refreshWebAttaches, 15000); return () => clearInterval(t) }, [])
1056
+ React.useEffect(() => {
1057
+ refreshWebAttaches()
1058
+ const t = setInterval(() => {
1059
+ refreshWebAttaches()
1060
+ }, 15000)
1061
+ return () => clearInterval(t)
1062
+ }, [])
960
1063
  const webAttachAction = (action, machineId, extra) => {
961
1064
  setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
962
1065
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
@@ -968,23 +1071,60 @@ window.__ModuleLoader__.load({
968
1071
  // ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
969
1072
  // The probe is read-only, so it is safe to run before the user agrees to
970
1073
  // anything; the install requires an explicit second click (confirm: true).
1074
+ //
1075
+ // Install is a HOST-SIDE BACKGROUND TASK: the call returns immediately
1076
+ // with a task handle, and progress arrives by polling. Closing the window
1077
+ // loses nothing — reopening picks the task back up from the periodic
1078
+ // web-attach poll (which carries the deploy state), and clicking 部署并验证
1079
+ // again while one runs RESUMES it rather than starting a second npm.
1080
+ const deployPoll = () => api('GET', '/dsh-remote/web-attach')
1081
+ .then((r) => {
1082
+ if (!r) return null
1083
+ // Refresh the attach list like before…
1084
+ setWebAttaches(r.attaches || [])
1085
+ // …and adopt any deploy task for the selected machine.
1086
+ const t = r.deploy || (r.deploys || []).find((d) => d.machineId === webAttachMachine) || null
1087
+ if (t) {
1088
+ setDeployTask(t)
1089
+ if (t.status === 'running' || t.status === 'queued') {
1090
+ setDeployBusy('install')
1091
+ } else if (deployBusy === 'install') {
1092
+ // The task settled since the last poll.
1093
+ setDeployBusy('')
1094
+ if (t.status === 'done') setDeployMsg(tr('settings.deployDone'))
1095
+ if (t.status === 'failed') setDeployErr(t.error || tr('settings.deployFailed'))
1096
+ }
1097
+ }
1098
+ return t
1099
+ })
1100
+ .catch(() => null)
1101
+
971
1102
  const deployAction = (action, machineId, extra) => {
972
- setDeployBusy(action); setDeployErr('')
1103
+ setDeployBusy(action); setDeployErr(''); setDeployMsg('')
973
1104
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
974
1105
  .then((r) => {
975
1106
  if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
976
- if (action === 'install') setDeploy({ kind: 'install', ...r, at: Date.now() })
1107
+ if (action === 'install') {
1108
+ // The task is running on the host NOW. Adopt it immediately so the
1109
+ // steps appear without waiting for the first poll tick.
1110
+ if (r && r.task) setDeployTask(r.task)
1111
+ if (r && r.resumed) setDeployMsg(tr('settings.deployResumed'))
1112
+ }
977
1113
  if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
978
1114
  return r
979
1115
  })
980
1116
  .catch((e) => {
981
1117
  const msg = String((e && e.message) || e)
982
1118
  setDeployErr(msg)
983
- // Keep the failing step list so the operator sees WHERE it stopped.
984
1119
  setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
985
1120
  return null
986
1121
  })
987
- .then((r) => { setDeployBusy(''); refreshWebAttaches(); return r })
1122
+ .then((r) => {
1123
+ if (action !== 'install') setDeployBusy('')
1124
+ // install keeps busy='install' until the task settles (poll clears it).
1125
+ refreshWebAttaches()
1126
+ return r
1127
+ })
988
1128
  }
989
1129
 
990
1130
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
@@ -997,15 +1137,17 @@ window.__ModuleLoader__.load({
997
1137
  encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
998
1138
  proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
999
1139
  proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
1140
+ // Issue #51: the transport backend is per machine.
1141
+ backend: m.backend === 'openssh' ? 'openssh' : 'native', sshBinary: m.sshBinary || '',
1000
1142
  id: m.id,
1001
1143
  useSshConfig: !!m.useSshConfig,
1002
1144
  })
1003
- // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
1004
- if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
1145
+ // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
1146
+ if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || (m.credentialBackend && m.credentialBackend !== 'plain')) {
1005
1147
  setAdvOpen(true)
1006
1148
  }
1007
1149
  }
1008
- const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '', useSshConfig: false })
1150
+ const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', id: '', useSshConfig: false })
1009
1151
  const save = (action) => {
1010
1152
  setBusy(true); setErr(''); setMsg('')
1011
1153
  if (action === 'delete') {
@@ -1017,6 +1159,8 @@ window.__ModuleLoader__.load({
1017
1159
  username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
1018
1160
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
1019
1161
  encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
1162
+ // Issue #51: the transport backend (and, for `openssh`, the executable).
1163
+ backend: form.backend || 'native', sshBinary: form.sshBinary || '',
1020
1164
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
1021
1165
  }).then((r) => {
1022
1166
  refresh(); clearForm()
@@ -1107,6 +1251,10 @@ window.__ModuleLoader__.load({
1107
1251
  m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
1108
1252
  : (m.username + '@' + m.host + ':' + m.port)),
1109
1253
  m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
1254
+ // Issue #51: a machine on the system-OpenSSH backend behaves
1255
+ // differently enough (auth from ~/.ssh/config, GSSAPI) that
1256
+ // the row must say so.
1257
+ m.backend === 'openssh' ? pill('', tr('settings.badgeOpenssh')) : null,
1110
1258
  m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
1111
1259
  m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
1112
1260
  (m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
@@ -1168,6 +1316,23 @@ window.__ModuleLoader__.load({
1168
1316
  style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
1169
1317
  }, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
1170
1318
  advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
1319
+ // Issue #51 — transport backend. Placed FIRST in the advanced block
1320
+ // because it changes what the fields below even mean: with the
1321
+ // system-OpenSSH backend, host/user/port/key all come from
1322
+ // ~/.ssh/config and only the machine name (an alias) matters.
1323
+ row(tr('settings.backend'), React.createElement('select', { value: form.backend || 'native', onChange: setF('backend'), style: { ...inputS, maxWidth: 260 } },
1324
+ React.createElement('option', { value: 'native' }, tr('settings.backendNative')),
1325
+ React.createElement('option', { value: 'openssh' }, tr('settings.backendOpenssh'))), 'be'),
1326
+ (form.backend === 'openssh')
1327
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.85, marginBottom: 8, marginLeft: LABEL_W + 8, lineHeight: 1.55 } },
1328
+ React.createElement('div', null, tr('settings.backendOpensshHint')),
1329
+ form.useSshConfig
1330
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 2 } }, tr('settings.backendOpensshAliasOk'))
1331
+ : null)
1332
+ : null,
1333
+ (form.backend === 'openssh')
1334
+ ? row(tr('settings.sshBinary'), React.createElement('input', { value: form.sshBinary || '', onChange: setF('sshBinary'), placeholder: tr('settings.sshBinaryPlaceholder'), style: inputS }), 'sb')
1335
+ : null,
1171
1336
  row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
1172
1337
  row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
1173
1338
  row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
@@ -1273,6 +1438,46 @@ window.__ModuleLoader__.load({
1273
1438
  disabled: !!deployBusy || !webAttachMachine,
1274
1439
  onClick: () => deployAction('probe', webAttachMachine) },
1275
1440
  deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
1441
+ // ── live deploy task (resumable; survives window close) ─────────
1442
+ deployTask
1443
+ ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, border: '1px solid ' + T.border, borderRadius: 8, padding: 8 } },
1444
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } },
1445
+ tr('settings.deployTaskTitle')
1446
+ + ' — ' + (deployTask.status === 'running' || deployTask.status === 'queued'
1447
+ ? tr('settings.deployTaskRunning')
1448
+ : deployTask.status === 'done' ? tr('settings.deployTaskDone')
1449
+ : deployTask.status === 'cancelled' ? tr('settings.deployTaskCancelled')
1450
+ : tr('settings.deployTaskFailed'))),
1451
+ // The in-flight step: the one thing a re-attached user most
1452
+ // wants to see ("现在装到哪了").
1453
+ deployTask.current && (deployTask.status === 'running')
1454
+ ? React.createElement('div', { style: { fontSize: 12, color: T.warn, marginBottom: 4 } }, '▶ ' + deployTask.current)
1455
+ : null,
1456
+ (deployTask.steps || []).length
1457
+ ? React.createElement('div', null,
1458
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 2, fontSize: 11, opacity: 0.8 } }, tr('settings.deploySteps')),
1459
+ deployTask.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
1460
+ React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
1461
+ React.createElement('span', null, s.title),
1462
+ !s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
1463
+ : null,
1464
+ deployTask.status === 'running' || deployTask.status === 'queued'
1465
+ ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
1466
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } },
1467
+ (deployTask.progress && deployTask.progress.doneSteps || 0) + '/' + (deployTask.progress && deployTask.progress.totalSteps || '?')),
1468
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1469
+ onClick: () => deployAction('install-cancel', webAttachMachine) }, tr('settings.deployCancel')))
1470
+ : null,
1471
+ // A finished task: continue the journey — connect & open.
1472
+ deployTask.status === 'done'
1473
+ ? React.createElement('div', { style: { marginTop: 6, color: T.ok, fontSize: 12 } },
1474
+ tr('settings.deployDone')
1475
+ + (deployTask.result && deployTask.result.command ? ' · ' + deployTask.result.command : ''))
1476
+ : null,
1477
+ deployTask.status === 'failed'
1478
+ ? React.createElement('div', { style: { marginTop: 6, color: T.danger, fontSize: 12, wordBreak: 'break-all' } }, deployTask.error || tr('settings.deployFailed'))
1479
+ : null)
1480
+ : null,
1276
1481
  // Verdict from the last probe/install, rendered as findings with the
1277
1482
  // severity the backend assigned — the UI never re-classifies them.
1278
1483
  deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
@@ -1317,9 +1522,13 @@ window.__ModuleLoader__.load({
1317
1522
  // reason to run. The host action re-probes internally anyway, so it is
1318
1523
  // safe to offer directly; clicking it is the explicit consent.
1319
1524
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1320
- disabled: !!deployBusy || !webAttachMachine,
1525
+ disabled: !webAttachMachine,
1321
1526
  onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1322
- deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
1527
+ // While a task runs, the button is the RESUME affordance (disabled
1528
+ // — the poll renders live state; a second click must not restart).
1529
+ (deployTask && (deployTask.status === 'running' || deployTask.status === 'queued'))
1530
+ ? tr('settings.deployInstalling')
1531
+ : deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
1323
1532
  // The AI escape hatch: offered after a failure, and ONLY on click —
1324
1533
  // it costs a model call, so it is never automatic.
1325
1534
  (deployErr || (deploy && deploy.ok === false))
@@ -1486,6 +1695,13 @@ window.__ModuleLoader__.load({
1486
1695
  const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
1487
1696
  const suggestTimer = React.useRef(null)
1488
1697
  const suggestRef = React.useRef(null)
1698
+ // Issue #52 defect 1: the modal backdrop must dismiss only when the press
1699
+ // STARTED on it (a suggestion click whose mouseup landed on the backdrop
1700
+ // used to close the whole picker). The local-browse popup below is its own
1701
+ // full-screen overlay, so it gets its own guard ref — sharing one ref
1702
+ // would make a press on either backdrop arm the other.
1703
+ const backdrop = useBackdropGuard()
1704
+ const localBackdrop = useBackdropGuard()
1489
1705
  // Autocomplete performance (slow per-keystroke remote scans):
1490
1706
  // - postedCurrentRef/currentPromiseRef: POST /current once per machine
1491
1707
  // switch; concurrent callers share ONE in-flight promise and a failed
@@ -1915,7 +2131,8 @@ window.__ModuleLoader__.load({
1915
2131
  const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
1916
2132
  return React.createElement('div', {
1917
2133
  style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
1918
- onClick: () => setLocalPop(null),
2134
+ onMouseDown: localBackdrop.onMouseDown,
2135
+ onClick: (e) => { if (localBackdrop.onClick(e)) setLocalPop(null) },
1919
2136
  },
1920
2137
  React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
1921
2138
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
@@ -2026,7 +2243,15 @@ window.__ModuleLoader__.load({
2026
2243
  onClick: () => switchTab(t),
2027
2244
  style: { ...buttonS, fontWeight: tab === t ? 700 : 400, padding: '5px 14px' },
2028
2245
  }, lbl)
2029
- return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy && !mkdirPrompt) onCancel() } },
2246
+ return React.createElement('div', {
2247
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
2248
+ onMouseDown: backdrop.onMouseDown,
2249
+ // Issue #52 defect 1: this was a bare onClick, so picking an autocomplete
2250
+ // suggestion (mousedown inside the panel, panel re-laid-out, mouseup on
2251
+ // the backdrop) closed the whole dialog. Only a press that began on the
2252
+ // backdrop dismisses now.
2253
+ onClick: (e) => { if (backdrop.onClick(e) && !busy && !mkdirPrompt) onCancel() },
2254
+ },
2030
2255
  React.createElement('div', { className: 'dsh-rw-modal-panel', style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
2031
2256
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
2032
2257
  React.createElement('div', { style: { fontSize: 15, fontWeight: 650 } }, tr('picker.title')),
@@ -2047,7 +2272,7 @@ window.__ModuleLoader__.load({
2047
2272
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
2048
2273
  localPop ? renderLocalPopup() : null,
2049
2274
  )
2050
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
2275
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: suggestOpen ? 'visible' : 'auto' } },
2051
2276
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
2052
2277
  React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
2053
2278
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
@@ -2087,14 +2312,29 @@ window.__ModuleLoader__.load({
2087
2312
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
2088
2313
  ),
2089
2314
  (suggestOpen && suggest.length)
2090
- ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
2091
- suggest.map((s, i) => React.createElement('div', { key: s + i, className: 'dsh-rw-list-item', onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
2315
+ // Issue #52 defect 2: this used to be an in-flow block, so
2316
+ // expanding it pushed the 「设为远程工作区」 button down and the
2317
+ // user could never hit a button that was moving. It is now an
2318
+ // absolutely-positioned overlay hanging off `suggestRef`
2319
+ // (position: relative), which also keeps the modal's height —
2320
+ // and therefore its centred edges — stable while open (that
2321
+ // stability is what makes the press-origin guard above exact).
2322
+ // `paddingBottom` lets the last entries scroll clear of the
2323
+ // button row, which is lifted over the dropdown by zIndex.
2324
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 60, marginTop: 6, paddingBottom: 36, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
2325
+ // Issue #52 defect 1, local belt-and-braces: swallow the
2326
+ // gesture so no ancestor (least of all the backdrop) can
2327
+ // observe a click that began on a suggestion.
2328
+ suggest.map((s, i) => React.createElement('div', { key: s + i, className: 'dsh-rw-list-item', onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); selectSuggestion(s) }, style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
2092
2329
  )
2093
2330
  : null,
2094
2331
  ),
2095
2332
  popOpen ? renderDirPopup() : null,
2096
2333
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
2097
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
2334
+ // The action row rides ABOVE the dropdown (zIndex 70) on its own
2335
+ // opaque layer, so the list slides out from behind it instead of
2336
+ // covering 「设为远程工作区」.
2337
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', position: 'relative', zIndex: 70, background: v('--dsw-alias-bg-layer-1', '#18181b') } },
2098
2338
  React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? tr('picker.selected', { path }) : ''),
2099
2339
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? tr('picker.mirroring') : tr('picker.setRemoteWs')),
2100
2340
  ),
@@ -2153,6 +2393,199 @@ window.__ModuleLoader__.load({
2153
2393
  .catch(() => '')
2154
2394
  }
2155
2395
 
2396
+ // ── which sessions are remote (issue #49) ────────────────────────────────
2397
+ //
2398
+ // The sidebar must say, at a glance, whether a Session row is a LOCAL
2399
+ // session or one working on a remote host — and which host. The truth is
2400
+ // already on the client: every Session summary carries its own cwd, and a
2401
+ // cwd that lives inside a `$DSH_HOME/remote-workspaces/<host-user-port>/…`
2402
+ // mirror IS a remote session bound to that mirror's recorded origin (the
2403
+ // same invariant lib/binding.js uses host-side; see issue #13).
2404
+ //
2405
+ // So one GET /dsh-remote/bindings (the whole mirror registry, with each
2406
+ // mirror's recorded host/port/user/path) answers every row, instead of one
2407
+ // request per row. The active machine is deliberately NOT consulted: a saved
2408
+ // machine that happens to be "current" must never label a local session.
2409
+ //
2410
+ // Failure is not fatal and not retried in a loop: no registry means no
2411
+ // badge, which is exactly what a user with zero mirrors sees.
2412
+
2413
+ const MIRRORS = { list: [], loaded: false, error: '' }
2414
+ const mirrorWatchers = new Set()
2415
+ let mirrorLoad = null
2416
+ let mirrorTimer = null
2417
+
2418
+ const notifyMirrors = () => {
2419
+ for (const fn of mirrorWatchers) { try { fn() } catch { /* one watcher must not stop the rest */ } }
2420
+ }
2421
+ /** Load the mirror registry once (concurrent callers share the request). */
2422
+ function ensureMirrors() {
2423
+ if (MIRRORS.loaded) return Promise.resolve(MIRRORS)
2424
+ if (mirrorLoad) return mirrorLoad
2425
+ mirrorLoad = api('GET', '/dsh-remote/bindings')
2426
+ .then((r) => {
2427
+ MIRRORS.list = (r && Array.isArray(r.mirrors)) ? r.mirrors : []
2428
+ MIRRORS.error = ''
2429
+ })
2430
+ .catch((e) => { MIRRORS.error = String((e && e.message) || e) })
2431
+ .then(() => {
2432
+ MIRRORS.loaded = true
2433
+ mirrorLoad = null
2434
+ notifyMirrors()
2435
+ return MIRRORS
2436
+ })
2437
+ return mirrorLoad
2438
+ }
2439
+ /** Force a refresh (mirrors appear when a workspace is picked). */
2440
+ function refreshMirrors() {
2441
+ MIRRORS.loaded = false
2442
+ return ensureMirrors()
2443
+ }
2444
+ /**
2445
+ * The mirror containing a local path, or null.
2446
+ *
2447
+ * Separator-aware on purpose: `/a/mirror-2` must NOT be treated as living
2448
+ * inside `/a/mirror` (the same trap lib/binding.js documents). The deepest
2449
+ * match wins, so a future nested layout stays stable. Windows paths compare
2450
+ * case-insensitively because the platform does.
2451
+ */
2452
+ function mirrorForCwd(cwd, mirrors) {
2453
+ const raw = String(cwd || '')
2454
+ if (!raw) return null
2455
+ const list = Array.isArray(mirrors) ? mirrors : []
2456
+ const isWin = raw.includes('\\')
2457
+ const sep = isWin ? '\\' : '/'
2458
+ const fold = (s) => (isWin ? s.toLowerCase() : s)
2459
+ const base = fold(raw.replace(/[\\/]+$/, ''))
2460
+ let best = null
2461
+ for (const m of list) {
2462
+ const dirRaw = String((m && m.dir) || '').replace(/[\\/]+$/, '')
2463
+ if (!dirRaw) continue
2464
+ const dir = fold(dirRaw)
2465
+ if (base !== dir && !base.startsWith(dir + sep) && !base.startsWith(dir + '/')) continue
2466
+ if (!best || dir.length > fold(String(best.dir).replace(/[\\/]+$/, '')).length) best = m
2467
+ }
2468
+ return best
2469
+ }
2470
+ /** `user@host:port` for a mirror record (port omitted when it is 22). */
2471
+ function mirrorTarget(m) {
2472
+ if (!m) return ''
2473
+ const user = m.username ? m.username + '@' : ''
2474
+ const port = Number(m.port) && Number(m.port) !== 22 ? ':' + m.port : ''
2475
+ return user + String(m.host || '') + port
2476
+ }
2477
+ /** Host as the reader asked for it: the address, plus the saved machine name. */
2478
+ function mirrorLabel(m) {
2479
+ if (!m) return ''
2480
+ const target = mirrorTarget(m)
2481
+ return m.name && m.name !== m.host ? target + ' · ' + m.name : target
2482
+ }
2483
+ /**
2484
+ * The remote binding of one Session row, read from that row's own cwd.
2485
+ *
2486
+ * A row with no cwd (a blank New Session) is not remote — a blank session
2487
+ * has no workspace yet, and must never inherit the badge from a neighbor.
2488
+ *
2489
+ * Hook shape is CONSTANT: when the framework does not hand the entry a
2490
+ * `useSessions` (older sidebar), a stable no-op hook is selected instead of
2491
+ * skipping the call, so the hook count cannot change across renders (a
2492
+ * conditional hook is a crash, not a degraded badge).
2493
+ */
2494
+ const NO_SELECT = () => undefined
2495
+ function useSessionMirror(sessionId, useSessions) {
2496
+ // `mirrorVersion` is only a re-render trigger: the registry arrives
2497
+ // asynchronously, and every mounted mark must repaint when it does.
2498
+ const [, bumpMirrorVersion] = React.useState(0)
2499
+ const select = typeof useSessions === 'function' ? useSessions : NO_SELECT
2500
+ const cwd = select((s) => (sessionId && s && s.byId && s.byId[sessionId] ? s.byId[sessionId].cwd : undefined))
2501
+ React.useEffect(() => {
2502
+ const fn = () => bumpMirrorVersion((n) => n + 1)
2503
+ mirrorWatchers.add(fn)
2504
+ ensureMirrors()
2505
+ // Mirrors change when a remote workspace is picked in THIS tab; a slow
2506
+ // poll covers a pick made in another tab without a per-render request.
2507
+ if (!mirrorTimer && typeof setInterval === 'function') {
2508
+ mirrorTimer = setInterval(refreshMirrors, 60000)
2509
+ }
2510
+ return () => {
2511
+ mirrorWatchers.delete(fn)
2512
+ if (!mirrorWatchers.size && mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null }
2513
+ }
2514
+ }, [])
2515
+ return mirrorForCwd(cwd, MIRRORS.list)
2516
+ }
2517
+
2518
+ // Leading seat of a Session row: the 16px cell before the title. A green dot
2519
+ // means "this session works on a remote host"; a local session renders
2520
+ // nothing at all, so the cell stays blank exactly as before. The owner only
2521
+ // mounts this seat while the row is idle (a running/pending row keeps its
2522
+ // own status dot there) — the hover card below therefore carries the host
2523
+ // for every row, running or not.
2524
+ function RemoteSessionMark(props) {
2525
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2526
+ if (!m) return null
2527
+ const target = mirrorTarget(m)
2528
+ return React.createElement('span', {
2529
+ 'data-dsh-remote-mark': '',
2530
+ role: 'img',
2531
+ 'aria-label': tr('session.remoteBadge', { target }),
2532
+ title: tr('session.remoteBadge', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
2533
+ style: { width: 16, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
2534
+ }, React.createElement('span', {
2535
+ 'aria-hidden': 'true',
2536
+ style: {
2537
+ width: 8, height: 8, borderRadius: '50%',
2538
+ background: 'var(--dsw-alias-state-success-primary, #4caf7d)',
2539
+ boxShadow: '0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary, #4caf7d) 22%, transparent)',
2540
+ },
2541
+ }))
2542
+ }
2543
+
2544
+ // Hover-card section of a Session row, between its relative time and its
2545
+ // trailing status line: names the remote host (and the remote path it
2546
+ // works on) for a remote row. A local row renders nothing.
2547
+ function RemoteSessionHover(props) {
2548
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2549
+ if (!m) return null
2550
+ return React.createElement('div', { 'data-dsh-remote-hover': '', style: { display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, lineHeight: '16px' } },
2551
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
2552
+ React.createElement('span', {
2553
+ 'aria-hidden': 'true',
2554
+ style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
2555
+ }),
2556
+ React.createElement('span', { style: { color: '#cfd3d6', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, mirrorLabel(m)),
2557
+ ),
2558
+ m.remotePath
2559
+ ? React.createElement('div', { style: { color: '#adb2b8', wordBreak: 'break-all', paddingLeft: 13 } }, m.remotePath)
2560
+ : null,
2561
+ )
2562
+ }
2563
+
2564
+ // Conversation header chip for the CURRENT session: the one place the host
2565
+ // can stay visible without hovering anything (the row's 16px cell is too
2566
+ // narrow for an address). Renders nothing for a local session.
2567
+ //
2568
+ // This seat is session-scoped, so the owner hands the entry its `sessionId`
2569
+ // (exactly like the shipped "open in app" utility) and the chip needs
2570
+ // nothing from the sidebar.
2571
+ function RemoteSessionChip(props) {
2572
+ const m = useSessionMirror(props.sessionId, props.useSessions)
2573
+ if (!m) return null
2574
+ const target = mirrorTarget(m)
2575
+ return React.createElement('span', {
2576
+ 'data-dsh-remote-chip': '',
2577
+ className: 'dsh-rw-pill is-ok',
2578
+ title: tr('session.remoteHover', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
2579
+ style: { maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-flex', alignItems: 'center', gap: 5 },
2580
+ },
2581
+ React.createElement('span', {
2582
+ 'aria-hidden': 'true',
2583
+ style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
2584
+ }),
2585
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, target),
2586
+ )
2587
+ }
2588
+
2156
2589
  function readRemoteFile(path, maxBytes, sessionId) {
2157
2590
  return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => {
2158
2591
  throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
@@ -2961,6 +3394,19 @@ window.__ModuleLoader__.load({
2961
3394
  inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
2962
3395
  })
2963
3396
  ctx.inject(['sidebarRightTabs'], registerNativeSidebar)
3397
+ // Issue #49: mark the remote Sessions in the sidebar's own Session list.
3398
+ // These three seats belong to the SHIPPED ui-workspace browser, so they
3399
+ // are injected (not assumed): a composition with a replacement sidebar
3400
+ // simply never declares them, and the mark is absent rather than broken.
3401
+ slots.inject('sidebar.session.row.leading', () =>
3402
+ slots.register({ name: 'sidebar.session.row.leading', id: 'dsh-remote-remote-mark', order: 5 }, RemoteSessionMark),
3403
+ )
3404
+ slots.inject('sidebar.session.row.hover', () =>
3405
+ slots.register({ name: 'sidebar.session.row.hover', id: 'dsh-remote-remote-host', order: 5 }, RemoteSessionHover),
3406
+ )
3407
+ slots.inject('conversation.session.header.utilities', () =>
3408
+ slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-remote-remote-chip', order: 15 }, RemoteSessionChip),
3409
+ )
2964
3410
  }
2965
3411
 
2966
3412
  exports.name = name