dsh-remote-panel 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,6 +19,8 @@ window.__ModuleLoader__.load({
19
19
  const API = '/remote-panel/api'
20
20
  const TOKEN_GLOBAL = '__DSH_REMOTE_PANEL__'
21
21
  const TOKEN_HEADER = 'x-dsh-remote-panel'
22
+ // HMR 时新模块实例接手旧实例的内容增强(同一页面只允许一个 Observer)
23
+ const CONTENT_CLEANUP_KEY = '__DSH_REMOTE_PANEL_CONTENT_ENHANCEMENTS__'
22
24
  const stroke = 'var(--dsw-elevation-stroke-color, rgba(128,128,128,.24))'
23
25
 
24
26
  const styles = {
@@ -35,6 +37,7 @@ window.__ModuleLoader__.load({
35
37
  title: { fontSize: 14, fontWeight: 600, margin: 0 },
36
38
  muted: { opacity: 0.62, margin: 0 },
37
39
  hint: { fontSize: 11, opacity: 0.5 },
40
+ link: { color: '#4d7dfe', textDecoration: 'underline', cursor: 'pointer' },
38
41
  row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
39
42
  grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 10 },
40
43
  field: { display: 'flex', flexDirection: 'column', gap: 4 },
@@ -280,7 +283,7 @@ window.__ModuleLoader__.load({
280
283
  jsx(Dot, { ok: check.state === 'ok' }),
281
284
  jsx('span', {
282
285
  style: { ...styles.hint, minWidth: 78, opacity: 0.75 },
283
- children: check.id,
286
+ children: check.label ?? check.id,
284
287
  }),
285
288
  jsx('span', { style: styles.hint, children: check.detail }),
286
289
  ],
@@ -409,6 +412,20 @@ window.__ModuleLoader__.load({
409
412
  ),
410
413
  ),
411
414
  }),
415
+ jsxs('label', {
416
+ style: { ...styles.row, marginTop: 8, fontSize: 12 },
417
+ children: [
418
+ jsx('input', {
419
+ type: 'checkbox',
420
+ checked: form.tls === true,
421
+ onChange: (event) => setForm((current) => ({ ...current, tls: event.target.checked })),
422
+ }),
423
+ jsx('span', {
424
+ style: styles.muted,
425
+ children: '启用 HTTPS(Caddy 内部 CA;手机需先装根证书,见「远程访问 → 手机全屏」里的下载链接)',
426
+ }),
427
+ ],
428
+ }),
412
429
  jsxs('div', {
413
430
  style: styles.row,
414
431
  children: [
@@ -524,7 +541,7 @@ window.__ModuleLoader__.load({
524
541
 
525
542
  // ---------------------------------------------------------------- 第二幕
526
543
 
527
- function StatusCard({ state, version, onRedeploy }) {
544
+ function StatusCard({ state, version, scheme, onRedeploy }) {
528
545
  const [probe, setProbe] = useState(null)
529
546
  const [rejects, setRejects] = useState([])
530
547
  const [busy, setBusy] = useState(false)
@@ -545,11 +562,16 @@ window.__ModuleLoader__.load({
545
562
  refresh()
546
563
  }, [refresh])
547
564
 
565
+ const cert = probe?.cert
566
+ const certHours = typeof cert?.hoursLeft === 'number' ? Math.round(cert.hoursLeft * 10) / 10 : null
548
567
  const lights = [
549
568
  ['EasyTier', probe?.easytier],
550
569
  ['Caddy', probe?.caddy],
551
570
  ['healthz', probe?.healthz],
552
571
  ['上游 DSH', probe?.upstream],
572
+ // 内部 CA 的叶子证书只有 12 小时,续签失败时探活照样握手成功(rejectUnauthorized:false),
573
+ // 所以单独看它:剩余不足 2 小时或 SAN 不匹配都算红灯
574
+ [certHours === null ? '证书' : `证书 ${certHours}h`, cert === undefined ? undefined : { ok: cert.ok === true }],
553
575
  ]
554
576
 
555
577
  return jsxs(Card, {
@@ -559,7 +581,7 @@ window.__ModuleLoader__.load({
559
581
  children: [
560
582
  jsx(Dot, { ok: probe?.ok }),
561
583
  jsx('strong', { style: styles.title, children: '运行状态' }),
562
- jsx('span', { style: styles.muted, children: `http://${state.ip}:${state.port}/` }),
584
+ jsx('span', { style: styles.muted, children: `${scheme ?? (window.location?.protocol ?? 'http:').replace(':', '')}://${state.ip}:${state.port}/` }),
563
585
  jsx('button', {
564
586
  type: 'button',
565
587
  style: styles.button,
@@ -569,6 +591,12 @@ window.__ModuleLoader__.load({
569
591
  }),
570
592
  ],
571
593
  }),
594
+ cert !== undefined && cert.ok !== true
595
+ ? jsx('p', {
596
+ style: { ...styles.hint, color: '#f2b8b5' },
597
+ children: `证书告警:${cert.reason ?? '不可用'}${certHours === null ? '' : `(剩余 ${certHours} 小时)`}`,
598
+ })
599
+ : null,
572
600
  jsx('div', {
573
601
  style: styles.row,
574
602
  children: lights.map(([label, item]) =>
@@ -651,12 +679,19 @@ window.__ModuleLoader__.load({
651
679
 
652
680
  function PairCard() {
653
681
  const [state, setState] = useState({ kind: 'loading' })
682
+ const [round, setRound] = useState(0)
683
+
684
+ // DSH 每次重启都会换令牌;面板开着时自动刷新,避免扫到过期的码
685
+ useEffect(() => {
686
+ const timer = setInterval(() => setRound((value) => value + 1), 60000)
687
+ return () => clearInterval(timer)
688
+ }, [])
654
689
 
655
690
  useEffect(() => {
656
691
  callApi('/pair')
657
692
  .then((data) => setState({ kind: 'ok', data }))
658
693
  .catch((error) => setState({ kind: 'error', message: error.message }))
659
- }, [])
694
+ }, [round])
660
695
 
661
696
  const data = state.kind === 'ok' ? state.data : null
662
697
  return jsxs(Card, {
@@ -667,6 +702,7 @@ window.__ModuleLoader__.load({
667
702
  jsx(Dot, { ok: data?.found === true }),
668
703
  jsx('strong', { style: styles.title, children: '手机配对' }),
669
704
  jsx('span', { style: styles.muted, children: state.kind === 'loading' ? '读取中…' : data?.found ? '扫码即可' : '未找到令牌' }),
705
+ jsx('button', { type: 'button', style: styles.button, onClick: () => setRound((value) => value + 1), children: '刷新' }),
670
706
  ],
671
707
  }),
672
708
  state.kind === 'error' && jsx('p', { style: styles.muted, children: state.message }),
@@ -685,20 +721,40 @@ window.__ModuleLoader__.load({
685
721
  jsxs('div', {
686
722
  style: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1, minWidth: 210 },
687
723
  children: [
688
- jsx('span', { style: styles.hint, children: '① 门禁(一年)' }),
724
+ jsx('span', { style: styles.hint, children: '扫这一张即可:先种门禁(一年),再自动跳到令牌' }),
725
+ // 二维码编的就是这条组合链接,显示同一个值,别再换成别的
689
726
  jsx('code', {
690
- style: { ...styles.code, maxHeight: 56 },
691
- children: data.urls.gate ?? '(回填码未含口令)',
727
+ style: { ...styles.code, maxHeight: 72 },
728
+ children: data.urls.combo ?? data.urls.token,
692
729
  }),
693
- jsx('span', { style: styles.hint, children: '② 令牌(30 天 Cookie)' }),
694
- jsx('code', { style: { ...styles.code, maxHeight: 56 }, children: data.urls.token }),
695
730
  jsxs('div', {
696
731
  style: styles.row,
697
732
  children: [
698
- jsx(CopyButton, { text: data.urls.token, label: '复制令牌链接' }),
699
- data.urls.gate
700
- ? jsx(CopyButton, { text: data.urls.gate, label: '复制门禁链接' })
701
- : null,
733
+ jsx(CopyButton, { text: data.urls.combo ?? data.urls.token, label: '复制配对链接' }),
734
+ ],
735
+ }),
736
+ jsx('details', {
737
+ children: [
738
+ jsx('summary', { style: styles.hint, children: '手动备用(门禁 / 令牌分开用)' }),
739
+ jsxs('div', {
740
+ style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 },
741
+ children: [
742
+ jsx('code', {
743
+ style: { ...styles.code, maxHeight: 56 },
744
+ children: data.urls.gate ?? '(回填码未含口令)',
745
+ }),
746
+ jsx('code', { style: { ...styles.code, maxHeight: 56 }, children: data.urls.token }),
747
+ jsxs('div', {
748
+ style: styles.row,
749
+ children: [
750
+ jsx(CopyButton, { text: data.urls.token, label: '复制令牌链接' }),
751
+ data.urls.gate
752
+ ? jsx(CopyButton, { text: data.urls.gate, label: '复制门禁链接' })
753
+ : null,
754
+ ],
755
+ }),
756
+ ],
757
+ }),
702
758
  ],
703
759
  }),
704
760
  ],
@@ -837,16 +893,108 @@ window.__ModuleLoader__.load({
837
893
 
838
894
  // ---------------------------------------------------------------- 总装
839
895
 
896
+ /** 手机 App 化:全屏按钮 + 添加到主屏幕的指引(iOS 靠 A2HS,Android 靠 PWA/全屏 API)。 */
897
+ function AppCard() {
898
+ const [full, setFull] = useState(false)
899
+ useEffect(() => {
900
+ const sync = () => setFull(document.fullscreenElement != null)
901
+ document.addEventListener('fullscreenchange', sync)
902
+ sync()
903
+ return () => document.removeEventListener('fullscreenchange', sync)
904
+ }, [])
905
+ const standalone =
906
+ typeof window !== 'undefined' &&
907
+ (window.matchMedia?.('(display-mode: standalone)')?.matches === true || window.navigator?.standalone === true)
908
+ const secure = typeof window !== 'undefined' && window.location?.protocol === 'https:'
909
+ const [ping, setPing] = useState(() => {
910
+ try {
911
+ return localStorage.getItem('dsh-remote-panel:attention') !== 'off'
912
+ } catch {
913
+ return true
914
+ }
915
+ })
916
+ const togglePing = () => {
917
+ const next = !ping
918
+ setPing(next)
919
+ try {
920
+ localStorage.setItem('dsh-remote-panel:attention', next ? 'on' : 'off')
921
+ } catch {
922
+ /* 隐私模式忽略 */
923
+ }
924
+ }
925
+ const supported = typeof document !== 'undefined' && document.fullscreenEnabled === true
926
+ const enter = () => {
927
+ document.documentElement.requestFullscreen?.().catch((error) => {
928
+ console.warn('[remote-panel] fullscreen failed', error)
929
+ })
930
+ }
931
+ return jsxs(Card, {
932
+ children: [
933
+ jsx('div', { style: styles.head, children: jsx('p', { style: styles.title, children: '手机 App' }) }),
934
+ jsx('p', {
935
+ style: styles.muted,
936
+ children: standalone
937
+ ? '✓ 已经在独立窗口里运行,没有地址栏。'
938
+ : secure
939
+ ? '把它装到主屏幕:装完后从图标打开就是独立窗口,不再有浏览器 UI。'
940
+ : '当前是 http,装不成独立应用;先用下面的按钮进全屏(或按 README 开启 HTTPS 再装)。',
941
+ }),
942
+ standalone || !secure
943
+ ? null
944
+ : jsx('p', {
945
+ style: styles.hint,
946
+ children: 'Android:浏览器菜单 → 安装应用 / 添加到主屏幕;iPhone:分享 → 添加到主屏幕。',
947
+ }),
948
+ jsx('p', {
949
+ style: styles.hint,
950
+ children: [
951
+ '第一次装之前要先信任 Caddy 根证书:',
952
+ jsx('a', {
953
+ href: '/remote-panel/app/ca.crt',
954
+ download: 'dsh-caddy-root.crt',
955
+ style: styles.link,
956
+ children: ' 下载根证书 ',
957
+ }),
958
+ '(Android:设置 → 安全 → 加密与凭据 → 安装证书;iOS:下载后到「设置 → 通用 → 关于本机 → 证书信任设置」打开信任)。',
959
+ ],
960
+ }),
961
+ supported && !standalone && !full
962
+ ? jsx('div', {
963
+ style: styles.row,
964
+ children: jsx('button', {
965
+ type: 'button',
966
+ style: styles.primary,
967
+ onClick: enter,
968
+ children: '进入全屏',
969
+ }),
970
+ })
971
+ : null,
972
+ jsxs('label', {
973
+ style: { ...styles.row, marginTop: 10, fontSize: 12 },
974
+ children: [
975
+ jsx('input', { type: 'checkbox', checked: ping, onChange: togglePing }),
976
+ jsx('span', {
977
+ style: styles.muted,
978
+ children: '任务跑完 / 需要确认时震动并闪标题(页面在后台时)',
979
+ }),
980
+ ],
981
+ }),
982
+ ],
983
+ })
984
+ }
985
+
840
986
  function Panel() {
841
987
  const [stage, setStage] = useState('loading')
842
988
  const [state, setState] = useState(null)
843
989
  const [version, setVersion] = useState(null)
990
+ const [scheme, setScheme] = useState(null)
844
991
  const [showWizard, setShowWizard] = useState(false)
845
992
 
846
993
  const load = useCallback(async () => {
847
994
  try {
848
995
  const status = await callApi('/status')
849
996
  setVersion(status.version)
997
+ setScheme(status.scheme ?? null)
850
998
  const current = (await callApi('/state')).state
851
999
  setState(current)
852
1000
  setStage(current === null ? 'fresh' : 'running')
@@ -890,7 +1038,7 @@ window.__ModuleLoader__.load({
890
1038
  }),
891
1039
  state === null
892
1040
  ? null
893
- : jsx(StatusCard, { state, version, onRedeploy: () => setShowWizard(false) }),
1041
+ : jsx(StatusCard, { state, version, scheme, onRedeploy: () => setShowWizard(false) }),
894
1042
  state === null ? null : jsx(PairCard, {}),
895
1043
  ],
896
1044
  })
@@ -900,9 +1048,10 @@ window.__ModuleLoader__.load({
900
1048
  style: styles.wrap,
901
1049
  children: [
902
1050
  jsx(StaleBanner, {}),
903
- jsx(StatusCard, { state, version, onRedeploy: () => setShowWizard(true) }),
1051
+ jsx(StatusCard, { state, version, scheme, onRedeploy: () => setShowWizard(true) }),
904
1052
  jsx(PairCard, {}),
905
1053
  jsx(AccessCard, {}),
1054
+ jsx(AppCard, {}),
906
1055
  ],
907
1056
  })
908
1057
  }
@@ -910,6 +1059,7 @@ window.__ModuleLoader__.load({
910
1059
  // ---------------------------------------------------------------- 外壳
911
1060
 
912
1061
  const SHELL_CSS = `
1062
+ /* 手机外壳:结构(主题无关) */
913
1063
  @media (max-width: 1023px) {
914
1064
  [class*="_frame"] { grid-template-columns: minmax(0, 1fr) 0 0 !important; }
915
1065
  [class*="_sidebarCol"]:has([class*="_collapsed"]) { display: none !important; }
@@ -919,20 +1069,732 @@ window.__ModuleLoader__.load({
919
1069
  height: auto !important;
920
1070
  z-index: 900;
921
1071
  padding-bottom: env(safe-area-inset-bottom, 0px);
922
- box-shadow: 0 0 32px rgba(0,0,0,.32);
1072
+ border-radius: 0 16px 16px 0 !important;
1073
+ box-shadow: 0 0 28px rgba(0, 0, 0, .32) !important;
1074
+ }
1075
+ /* 独立窗口里别再有下拉刷新 / 橡皮筋 */
1076
+ body { overscroll-behavior: none; }
1077
+
1078
+ /* 顶栏:52px;刘海屏/独立窗口把状态栏让出来 */
1079
+ .wSkVaW_header {
1080
+ min-height: calc(52px + env(safe-area-inset-top, 0px)) !important;
1081
+ padding: calc(11px + env(safe-area-inset-top, 0px)) 60px 0 10px !important;
1082
+ }
1083
+ .wSkVaW_headerSessionless { padding-top: calc(11px + env(safe-area-inset-top, 0px)) !important; }
1084
+ .wSkVaW_headerCorner { display: none !important; }
1085
+ /* 手机的「用文件管理器打开」是桌面动作,收掉 */
1086
+ .OMoRSG_split { display: none !important; }
1087
+
1088
+ /* 会话内顶栏瘦身(只影响手机):
1089
+ 原生在标题右边依次摆「子智能体 / 后台任务」动作条和「更多操作(导出会话日志)」。
1090
+ 390 下这两组固定件只给标题留下约 104px。这里:
1091
+ 1) 「更多操作」整组收掉(桌面仍可导出);
1092
+ 2) 子智能体只留 spinner + 箭头,后台任务只留数字角标;
1093
+ 3) 标题允许吃满剩余宽度。
1094
+ ☰ 和右下角的 +(新建对话)都不动。 */
1095
+ .wSkVaW_headerUtilities { display: none !important; }
1096
+ .wSkVaW_titleCluster { gap: 4px !important; }
1097
+ .wSkVaW_headerActions { gap: 4px !important; }
1098
+ .wSkVaW_crumbCurrent { max-width: 100% !important; }
1099
+ .ZKlsPq_trigger { padding: 2px !important; gap: 3px !important; }
1100
+ /* 子智能体:只留 spinner + 展开箭头(文字收进 aria-label / 弹层) */
1101
+ .ZKlsPq_trigger > span:not([class]) { display: none !important; }
1102
+ /* 后台任务:数字收成角标(data-rp-jobs 由脚本从原文案里提取) */
1103
+ .QsffPG_trigger { gap: 2px !important; padding: 2px !important; }
1104
+ .QsffPG_count { display: none !important; }
1105
+ .QsffPG_trigger::after {
1106
+ content: attr(data-rp-jobs);
1107
+ display: inline-flex;
1108
+ align-items: center;
1109
+ justify-content: center;
1110
+ box-sizing: border-box;
1111
+ min-width: 16px;
1112
+ height: 16px;
1113
+ padding: 0 3px;
1114
+ border-radius: 999px;
1115
+ background: rgba(128, 128, 128, .18);
1116
+ font-size: 11px;
1117
+ font-weight: 600;
1118
+ line-height: 16px;
1119
+ font-variant-numeric: tabular-nums;
1120
+ }
1121
+
1122
+ /* 工作区 chip 右侧的 ×:回到原生「请选择工作区」空态(不发明新会话状态) */
1123
+ .dsh-mobile-clear-workspace {
1124
+ align-items: center;
1125
+ justify-content: center;
1126
+ flex: 0 0 auto;
1127
+ width: 22px;
1128
+ height: 22px;
1129
+ margin-left: 2px;
1130
+ padding: 0;
1131
+ border: 0;
1132
+ border-radius: 999px;
1133
+ background: transparent;
1134
+ color: rgba(255, 255, 255, .62);
1135
+ cursor: pointer;
1136
+ }
1137
+ .dsh-mobile-clear-workspace:hover { background: rgba(255, 255, 255, .12); color: #fff; }
1138
+ .dsh-mobile-hamburger { display: inline-flex !important; align-items: center; justify-content: center; height: 40px; padding: 0 !important; }
1139
+ .dsh-mobile-newchat,
1140
+ .dsh-mobile-clear-workspace { display: inline-flex !important; }
1141
+ /* 已经回到「请选择工作区」空态时没有可清空的对象 */
1142
+ .dsh-mobile-clear-workspace[data-none="true"] { display: none !important; }
1143
+ /* 抽屉/遮罩打开时顶栏按钮让位 */
1144
+ html.dsh-drawer-open .dsh-mobile-newchat { display: none !important; }
1145
+
1146
+ /* 空态:鱼标按原型放大并竖排到标题上方;标题 22/650,收掉「预览版」角标 */
1147
+ .pXSMma_headline { flex-direction: column !important; align-items: center !important; gap: 14px !important; }
1148
+ .pXSMma_fish, .pXSMma_fishHitbox { width: 112px !important; height: 82.4px !important; }
1149
+ .pXSMma_fish { color: #4d7dfe !important; }
1150
+ .pXSMma_titleGroup { justify-content: center !important; }
1151
+ .pXSMma_titleGroup,
1152
+ .pXSMma_titleGroup > * { font-size: 22px !important; font-weight: 650 !important; letter-spacing: -.02em !important; }
1153
+ .pXSMma_previewBadge { display: none !important; }
1154
+ /* 原生把「问候语 + 输入条」一起居中;手机按原型拆开:
1155
+ 问候语留在屏幕中间(原生居中),输入条钉到底部 */
1156
+ .wSkVaW_composerHero .uV2eYG_root {
1157
+ position: fixed;
1158
+ left: 0;
1159
+ right: 0;
1160
+ bottom: max(18px, env(safe-area-inset-bottom, 0px));
1161
+ z-index: 55;
1162
+ }
1163
+
1164
+ /* 工作区 chip:从空态 hero 行提到顶栏中间;只在空态存在,会话里不出现 */
1165
+ .wSkVaW_composerHero .wSkVaW_heroWorkspaceRow {
1166
+ position: fixed;
1167
+ left: 0;
1168
+ right: 0;
1169
+ z-index: 60;
1170
+ top: env(safe-area-inset-top, 0px);
1171
+ height: 52px;
1172
+ margin: 0;
1173
+ padding: 0 64px;
1174
+ align-items: center;
1175
+ justify-content: center;
1176
+ pointer-events: none;
1177
+ }
1178
+ .wSkVaW_heroWorkspaceRow > * { pointer-events: auto; }
1179
+ .wSkVaW_heroWorkspaceRow > :not([class*="_workspace"]):not(.dsh-mobile-clear-workspace) { display: none !important; }
1180
+ .pXSMma_workspace {
1181
+ height: 30px !important;
1182
+ padding: 0 10px 0 9px !important;
1183
+ border-radius: 999px !important;
1184
+ gap: 6px !important;
1185
+ }
1186
+ .pXSMma_workspaceLabel { font-size: 13px !important; font-weight: 600 !important; max-width: 160px; }
1187
+ .pXSMma_workspace svg { width: 14px; height: 14px; }
1188
+
1189
+ /* 输入条:一整块圆角卡片。按钮行保留原生全套(+ / 权限 / GenUI / 模型 / 发送) */
1190
+ .uV2eYG_card { border: 0 !important; border-radius: 26px !important; }
1191
+ .uV2eYG_scroll { padding: 10px 14px 0 !important; }
1192
+ .uV2eYG_input, .uV2eYG_placeholder { font-size: 15px !important; }
1193
+ .uV2eYG_row { padding: 6px 10px 10px !important; }
1194
+
1195
+ /* 抽屉:工作区列表置顶,面板入口退到列表下面,删掉品牌行与「新会话」行 */
1196
+ .hHd-Xa_root { display: flex; flex-direction: column; padding-top: env(safe-area-inset-top, 0px) !important; }
1197
+ .hHd-Xa_logoRow, .hHd-Xa_newSession { display: none !important; }
1198
+ .hHd-Xa_regionArea { order: 1; }
1199
+ /* 手机上的 DSH = 对话 + 改动 + 余额:只留「工作区 Git」,其余面板入口全收 */
1200
+ .hHd-Xa_panelList { order: 2; margin: 0 8px 4px !important; }
1201
+ .hHd-Xa_panelRow[aria-label="插件"],
1202
+ .hHd-Xa_panelRow[aria-label="自动化任务"],
1203
+ .hHd-Xa_panelRow[aria-label="命令面板"],
1204
+ .hHd-Xa_panelRow[aria-label="MCP连接器"],
1205
+ .hHd-Xa_panelRow[aria-label="更多"] { display: none !important; }
1206
+ .hHd-Xa_footArea { order: 3; border-top: 0 !important; }
1207
+ .bhn1Oq_sectionHeader { height: 32px !important; }
1208
+ .bhn1Oq_sectionLabel { font-size: 13px !important; }
1209
+ .YDXeBa_projectRow { height: 35px !important; border-radius: 12px !important; margin: 1px 10px !important; }
1210
+ .YDXeBa_sessionRow { height: 42px !important; border-radius: 12px !important; margin: 1px 10px !important; padding: 0 6px 0 10px !important; }
1211
+ .YDXeBa_sessionRow .YDXeBa_title { font-size: 13px !important; }
1212
+
1213
+ /* 搜索展开:抽屉整屏、盖掉面板入口与账号区(原型里搜索是一整页) */
1214
+ html.dsh-search-open [class*="_sidebarCol"]:not(:has([class*="_collapsed"])) {
1215
+ width: 100% !important;
1216
+ border-radius: 0 !important;
1217
+ box-shadow: none !important;
1218
+ }
1219
+ html.dsh-search-open .hHd-Xa_panelList,
1220
+ html.dsh-search-open .hHd-Xa_footArea { display: none !important; }
1221
+ html.dsh-search-open .bhn1Oq_searchExpanded { height: 40px !important; border-radius: 16px !important; }
1222
+ html.dsh-search-open .bhn1Oq_searchInput { height: 38px !important; }
1223
+
1224
+ /* 点遮罩关抽屉:原生窄屏没有这层,补一个(抽屉之下、正文之上) */
1225
+ html.dsh-drawer-open .dsh-mobile-scrim {
1226
+ display: block;
1227
+ position: fixed;
1228
+ inset: 0;
1229
+ z-index: 899;
1230
+ background: rgba(0, 0, 0, .35);
1231
+ }
1232
+ .dsh-mobile-scrim { display: none; }
1233
+
1234
+ /* 会话内容:代码块 / 表格 / 图片 / 附件在手机上不撑破页面 */
1235
+ .md-code-block { max-width: 100%; }
1236
+ .md-code-block pre,
1237
+ ._markdown_1ypvv_5 pre {
1238
+ box-sizing: border-box;
1239
+ max-width: 100%;
1240
+ overflow-x: auto !important;
1241
+ -webkit-overflow-scrolling: touch;
1242
+ /* 原生 11px 在手机上太小;长 token 不再从中间断词,改为块内横向滚动 */
1243
+ font-size: 13px !important;
1244
+ line-height: 20px !important;
1245
+ word-break: normal;
1246
+ }
1247
+ .md-code-block pre code,
1248
+ ._markdown_1ypvv_5 pre code,
1249
+ .md-code-block pre .line {
1250
+ font-size: 13px !important;
1251
+ line-height: 20px !important;
1252
+ }
1253
+ /* 原生 11px 的「复制」太小,手机上换成脚本注入的 28px 按钮 */
1254
+ .md-code-block [class*="_copyButton_"] { display: none !important; }
1255
+ .dsh-code-copy {
1256
+ box-sizing: border-box;
1257
+ min-width: 48px;
1258
+ min-height: 28px;
1259
+ padding: 2px 10px;
1260
+ border: 1px solid rgba(128, 128, 128, .28);
1261
+ border-radius: 8px;
1262
+ background: transparent;
1263
+ color: inherit;
1264
+ font: inherit;
1265
+ font-size: 12px;
1266
+ line-height: 20px;
1267
+ cursor: pointer;
1268
+ -webkit-tap-highlight-color: transparent;
1269
+ }
1270
+ .dsh-code-copy[data-done="true"] { opacity: .6; }
1271
+ .dsh-code-copy--float {
1272
+ position: absolute;
1273
+ top: 6px;
1274
+ right: 6px;
1275
+ z-index: 2;
1276
+ background: var(--dsw-alias-bg-base, rgba(0, 0, 0, .4));
1277
+ }
1278
+ ._tableScroll_1ypvv_192 {
1279
+ max-width: 100%;
1280
+ overflow-x: auto !important;
1281
+ -webkit-overflow-scrolling: touch;
1282
+ }
1283
+ ._markdown_1ypvv_5 img { max-width: 100% !important; height: auto !important; }
1284
+ /* 附件行:文件卡片 / 图片缩略图都不横向溢出 */
1285
+ .Sixlwa_attachmentRow {
1286
+ max-width: 100%;
1287
+ min-width: 0;
1288
+ overflow-x: auto;
1289
+ -webkit-overflow-scrolling: touch;
1290
+ }
1291
+ .Sixlwa_fileCard { flex: 0 1 240px; max-width: 100%; min-width: 0; }
1292
+ .R_Yw7q_gallery { max-width: 100%; min-width: 0; }
1293
+
1294
+ }
1295
+
1296
+ /* 手机外壳:深色配色(浅色主题保持原生配色,只换骨架) */
1297
+ @media (max-width: 1023px) {
1298
+ body[data-ds-dark-theme] { background: #000 !important; }
1299
+ body[data-ds-dark-theme] [class*="_frame"],
1300
+ body[data-ds-dark-theme] [class*="_centerCol"] { background: #000 !important; }
1301
+ body[data-ds-dark-theme] [class*="_sidebarCol"] {
1302
+ background: #000 !important;
1303
+ border: 0 !important;
1304
+ box-shadow: 0 0 32px rgba(0, 0, 0, .45);
1305
+ }
1306
+ body[data-ds-dark-theme] .hHd-Xa_root { background: #000 !important; }
1307
+ body[data-ds-dark-theme] .wSkVaW_header { border-bottom: 0 !important; background: transparent !important; }
1308
+ body[data-ds-dark-theme] .uV2eYG_card {
1309
+ background: #141416 !important;
1310
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06) !important;
1311
+ }
1312
+ body[data-ds-dark-theme] .pXSMma_workspace { background: rgba(255, 255, 255, .07) !important; }
1313
+ /* 列表底部的滚动渐隐原本渐到 #1b1b1c,在纯黑抽屉上会显出一条灰条 */
1314
+ body[data-ds-dark-theme] .bhn1Oq_fade {
1315
+ background-image: linear-gradient(rgba(0, 0, 0, 0), #000) !important;
923
1316
  }
924
- [class*="_centerCol"] { padding-bottom: 56px; box-sizing: border-box; }
925
- html.dsh-drawer-open .dsh-mobile-bar { display: none; }
926
- .dsh-mobile-hamburger { display: inline-flex !important; align-items: center; }
927
1317
  }
928
1318
 
929
1319
  @media (min-width: 1024px) {
930
- .dsh-mobile-bar { display: none !important; }
931
- .dsh-mobile-hamburger { display: none !important; }
1320
+ .dsh-mobile-hamburger, .dsh-mobile-newchat, .dsh-mobile-clear-workspace { display: none !important; }
932
1321
  }
1322
+ /* 断线胶囊:连续两次探活失败才出现,恢复即消失(任何宽度都显示) */
1323
+ .dsh-mobile-offline {
1324
+ position: fixed;
1325
+ top: calc(env(safe-area-inset-top, 0px) + 6px);
1326
+ left: 50%;
1327
+ transform: translateX(-50%);
1328
+ z-index: 80;
1329
+ display: flex;
1330
+ align-items: center;
1331
+ gap: 6px;
1332
+ padding: 6px 12px;
1333
+ border-radius: 999px;
1334
+ font-size: 12px;
1335
+ line-height: 1.2;
1336
+ color: #f2b8b5;
1337
+ background: rgba(40, 40, 44, .94);
1338
+ border: 1px solid rgba(242, 184, 181, .25);
1339
+ pointer-events: none;
1340
+ }
1341
+ .dsh-mobile-offline::before {
1342
+ content: '';
1343
+ width: 6px;
1344
+ height: 6px;
1345
+ border-radius: 999px;
1346
+ background: #f2b8b5;
1347
+ }
1348
+
1349
+
1350
+ /* 设置弹窗:原生是「左导航 188 + 右内容」,到 390 内容列只剩 154。
1351
+ 这里把导航改成顶部一条可横滑的标签条,内容占满宽度;只匹配「有导航列的 dialog」。 */
1352
+ [role="dialog"]:has(> [class*="_nav"]) {
1353
+ flex-direction: column !important;
1354
+ width: calc(100vw - 16px) !important;
1355
+ max-width: none !important;
1356
+ max-height: calc(100dvh - 24px);
1357
+ }
1358
+ [role="dialog"] > [class*="_nav"] {
1359
+ flex: 0 0 auto !important;
1360
+ width: 100% !important;
1361
+ height: auto !important;
1362
+ padding: 4px 8px 0 !important;
1363
+ border-right: 0 !important;
1364
+ border-bottom: 1px solid rgba(255, 255, 255, .08);
1365
+ }
1366
+ [role="dialog"] > [class*="_nav"] > [class*="_navTitle"] { display: none !important; }
1367
+ [role="dialog"] > [class*="_nav"] [class*="_navList"] {
1368
+ flex-direction: row !important;
1369
+ height: auto !important;
1370
+ overflow-x: auto;
1371
+ overflow-y: hidden;
1372
+ gap: 2px;
1373
+ padding-bottom: 4px;
1374
+ scrollbar-width: none;
1375
+ }
1376
+ [role="dialog"] > [class*="_nav"] [class*="_navList"]::-webkit-scrollbar { height: 0; }
1377
+ [role="dialog"] > [class*="_nav"] [class*="_navCell"] {
1378
+ flex: 0 0 auto !important;
1379
+ width: auto !important;
1380
+ height: 34px !important;
1381
+ padding: 0 10px !important;
1382
+ white-space: nowrap;
1383
+ }
1384
+ [role="dialog"] > [class*="_content"] {
1385
+ flex: 1 1 auto !important;
1386
+ width: 100% !important;
1387
+ min-width: 0 !important;
1388
+ overflow-y: auto;
1389
+ -webkit-overflow-scrolling: touch;
1390
+ overscroll-behavior: contain;
1391
+ padding-bottom: env(safe-area-inset-bottom, 0px) !important;
1392
+ }
1393
+
1394
+ /* 长会话滚动:离屏的 markdown 块不参与布局/绘制。
1395
+ DSH 不虚拟化消息,几百轮之后节点全在文档里;content-visibility 让浏览器跳过离屏块,
1396
+ 滚动时只画视口附近的内容。只在窄屏生效,桌面保持原样。 */
1397
+ @media (max-width: 1023px) {
1398
+ [class*="_markdown"] {
1399
+ content-visibility: auto;
1400
+ contain-intrinsic-size: auto 320px;
1401
+ }
1402
+ [class*="_scrollBody"] {
1403
+ overscroll-behavior: contain;
1404
+ -webkit-overflow-scrolling: touch;
1405
+ }
1406
+ }
1407
+
933
1408
  `
1409
+ /**
1410
+ * 工作区 chip 右侧的 ×。
1411
+ * 宿主没有「无工作区草稿」——所以这里只做一件原生已有的事:
1412
+ * 取消当前选中(uiWorkspace.clearMain),回到「请选择工作区」的空态。
1413
+ */
1414
+ function installWorkspaceClear(ctx) {
1415
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {}
1416
+ const tag = `rp-${Math.random().toString(36).slice(2, 8)}`
1417
+ // 没有选中会话(原生「请选择工作区」空态)时不该出现 ×
1418
+ const isEmpty = () => ctx.get('uiWorkspace')?.mainReference === undefined
1419
+ const ensure = () => {
1420
+ const row = document.querySelector('.wSkVaW_heroWorkspaceRow')
1421
+ if (!row) return
1422
+ const existing = row.querySelector('.dsh-mobile-clear-workspace')
1423
+ if (existing && existing.dataset.rp === tag) {
1424
+ existing.dataset.none = String(isEmpty())
1425
+ return
1426
+ }
1427
+ if (existing) existing.remove()
1428
+ const clear = document.createElement('button')
1429
+ clear.type = 'button'
1430
+ clear.className = 'dsh-mobile-clear-workspace'
1431
+ clear.title = '重新选择工作区'
1432
+ clear.setAttribute('aria-label', '重新选择工作区')
1433
+ clear.dataset.rp = tag
1434
+ clear.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
1435
+ clear.addEventListener('click', (event) => {
1436
+ event.preventDefault()
1437
+ event.stopPropagation()
1438
+ try {
1439
+ ctx.get('uiWorkspace')?.clearMain?.()
1440
+ } catch (error) {
1441
+ console.warn('[remote-panel] clear selection failed', error)
1442
+ }
1443
+ })
1444
+ clear.dataset.none = String(isEmpty())
1445
+ row.appendChild(clear)
1446
+ }
1447
+ const observer = new MutationObserver(ensure)
1448
+ observer.observe(document.body, { subtree: true, childList: true })
1449
+ ensure()
1450
+ return () => {
1451
+ observer.disconnect()
1452
+ document.querySelector('.dsh-mobile-clear-workspace')?.remove()
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * 手机返回键:抽屉打开时接管,先收起抽屉而不是退出应用。
1458
+ * 做法是打开时 push 一条带标记的历史,关闭时只回退自己那一条。
1459
+ */
1460
+ function installBackGesture(layout) {
1461
+ if (typeof window === 'undefined' || typeof MutationObserver === 'undefined') return () => {}
1462
+ const query = window.matchMedia?.('(max-width: 1023px)')
1463
+ const isOpen = () => document.documentElement.classList.contains('dsh-drawer-open')
1464
+ let pushed = false
1465
+ const onPop = () => {
1466
+ if (isOpen()) layout?.toggleSidebar?.()
1467
+ }
1468
+ const observer = new MutationObserver(() => {
1469
+ if (query && !query.matches) return
1470
+ const open = isOpen()
1471
+ if (open && !pushed) {
1472
+ pushed = true
1473
+ try {
1474
+ history.pushState({ dshDrawer: true }, '')
1475
+ } catch {
1476
+ /* 某些 WebView 禁 pushState,忽略 */
1477
+ }
1478
+ } else if (!open && pushed) {
1479
+ pushed = false
1480
+ if (history.state !== null && history.state.dshDrawer === true) history.back()
1481
+ }
1482
+ })
1483
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
1484
+ window.addEventListener('popstate', onPop)
1485
+ return () => {
1486
+ observer.disconnect()
1487
+ window.removeEventListener('popstate', onPop)
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * 本机反馈:任务跑完 / 等你确认时,如果页面在后台,闪一下标题 + 震动。
1493
+ * 不依赖推送服务(v1 不做推送),也不需要服务端配合。
1494
+ */
1495
+ function installAttention() {
1496
+ if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
1497
+ const KEY = 'dsh-remote-panel:attention'
1498
+ let timer = 0
1499
+ let flashTimer = 0
1500
+ let flashing = false
1501
+ let wasRunning = false
1502
+ let wasWaiting = false
1503
+
1504
+ const enabled = () => {
1505
+ try {
1506
+ return localStorage.getItem(KEY) !== 'off'
1507
+ } catch {
1508
+ return true
1509
+ }
1510
+ }
1511
+ const stopFlash = () => {
1512
+ if (flashTimer !== 0) {
1513
+ clearInterval(flashTimer)
1514
+ flashTimer = 0
1515
+ }
1516
+ if (flashing) {
1517
+ flashing = false
1518
+ document.title = baseTitle
1519
+ }
1520
+ }
1521
+ let baseTitle = document.title
1522
+ const flash = (label) => {
1523
+ if (typeof document.hidden === 'boolean' && !document.hidden && document.hasFocus?.()) return
1524
+ baseTitle = document.title
1525
+ if (flashing) return
1526
+ flashing = true
1527
+ let on = false
1528
+ flashTimer = setInterval(() => {
1529
+ on = !on
1530
+ document.title = on ? label : baseTitle
1531
+ }, 900)
1532
+ notifyVibrate()
1533
+ window.setTimeout(stopFlash, 20000)
1534
+ }
1535
+ const notifyVibrate = () => {
1536
+ try {
1537
+ navigator.vibrate?.([120, 60, 120])
1538
+ } catch {
1539
+ /* 不支持震动就算了 */
1540
+ }
1541
+ }
1542
+ const isRunning = () => {
1543
+ const button = document.querySelector('.uV2eYG_primary')
1544
+ const aria = button?.getAttribute('aria-label') ?? ''
1545
+ // 空闲时是「发送消息」,生成中会换成「停止…」
1546
+ return aria !== '' && !aria.includes('发送')
1547
+ }
1548
+ const isWaiting = () => {
1549
+ const dialog = document.querySelector('[role="dialog"], [class*="_dialog"], [class*="_modal"]')
1550
+ if (dialog === null) return false
1551
+ return /允许|批准|拒绝|确认执行/.test(dialog.innerText ?? '')
1552
+ }
1553
+ const tick = () => {
1554
+ if (!enabled()) return
1555
+ const running = isRunning()
1556
+ const waiting = isWaiting()
1557
+ if (wasRunning && !running) flash('✔ 已完成 · DSH')
1558
+ if (!wasWaiting && waiting) flash('● 待确认 · DSH')
1559
+ wasRunning = running
1560
+ wasWaiting = waiting
1561
+ }
1562
+ const onVisible = () => {
1563
+ if (!document.hidden) {
1564
+ stopFlash()
1565
+ tick()
1566
+ }
1567
+ }
1568
+ timer = window.setInterval(tick, 1500)
1569
+ document.addEventListener('visibilitychange', onVisible)
1570
+ window.addEventListener('focus', stopFlash)
1571
+ return () => {
1572
+ if (timer !== 0) clearInterval(timer)
1573
+ document.removeEventListener('visibilitychange', onVisible)
1574
+ window.removeEventListener('focus', stopFlash)
1575
+ stopFlash()
1576
+ }
1577
+ }
1578
+
1579
+ /**
1580
+ * 会话内容增强(只在 ≤1023px 生效):
1581
+ * - 代码块:注入 28px 的「复制」按钮;原生 11px 的复制按钮由 CSS 隐藏;
1582
+ * - 后台任务 chip:把「N 个后台任务」收成数字角标(data-rp-jobs + CSS ::after);
1583
+ * - markdown 图片:原生已有点击看原图时让给原生,其余兜底新标签打开原图。
1584
+ * 所有注入幂等(Observer 反复执行不叠加),返回 cleanup;HMR / 切换会话都安全。
1585
+ */
1586
+ function installContentEnhancements() {
1587
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {}
1588
+ if (document.body === null) return () => {}
1589
+ // HMR:上一个模块实例如果还活着,先让它自清理,避免 Observer / 按钮叠加
1590
+ try {
1591
+ globalThis[CONTENT_CLEANUP_KEY]?.()
1592
+ } catch {
1593
+ /* 旧实例清理失败不阻塞新实例 */
1594
+ }
1595
+ const query = window.matchMedia?.('(max-width: 1023px)')
1596
+ const tag = `rp-${Math.random().toString(36).slice(2, 8)}`
1597
+ const copyTimers = new Set()
1598
+ const zoomImages = new Map()
1599
+
1600
+ const isNarrow = () => (query === undefined ? window.innerWidth < 1024 : query.matches)
1601
+
1602
+ const writeClipboard = (text) => {
1603
+ // 失败静默:不弹窗、不 alert
1604
+ try {
1605
+ if (navigator.clipboard?.writeText) {
1606
+ return navigator.clipboard.writeText(text).then(
1607
+ () => true,
1608
+ () => false,
1609
+ )
1610
+ }
1611
+ } catch {
1612
+ /* 非安全上下文 / 旧 WebView:静默 */
1613
+ }
1614
+ return Promise.resolve(false)
1615
+ }
1616
+
1617
+ const makeCopyButton = (pre, block) => {
1618
+ const button = document.createElement('button')
1619
+ button.type = 'button'
1620
+ button.className = 'dsh-code-copy'
1621
+ button.dataset.rp = tag
1622
+ button.textContent = '复制'
1623
+ button.setAttribute('aria-label', '复制代码')
1624
+ button.addEventListener('click', (event) => {
1625
+ event.preventDefault()
1626
+ event.stopPropagation()
1627
+ // 流式输出时 React 可能换掉 pre:点击时重新解析当前节点
1628
+ const target = pre.isConnected ? pre : block?.querySelector('pre')
1629
+ if (!target) return
1630
+ // 用 textContent:折叠在「思考」里的代码块 innerText 会是空串
1631
+ const text = target.textContent ?? ''
1632
+ writeClipboard(text).then((ok) => {
1633
+ if (!ok) return
1634
+ button.dataset.done = 'true'
1635
+ button.textContent = '已复制'
1636
+ const timer = window.setTimeout(() => {
1637
+ copyTimers.delete(timer)
1638
+ button.dataset.done = 'false'
1639
+ button.textContent = '复制'
1640
+ }, 1200)
1641
+ copyTimers.add(timer)
1642
+ })
1643
+ })
1644
+ return button
1645
+ }
1646
+
1647
+ const enhanceCodeBlocks = () => {
1648
+ for (const block of document.querySelectorAll('.md-code-block')) {
1649
+ const action = block.querySelector('[data-code-block-banner] [class*="_action_"]')
1650
+ const pre = block.querySelector('pre')
1651
+ if (!action || !pre) continue
1652
+ if (action.querySelector('.dsh-code-copy') !== null) continue
1653
+ action.appendChild(makeCopyButton(pre, block))
1654
+ }
1655
+ // 没有 banner 的 pre(少见):浮在右上角,父节点临时 position: relative
1656
+ for (const pre of document.querySelectorAll('pre')) {
1657
+ if (pre.closest('.md-code-block') !== null) continue
1658
+ const parent = pre.parentElement
1659
+ if (!parent || parent.querySelector('.dsh-code-copy') !== null) continue
1660
+ if (getComputedStyle(parent).position === 'static') {
1661
+ parent.dataset.rpPosition = tag
1662
+ parent.dataset.rpPrevPosition = parent.style.position
1663
+ parent.style.position = 'relative'
1664
+ }
1665
+ const button = makeCopyButton(pre, null)
1666
+ button.classList.add('dsh-code-copy--float')
1667
+ parent.appendChild(button)
1668
+ }
1669
+ }
1670
+
1671
+ const enhanceJobsBadge = () => {
1672
+ for (const trigger of document.querySelectorAll('[class*="QsffPG_trigger"]')) {
1673
+ const label = trigger.getAttribute('aria-label') ?? trigger.textContent ?? ''
1674
+ const value = label.match(/\d+/)?.[0]
1675
+ if (value !== undefined && trigger.dataset.rpJobs !== value) trigger.dataset.rpJobs = value
1676
+ }
1677
+ }
1678
+
1679
+ const enhanceImages = () => {
1680
+ for (const img of document.querySelectorAll('._markdown_1ypvv_5 img')) {
1681
+ if (zoomImages.has(img)) continue
1682
+ // 原生 markdown 图片已经包在 button 里(点击打开内置大图查看),不重复接管
1683
+ if (img.closest('button, a') !== null) continue
1684
+ const handler = () => {
1685
+ const src = img.currentSrc || img.src
1686
+ if (!src || src.startsWith('data:')) return
1687
+ window.open(src, '_blank', 'noopener,noreferrer')
1688
+ }
1689
+ img.addEventListener('click', handler)
1690
+ img.dataset.rpZoom = tag
1691
+ zoomImages.set(img, handler)
1692
+ }
1693
+ }
1694
+
1695
+ const removeOwned = () => {
1696
+ for (const button of document.querySelectorAll(`.dsh-code-copy[data-rp="${tag}"]`)) button.remove()
1697
+ for (const timer of copyTimers) clearTimeout(timer)
1698
+ copyTimers.clear()
1699
+ for (const [img, handler] of zoomImages) {
1700
+ img.removeEventListener('click', handler)
1701
+ delete img.dataset.rpZoom
1702
+ }
1703
+ zoomImages.clear()
1704
+ for (const parent of document.querySelectorAll(`[data-rp-position="${tag}"]`)) {
1705
+ parent.style.position = parent.dataset.rpPrevPosition ?? ''
1706
+ delete parent.dataset.rpPosition
1707
+ delete parent.dataset.rpPrevPosition
1708
+ }
1709
+ for (const trigger of document.querySelectorAll('[data-rp-jobs]')) delete trigger.dataset.rpJobs
1710
+ }
1711
+
1712
+ let scheduled = 0
1713
+ const enhance = () => {
1714
+ if (!isNarrow()) {
1715
+ removeOwned()
1716
+ return
1717
+ }
1718
+ enhanceCodeBlocks()
1719
+ enhanceJobsBadge()
1720
+ enhanceImages()
1721
+ }
1722
+ const observer = new MutationObserver(() => {
1723
+ if (scheduled !== 0) return
1724
+ scheduled = requestAnimationFrame(() => {
1725
+ scheduled = 0
1726
+ enhance()
1727
+ })
1728
+ })
1729
+ observer.observe(document.body, { subtree: true, childList: true })
1730
+ const onQueryChange = () => enhance()
1731
+ query?.addEventListener?.('change', onQueryChange)
1732
+ enhance()
1733
+
1734
+ let disposed = false
1735
+ const cleanup = () => {
1736
+ if (disposed) return
1737
+ disposed = true
1738
+ if (scheduled !== 0) cancelAnimationFrame(scheduled)
1739
+ observer.disconnect()
1740
+ query?.removeEventListener?.('change', onQueryChange)
1741
+ removeOwned()
1742
+ if (globalThis[CONTENT_CLEANUP_KEY] === cleanup) delete globalThis[CONTENT_CLEANUP_KEY]
1743
+ }
1744
+ globalThis[CONTENT_CLEANUP_KEY] = cleanup
1745
+ return cleanup
1746
+ }
1747
+
1748
+ /**
1749
+ * 断线重连提示:DSH 前端没有「连接断开」的可见反馈,网络一抖页面只是不动了。
1750
+ * 每 10s 打一次面板自己的 /status,连续两次失败才显示,恢复后自动消失。
1751
+ */
1752
+ function installConnectivity() {
1753
+ if (typeof window === 'undefined' || typeof fetch !== 'function') return () => {}
1754
+ let failures = 0
1755
+ let timer = 0
1756
+ let banner = null
1757
+ const show = () => {
1758
+ if (banner !== null) return
1759
+ banner = document.createElement('div')
1760
+ banner.className = 'dsh-mobile-offline'
1761
+ banner.setAttribute('role', 'status')
1762
+ banner.textContent = '连接已断开,正在重连…'
1763
+ document.body.appendChild(banner)
1764
+ }
1765
+ const hide = () => {
1766
+ banner?.remove()
1767
+ banner = null
1768
+ }
1769
+ const probe = async () => {
1770
+ try {
1771
+ await callApi('/status')
1772
+ failures = 0
1773
+ hide()
1774
+ } catch {
1775
+ failures += 1
1776
+ if (failures >= 2) show()
1777
+ }
1778
+ }
1779
+ const onOnline = () => {
1780
+ failures = 0
1781
+ probe()
1782
+ }
1783
+ timer = window.setInterval(() => {
1784
+ if (!document.hidden) probe()
1785
+ }, 10000)
1786
+ window.addEventListener('online', onOnline)
1787
+ window.addEventListener('offline', show)
1788
+ probe()
1789
+ return () => {
1790
+ if (timer !== 0) clearInterval(timer)
1791
+ window.removeEventListener('online', onOnline)
1792
+ window.removeEventListener('offline', show)
1793
+ hide()
1794
+ }
1795
+ }
934
1796
 
935
- function installShellCss() {
1797
+ function installShellCss(layout) {
936
1798
  if (typeof document === 'undefined') return () => {}
937
1799
  const style = document.createElement('style')
938
1800
  style.id = 'dsh-remote-panel-shell'
@@ -946,6 +1808,11 @@ window.__ModuleLoader__.load({
946
1808
  const column = document.querySelector('[class*="_sidebarCol"]')
947
1809
  const open = column !== null && column.querySelector('[class*="_collapsed"]') === null
948
1810
  document.documentElement.classList.toggle('dsh-drawer-open', open)
1811
+ // 搜索展开时抽屉整屏(原生只是把输入框在 280px 抽屉里拉长)
1812
+ document.documentElement.classList.toggle(
1813
+ 'dsh-search-open',
1814
+ open && document.querySelector('[class*="_searchExpanded"]') !== null,
1815
+ )
949
1816
  })
950
1817
  }
951
1818
  const observer = new MutationObserver(sync)
@@ -958,71 +1825,116 @@ window.__ModuleLoader__.load({
958
1825
  }
959
1826
  }, 4000)
960
1827
 
1828
+ // 窄屏:点遮罩关抽屉;选中会话 / 面板后也自动收起(桌面保持原样,不参与)
1829
+ const narrow = window.matchMedia('(max-width: 1023px)')
1830
+ const scrim = document.createElement('div')
1831
+ scrim.className = 'dsh-mobile-scrim'
1832
+ scrim.addEventListener('click', () => {
1833
+ if (!narrow.matches) return
1834
+ try {
1835
+ layout?.toggleSidebar?.()
1836
+ } catch (error) {
1837
+ console.warn('[remote-panel] close drawer failed', error)
1838
+ }
1839
+ })
1840
+ document.body.appendChild(scrim)
1841
+
1842
+ const onDocumentClick = (event) => {
1843
+ if (!narrow.matches) return
1844
+ if (!document.documentElement.classList.contains('dsh-drawer-open')) return
1845
+ const target = event.target
1846
+ if (!(target instanceof Element)) return
1847
+ if (!target.closest('[class*="_sidebarCol"]')) return
1848
+ if (!target.closest('[class*="_sessionRow"], [class*="_panelRow"]')) return
1849
+ setTimeout(() => {
1850
+ try {
1851
+ layout?.toggleSidebar?.()
1852
+ } catch (error) {
1853
+ console.warn('[remote-panel] close drawer failed', error)
1854
+ }
1855
+ }, 0)
1856
+ }
1857
+ document.addEventListener('click', onDocumentClick, true)
1858
+
1859
+ // 抽屉开着时 ESC 收起(宿主窄屏没有这个键位)
1860
+ const onKeyDown = (event) => {
1861
+ if (event.key !== 'Escape' || !narrow.matches) return
1862
+ if (!document.documentElement.classList.contains('dsh-drawer-open')) return
1863
+ event.preventDefault()
1864
+ try {
1865
+ layout?.toggleSidebar?.()
1866
+ } catch (error) {
1867
+ console.warn('[remote-panel] close drawer failed', error)
1868
+ }
1869
+ }
1870
+ document.addEventListener('keydown', onKeyDown)
1871
+
961
1872
  return () => {
962
1873
  clearTimeout(canary)
963
1874
  cancelAnimationFrame(frame)
964
1875
  observer.disconnect()
1876
+ document.removeEventListener('click', onDocumentClick, true)
1877
+ document.removeEventListener('keydown', onKeyDown)
1878
+ scrim.remove()
965
1879
  style.remove()
966
- document.documentElement.classList.remove('dsh-drawer-open')
1880
+ document.documentElement.classList.remove('dsh-drawer-open', 'dsh-search-open')
967
1881
  }
968
1882
  }
969
1883
 
970
- function MobileBar(props) {
971
- const layout = props?.layout
972
- const bar = {
973
- position: 'fixed',
974
- left: 0,
975
- right: 0,
976
- bottom: 0,
977
- height: 56,
978
- display: 'flex',
979
- alignItems: 'stretch',
980
- background: 'color-mix(in srgb, Canvas 92%, transparent)',
981
- borderTop: `1px solid ${stroke}`,
982
- paddingBottom: 'env(safe-area-inset-bottom, 0px)',
983
- zIndex: 2147483000,
984
- pointerEvents: 'auto',
985
- backdropFilter: 'blur(12px)',
986
- }
987
- const item = {
988
- flex: 1,
989
- font: 'inherit',
990
- fontSize: 12,
991
- border: 0,
992
- background: 'transparent',
993
- color: 'CanvasText',
994
- cursor: 'pointer',
995
- }
996
- const act = (fn) => () => {
1884
+ function NewChatButton(props) {
1885
+ // 宿主没有顶栏「新建」;这里复用 uiWorkspace 的落点规则:
1886
+ // 当前会话所在工作区 → 最近工作区;都没有就回到空态(由工作区 chip 接手)。
1887
+ const run = () => {
997
1888
  try {
998
- fn?.()
1889
+ const ui = props?.uiWorkspace
1890
+ if (ui && typeof ui.startSession === 'function') {
1891
+ ui.startSession()
1892
+ return
1893
+ }
1894
+ props?.layout?.selectPanel?.('chat')
999
1895
  } catch (error) {
1000
- console.warn('[remote-panel] shell action failed', error)
1896
+ console.warn('[remote-panel] new chat failed', error)
1001
1897
  }
1002
1898
  }
1003
- return jsxs('div', {
1004
- className: 'dsh-mobile-bar',
1005
- style: bar,
1006
- children: [
1007
- jsx('button', {
1008
- type: 'button',
1009
- style: item,
1010
- onClick: act(() => layout?.toggleSidebar?.()),
1011
- children: '会话',
1012
- }),
1013
- jsx('button', {
1014
- type: 'button',
1015
- style: item,
1016
- onClick: act(() => layout?.selectPanel?.('chat')),
1017
- children: '对话',
1018
- }),
1019
- jsx('button', {
1020
- type: 'button',
1021
- style: item,
1022
- onClick: act(() => layout?.selectPanel?.('plugins')),
1023
- children: '插件',
1024
- }),
1025
- ],
1899
+ return jsx('button', {
1900
+ type: 'button',
1901
+ 'aria-label': '新建对话',
1902
+ title: '新建对话',
1903
+ className: 'dsh-mobile-newchat',
1904
+ onClick: run,
1905
+ style: {
1906
+ display: 'none',
1907
+ position: 'fixed',
1908
+ top: 6,
1909
+ right: 10,
1910
+ zIndex: 70,
1911
+ alignItems: 'center',
1912
+ justifyContent: 'center',
1913
+ width: 40,
1914
+ height: 40,
1915
+ padding: 0,
1916
+ border: 0,
1917
+ borderRadius: 12,
1918
+ background: 'transparent',
1919
+ color: 'inherit',
1920
+ cursor: 'pointer',
1921
+ font: 'inherit',
1922
+ },
1923
+ children: jsx('svg', {
1924
+ width: 18,
1925
+ height: 18,
1926
+ viewBox: '0 0 16 16',
1927
+ fill: 'none',
1928
+ 'aria-hidden': 'true',
1929
+ children: [
1930
+ jsx('path', {
1931
+ d: 'M2.37091 11.2501C1.58745 9.89288 1.32067 8.29835 1.61969 6.76006C1.91872 5.22177 2.76342 3.8433 3.99826 2.87846C5.2331 1.91362 6.77494 1.42737 8.33988 1.50925C9.90482 1.59113 11.3875 2.23562 12.5149 3.32406C13.6425 4.41269 14.3387 5.87206 14.4754 7.4334C14.612 8.99474 14.18 10.5529 13.2587 11.8209C12.3375 13.0888 10.9891 13.9813 9.46194 14.3337C8.18691 14.628 6.85895 14.5294 5.64989 14.0605C5.1712 13.8748 4.76962 13.4932 4.26534 13.3967C3.67413 13.2835 2.95257 13.5598 2.03794 14.3337',
1932
+ stroke: 'currentColor',
1933
+ }),
1934
+ jsx('path', { d: 'M8 5V11', stroke: 'currentColor' }),
1935
+ jsx('path', { d: 'M5 8H11', stroke: 'currentColor' }),
1936
+ ],
1937
+ }),
1026
1938
  })
1027
1939
  }
1028
1940
 
@@ -1047,25 +1959,48 @@ window.__ModuleLoader__.load({
1047
1959
  background: 'transparent',
1048
1960
  color: 'inherit',
1049
1961
  cursor: 'pointer',
1962
+ width: 40,
1963
+ height: 40,
1050
1964
  padding: '0 8px',
1051
1965
  },
1052
- children: '☰',
1966
+ children: jsx('svg', {
1967
+ width: 18,
1968
+ height: 12,
1969
+ viewBox: '0 0 18 12',
1970
+ fill: 'none',
1971
+ 'aria-hidden': 'true',
1972
+ children: [
1973
+ jsx('path', { d: 'M1 1h16', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' }),
1974
+ jsx('path', { d: 'M1 9h11', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' }),
1975
+ ],
1976
+ }),
1053
1977
  })
1054
1978
  }
1055
1979
 
1056
1980
  function apply(ctx) {
1057
- const cleanup = installShellCss()
1981
+ const cleanup = installShellCss(ctx.layout)
1982
+ const cleanupClear = installWorkspaceClear(ctx)
1983
+ const cleanupBack = installBackGesture(ctx.layout)
1984
+ const cleanupAttention = installAttention()
1985
+ const cleanupContent = installContentEnhancements()
1986
+ const cleanupNet = installConnectivity()
1987
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupClear, 'remote-panel: workspace clear')
1988
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupBack, 'remote-panel: back gesture')
1989
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupAttention, 'remote-panel: attention')
1990
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupContent, 'remote-panel: content enhancements')
1991
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanupNet, 'remote-panel: connectivity')
1058
1992
  if (typeof ctx.effect === 'function') ctx.effect(() => cleanup, 'remote-panel: mobile shell css')
1059
1993
 
1060
1994
  ctx.slots.inject('shell.overlay', () =>
1061
1995
  ctx.slots.register(
1062
1996
  {
1063
1997
  name: 'shell.overlay',
1064
- id: 'remote-panel.mobile-bar',
1998
+ id: 'remote-panel.new-chat',
1065
1999
  order: 100,
1066
- inject: () => ({ layout: ctx.layout }),
2000
+ // uiWorkspace 只在 web 端由 dsh-client-ui-workspace 提供;拿不到时退回切面板
2001
+ inject: () => ({ layout: ctx.layout, uiWorkspace: ctx.get('uiWorkspace') }),
1067
2002
  },
1068
- MobileBar,
2003
+ NewChatButton,
1069
2004
  ),
1070
2005
  )
1071
2006
 
@@ -1085,7 +2020,7 @@ window.__ModuleLoader__.load({
1085
2020
  return {
1086
2021
  inject: ['slots', 'layout'],
1087
2022
  apply,
1088
- components: { Panel, DeployWizard, StatusCard, PairCard, AccessCard, StaleBanner },
2023
+ components: { Panel, DeployWizard, StatusCard, PairCard, AccessCard, StaleBanner, AppCard },
1089
2024
  }
1090
2025
  },
1091
2026
  })