dsh-remote-plugin 0.6.19 → 0.6.21

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/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
7
  */
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
- import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
10
10
  import { stat } from 'node:fs/promises'
11
11
  import net from 'node:net'
12
12
  import { homedir, hostname, networkInterfaces } from 'node:os'
@@ -14,7 +14,7 @@ import { dirname, extname, normalize, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
16
16
  export const name = 'dsh-remote'
17
- export const inject = ['webServer', 'commands', 'agents']
17
+ export const inject = ['webServer', 'commands', 'agents', 'connection']
18
18
 
19
19
  const MOUNT = '/remote'
20
20
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
@@ -68,6 +68,42 @@ try {
68
68
 
69
69
  // DSH 实际监听地址由 apply 时从 webServer 服务读取
70
70
  let dshListen = { host: '127.0.0.1', port: 3080 }
71
+ let dshConnection = null
72
+
73
+ function dshUpstreamCookieFile() {
74
+ return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
75
+ }
76
+
77
+ /**
78
+ * DSH 0.1.2-alpha.1 起,Host RPC 与 WebSocket 都要求浏览器会话 Cookie。
79
+ * 新版 connection 服务可把仅进程内可见的启动令牌兑换为 Cookie;旧版没有
80
+ * authenticatedUrl,直接跳过即可。文件只让同一用户的独立网关读取。
81
+ */
82
+ async function refreshDshUpstreamCookie() {
83
+ if (typeof dshConnection?.authenticatedUrl !== 'function') return false
84
+ const upstream = `http://${dshListen.host}:${dshListen.port}`
85
+ try {
86
+ const loginUrl = dshConnection.authenticatedUrl(upstream)
87
+ const response = await fetch(loginUrl, {
88
+ redirect: 'manual',
89
+ signal: AbortSignal.timeout(2500),
90
+ })
91
+ const setCookie = response.headers.get('set-cookie') || ''
92
+ const cookie = setCookie.split(';', 1)[0].trim()
93
+ if (response.status !== 303 || !cookie.includes('=') || cookie.length > 4096 || /[\0\r\n]/.test(cookie)) {
94
+ throw new Error(`认证交换返回 HTTP ${response.status}`)
95
+ }
96
+ const file = dshUpstreamCookieFile()
97
+ mkdirSync(dirname(file), { recursive: true })
98
+ writeFileSync(file, cookie + '\n', { mode: 0o600 })
99
+ chmodSync(file, 0o600)
100
+ return true
101
+ } catch (e) {
102
+ // 禁止记录带启动令牌的 URL 或 Cookie,只保留不含凭据的错误摘要。
103
+ logGateway('刷新 DSH 上游认证失败: ' + (e?.message || String(e)))
104
+ return false
105
+ }
106
+ }
71
107
 
72
108
  function lanIPs() {
73
109
  const out = [...configuredAdvertisedHosts()]
@@ -169,6 +205,7 @@ function runExit(cmd, args) {
169
205
 
170
206
  const GATEWAY_ENV_KEYS = [
171
207
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
208
+ 'DSH_REMOTE_DSH_COOKIE_FILE',
172
209
  'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
173
210
  'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
174
211
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
@@ -363,6 +400,7 @@ function ensureGateway() {
363
400
  if (ensurePromise) return ensurePromise
364
401
  ensurePromise = (async () => {
365
402
  try {
403
+ await refreshDshUpstreamCookie()
366
404
  const health = await gatewayRunning()
367
405
  if (!health.running) {
368
406
  const out = await startGateway()
@@ -475,6 +513,23 @@ async function resolveAgent(ctx, sessionId) {
475
513
  }
476
514
  }
477
515
 
516
+ function commandHead(line) {
517
+ const match = /^\/+(\S+)/.exec(String(line || '').trim())
518
+ return match ? match[1].replace(/^\/+/, '') : ''
519
+ }
520
+
521
+ function commandEntryName(entry) {
522
+ const value = typeof entry === 'string' ? entry : entry?.name
523
+ return String(value || '').trim().replace(/^\/+/, '')
524
+ }
525
+
526
+ function commandWasExecuted(result) {
527
+ // DSH 0.1.x 的 commands.execute() 成功时可以返回 void。只要 Promise 正常
528
+ // 完成,就不应把同一条 /command 再降级为 session.prompt 的普通文本;只有
529
+ // 执行器明确返回 false / { executed: false } 才表示未处理。
530
+ return result !== false && result?.executed !== false
531
+ }
532
+
478
533
  async function resolveFile(pathname) {
479
534
  let abs = targetPath(pathname)
480
535
  if (abs === null) return null
@@ -733,15 +788,27 @@ async function serveStatic(req, res, ctx) {
733
788
  sendJson(res, 200, { ok: false, message: 'agent not found', debug: { resolvePath, commandNames: [] } })
734
789
  return
735
790
  }
791
+ let commandEntries = null
736
792
  let commandNames = []
737
793
  try {
738
- commandNames = (await ctx.commands.list(agent)).map(c => c.name)
794
+ const listed = await ctx.commands.list(agent)
795
+ commandEntries = Array.isArray(listed) ? listed : null
796
+ commandNames = commandEntries ? commandEntries.map(commandEntryName).filter(Boolean) : []
739
797
  } catch (e) {
740
798
  commandNames = ['list-error: ' + (e?.message || String(e))]
741
799
  }
800
+ const name = commandHead(line)
801
+ if (commandEntries && (!name || !commandEntries.some(entry => commandEntryName(entry) === name))) {
802
+ // 仅把 DSH 已登记的 slash command 截留到命令服务。未知 /xxx 仍让客户端
803
+ // 按普通文本发送,保持 DSH 自己处理自定义提示词的兼容行为。
804
+ sendJson(res, 200, { ok: true, executed: false, debug: { resolvePath, commandNames, reason: 'unknown-command' } })
805
+ return
806
+ }
742
807
  const signal = AbortSignal.timeout(30000)
743
- const result = await ctx.commands.execute(agent, line, signal)
744
- sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
808
+ const result = ctx.commands.execute.length === 3
809
+ ? await ctx.commands.execute(agent, line, signal)
810
+ : await ctx.commands.execute(agent, line, [], signal)
811
+ sendJson(res, 200, { ok: true, executed: commandWasExecuted(result), debug: { resolvePath, commandNames } })
745
812
  } catch (e) {
746
813
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
747
814
  }
@@ -783,6 +850,7 @@ async function serveStatic(req, res, ctx) {
783
850
 
784
851
  export function apply(ctx) {
785
852
  dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
853
+ dshConnection = ctx.connection
786
854
  ctx.effect(() => ctx.webServer.register({
787
855
  kind: 'prefix',
788
856
  path: MOUNT,
@@ -794,4 +862,10 @@ export function apply(ctx) {
794
862
  })
795
863
  // DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
796
864
  void ensureGateway()
865
+ // apply 可能早于 Web 监听完成;延迟再交换一次新版 DSH 的会话 Cookie。
866
+ ctx.effect(() => {
867
+ const timer = setTimeout(() => { void ensureGateway() }, 5000)
868
+ timer.unref?.()
869
+ return () => clearTimeout(timer)
870
+ }, 'dsh-remote: refresh upstream authentication')
797
871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/app.js CHANGED
@@ -938,10 +938,81 @@ function closeGroupMenu() {
938
938
  if (menu) menu.classList.add('hidden')
939
939
  }
940
940
 
941
+ let groupDrawerReturnFocus = null
942
+
943
+ function groupDrawerOpen() {
944
+ return !$('group-drawer')?.classList.contains('hidden')
945
+ }
946
+
947
+ function renderGroupDrawer() {
948
+ const trigger = $('group-drawer-trigger')
949
+ const brandGroup = $('brand-group-name')
950
+ const current = $('group-drawer-current')
951
+ const list = $('group-drawer-list')
952
+ if (brandGroup) brandGroup.textContent = state.activeGroup
953
+ if (trigger) trigger.setAttribute('aria-label', t('groups.drawerOpen') + ':' + state.activeGroup)
954
+ if (current) current.textContent = state.activeGroup
955
+ if (!list) return
956
+ list.innerHTML = state.groups.map(group => {
957
+ const servers = groupServers(group)
958
+ const selected = group === state.activeGroup
959
+ return `<button type="button" class="group-drawer-option ${selected ? 'current' : ''}" data-quick-group="${esc(group)}" ${selected ? 'aria-current="true"' : ''}>
960
+ <span class="group-drawer-option-mark" aria-hidden="true"></span>
961
+ <span class="group-drawer-option-copy"><span class="group-drawer-option-name">${esc(group)}</span><span class="group-drawer-option-meta">${t('groups.serverCount', { count: servers.length })}</span></span>
962
+ <span class="group-drawer-option-check" aria-hidden="true">${selected ? '✓' : ''}</span>
963
+ </button>`
964
+ }).join('')
965
+ }
966
+
967
+ function openGroupDrawer() {
968
+ const drawer = $('group-drawer')
969
+ const backdrop = $('group-drawer-backdrop')
970
+ const trigger = $('group-drawer-trigger')
971
+ if (!drawer || !backdrop || !trigger) return
972
+ renderGroupDrawer()
973
+ groupDrawerReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : trigger
974
+ drawer.classList.remove('hidden')
975
+ backdrop.classList.remove('hidden')
976
+ document.body.classList.add('group-drawer-open')
977
+ trigger.setAttribute('aria-expanded', 'true')
978
+ requestAnimationFrame(() => (drawer.querySelector('[data-quick-group].current') || drawer.querySelector('[data-quick-group]') || drawer).focus({ preventScroll: true }))
979
+ }
980
+
981
+ function closeGroupDrawer({ restoreFocus = true } = {}) {
982
+ const drawer = $('group-drawer')
983
+ const backdrop = $('group-drawer-backdrop')
984
+ const trigger = $('group-drawer-trigger')
985
+ if (!drawer || !backdrop || drawer.classList.contains('hidden')) return
986
+ drawer.classList.add('hidden')
987
+ backdrop.classList.add('hidden')
988
+ document.body.classList.remove('group-drawer-open')
989
+ trigger?.setAttribute('aria-expanded', 'false')
990
+ if (restoreFocus) (groupDrawerReturnFocus || trigger)?.focus({ preventScroll: true })
991
+ groupDrawerReturnFocus = null
992
+ }
993
+
994
+ function keepGroupDrawerFocus(event) {
995
+ if (!groupDrawerOpen()) return
996
+ if (event.key === 'Escape') {
997
+ event.preventDefault()
998
+ closeGroupDrawer()
999
+ return
1000
+ }
1001
+ if (event.key !== 'Tab') return
1002
+ const drawer = $('group-drawer')
1003
+ const targets = [...drawer.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')]
1004
+ if (!targets.length) return
1005
+ const first = targets[0]
1006
+ const last = targets[targets.length - 1]
1007
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
1008
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
1009
+ }
1010
+
941
1011
  function renderServers() {
942
1012
  const box = $('server-list')
943
1013
  if (!box) return
944
1014
  renderGroupSelect()
1015
+ renderGroupDrawer()
945
1016
  const groupsHtml = state.groups.map(g => {
946
1017
  const list = groupServers(g)
947
1018
  const auto = state.autoSelect[g] !== false
@@ -2108,6 +2179,7 @@ async function openSession(id) {
2108
2179
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
2109
2180
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2110
2181
  renderQueue()
2182
+ renderSessionPending()
2111
2183
  restoreCachedHistory()
2112
2184
  await loadHistory(true)
2113
2185
  renderSessionCards()
@@ -2140,6 +2212,7 @@ async function closeSession() {
2140
2212
  const discard = await shouldDiscardEmptySession(sessionId)
2141
2213
  if (state.current !== sessionId) return
2142
2214
  state.current = null
2215
+ renderSessionPending()
2143
2216
  if (discard) removeLocalSessionRecord(sessionId)
2144
2217
  setComposerFullscreen(false)
2145
2218
  clearComposerImages()
@@ -3329,7 +3402,7 @@ function renderOverview() {
3329
3402
  $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
3330
3403
  const title = titleOf(state.byId.get(item.sessionId))
3331
3404
  if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
3332
- <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item.reason || t('pending.noReason'))} · ${esc(title)}</span></span>
3405
+ <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(approvalDetail(item))} · ${esc(title)}</span></span>
3333
3406
  <span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
3334
3407
  </div>`
3335
3408
  return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
@@ -3394,7 +3467,7 @@ function renderPending() {
3394
3467
  const title = titleOf(state.byId.get(a.sessionId))
3395
3468
  return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
3396
3469
  <div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
3397
- <div class="pc-desc">${esc(a.reason || t('pending.noReason'))}</div>
3470
+ <div class="pc-desc">${esc(approvalDetail(a))}</div>
3398
3471
  <div class="pc-session">${esc(title)}</div>
3399
3472
  <div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
3400
3473
  </div>`
@@ -3414,10 +3487,48 @@ function renderPending() {
3414
3487
  })
3415
3488
  list.querySelectorAll('[data-question]').forEach(btn =>
3416
3489
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
3490
+ renderSessionPending()
3417
3491
  updatePendingBadge()
3418
3492
  renderOverview()
3419
3493
  }
3420
3494
 
3495
+ function approvalDetail(a) {
3496
+ const lines = []
3497
+ if (a?.reason) lines.push(String(a.reason))
3498
+ if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
3499
+ if (a?.callId) lines.push(`callId: ${a.callId}`)
3500
+ return lines.join('\n') || t('pending.noReason')
3501
+ }
3502
+
3503
+ function renderSessionPending() {
3504
+ const box = $('session-pending')
3505
+ if (!box) return
3506
+ const sessionId = state.current
3507
+ const items = [
3508
+ ...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
3509
+ ...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
3510
+ ]
3511
+ box.classList.toggle('hidden', !sessionId || !items.length)
3512
+ if (!sessionId || !items.length) { box.innerHTML = ''; return }
3513
+ box.innerHTML = `<div class="session-pending-head"><span>${esc(t('overview.attention'))}</span><span>${esc(t('pending.count', { n: items.length }))}</span></div><div class="session-pending-list">${items.map(({ kind, item }) => {
3514
+ if (kind === 'approval') return `<article class="pending-card approval" data-session-approval="${esc(item.approvalId)}">
3515
+ <div class="pc-title">${esc(item.toolName || t('tool.default'))}</div>
3516
+ <div class="pc-desc">${esc(approvalDetail(item))}</div>
3517
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-session-approve="0">${t('pending.reject')}</button></div>
3518
+ </article>`
3519
+ return `<article class="pending-card question" data-session-question="${esc(item.rpcId)}">
3520
+ <div class="pc-title">❓ ${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / ') || t('notify.questionTitle'))}</div>
3521
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-answer>${t('pending.answer')}</button></div>
3522
+ </article>`
3523
+ }).join('')}</div>`
3524
+ box.querySelectorAll('[data-session-approve]').forEach(button => {
3525
+ button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1'))
3526
+ })
3527
+ box.querySelectorAll('[data-session-answer]').forEach(button => {
3528
+ button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion)))
3529
+ })
3530
+ }
3531
+
3421
3532
  async function approveApproval(id, allow) {
3422
3533
  const a = state.approvals.find(x => x.approvalId === id)
3423
3534
  if (!a) return
@@ -6839,6 +6950,22 @@ function bindUi() {
6839
6950
  $('btn-server-add').addEventListener('click', addServer)
6840
6951
  $('btn-group-add').addEventListener('click', addGroup)
6841
6952
  $('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
6953
+ $('group-drawer-trigger').addEventListener('click', () => { groupDrawerOpen() ? closeGroupDrawer() : openGroupDrawer() })
6954
+ $('group-drawer-close').addEventListener('click', () => closeGroupDrawer())
6955
+ $('group-drawer-backdrop').addEventListener('click', () => closeGroupDrawer())
6956
+ $('group-drawer-list').addEventListener('click', (e) => {
6957
+ const option = e.target.closest('[data-quick-group]')
6958
+ if (!option) return
6959
+ const group = option.dataset.quickGroup
6960
+ closeGroupDrawer({ restoreFocus: false })
6961
+ if (group !== state.activeGroup) switchGroup(group)
6962
+ })
6963
+ $('group-drawer-manage').addEventListener('click', () => {
6964
+ closeGroupDrawer({ restoreFocus: false })
6965
+ showView('view-settings')
6966
+ showSettingsPage('servers')
6967
+ })
6968
+ document.addEventListener('keydown', keepGroupDrawerFocus)
6842
6969
  document.addEventListener('click', (e) => {
6843
6970
  if (!e.target.closest('#group-select')) closeGroupMenu()
6844
6971
  })
@@ -9,9 +9,16 @@ html, body {
9
9
  .ds-app { position: fixed; inset: 0; display: flex; overflow: hidden; }
10
10
  .ds-sidebar { width: 280px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--dsr-line); background: linear-gradient(180deg, var(--dsr-panel), var(--dsr-bg)); box-shadow: 12px 0 34px var(--dsr-shadow); overflow: hidden; }
11
11
  .ds-sidebar-backdrop { display: none; }
12
- .ds-brand { display: flex; align-items: center; gap: 8px; padding: 17px 18px 12px; font-weight: 750; font-size: 15px; letter-spacing: .1px; }
12
+ .ds-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px 18px; font-weight: 750; font-size: 15px; letter-spacing: .1px; }
13
+ .ds-brand-trigger { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; padding: 5px 6px 5px 0; border: 0; border-radius: 10px; background: transparent; color: var(--dsr-text); font: inherit; text-align: left; cursor: pointer; }
14
+ .ds-brand-trigger:hover { background: var(--dsr-accent-soft); }
15
+ .ds-brand-trigger:focus-visible { outline: 2px solid var(--dsr-accent); outline-offset: 2px; }
13
16
  .ds-logo { color: var(--dsr-accent-strong); }
14
- .ds-brand-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
17
+ .ds-brand-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 0; line-height: 1.14; }
18
+ .ds-brand-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
19
+ .ds-brand-group-name { min-width: 0; overflow: hidden; color: var(--dsr-muted); font-size: 10px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
20
+ .ds-brand-caret { flex: none; color: var(--dsr-muted); font-size: 15px; line-height: 1; transition: transform .18s ease, color .18s ease; }
21
+ .ds-brand-trigger[aria-expanded="true"] .ds-brand-caret { color: var(--dsr-accent-strong); transform: rotate(180deg); }
15
22
  .ds-gh { color: var(--dsr-accent-2); display: inline-flex; }
16
23
  .ds-gh svg { width: 17px; height: 17px; fill: currentColor; }
17
24
  .ds-new-session { padding: 0 12px 8px; }
@@ -225,15 +232,15 @@ html.reorder-scroll-lock body { overscroll-behavior: none; }
225
232
  .ds-overview-attention-list, .ds-overview-session-list { display:flex; flex-direction:column; gap:8px; }
226
233
  .ds-overview-attention-item, .ds-overview-session-item { width:100%; min-width:0; display:flex; align-items:center; gap:10px; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:12px; background:var(--dsr-bg-2); color:var(--dsr-text); text-align:left; }
227
234
  button.ds-overview-attention-item, button.ds-overview-session-item { cursor:pointer; font:inherit; }
228
- .ds-overview-attention-item { border-left:3px solid var(--dsr-warning); }
235
+ .ds-overview-attention-item { align-items:flex-start; border-left:3px solid var(--dsr-warning); }
229
236
  .ds-overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
230
237
  .ds-overview-mark { flex:0 0 auto; width:26px; height:26px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
231
238
  .ds-overview-attention-item.question .ds-overview-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
232
239
  .ds-overview-copy { flex:1; min-width:0; }
233
- .ds-overview-item-title, .ds-overview-item-desc { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
240
+ .ds-overview-item-title, .ds-overview-item-desc { display:block; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
234
241
  .ds-overview-item-title { font-size:12px; font-weight:650; }
235
- .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; }
236
- .ds-overview-actions { flex:0 0 auto; display:flex; gap:4px; }
242
+ .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; white-space:normal; }
243
+ .ds-overview-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
237
244
  .ds-overview-actions .ds-btn { min-height:27px; padding:2px 8px; font-size:11px; }
238
245
  .ds-overview-actions .allow { background:var(--dsr-success-soft); border-color:var(--dsr-success-line); color:var(--dsr-success); }
239
246
  .ds-overview-actions .reject { background:var(--dsr-warning-soft); border-color:var(--dsr-warning-line); color:var(--dsr-warning); }
@@ -287,6 +294,11 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
287
294
  .ds-presets-guide { padding: 2px 8px 10px; font-size: 11px; line-height: 1.5; }
288
295
  .ds-session-cards { flex: none; max-height: 220px; overflow-y: auto; padding: 10px 22px 0; display: flex; flex-direction: column; gap: 8px; }
289
296
  .ds-session-cards:empty { display: none; }
297
+ .ds-session-pending { flex:none; margin:10px 22px 0; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
298
+ .ds-session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
299
+ .ds-session-pending-list { display:flex; flex-direction:column; gap:8px; }
300
+ .ds-session-pending .ds-notif-card { min-width:0; box-shadow:none; animation:none; }
301
+ .ds-session-pending .ds-notif-title, .ds-session-pending .ds-notif-body { overflow-wrap:anywhere; word-break:break-word; }
290
302
  .ds-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
291
303
  .ds-card-title { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; margin-bottom: 6px; text-transform: uppercase; }
292
304
  .ds-goal-disclosure { width: 100%; min-width: 0; display: flex; justify-content: flex-end; }
@@ -465,6 +477,30 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
465
477
  .ds-drawer-body::-webkit-scrollbar-thumb { background: var(--dsr-line); border-radius: 999px; }
466
478
  .ds-drawer-body::-webkit-scrollbar-thumb:hover { background: var(--dsr-muted); }
467
479
 
480
+ /* 左上角应用名展开的组快捷切换抽屉 */
481
+ .ds-group-drawer-backdrop { position: fixed; inset: 0; z-index: 108; border: 0; background: var(--dsr-overlay); }
482
+ .ds-group-drawer { position: fixed; inset: 0 auto 0 0; z-index: 109; width: min(360px, calc(100vw - 18px)); display: flex; flex-direction: column; background: var(--dsr-panel); border-right: 1px solid var(--dsr-line); box-shadow: 18px 0 48px var(--dsr-shadow); animation: ds-group-drawer-in .22s cubic-bezier(.2,.8,.2,1); }
483
+ .ds-group-drawer.hidden, .ds-group-drawer-backdrop.hidden { display: none; }
484
+ @keyframes ds-group-drawer-in { from { opacity: .01; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }
485
+ .ds-group-drawer-head { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 19px 16px 15px; border-bottom: 1px solid var(--dsr-line); }
486
+ .ds-group-drawer-eyebrow { margin-bottom: 3px; color: var(--dsr-accent-strong); font-size: 10px; font-weight: 750; letter-spacing: 1.5px; }
487
+ .ds-group-drawer-head h2 { margin: 0; color: var(--dsr-text); font-size: 19px; line-height: 1.2; }
488
+ .ds-group-drawer-current { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 14px 8px; padding: 10px 12px; border: 1px solid var(--dsr-accent-line); border-radius: 12px; background: var(--dsr-accent-soft); color: var(--dsr-muted); font-size: 12px; }
489
+ .ds-group-drawer-current strong { min-width: 0; overflow: hidden; color: var(--dsr-accent-strong); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
490
+ .ds-group-drawer-list { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 10px 14px; display: flex; flex-direction: column; gap: 4px; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--dsr-line) transparent; }
491
+ .ds-group-drawer-option { width: 100%; min-height: 56px; display: flex; align-items: center; gap: 11px; padding: 9px 11px; border: 1px solid transparent; border-radius: 13px; background: transparent; color: var(--dsr-text); font: inherit; text-align: left; cursor: pointer; }
492
+ .ds-group-drawer-option:hover { background: var(--dsr-bg-2); }
493
+ .ds-group-drawer-option.current { border-color: var(--dsr-accent-line); background: linear-gradient(100deg, var(--dsr-accent-soft), transparent); }
494
+ .ds-group-drawer-mark { width: 10px; height: 10px; flex: none; border: 2px solid var(--dsr-line); border-radius: 50%; }
495
+ .ds-group-drawer-option.current .ds-group-drawer-mark { border-color: var(--dsr-accent); background: var(--dsr-accent); box-shadow: 0 0 0 4px var(--dsr-accent-soft); }
496
+ .ds-group-drawer-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
497
+ .ds-group-drawer-name { overflow: hidden; font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
498
+ .ds-group-drawer-meta { overflow: hidden; color: var(--dsr-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
499
+ .ds-group-drawer-check { flex: none; color: var(--dsr-accent-strong); font-size: 18px; font-weight: 750; }
500
+ .ds-group-drawer-foot { flex: none; padding: 12px 14px; border-top: 1px solid var(--dsr-line); background: var(--dsr-bg-2); }
501
+ .ds-group-drawer-foot .ds-btn { width: 100%; justify-content: space-between; }
502
+ .ds-group-drawer-foot p { margin: 7px 2px 0; color: var(--dsr-muted); font-size: 11px; }
503
+
468
504
  /* 通知卡片栈(右下角) */
469
505
  .ds-toast-stack { position: fixed; right: 16px; bottom: 16px; z-index: 100; display: flex; flex-direction: column; gap: 10px; width: min(380px, calc(100vw - 32px)); }
470
506
  .ds-notif-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-left: 3px solid var(--dsr-accent-strong); border-radius: 12px; box-shadow: 0 10px 28px rgba(0,0,0,.3); padding: 11px 13px; animation: dsSlideIn .22s ease; }
@@ -12,7 +12,9 @@
12
12
  <div class="ds-app" id="ds-app">
13
13
  <aside class="ds-sidebar" id="ds-sidebar">
14
14
  <div class="ds-brand">
15
- <span class="ds-logo">◆</span><span class="ds-brand-name">DSH Remote</span>
15
+ <button id="ds-group-drawer-trigger" class="ds-brand-trigger" type="button" aria-haspopup="dialog" aria-controls="ds-group-drawer" aria-expanded="false" data-i18n-aria="ds.groupsDrawerOpen">
16
+ <span class="ds-logo">◆</span><span class="ds-brand-copy"><span class="ds-brand-name">DSH Remote</span><span id="ds-brand-group-name" class="ds-brand-group-name">默认</span></span><span class="ds-brand-caret" aria-hidden="true">⌄</span>
17
+ </button>
16
18
  <a class="ds-gh" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" data-i18n-title="ds.repo" data-i18n-aria="ds.repo">
17
19
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
18
20
  </a>
@@ -82,6 +84,19 @@
82
84
  </div>
83
85
  </aside>
84
86
  <button id="ds-sidebar-backdrop" class="ds-sidebar-backdrop hidden" type="button" data-i18n-aria="ds.close" aria-label="关闭菜单"></button>
87
+ <button id="ds-group-drawer-backdrop" class="ds-group-drawer-backdrop hidden" type="button" tabindex="-1" aria-hidden="true"></button>
88
+ <aside id="ds-group-drawer" class="ds-group-drawer hidden" role="dialog" aria-modal="true" aria-labelledby="ds-group-drawer-title" tabindex="-1">
89
+ <div class="ds-group-drawer-head">
90
+ <div><div class="ds-group-drawer-eyebrow" data-i18n="ds.groupsDrawerEyebrow">QUICK SWITCH</div><h2 id="ds-group-drawer-title" data-i18n="ds.groupsDrawerTitle">切换服务器组</h2></div>
91
+ <button id="ds-group-drawer-close" class="ds-icon-btn" type="button" data-i18n-aria="ds.groupsDrawerClose" data-i18n-title="ds.groupsDrawerClose">×</button>
92
+ </div>
93
+ <div class="ds-group-drawer-current"><span data-i18n="ds.groupsDrawerCurrent">当前连接组</span><strong id="ds-group-drawer-current">默认</strong></div>
94
+ <div id="ds-group-drawer-list" class="ds-group-drawer-list" role="group" data-i18n-aria="ds.groupsDrawerTitle"></div>
95
+ <div class="ds-group-drawer-foot">
96
+ <button id="ds-group-drawer-manage" class="ds-btn" type="button"><span data-i18n="ds.groupsDrawerManage">管理服务器与组</span><span aria-hidden="true">›</span></button>
97
+ <p data-i18n="ds.groupsDrawerManageDesc">编辑、测速和调整组</p>
98
+ </div>
99
+ </aside>
85
100
 
86
101
  <div class="ds-main">
87
102
  <header class="ds-topbar">
@@ -151,6 +166,7 @@
151
166
  <section id="view-chat" class="ds-view">
152
167
  <div id="session-cards" class="ds-session-cards"></div>
153
168
  <div id="queue-dock" class="ds-queue-dock hidden" aria-live="polite"></div>
169
+ <section id="session-pending" class="ds-session-pending hidden" aria-live="polite"></section>
154
170
  <div id="history" class="ds-history" aria-live="polite"></div>
155
171
  <div class="ds-composer">
156
172
  <div id="composer-status" class="ds-composer-status hidden" role="status" aria-live="polite"><span class="ds-composer-status-dot" aria-hidden="true"></span><span data-i18n="ds.composerRunning">运行中…</span></div>
@@ -527,6 +543,8 @@
527
543
  'ds.groupsPromptRename': '重命名组「{group}」:', 'ds.groupsRenamed': '组已重命名',
528
544
  'ds.groupsCannotDeleteDefault': '默认组不可删除',
529
545
  'ds.groupsConfirmDelete': '删除组「{group}」?组内服务器将归入默认组。', 'ds.groupsDeleted': '组已删除',
546
+ 'ds.groupsDrawerOpen': '切换服务器组', 'ds.groupsDrawerClose': '关闭组列表', 'ds.groupsDrawerEyebrow': '快速切换', 'ds.groupsDrawerTitle': '切换服务器组', 'ds.groupsDrawerCurrent': '当前连接组',
547
+ 'ds.groupsDrawerManage': '管理服务器与组', 'ds.groupsDrawerManageDesc': '编辑、测速和调整组', 'ds.groupsServerCount': '{count} 台服务器',
530
548
  'ds.theme.default': '默认深空', 'ds.theme.dark': '落日', 'ds.theme.light': '易北爱乐厅', 'ds.theme.neutral': '草原孤塔', 'ds.theme.mono': '黑曜白',
531
549
  'ds.role.me': '我', 'ds.role.dsh': 'DSH', 'ds.toolDefault': '工具',
532
550
  'ds.eventSystemReminder': '系统提示',
@@ -630,6 +648,8 @@
630
648
  'ds.groupsPromptRename': 'Rename group "{group}":', 'ds.groupsRenamed': 'Group renamed',
631
649
  'ds.groupsCannotDeleteDefault': 'The default group cannot be deleted',
632
650
  'ds.groupsConfirmDelete': 'Delete group "{group}"? Its servers move to the default group.', 'ds.groupsDeleted': 'Group deleted',
651
+ 'ds.groupsDrawerOpen': 'Switch server group', 'ds.groupsDrawerClose': 'Close group list', 'ds.groupsDrawerEyebrow': 'QUICK SWITCH', 'ds.groupsDrawerTitle': 'Switch server group', 'ds.groupsDrawerCurrent': 'Current connection group',
652
+ 'ds.groupsDrawerManage': 'Manage servers and groups', 'ds.groupsDrawerManageDesc': 'Edit, test, and organize groups', 'ds.groupsServerCount': '{count} servers',
633
653
  'ds.theme.default': 'Deep Space', 'ds.theme.dark': 'Sunset', 'ds.theme.light': 'Elbphilharmonie', 'ds.theme.neutral': 'Prairie Tower', 'ds.theme.mono': 'Monochrome',
634
654
  'ds.role.me': 'Me', 'ds.role.dsh': 'DSH', 'ds.toolDefault': 'tool',
635
655
  'ds.eventSystemReminder': 'System reminder',