dsh-multi-session 0.3.4 → 0.5.3

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/client.js CHANGED
@@ -57,7 +57,7 @@ window.__ModuleLoader__.load({
57
57
  })()
58
58
 
59
59
  /** 诊断代号:每次改动递增,用来区分浏览器里跑的是哪一代 bundle。 */
60
- const BUILD = 'panel-v51'
60
+ const BUILD = 'panel-v56'
61
61
 
62
62
  /** apply() 的 ctx,用于点击跳转。 */
63
63
  let rootCtx
@@ -475,6 +475,8 @@ let promptProbed = false
475
475
 
476
476
  const RUNNING_COLOR = '#22a06b'
477
477
  const FINISHED_COLOR = '#3b6cf6'
478
+ /** 「在等你」用琥珀色:和运行中的绿、待查看的蓝必须一眼分开。 */
479
+ const WAITING_COLOR = '#d97706'
478
480
 
479
481
  const S = {
480
482
  root: {
@@ -546,7 +548,7 @@ let promptProbed = false
546
548
  borderRadius: 8,
547
549
  border: '1px solid var(--dsw-alias-border-l1)',
548
550
  background: 'var(--dsw-alias-bg-layer-1)',
549
- cursor: 'pointer',
551
+ // 整卡不再是可点区域(点它会误触跳转),所以也不再给手型光标。
550
552
  transition: 'background 120ms ease, border-color 120ms ease',
551
553
  },
552
554
  cardRunning: { borderColor: 'rgba(34,160,107,0.45)' },
@@ -563,6 +565,49 @@ let promptProbed = false
563
565
  },
564
566
  cardBody: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0, flex: '1 1 auto' },
565
567
  cardTitleRow: { display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 },
568
+ /* 「改了什么文件」:比读对话更快判断它干完没有。 */
569
+ cardChanges: {
570
+ display: 'flex',
571
+ alignItems: 'center',
572
+ flexWrap: 'wrap',
573
+ gap: 6,
574
+ marginTop: 3,
575
+ fontSize: 11,
576
+ lineHeight: 1.4,
577
+ color: 'var(--dsw-alias-label-secondary)',
578
+ },
579
+ cardChangesCount: { color: RUNNING_COLOR, fontWeight: 600, flex: '0 0 auto' },
580
+ cardChangesDiff: {
581
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
582
+ fontSize: 10.5,
583
+ flex: '0 0 auto',
584
+ },
585
+ cardChangesNames: {
586
+ opacity: 0.85,
587
+ overflow: 'hidden',
588
+ textOverflow: 'ellipsis',
589
+ whiteSpace: 'nowrap',
590
+ minWidth: 0,
591
+ flex: '1 1 auto',
592
+ },
593
+ /* 卡片底部两个明确动作:并排看 / 打开。整卡不再是可点区域。 */
594
+ cardActions: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' },
595
+ cardOpenBtn: {
596
+ flex: '0 0 auto',
597
+ display: 'inline-flex',
598
+ alignItems: 'center',
599
+ border: '1px solid var(--dsw-alias-border-l1)',
600
+ background: 'transparent',
601
+ color: 'var(--dsw-alias-label-secondary)',
602
+ fontSize: 11,
603
+ lineHeight: 1.5,
604
+ fontWeight: 500,
605
+ cursor: 'pointer',
606
+ padding: '3px 9px',
607
+ borderRadius: 999,
608
+ whiteSpace: 'nowrap',
609
+ transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
610
+ },
566
611
  cardTitle: {
567
612
  fontWeight: 500,
568
613
  overflow: 'hidden',
@@ -579,6 +624,14 @@ let promptProbed = false
579
624
  border: '1px solid currentColor',
580
625
  opacity: 0.9,
581
626
  },
627
+ /* 「在等你」用琥珀色,和运行中的绿色、待查看的蓝色都区分开。 */
628
+ badgeWaiting: {
629
+ color: WAITING_COLOR,
630
+ background: 'rgba(217,119,6,0.14)',
631
+ border: '1px solid rgba(217,119,6,0.42)',
632
+ fontWeight: 600,
633
+ opacity: 1,
634
+ },
582
635
  metaRow: {
583
636
  display: 'flex',
584
637
  flexWrap: 'wrap',
@@ -618,6 +671,132 @@ let promptProbed = false
618
671
  borderRight: 'none',
619
672
  paddingRight: 0,
620
673
  },
674
+ /* 搜索框:跨会话全文搜索(Host 走 sessionController.search,不激活会话)。 */
675
+ searchBar: {
676
+ display: 'flex',
677
+ alignItems: 'center',
678
+ gap: 6,
679
+ padding: '0 12px 8px',
680
+ },
681
+ searchInput: {
682
+ flex: '1 1 auto',
683
+ minWidth: 0,
684
+ fontSize: 12,
685
+ padding: '5px 9px',
686
+ borderRadius: 6,
687
+ border: '1px solid var(--dsw-alias-border-l1)',
688
+ background: 'var(--dsw-alias-bg-layer-1)',
689
+ color: 'var(--dsw-alias-label-primary)',
690
+ outline: 'none',
691
+ },
692
+ searchBtn: {
693
+ flex: '0 0 auto',
694
+ fontSize: 11.5,
695
+ padding: '5px 11px',
696
+ borderRadius: 6,
697
+ border: '1px solid var(--dsw-alias-border-l1)',
698
+ background: 'var(--dsw-alias-bg-layer-2)',
699
+ color: 'var(--dsw-alias-label-secondary)',
700
+ cursor: 'pointer',
701
+ },
702
+ searchResults: { display: 'flex', flexDirection: 'column', gap: 10 },
703
+ searchSummary: {
704
+ fontSize: 11.5,
705
+ color: 'var(--dsw-alias-label-secondary)',
706
+ padding: '2px 0',
707
+ },
708
+ searchHitGroup: {
709
+ display: 'flex',
710
+ flexDirection: 'column',
711
+ gap: 4,
712
+ padding: '7px 9px',
713
+ borderRadius: 8,
714
+ border: '1px solid var(--dsw-alias-border-l1)',
715
+ background: 'var(--dsw-alias-bg-layer-1)',
716
+ },
717
+ searchHitTitle: {
718
+ fontSize: 12,
719
+ fontWeight: 600,
720
+ color: 'var(--dsw-alias-brand-primary)',
721
+ cursor: 'pointer',
722
+ overflow: 'hidden',
723
+ textOverflow: 'ellipsis',
724
+ whiteSpace: 'nowrap',
725
+ },
726
+ searchSnippet: {
727
+ fontSize: 11.5,
728
+ lineHeight: 1.5,
729
+ color: 'var(--dsw-alias-label-secondary)',
730
+ whiteSpace: 'pre-wrap',
731
+ wordBreak: 'break-word',
732
+ paddingLeft: 8,
733
+ borderLeft: '2px solid var(--dsw-alias-border-l1)',
734
+ },
735
+ /* 会话间传话的目标选择条。 */
736
+ forwardBar: {
737
+ display: 'flex',
738
+ alignItems: 'center',
739
+ flexWrap: 'wrap',
740
+ gap: 7,
741
+ margin: '0 12px 8px',
742
+ padding: '7px 10px',
743
+ borderRadius: 8,
744
+ border: '1px solid rgba(59,108,246,0.4)',
745
+ background: 'rgba(59,108,246,0.08)',
746
+ },
747
+ forwardLabel: {
748
+ fontSize: 11.5,
749
+ color: 'var(--dsw-alias-brand-primary)',
750
+ fontWeight: 600,
751
+ flex: '0 0 auto',
752
+ },
753
+ forwardSelect: {
754
+ flex: '1 1 200px',
755
+ minWidth: 0,
756
+ fontSize: 11.5,
757
+ padding: '4px 8px',
758
+ borderRadius: 6,
759
+ border: '1px solid var(--dsw-alias-border-l1)',
760
+ background: 'var(--dsw-alias-bg-layer-1)',
761
+ color: 'var(--dsw-alias-label-primary)',
762
+ },
763
+ forwardBtnOk: {
764
+ flex: '0 0 auto',
765
+ fontSize: 11.5,
766
+ padding: '4px 12px',
767
+ borderRadius: 6,
768
+ border: '1px solid var(--dsw-alias-brand-primary)',
769
+ background: 'var(--dsw-alias-brand-primary)',
770
+ color: '#fff',
771
+ cursor: 'pointer',
772
+ },
773
+ forwardCancel: {
774
+ flex: '0 0 auto',
775
+ fontSize: 11.5,
776
+ padding: '4px 10px',
777
+ borderRadius: 6,
778
+ border: '1px solid var(--dsw-alias-border-l1)',
779
+ background: 'transparent',
780
+ color: 'var(--dsw-alias-label-secondary)',
781
+ cursor: 'pointer',
782
+ },
783
+ forwardNote: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', flex: '0 0 auto' },
784
+ digestRoleRow: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 },
785
+ forwardBtn: {
786
+ display: 'inline-flex',
787
+ alignItems: 'center',
788
+ border: '1px solid rgba(59,108,246,0.45)',
789
+ background: 'rgba(59,108,246,0.12)',
790
+ color: 'var(--dsw-alias-brand-primary)',
791
+ fontSize: 10.5,
792
+ lineHeight: '15px',
793
+ fontWeight: 600,
794
+ padding: '1px 7px',
795
+ borderRadius: 999,
796
+ cursor: 'pointer',
797
+ whiteSpace: 'nowrap',
798
+ transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
799
+ },
621
800
  board: {
622
801
  display: 'flex',
623
802
  flex: 1,
@@ -685,23 +864,30 @@ let promptProbed = false
685
864
  flexDirection: 'column',
686
865
  gap: 2,
687
866
  },
688
- digestMsgUser: {
867
+ /* 一左一右:我说的话靠右、用品牌色;助手的回复靠左、用中性底色。
868
+ 靠对齐 + 配色两重信号,扫一眼就知道这句是谁说的。 */
869
+ digestRow: {
689
870
  display: 'flex',
690
871
  flexDirection: 'column',
691
872
  gap: 3,
692
- padding: '7px 9px',
693
- borderRadius: 8,
694
- borderLeft: '2px solid var(--dsw-alias-brand-primary)',
695
- background: 'var(--dsw-alias-bg-layer-2)',
873
+ marginBottom: 9,
874
+ maxWidth: '100%',
696
875
  },
697
- digestMsgAssistant: {
698
- display: 'flex',
699
- flexDirection: 'column',
700
- gap: 3,
701
- padding: '7px 9px',
702
- borderRadius: 8,
703
- borderLeft: `2px solid ${RUNNING_COLOR}`,
704
- background: 'var(--dsw-alias-bg-base)',
876
+ digestRowUser: { alignItems: 'flex-end' },
877
+ digestRowAssistant: { alignItems: 'flex-start' },
878
+ digestBubbleUser: {
879
+ maxWidth: '86%',
880
+ padding: '7px 10px',
881
+ borderRadius: '10px 10px 2px 10px',
882
+ background: 'rgba(59, 108, 246, 0.14)',
883
+ border: '1px solid rgba(59, 108, 246, 0.45)',
884
+ },
885
+ digestBubbleAssistant: {
886
+ maxWidth: '95%',
887
+ padding: '7px 10px',
888
+ borderRadius: '10px 10px 10px 2px',
889
+ background: 'var(--dsw-alias-bg-layer-2)',
890
+ border: '1px solid var(--dsw-alias-border-l1)',
705
891
  },
706
892
  moreBtn: {
707
893
  alignSelf: 'stretch',
@@ -761,6 +947,21 @@ let promptProbed = false
761
947
  fontWeight: 600,
762
948
  letterSpacing: '0.02em',
763
949
  textTransform: 'uppercase',
950
+ display: 'inline-flex',
951
+ alignItems: 'center',
952
+ gap: 3,
953
+ },
954
+ digestRoleUser: {
955
+ padding: '1px 7px',
956
+ borderRadius: 999,
957
+ background: 'rgba(59, 108, 246, 0.18)',
958
+ color: 'var(--dsw-alias-brand-primary)',
959
+ },
960
+ digestRoleAssistant: {
961
+ padding: '1px 7px',
962
+ borderRadius: 999,
963
+ background: 'rgba(34, 160, 107, 0.16)',
964
+ color: RUNNING_COLOR,
764
965
  },
765
966
  digestText: {
766
967
  fontSize: 12,
@@ -769,19 +970,130 @@ let promptProbed = false
769
970
  whiteSpace: 'pre-wrap',
770
971
  wordBreak: 'break-word',
771
972
  },
973
+ /* ---- Markdown 渲染样式(并排对话正文) ---- */
974
+ mdP: { margin: '0 0 6px', whiteSpace: 'pre-wrap' },
975
+ mdH1: {
976
+ margin: '8px 0 5px',
977
+ fontSize: 13.5,
978
+ fontWeight: 700,
979
+ lineHeight: 1.35,
980
+ color: 'var(--dsw-alias-label-primary)',
981
+ },
982
+ mdH2: {
983
+ margin: '7px 0 4px',
984
+ fontSize: 12.5,
985
+ fontWeight: 600,
986
+ lineHeight: 1.35,
987
+ color: 'var(--dsw-alias-label-primary)',
988
+ },
989
+ mdList: { margin: '0 0 6px', paddingLeft: 18 },
990
+ mdLi: { margin: '1px 0', whiteSpace: 'pre-wrap' },
991
+ mdStrong: { fontWeight: 700, color: 'var(--dsw-alias-label-primary)' },
992
+ mdEm: { fontStyle: 'italic' },
993
+ mdCode: {
994
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
995
+ fontSize: 11,
996
+ padding: '1px 4px',
997
+ borderRadius: 4,
998
+ background: 'var(--dsw-alias-bg-layer-2)',
999
+ border: '1px solid var(--dsw-alias-border-l1)',
1000
+ },
1001
+ mdPre: {
1002
+ margin: '4px 0 7px',
1003
+ padding: '7px 9px',
1004
+ borderRadius: 6,
1005
+ background: 'var(--dsw-alias-bg-layer-2)',
1006
+ border: '1px solid var(--dsw-alias-border-l1)',
1007
+ overflowX: 'auto',
1008
+ maxWidth: '100%',
1009
+ },
1010
+ mdPreLang: {
1011
+ fontSize: 10,
1012
+ color: 'var(--dsw-alias-label-secondary)',
1013
+ marginBottom: 3,
1014
+ textTransform: 'uppercase',
1015
+ letterSpacing: '0.04em',
1016
+ },
1017
+ mdPreCode: {
1018
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1019
+ fontSize: 11,
1020
+ lineHeight: 1.5,
1021
+ whiteSpace: 'pre',
1022
+ color: 'var(--dsw-alias-label-primary)',
1023
+ },
1024
+ mdQuote: {
1025
+ margin: '4px 0 7px',
1026
+ paddingLeft: 9,
1027
+ borderLeft: '3px solid var(--dsw-alias-border-l2)',
1028
+ color: 'var(--dsw-alias-label-secondary)',
1029
+ whiteSpace: 'pre-wrap',
1030
+ },
1031
+ mdHr: {
1032
+ margin: '8px 0',
1033
+ height: 1,
1034
+ background: 'var(--dsw-alias-border-l1)',
1035
+ border: 'none',
1036
+ },
1037
+ mdLink: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'none' },
1038
+ mdTableWrap: { margin: '4px 0 7px', overflowX: 'auto', maxWidth: '100%' },
1039
+ mdTable: { borderCollapse: 'collapse', fontSize: 11.5, lineHeight: 1.45 },
1040
+ mdTh: {
1041
+ border: '1px solid var(--dsw-alias-border-l1)',
1042
+ padding: '3px 7px',
1043
+ background: 'var(--dsw-alias-bg-layer-2)',
1044
+ fontWeight: 600,
1045
+ textAlign: 'left',
1046
+ whiteSpace: 'nowrap',
1047
+ },
1048
+ mdTd: {
1049
+ border: '1px solid var(--dsw-alias-border-l1)',
1050
+ padding: '3px 7px',
1051
+ verticalAlign: 'top',
1052
+ },
1053
+ /**
1054
+ * 卡片上的「并排看」按钮。
1055
+ * 之前只是一个 12px 的 ⊞ 字形、透明底,用户既看不出它能点,
1056
+ * 又因为点不中而误触卡片本体(整张卡片都是"打开会话"),
1057
+ * 所以这里做成有边框、有底色、带文字的实心小按钮,命中区放大到能一眼认出来。
1058
+ */
772
1059
  pinBtn: {
773
1060
  flex: '0 0 auto',
774
- border: 'none',
775
- background: 'transparent',
1061
+ display: 'inline-flex',
1062
+ alignItems: 'center',
1063
+ gap: 4,
1064
+ border: '1px solid var(--dsw-alias-border-l2)',
1065
+ background: 'var(--dsw-alias-bg-layer-2)',
776
1066
  color: 'var(--dsw-alias-label-secondary)',
777
- fontSize: 12,
778
- lineHeight: 1,
1067
+ fontSize: 11,
1068
+ lineHeight: 1.5,
1069
+ fontWeight: 500,
779
1070
  cursor: 'pointer',
780
- padding: '0 2px',
781
- borderRadius: 4,
1071
+ padding: '3px 9px',
1072
+ borderRadius: 999,
1073
+ whiteSpace: 'nowrap',
1074
+ transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
782
1075
  },
783
1076
  pinBtnActive: {
784
1077
  color: 'var(--dsw-alias-brand-primary)',
1078
+ borderColor: 'var(--dsw-alias-brand-primary)',
1079
+ background: 'rgba(59,108,246,0.12)',
1080
+ },
1081
+ /* 并排列头上那个"×"仍然要小,但也要有边界,不然同样看不出可点。 */
1082
+ colCloseBtn: {
1083
+ flex: '0 0 auto',
1084
+ display: 'inline-flex',
1085
+ alignItems: 'center',
1086
+ justifyContent: 'center',
1087
+ width: 20,
1088
+ height: 20,
1089
+ border: '1px solid var(--dsw-alias-border-l1)',
1090
+ background: 'transparent',
1091
+ color: 'var(--dsw-alias-label-secondary)',
1092
+ fontSize: 13,
1093
+ lineHeight: 1,
1094
+ cursor: 'pointer',
1095
+ padding: 0,
1096
+ borderRadius: 6,
785
1097
  },
786
1098
  boardEmpty: {
787
1099
  display: 'flex',
@@ -800,25 +1112,32 @@ let promptProbed = false
800
1112
  * ------------------------------------------------------------------ */
801
1113
 
802
1114
  function StatusDot(props) {
803
- const color = props.running ? RUNNING_COLOR : props.finished ? FINISHED_COLOR : 'var(--dsw-alias-label-secondary)'
1115
+ const color = props.waiting
1116
+ ? WAITING_COLOR
1117
+ : props.running
1118
+ ? RUNNING_COLOR
1119
+ : props.finished
1120
+ ? FINISHED_COLOR
1121
+ : 'var(--dsw-alias-label-secondary)'
804
1122
  const style = { ...S.dot, background: color, marginTop: 5 }
805
- if (props.running) {
1123
+ if (props.waiting) {
1124
+ style.boxShadow = `0 0 0 3px rgba(217,119,6,0.2)`
1125
+ } else if (props.running) {
806
1126
  style.boxShadow = `0 0 0 3px rgba(34,160,107,0.16)`
807
1127
  }
808
1128
  return h('span', { style })
809
1129
  }
810
1130
 
811
1131
  /**
812
- * 列表卡片上的一行「最后说了什么」。
813
- * 只对运行中 / 刚完成的卡片挂载,且只拉一次(不轮询)——目的是不用钉住也能知道结果,
814
- * 所以刻意用很小的 limit,别让列表本身变成 15 个并发重放。
1132
+ * 卡片状态:最后说了什么 / 是不是在等你 / 改了哪些文件。
1133
+ *
1134
+ * 一次 /digest 同时拿到三样东西,所以卡片只发一次请求。
1135
+ * 轮询策略:只有运行中的会话才轮(1.2 秒,通常也就 1–3 个),
1136
+ * 停下来的只拉一次 —— 否则 15 张卡片会让列表页变成负载源。
815
1137
  */
816
- function LastLine(props) {
817
- const entry = props.entry
818
- // 第五个参数 false:卡片末行只拉一次,不参与慢轮询。
819
- // 否则 15 张卡片会各自每 8 秒打一次 /digest,列表页瞬间变成负载源。
820
- const digest = useDigest(entry.id, false, 3, 0, false)
821
- if (digest.error !== '' || digest.items.length === 0) return null
1138
+ function useCardStatus(entry) {
1139
+ // 第五个参数 false:非运行中的会话不参与慢轮询。
1140
+ const digest = useDigest(entry.id, Boolean(entry.running), 3, 0, false)
822
1141
  let text = ''
823
1142
  for (let index = digest.items.length - 1; index >= 0; index -= 1) {
824
1143
  const item = digest.items[index]
@@ -833,12 +1152,22 @@ let promptProbed = false
833
1152
  const last = digest.items[digest.items.length - 1]
834
1153
  if (last && last.text) text = `[我] ${last.text}`
835
1154
  }
836
- if (text === '') return null
837
- return h('div', { style: S.cardLast }, clip(text.replace(/\s+/g, ' ').trim(), 160))
1155
+ return { digest, lastText: text }
1156
+ }
1157
+
1158
+ /** 列表卡片上的一行「最后说了什么」。 */
1159
+ function LastLine(props) {
1160
+ const { lastText } = useCardStatus(props.entry)
1161
+ if (lastText === '') return null
1162
+ return h('div', { style: S.cardLast }, clip(lastText.replace(/\s+/g, ' ').trim(), 160))
838
1163
  }
839
1164
 
840
1165
  function SessionCard(props) {
841
1166
  const entry = props.entry
1167
+ // 一次 /digest 同时拿到末行摘要、在等你、改了哪些文件,卡片只发一次请求。
1168
+ const status = useCardStatus(entry)
1169
+ const waiting = entry.waiting || status.digest.waiting || ''
1170
+ const changes = status.digest.changes
842
1171
  const meta = []
843
1172
  if (entry.running) meta.push('运行中')
844
1173
  else if (entry.finishedAt) meta.push(`完成于 ${relativeTime(entry.finishedAt)}`)
@@ -852,11 +1181,15 @@ let promptProbed = false
852
1181
  if (entry.running) Object.assign(cardStyle, S.cardRunning)
853
1182
  else if (entry.finishedAt) Object.assign(cardStyle, S.cardFinished)
854
1183
 
1184
+ /**
1185
+ * 整张卡片不再绑定"打开会话"。
1186
+ * 之前卡片任意位置一点就跳走,用户想点右边那个小按钮时会误触到卡片本体,
1187
+ * 结果"想展开却变成了跳进另一个会话"。现在只有下面两个明确的按钮才产生动作。
1188
+ */
855
1189
  return h(
856
1190
  'div',
857
1191
  {
858
1192
  style: cardStyle,
859
- onClick: () => props.onOpen(entry.id),
860
1193
  onMouseEnter: (event) => {
861
1194
  event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover)'
862
1195
  },
@@ -865,7 +1198,11 @@ let promptProbed = false
865
1198
  },
866
1199
  title: entry.cwd || entry.title,
867
1200
  },
868
- h(StatusDot, { running: entry.running, finished: Boolean(entry.finishedAt) }),
1201
+ h(StatusDot, {
1202
+ running: entry.running,
1203
+ finished: Boolean(entry.finishedAt),
1204
+ waiting: Boolean(waiting),
1205
+ }),
869
1206
  h(
870
1207
  'div',
871
1208
  { style: S.cardBody },
@@ -873,6 +1210,26 @@ let promptProbed = false
873
1210
  'div',
874
1211
  { style: S.cardTitleRow },
875
1212
  h('span', { style: S.cardTitle }, entry.title),
1213
+ /*
1214
+ * 「在等你」:会话卡住等你回话时 running 仍然是 true,
1215
+ * 光看运行中分不出来 —— 它可能在空转十几分钟,你却以为它在干活。
1216
+ * 所以这个标记放在最前面,且配色与「运行中」区分开。
1217
+ */
1218
+ entry.waiting || waiting
1219
+ ? h(
1220
+ 'span',
1221
+ {
1222
+ style: { ...S.badge, ...S.badgeWaiting },
1223
+ title:
1224
+ waiting === 'question'
1225
+ ? '它在等你回答问题'
1226
+ : waiting === 'approval'
1227
+ ? '它在等你批准一个操作'
1228
+ : '它在等你',
1229
+ },
1230
+ waiting === 'question' ? '等你回话' : '等你批准',
1231
+ )
1232
+ : null,
876
1233
  entry.running
877
1234
  ? h('span', { style: { ...S.badge, color: RUNNING_COLOR } }, '运行中')
878
1235
  : null,
@@ -882,26 +1239,72 @@ let promptProbed = false
882
1239
  entry.parentId || entry.origin === 'subagent'
883
1240
  ? h('span', { style: { ...S.badge, color: 'var(--dsw-alias-label-secondary)' } }, '子代理')
884
1241
  : null,
1242
+ ),
1243
+ meta.length > 0 ? h('div', { style: S.metaRow }, meta.map((text, index) => h('span', { key: index }, text))) : null,
1244
+ status.lastText !== '' ? h('div', { style: S.cardLast }, clip(status.lastText.replace(/\s+/g, ' ').trim(), 160)) : null,
1245
+ changes ? h(ChangesLine, { changes }) : null,
1246
+ h(
1247
+ 'div',
1248
+ { style: S.cardActions },
885
1249
  h(
886
1250
  'button',
887
1251
  {
888
1252
  type: 'button',
889
1253
  style: { ...S.pinBtn, ...(props.pinned ? S.pinBtnActive : {}) },
890
- title: props.pinned ? '取消钉住' : '钉住,并排看它的对话',
1254
+ title: props.pinned ? '从并排视图中移除' : '把它钉到右边,并排看它的对话',
891
1255
  onClick: (event) => {
892
1256
  event.stopPropagation()
893
1257
  props.onPin(entry.id)
894
1258
  },
895
1259
  },
896
- props.pinned ? '⊟' : '⊞',
1260
+ props.pinned ? '− 移出并排' : '+ 并排看',
1261
+ ),
1262
+ h(
1263
+ 'button',
1264
+ {
1265
+ type: 'button',
1266
+ style: S.cardOpenBtn,
1267
+ title: '切到这个会话',
1268
+ onClick: (event) => {
1269
+ event.stopPropagation()
1270
+ props.onOpen(entry.id)
1271
+ },
1272
+ },
1273
+ '打开',
897
1274
  ),
898
1275
  ),
899
- meta.length > 0 ? h('div', { style: S.metaRow }, meta.map((text, index) => h('span', { key: index }, text))) : null,
900
- entry.running || entry.finishedAt ? h(LastLine, { entry }) : null,
901
1276
  ),
902
1277
  )
903
1278
  }
904
1279
 
1280
+ /**
1281
+ * 卡片上的一行「改了什么文件」。
1282
+ * 对"同时跑五六个会话改代码"的场景,这比读对话更快判断它到底干完没有。
1283
+ */
1284
+ function ChangesLine(props) {
1285
+ const changes = props.changes
1286
+ if (!changes || !(changes.total > 0)) return null
1287
+ const parts = []
1288
+ if (changes.added > 0) parts.push(`+${changes.added}`)
1289
+ if (changes.deleted > 0) parts.push(`−${changes.deleted}`)
1290
+ const names = (Array.isArray(changes.files) ? changes.files : [])
1291
+ .slice(0, 3)
1292
+ .map((file) => String(file.display || file.path || '').split(/[\\/]/).pop())
1293
+ .filter((name) => name !== '')
1294
+ return h(
1295
+ 'div',
1296
+ {
1297
+ style: S.cardChanges,
1298
+ title: (Array.isArray(changes.files) ? changes.files : [])
1299
+ .map((file) => `${file.display || file.path} +${file.added} −${file.deleted}`)
1300
+ .join('\n'),
1301
+ },
1302
+ h('span', { style: S.cardChangesCount }, `${changes.total} 个文件`),
1303
+ parts.length > 0 ? h('span', { style: S.cardChangesDiff }, parts.join(' ')) : null,
1304
+ names.length > 0 ? h('span', { style: S.cardChangesNames }, names.join('、')) : null,
1305
+ )
1306
+ }
1307
+
905
1308
  /** 精简模式下最多列多少个"闲着"的会话(运行中/刚完成的永远全列)。 */
906
1309
  const IDLE_LIMIT = 15
907
1310
 
@@ -957,7 +1360,15 @@ let promptProbed = false
957
1360
  const bump = Number(nonce) || 0
958
1361
  /** 是否开启"停下之后继续慢轮询"。列表卡片的末行摘要传 false,只拉一次。 */
959
1362
  const keepPolling = poll !== false
960
- const [state, setState] = React.useState({ items: [], turns: 0, endReason: null, error: '', loading: true })
1363
+ const [state, setState] = React.useState({
1364
+ items: [],
1365
+ turns: 0,
1366
+ endReason: null,
1367
+ waiting: '',
1368
+ changes: null,
1369
+ error: '',
1370
+ loading: true,
1371
+ })
961
1372
  /**
962
1373
  * 上一次是不是"运行中"。用来识别"刚跑完"这一帧:
963
1374
  * 这时候必须绕开缓存拿最新的,否则会显示成"没有回复也没有完成"。
@@ -976,6 +1387,8 @@ let promptProbed = false
976
1387
  items: Array.isArray(body.items) ? body.items : [],
977
1388
  turns: Number(body.turns) || 0,
978
1389
  endReason: body.endReason || null,
1390
+ waiting: typeof body.waiting === 'string' ? body.waiting : '',
1391
+ changes: body.changes !== null && typeof body.changes === 'object' ? body.changes : null,
979
1392
  error: '',
980
1393
  loading: false,
981
1394
  })
@@ -1006,23 +1419,297 @@ let promptProbed = false
1006
1419
  return value.length > max ? `${value.slice(0, max)}…` : value
1007
1420
  }
1008
1421
 
1422
+ /* ------------------------------------------------------------------ *
1423
+ * 极简 Markdown 渲染
1424
+ *
1425
+ * 为什么需要它:会话正文本来就是 Markdown。以前直接把源码 pre-wrap 出来,
1426
+ * 于是并排列里全是 `**关键实证**`、`## 机制`、``` 代码围栏 ``` 这类标记,
1427
+ * 又长又乱 —— 这不是"内容太多",是"当纯文本显示"造成的。
1428
+ * 渲染成真正的标题/列表/粗体/代码块之后,同样的内容短了将近一半,
1429
+ * 观感也才和 DSH 官方会话视图对得上。
1430
+ *
1431
+ * 安全约束:全程用 React 元素构造,绝不拼 HTML 字符串、不用 dangerouslySetInnerHTML,
1432
+ * 所以会话内容里的任何标签都只会被当成文字显示。
1433
+ * ------------------------------------------------------------------ */
1434
+
1435
+ const MD_MAX_BLOCKS = 400
1436
+ const mdInlineCache = new Map()
1437
+
1438
+ /** 行内语法:`code`、**粗体**、*斜体*、[文字](链接)。按此顺序切分,互不吞并。 */
1439
+ function renderInline(text, keyPrefix) {
1440
+ const source = String(text ?? '')
1441
+ if (source === '') return null
1442
+ const cached = mdInlineCache.get(source)
1443
+ if (cached !== undefined) return cached
1444
+ const out = []
1445
+ const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)\s]+\))/g
1446
+ let last = 0
1447
+ let match
1448
+ let index = 0
1449
+ while ((match = pattern.exec(source)) !== null) {
1450
+ if (match.index > last) out.push(source.slice(last, match.index))
1451
+ const token = match[0]
1452
+ if (token.startsWith('`')) {
1453
+ out.push(h('code', { key: `c${index}`, style: S.mdCode }, token.slice(1, -1)))
1454
+ } else if (token.startsWith('**')) {
1455
+ out.push(h('strong', { key: `b${index}`, style: S.mdStrong }, token.slice(2, -2)))
1456
+ } else if (token.startsWith('*')) {
1457
+ out.push(h('em', { key: `i${index}`, style: S.mdEm }, token.slice(1, -1)))
1458
+ } else {
1459
+ const linkMatch = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
1460
+ if (linkMatch !== null) {
1461
+ out.push(
1462
+ h(
1463
+ 'a',
1464
+ {
1465
+ key: `a${index}`,
1466
+ href: linkMatch[2],
1467
+ target: '_blank',
1468
+ rel: 'noreferrer noopener',
1469
+ style: S.mdLink,
1470
+ },
1471
+ linkMatch[1],
1472
+ ),
1473
+ )
1474
+ } else {
1475
+ out.push(token)
1476
+ }
1477
+ }
1478
+ last = match.index + token.length
1479
+ index += 1
1480
+ }
1481
+ if (last < source.length) out.push(source.slice(last))
1482
+ const result = out.length === 0 ? null : out
1483
+ // 缓存上限:会话里重复的行内片段很多(列表项、表格单元格),
1484
+ // 但正文长度无上限,所以到量就整表清空,简单且不会无界增长。
1485
+ if (mdInlineCache.size > 2000) mdInlineCache.clear()
1486
+ mdInlineCache.set(source, result)
1487
+ return result
1488
+ }
1489
+
1490
+ /**
1491
+ * 块级解析:围栏代码块、标题、无序/有序列表、引用、水平线、表格、段落。
1492
+ * 认不出的行一律并进段落 —— 宁可朴素一点,也不要把内容吃掉。
1493
+ */
1494
+ function renderMarkdown(text) {
1495
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
1496
+ const blocks = []
1497
+ let index = 0
1498
+
1499
+ const flushParagraph = (buffer) => {
1500
+ if (buffer.length === 0) return
1501
+ blocks.push(h('p', { key: `p${blocks.length}`, style: S.mdP }, renderInline(buffer.join('\n'), `p${blocks.length}`)))
1502
+ buffer.length = 0
1503
+ }
1504
+ const paragraph = []
1505
+
1506
+ while (index < lines.length && blocks.length < MD_MAX_BLOCKS) {
1507
+ const line = lines[index]
1508
+
1509
+ // 围栏代码块:``` 或 ~~~ 开头,闭合标记可以带语言后缀。
1510
+ const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line)
1511
+ if (fence !== null) {
1512
+ flushParagraph(paragraph)
1513
+ const marker = fence[1][0]
1514
+ const language = fence[2].trim()
1515
+ const body = []
1516
+ index += 1
1517
+ while (index < lines.length && !new RegExp(`^\\s*${marker}{3,}\\s*$`).test(lines[index])) {
1518
+ body.push(lines[index])
1519
+ index += 1
1520
+ }
1521
+ index += 1
1522
+ blocks.push(
1523
+ h(
1524
+ 'pre',
1525
+ { key: `pre${blocks.length}`, style: S.mdPre },
1526
+ language !== '' ? h('div', { style: S.mdPreLang }, language) : null,
1527
+ h('code', { style: S.mdPreCode }, body.join('\n')),
1528
+ ),
1529
+ )
1530
+ continue
1531
+ }
1532
+
1533
+ // 标题
1534
+ const heading = /^\s*(#{1,6})\s+(.*)$/.exec(line)
1535
+ if (heading !== null) {
1536
+ flushParagraph(paragraph)
1537
+ const level = heading[1].length
1538
+ // 会话正文的 h1/h2 对面板来说过大,整体降一级:## 渲染成 h3,
1539
+ // 又因为 h6 是上限,更深的标题共用最小号。
1540
+ blocks.push(
1541
+ h(
1542
+ `h${Math.min(level + 1, 6)}`,
1543
+ { key: `h${blocks.length}`, style: level <= 2 ? S.mdH1 : S.mdH2 },
1544
+ renderInline(heading[2], `h${blocks.length}`),
1545
+ ),
1546
+ )
1547
+ index += 1
1548
+ continue
1549
+ }
1550
+
1551
+ // 水平线
1552
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line) && line.trim().length >= 3) {
1553
+ flushParagraph(paragraph)
1554
+ blocks.push(h('div', { key: `hr${blocks.length}`, style: S.mdHr }))
1555
+ index += 1
1556
+ continue
1557
+ }
1558
+
1559
+ // 引用
1560
+ if (/^\s*>\s?/.test(line)) {
1561
+ flushParagraph(paragraph)
1562
+ const body = []
1563
+ while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
1564
+ body.push(lines[index].replace(/^\s*>\s?/, ''))
1565
+ index += 1
1566
+ }
1567
+ blocks.push(
1568
+ h('blockquote', { key: `q${blocks.length}`, style: S.mdQuote }, renderInline(body.join('\n'), `q${blocks.length}`)),
1569
+ )
1570
+ continue
1571
+ }
1572
+
1573
+ // 列表:连续的 -/*/+ 或 1. 2. 行合成一个列表。
1574
+ const bullet = /^\s*[-*+]\s+(.*)$/
1575
+ const ordered = /^\s*\d+[.)]\s+(.*)$/
1576
+ if (bullet.test(line) || ordered.test(line)) {
1577
+ flushParagraph(paragraph)
1578
+ const isOrdered = ordered.test(line)
1579
+ const items = []
1580
+ const pick = isOrdered ? ordered : bullet
1581
+ while (index < lines.length && pick.test(lines[index])) {
1582
+ items.push(pick.exec(lines[index])[1])
1583
+ index += 1
1584
+ }
1585
+ const tag = isOrdered ? 'ol' : 'ul'
1586
+ blocks.push(
1587
+ h(
1588
+ tag,
1589
+ { key: `l${blocks.length}`, style: S.mdList },
1590
+ items.map((item, itemIndex) =>
1591
+ h('li', { key: itemIndex, style: S.mdLi }, renderInline(item, `l${blocks.length}-${itemIndex}`)),
1592
+ ),
1593
+ ),
1594
+ )
1595
+ continue
1596
+ }
1597
+
1598
+ // 表格:至少两行、第二行是 |---| 分隔。
1599
+ if (line.includes('|') && index + 1 < lines.length && /^\s*\|?[\s:-]*\|[\s:|-]*$/.test(lines[index + 1])) {
1600
+ const splitRow = (row) =>
1601
+ row
1602
+ .trim()
1603
+ .replace(/^\|/, '')
1604
+ .replace(/\|$/, '')
1605
+ .split('|')
1606
+ .map((cell) => cell.trim())
1607
+ const header = splitRow(line)
1608
+ index += 2
1609
+ const rows = []
1610
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim() !== '') {
1611
+ rows.push(splitRow(lines[index]))
1612
+ index += 1
1613
+ }
1614
+ blocks.push(
1615
+ h(
1616
+ 'div',
1617
+ { key: `t${blocks.length}`, style: S.mdTableWrap },
1618
+ h(
1619
+ 'table',
1620
+ { style: S.mdTable },
1621
+ h(
1622
+ 'thead',
1623
+ null,
1624
+ h(
1625
+ 'tr',
1626
+ null,
1627
+ header.map((cell, cellIndex) =>
1628
+ h('th', { key: cellIndex, style: S.mdTh }, renderInline(cell, `th${cellIndex}`)),
1629
+ ),
1630
+ ),
1631
+ ),
1632
+ h(
1633
+ 'tbody',
1634
+ null,
1635
+ rows.map((row, rowIndex) =>
1636
+ h(
1637
+ 'tr',
1638
+ { key: rowIndex },
1639
+ row.map((cell, cellIndex) =>
1640
+ h('td', { key: cellIndex, style: S.mdTd }, renderInline(cell, `td${rowIndex}-${cellIndex}`)),
1641
+ ),
1642
+ ),
1643
+ ),
1644
+ ),
1645
+ ),
1646
+ ),
1647
+ )
1648
+ continue
1649
+ }
1650
+
1651
+ // 空行 = 段落边界
1652
+ if (line.trim() === '') {
1653
+ flushParagraph(paragraph)
1654
+ index += 1
1655
+ continue
1656
+ }
1657
+
1658
+ paragraph.push(line)
1659
+ index += 1
1660
+ }
1661
+
1662
+ flushParagraph(paragraph)
1663
+ return blocks
1664
+ }
1665
+
1009
1666
  /**
1010
- * 渲染一条对话。正文按原样保留换行、只做长度上限,不压成摘要。
1011
- * 并排视图只保留"我 ↔ AI 的对话正文",
1667
+ * 渲染一条对话。并排视图只保留"我 ↔ AI 的对话正文",
1012
1668
  * 工具调用/思考过程在 Host 侧折叠阶段就丢掉了,这里再兜一层:
1013
1669
  * 认不出的类型直接跳过,不画任何噪音行。
1670
+ * 正文按 Markdown 渲染(标题/列表/粗体/代码块/表格),这才是会话本来的样子。
1014
1671
  */
1015
- function renderDigestItem(item, index) {
1672
+ function renderDigestItem(item, index, onForward) {
1016
1673
  const kind = item && item.kind
1017
1674
  if (kind !== 'user' && kind !== 'assistant') return null
1018
1675
  const isUser = kind === 'user'
1019
1676
  const roleText = isUser ? '我' : '助手'
1020
- const color = isUser ? 'var(--dsw-alias-brand-primary)' : RUNNING_COLOR
1021
1677
  return h(
1022
1678
  'div',
1023
- { key: index, style: isUser ? S.digestMsgUser : S.digestMsgAssistant },
1024
- h('span', { style: { ...S.digestRole, color } }, roleText),
1025
- h('div', { style: S.digestText }, clip(item.text, 6000)),
1679
+ {
1680
+ key: index,
1681
+ style: isUser ? { ...S.digestRow, ...S.digestRowUser } : { ...S.digestRow, ...S.digestRowAssistant },
1682
+ },
1683
+ h(
1684
+ 'div',
1685
+ { style: isUser ? S.digestBubbleUser : S.digestBubbleAssistant },
1686
+ h(
1687
+ 'div',
1688
+ { style: S.digestRoleRow },
1689
+ h(
1690
+ 'div',
1691
+ { style: isUser ? { ...S.digestRole, ...S.digestRoleUser } : { ...S.digestRole, ...S.digestRoleAssistant } },
1692
+ roleText,
1693
+ ),
1694
+ // 传给别的会话:把这段结论一键塞给另一个会话当输入,不用手动复制粘贴再切过去。
1695
+ typeof onForward === 'function'
1696
+ ? h(
1697
+ 'button',
1698
+ {
1699
+ type: 'button',
1700
+ style: S.forwardBtn,
1701
+ title: '把这条转发给另一个会话',
1702
+ onClick: (event) => {
1703
+ event.stopPropagation()
1704
+ onForward(String(item.text ?? ''))
1705
+ },
1706
+ },
1707
+ '转发',
1708
+ )
1709
+ : null,
1710
+ ),
1711
+ h('div', { style: S.digestText }, renderMarkdown(clip(item.text, 20000))),
1712
+ ),
1026
1713
  )
1027
1714
  }
1028
1715
 
@@ -1179,7 +1866,7 @@ let promptProbed = false
1179
1866
  } else if (digest.items.length === 0) {
1180
1867
  content = h('div', { style: S.boardEmpty }, '这个会话还没有可显示的对话。')
1181
1868
  } else {
1182
- content = digest.items.map((item, index) => renderDigestItem(item, index))
1869
+ content = digest.items.map((item, index) => renderDigestItem(item, index, props.onForward))
1183
1870
  }
1184
1871
 
1185
1872
  const head = h(
@@ -1200,8 +1887,8 @@ let promptProbed = false
1200
1887
  'button',
1201
1888
  {
1202
1889
  type: 'button',
1203
- style: S.pinBtn,
1204
- title: '取消钉住',
1890
+ style: S.colCloseBtn,
1891
+ title: '从并排视图中移除这一列',
1205
1892
  onClick: () => props.onUnpin(entry.id),
1206
1893
  },
1207
1894
  '×',
@@ -1239,8 +1926,7 @@ let promptProbed = false
1239
1926
  moreBar,
1240
1927
  content,
1241
1928
  ),
1242
- h(SendBox, { entry, onSent: () => setNonce((value) => value + 1) }),
1243
- )
1929
+ h(SendBox, { entry, onSent: () => setNonce((value) => value + 1) }), )
1244
1930
  }
1245
1931
 
1246
1932
  function MultiSessionPanel(props) {
@@ -1255,6 +1941,21 @@ let promptProbed = false
1255
1941
  */
1256
1942
  const [pinned, setPinned] = React.useState(() => loadPinned())
1257
1943
  const [tick, setTick] = React.useState(0)
1944
+ /**
1945
+ * 跨会话全文搜索。
1946
+ * 走 Host 的 sessionController.search —— 官方语义是
1947
+ * "Search visible Session content without resuming an Agent",
1948
+ * 不会激活会话、不烧 token,找到的只是历史上已经写下的文字。
1949
+ */
1950
+ const [query, setQuery] = React.useState('')
1951
+ const [searching, setSearching] = React.useState(false)
1952
+ const [hits, setHits] = React.useState(null)
1953
+ /**
1954
+ * 会话间传话:{ text, fromId }。
1955
+ * 在并排列里点「转发」把这段结论塞进这里,再选一个目标会话发过去,
1956
+ * 省掉"复制 → 切过去 → 粘贴"三步。
1957
+ */
1958
+ const [forward, setForward] = React.useState(null)
1258
1959
 
1259
1960
  const workspaceIndex = React.useMemo(() => readWorkspaces(workspaces), [workspaces])
1260
1961
  const entries = React.useMemo(
@@ -1393,6 +2094,51 @@ let promptProbed = false
1393
2094
  })
1394
2095
  }, [])
1395
2096
 
2097
+ /** 跑一次跨会话搜索。空查询直接清空结果,不发请求。 */
2098
+ const runSearch = React.useCallback(async (text) => {
2099
+ const value = String(text ?? '').trim()
2100
+ if (value === '') {
2101
+ setHits(null)
2102
+ setSearching(false)
2103
+ return
2104
+ }
2105
+ setSearching(true)
2106
+ try {
2107
+ /*
2108
+ * 把当前面板里看得到的会话 id 一起传过去。
2109
+ * 原因:有些部署把 session-query 索引关掉了(openAt: "never"),
2110
+ * 官方全文检索直接报 "session search is disabled";Host 侧这时会退化成
2111
+ * 逐个会话扫事件日志——慢,所以只让它扫我们真的看得到的这批,而不是全部 400+。
2112
+ */
2113
+ const scanIds = entries
2114
+ .filter((entry) => !entry.archived && !entry.blank)
2115
+ .slice(0, 40)
2116
+ .map((entry) => entry.id)
2117
+ const scanParam = scanIds.length > 0 ? `&sessions=${encodeURIComponent(scanIds.join(','))}` : ''
2118
+ const response = await fetch(
2119
+ `/plugins/dsh-multi-session/search?q=${encodeURIComponent(value)}${scanParam}`,
2120
+ { cache: 'no-store' },
2121
+ )
2122
+ const body = await response.json()
2123
+ if (!response.ok || !body || body.ok !== true) {
2124
+ setHits({ error: String(body?.error ?? `HTTP ${response.status}`), items: [] })
2125
+ return
2126
+ }
2127
+ setHits({
2128
+ error: '',
2129
+ items: Array.isArray(body.items) ? body.items : [],
2130
+ hasMore: body.hasMore === true,
2131
+ via: typeof body.via === 'string' ? body.via : '',
2132
+ ms: Number(body.ms) || 0,
2133
+ scanned: Number(body.scanned) || 0,
2134
+ })
2135
+ } catch (cause) {
2136
+ setHits({ error: String(cause && cause.message ? cause.message : cause), items: [] })
2137
+ } finally {
2138
+ setSearching(false)
2139
+ }
2140
+ }, [entries])
2141
+
1396
2142
  const pinnedEntries = React.useMemo(
1397
2143
  () =>
1398
2144
  pinned
@@ -1453,8 +2199,56 @@ let promptProbed = false
1453
2199
  const listPane = h(
1454
2200
  'div',
1455
2201
  { style: splitMode ? S.listPane : S.listPaneWide },
1456
- listContent,
1457
- listTail,
2202
+ // 有搜索结果时,列表换成"命中的会话 + 命中的那句话"。
2203
+ hits !== null
2204
+ ? h(SearchResults, { hits, entries, onOpen: handleOpen, onClear: () => { setQuery(''); setHits(null) } })
2205
+ : listContent,
2206
+ hits !== null ? null : listTail,
2207
+ )
2208
+
2209
+ const searchBar = h(
2210
+ 'div',
2211
+ { style: S.searchBar },
2212
+ h('input', {
2213
+ type: 'search',
2214
+ value: query,
2215
+ placeholder: '在所有会话里搜一句话…',
2216
+ style: S.searchInput,
2217
+ onChange: (event) => setQuery(event.target.value),
2218
+ onKeyDown: (event) => {
2219
+ if (event.key === 'Enter') {
2220
+ event.preventDefault()
2221
+ runSearch(query)
2222
+ } else if (event.key === 'Escape') {
2223
+ setQuery('')
2224
+ setHits(null)
2225
+ }
2226
+ },
2227
+ }),
2228
+ h(
2229
+ 'button',
2230
+ {
2231
+ type: 'button',
2232
+ style: S.searchBtn,
2233
+ disabled: searching,
2234
+ onClick: () => runSearch(query),
2235
+ },
2236
+ searching ? '搜索中…' : '搜索',
2237
+ ),
2238
+ hits !== null
2239
+ ? h(
2240
+ 'button',
2241
+ {
2242
+ type: 'button',
2243
+ style: S.searchBtn,
2244
+ onClick: () => {
2245
+ setQuery('')
2246
+ setHits(null)
2247
+ },
2248
+ },
2249
+ '清空',
2250
+ )
2251
+ : null,
1458
2252
  )
1459
2253
 
1460
2254
  return h(
@@ -1523,6 +2317,15 @@ let promptProbed = false
1523
2317
  )
1524
2318
  : null,
1525
2319
  ),
2320
+ searchBar,
2321
+ forward !== null
2322
+ ? h(ForwardBar, {
2323
+ pending: forward,
2324
+ entries,
2325
+ onCancel: () => setForward(null),
2326
+ onDone: () => setForward(null),
2327
+ })
2328
+ : null,
1526
2329
  splitMode
1527
2330
  ? h(
1528
2331
  'div',
@@ -1537,11 +2340,153 @@ let promptProbed = false
1537
2340
  entry,
1538
2341
  onOpen: handleOpen,
1539
2342
  onUnpin: unpin,
2343
+ onForward: (text) => setForward({ text, fromId: entry.id, fromTitle: entry.title }),
1540
2344
  }),
1541
2345
  ),
1542
2346
  ),
1543
2347
  )
1544
- : h('div', { style: S.body }, listContent, listTail),
2348
+ /*
2349
+ * 没钉住会话时也要走 listPane,不能直接铺 listContent。
2350
+ * 之前这里写的是 listContent,于是"有搜索结果时把列表换成命中结果"这段
2351
+ * 只在并排模式下生效——总览模式搜完什么都不变,看起来就是搜索没反应。
2352
+ */
2353
+ : h('div', { style: S.body }, listPane),
2354
+ )
2355
+ }
2356
+
2357
+ /**
2358
+ * 会话间传话:把一段结论发到别的会话里。
2359
+ * 默认用 queue(排进对方收件箱,不打断它正在跑的回合)——
2360
+ * 传话的语义是"补充信息",不是"立刻打断它"。
2361
+ */
2362
+ function ForwardBar(props) {
2363
+ const pending = props.pending
2364
+ const [targetId, setTargetId] = React.useState('')
2365
+ const [busy, setBusy] = React.useState(false)
2366
+ const [note, setNote] = React.useState('')
2367
+
2368
+ // 目标候选:所有非归档、非自己的会话,按最近活跃排序,只列前 30 个免得下拉框爆炸。
2369
+ const options = React.useMemo(() => {
2370
+ return props.entries
2371
+ .filter((entry) => entry.id !== pending.fromId && !entry.archived && !entry.blank)
2372
+ .sort((left, right) => right.updatedAt - left.updatedAt)
2373
+ .slice(0, 30)
2374
+ }, [props.entries, pending.fromId])
2375
+
2376
+ const send = async () => {
2377
+ if (busy || targetId === '') return
2378
+ setBusy(true)
2379
+ setNote('')
2380
+ try {
2381
+ const response = await fetch('/plugins/dsh-multi-session/prompt', {
2382
+ method: 'POST',
2383
+ cache: 'no-store',
2384
+ headers: { 'content-type': 'application/json' },
2385
+ body: JSON.stringify({ sessionId: targetId, text: pending.text, mode: 'queue' }),
2386
+ })
2387
+ const body = await response.json().catch(() => null)
2388
+ if (!response.ok || !body || body.ok !== true) {
2389
+ throw new Error((body && body.error) || `HTTP ${response.status}`)
2390
+ }
2391
+ props.onDone()
2392
+ } catch (cause) {
2393
+ setNote(`发送失败:${cause && cause.message ? cause.message : cause}`)
2394
+ } finally {
2395
+ setBusy(false)
2396
+ }
2397
+ }
2398
+
2399
+ return h(
2400
+ 'div',
2401
+ { style: S.forwardBar },
2402
+ h('span', { style: S.forwardLabel }, `转发自「${clip(pending.fromTitle, 20)}」`),
2403
+ h(
2404
+ 'select',
2405
+ {
2406
+ style: S.forwardSelect,
2407
+ value: targetId,
2408
+ onChange: (event) => setTargetId(event.target.value),
2409
+ },
2410
+ h('option', { value: '' }, '选一个目标会话…'),
2411
+ options.map((entry) => h('option', { key: entry.id, value: entry.id }, entry.title)),
2412
+ ),
2413
+ h(
2414
+ 'button',
2415
+ {
2416
+ type: 'button',
2417
+ style: { ...S.forwardBtnOk, opacity: busy || targetId === '' ? 0.5 : 1 },
2418
+ disabled: busy || targetId === '',
2419
+ onClick: send,
2420
+ },
2421
+ busy ? '…' : '发过去',
2422
+ ),
2423
+ h(
2424
+ 'button',
2425
+ { type: 'button', style: S.forwardCancel, onClick: props.onCancel },
2426
+ '取消',
2427
+ ),
2428
+ note !== '' ? h('span', { style: S.forwardNote }, note) : null,
2429
+ )
2430
+ }
2431
+
2432
+ /**
2433
+ * 搜索结果:每个命中显示"是哪个会话 + 那句话",点一下直接跳过去。
2434
+ * 命中的会话可能没在当前过滤条件里(比如是子代理),所以标题从 entries 里兜底查。
2435
+ */
2436
+ function SearchResults(props) {
2437
+ const hits = props.hits
2438
+ const items = Array.isArray(hits.items) ? hits.items : []
2439
+ if (hits.error) {
2440
+ return h('div', { style: S.empty }, `搜索失败:${hits.error}`)
2441
+ }
2442
+ if (items.length === 0) {
2443
+ return h('div', { style: S.empty }, '没有找到。换个词试试,搜的是会话里写过的正文。')
2444
+ }
2445
+ // 同一个会话可能命中多句,按会话归组更省眼。
2446
+ const groups = new Map()
2447
+ for (const item of items) {
2448
+ const key = String(item.sessionId ?? '')
2449
+ let bucket = groups.get(key)
2450
+ if (!bucket) {
2451
+ const entry = props.entries.find((candidate) => candidate.id === key)
2452
+ bucket = { key, title: entry ? entry.title : key.slice(0, 18), snippets: [] }
2453
+ groups.set(key, bucket)
2454
+ }
2455
+ if (bucket.snippets.length < 4) bucket.snippets.push(String(item.snippet ?? ''))
2456
+ }
2457
+ // 索引被关掉的部署走的是"逐会话扫日志",把这件事告诉用户,免得他觉得慢是卡了。
2458
+ const scanNote =
2459
+ hits.via === 'scan'
2460
+ ? `(本机没开全文索引,直接扫了 ${hits.scanned || 0} 个会话的日志,${hits.ms > 0 ? `${(hits.ms / 1000).toFixed(1)} 秒` : ''})`
2461
+ : ''
2462
+ return h(
2463
+ 'div',
2464
+ { style: S.searchResults },
2465
+ h(
2466
+ 'div',
2467
+ { style: S.searchSummary },
2468
+ `命中 ${items.length} 处,分布在 ${groups.size} 个会话`,
2469
+ hits.hasMore ? '(结果已截断)' : '',
2470
+ scanNote,
2471
+ ),
2472
+ Array.from(groups.values()).map((group) =>
2473
+ h(
2474
+ 'div',
2475
+ { key: group.key, style: S.searchHitGroup },
2476
+ h(
2477
+ 'div',
2478
+ {
2479
+ style: S.searchHitTitle,
2480
+ title: '跳到这个会话',
2481
+ onClick: () => props.onOpen(group.key),
2482
+ },
2483
+ group.title,
2484
+ ),
2485
+ group.snippets.map((snippet, index) =>
2486
+ h('div', { key: index, style: S.searchSnippet }, snippet),
2487
+ ),
2488
+ ),
2489
+ ),
1545
2490
  )
1546
2491
  }
1547
2492
 
@@ -1925,7 +2870,19 @@ let promptProbed = false
1925
2870
  inject: ['slots'],
1926
2871
  // 仅供离线 harness 直接渲染内部组件;宿主只读 inject/apply,多出的键无副作用。
1927
2872
  __internals: DIAG
1928
- ? { PinnedColumn, renderDigestItem, fetchDigest, MultiSessionPanel, SessionCard, LastLine, SendBox, openSession }
2873
+ ? {
2874
+ PinnedColumn,
2875
+ renderDigestItem,
2876
+ fetchDigest,
2877
+ MultiSessionPanel,
2878
+ SessionCard,
2879
+ LastLine,
2880
+ ChangesLine,
2881
+ SendBox,
2882
+ SearchResults,
2883
+ ForwardBar,
2884
+ openSession,
2885
+ }
1929
2886
  : undefined,
1930
2887
  apply(ctx) {
1931
2888
  rootCtx = ctx