@linxin666/dsh-remote-web-ui 0.2.9 → 0.3.1

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.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +46 -8
  3. package/README.zh.md +7 -2
  4. package/lib/client.js +103 -58
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +704 -61
  7. package/lib/mobile.js +874 -64
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/index.js +71 -19
  11. package/lib/types/client/remote-channel.d.ts +4 -5
  12. package/lib/types/client/remote-channel.d.ts.map +1 -1
  13. package/lib/types/client/remote-channel.js +13 -28
  14. package/lib/types/http.d.ts +37 -8
  15. package/lib/types/http.d.ts.map +1 -1
  16. package/lib/types/http.js +76 -11
  17. package/lib/types/index.d.ts.map +1 -1
  18. package/lib/types/index.js +25 -1
  19. package/lib/types/mobile/api.d.ts +57 -0
  20. package/lib/types/mobile/api.d.ts.map +1 -1
  21. package/lib/types/mobile/api.js +29 -0
  22. package/lib/types/mobile/markdown.d.ts +7 -1
  23. package/lib/types/mobile/markdown.d.ts.map +1 -1
  24. package/lib/types/mobile/markdown.js +16 -7
  25. package/lib/types/mobile/markdown.test.js +8 -0
  26. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  27. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  28. package/lib/types/mobile/mobile-styles.js +325 -6
  29. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  30. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  31. package/lib/types/mobile/views/ChatView.js +205 -8
  32. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  35. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  36. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  37. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  38. package/lib/types/mobile-api.d.ts +9 -3
  39. package/lib/types/mobile-api.d.ts.map +1 -1
  40. package/lib/types/mobile-api.js +61 -16
  41. package/lib/types/mobile-pending.d.ts +38 -0
  42. package/lib/types/mobile-pending.d.ts.map +1 -0
  43. package/lib/types/mobile-pending.js +59 -0
  44. package/lib/types/paired-model-catalog.d.ts +17 -0
  45. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  46. package/lib/types/paired-model-catalog.js +311 -0
  47. package/lib/types/pairing.d.ts +13 -4
  48. package/lib/types/pairing.d.ts.map +1 -1
  49. package/lib/types/pairing.js +13 -5
  50. package/lib/types/remote-api.d.ts.map +1 -1
  51. package/lib/types/remote-channel-boot.d.ts +26 -0
  52. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  53. package/lib/types/remote-channel-boot.js +128 -0
  54. package/lib/types/remote-channel-rules.d.ts +40 -0
  55. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  56. package/lib/types/remote-channel-rules.js +32 -0
  57. package/lib/types/routes.d.ts.map +1 -1
  58. package/lib/types/routes.js +12 -23
  59. package/lib/types/uuid-polyfill.d.ts +10 -0
  60. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  61. package/lib/types/uuid-polyfill.js +27 -0
  62. package/package.json +1 -1
  63. package/src/client/index.ts +72 -18
  64. package/src/client/remote-channel.ts +18 -28
  65. package/src/http.ts +87 -12
  66. package/src/index.ts +27 -2
  67. package/src/mobile/api.ts +91 -0
  68. package/src/mobile/markdown.test.ts +9 -0
  69. package/src/mobile/markdown.ts +17 -7
  70. package/src/mobile/mobile-styles.ts +326 -6
  71. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  72. package/src/mobile/views/ChatView.tsx +328 -10
  73. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  74. package/src/mobile/views/WorkspaceView.tsx +152 -26
  75. package/src/mobile-api.ts +59 -17
  76. package/src/mobile-pending.ts +88 -0
  77. package/src/paired-model-catalog.ts +272 -0
  78. package/src/pairing.ts +13 -5
  79. package/src/remote-api.ts +0 -1
  80. package/src/remote-channel-boot.ts +131 -0
  81. package/src/remote-channel-rules.ts +62 -0
  82. package/src/routes.ts +13 -24
  83. package/src/uuid-polyfill.ts +28 -0
@@ -10,14 +10,24 @@
10
10
  * @module dsh-remote-web-ui/mobile/markdown
11
11
  */
12
12
 
13
- /** Escape HTML special characters. */
13
+ /** HTML special-character map for {@link escapeHtml}. */
14
+ const HTML_ESCAPE_MAP: Record<string, string> = {
15
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&#39;',
20
+ }
21
+
22
+ /**
23
+ * Escape HTML special characters. One regex pass replaces all five
24
+ * characters through the map; the output is identical to five sequential
25
+ * passes (each replacement string contains none of the escaped characters),
26
+ * and the common no-special-character case scans the string only once
27
+ * instead of five times.
28
+ */
14
29
  export function escapeHtml(text: string): string {
15
- return text
16
- .replace(/&/g, '&amp;')
17
- .replace(/</g, '&lt;')
18
- .replace(/>/g, '&gt;')
19
- .replace(/"/g, '&quot;')
20
- .replace(/'/g, '&#39;')
30
+ return text.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char] ?? char)
21
31
  }
22
32
 
23
33
  /**
@@ -588,16 +588,33 @@ body {
588
588
  gap: 8px;
589
589
  }
590
590
 
591
- .chat-tool-item {
591
+ .chat-tool-card {
592
592
  display: flex;
593
593
  flex-direction: column;
594
- gap: 2px;
594
+ gap: 4px;
595
+ padding: 6px 8px;
596
+ border: 1px solid var(--m-border);
597
+ border-radius: 8px;
598
+ background: var(--m-bg-raised);
595
599
  }
596
600
 
597
- .chat-tool-name {
598
- color: var(--m-text);
599
- font-size: 12.5px;
601
+ .chat-tool-pills {
602
+ display: flex;
603
+ gap: 4px;
604
+ flex-wrap: wrap;
605
+ align-items: center;
606
+ }
607
+
608
+ .chat-tool-pill {
609
+ display: inline-block;
610
+ padding: 1px 7px;
611
+ border-radius: 999px;
612
+ background: var(--m-accent-soft);
613
+ color: var(--m-accent);
614
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
615
+ font-size: 11px;
600
616
  font-weight: 600;
617
+ white-space: nowrap;
601
618
  }
602
619
 
603
620
  .chat-tool-args {
@@ -611,8 +628,245 @@ body {
611
628
  line-height: 1.5;
612
629
  overflow-wrap: break-word;
613
630
  white-space: pre-wrap;
631
+ max-height: 200px;
632
+ overflow-y: auto;
633
+ }
634
+
635
+ /* ── turn status indicator (#1017) ───────────────────────────────────── */
636
+
637
+ .chat-turn-status {
638
+ display: flex;
639
+ align-items: center;
640
+ gap: 6px;
641
+ padding: 10px 16px;
642
+ color: var(--m-accent);
643
+ font-size: 13px;
644
+ font-weight: 600;
645
+ background: linear-gradient(
646
+ 90deg,
647
+ var(--m-accent) 0%,
648
+ color-mix(in srgb, var(--m-accent) 60%, #ffffff) 50%,
649
+ var(--m-accent) 100%
650
+ );
651
+ background-size: 200% 100%;
652
+ background-clip: text;
653
+ -webkit-background-clip: text;
654
+ -webkit-text-fill-color: transparent;
655
+ animation: dsh-shimmer 1.8s linear infinite;
656
+ }
657
+
658
+ .chat-turn-dots {
659
+ display: inline-flex;
660
+ gap: 3px;
661
+ }
662
+
663
+ .chat-turn-dots span {
664
+ display: inline-block;
665
+ width: 5px;
666
+ height: 5px;
667
+ border-radius: 50%;
668
+ background: var(--m-accent);
669
+ animation: dsh-dot-bounce 1.2s ease-in-out infinite;
670
+ }
671
+
672
+ .chat-turn-dots span:nth-child(2) {
673
+ animation-delay: 0.15s;
674
+ }
675
+
676
+ .chat-turn-dots span:nth-child(3) {
677
+ animation-delay: 0.3s;
678
+ }
679
+
680
+ @keyframes dsh-shimmer {
681
+ from { background-position: 200% 0; }
682
+ to { background-position: -200% 0; }
683
+ }
684
+
685
+ @keyframes dsh-dot-bounce {
686
+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
687
+ 40% { transform: translateY(-4px); opacity: 1; }
688
+ }
689
+
690
+ /* ── approval panel (#1025) ──────────────────────────────────────────── */
691
+
692
+ .chat-approval-panel {
693
+ margin: 8px 12px;
694
+ padding: 10px 12px;
695
+ border: 1px solid var(--m-accent);
696
+ border-radius: var(--m-radius);
697
+ background: var(--m-bg-raised);
698
+ box-shadow: var(--m-shadow);
699
+ }
700
+
701
+ .chat-approval-header {
702
+ display: flex;
703
+ align-items: center;
704
+ gap: 8px;
705
+ margin-bottom: 8px;
614
706
  }
615
707
 
708
+ .chat-approval-reason {
709
+ color: var(--m-text-secondary);
710
+ font-size: 12.5px;
711
+ }
712
+
713
+ .chat-approval-error {
714
+ margin: 4px 0;
715
+ color: var(--m-danger);
716
+ font-size: 12px;
717
+ }
718
+
719
+ .chat-approval-actions {
720
+ display: flex;
721
+ gap: 8px;
722
+ }
723
+
724
+ .chat-approval-allow {
725
+ flex: 1;
726
+ height: 34px;
727
+ border: none;
728
+ border-radius: 8px;
729
+ background: var(--m-accent);
730
+ color: #fff;
731
+ font: inherit;
732
+ font-size: 13px;
733
+ font-weight: 600;
734
+ cursor: pointer;
735
+ }
736
+
737
+ .chat-approval-allow:disabled {
738
+ opacity: 0.6;
739
+ }
740
+
741
+ .chat-approval-reject {
742
+ flex: 1;
743
+ height: 34px;
744
+ border: 1px solid var(--m-border);
745
+ border-radius: 8px;
746
+ background: var(--m-bg-raised);
747
+ color: var(--m-text);
748
+ font: inherit;
749
+ font-size: 13px;
750
+ cursor: pointer;
751
+ }
752
+
753
+ .chat-approval-reject:disabled {
754
+ opacity: 0.6;
755
+ }
756
+
757
+ /* ── question panel (#1025) ──────────────────────────────────────────── */
758
+
759
+ .chat-question-panel {
760
+ margin: 8px 12px;
761
+ padding: 10px 12px;
762
+ border: 1px solid var(--m-accent);
763
+ border-radius: var(--m-radius);
764
+ background: var(--m-bg-raised);
765
+ box-shadow: var(--m-shadow);
766
+ display: flex;
767
+ flex-direction: column;
768
+ gap: 10px;
769
+ }
770
+
771
+ .chat-question-group {
772
+ display: flex;
773
+ flex-direction: column;
774
+ gap: 6px;
775
+ }
776
+
777
+ .chat-question-header {
778
+ font-size: 14px;
779
+ font-weight: 600;
780
+ color: var(--m-text);
781
+ }
782
+
783
+ .chat-question-text {
784
+ font-size: 13px;
785
+ color: var(--m-text);
786
+ line-height: 1.5;
787
+ }
788
+
789
+ .chat-question-detail {
790
+ font-size: 12px;
791
+ color: var(--m-text-secondary);
792
+ line-height: 1.5;
793
+ }
794
+
795
+ .chat-question-options {
796
+ display: flex;
797
+ flex-direction: column;
798
+ gap: 4px;
799
+ }
800
+
801
+ .chat-question-option {
802
+ display: flex;
803
+ align-items: flex-start;
804
+ gap: 8px;
805
+ padding: 8px 10px;
806
+ border: 1px solid var(--m-border);
807
+ border-radius: 8px;
808
+ cursor: pointer;
809
+ font-size: 13px;
810
+ transition: border-color 0.12s, background 0.12s;
811
+ }
812
+
813
+ .chat-question-option input {
814
+ margin: 2px 0 0;
815
+ flex: none;
816
+ }
817
+
818
+ .chat-question-option-selected {
819
+ border-color: var(--m-accent);
820
+ background: var(--m-accent-soft);
821
+ }
822
+
823
+ .chat-question-option-label {
824
+ color: var(--m-text);
825
+ font-weight: 500;
826
+ }
827
+
828
+ .chat-question-option-desc {
829
+ display: block;
830
+ color: var(--m-text-tertiary);
831
+ font-size: 12px;
832
+ }
833
+
834
+ .chat-question-custom {
835
+ width: 100%;
836
+ min-height: 40px;
837
+ box-sizing: border-box;
838
+ padding: 8px 10px;
839
+ border: 1px solid var(--m-border);
840
+ border-radius: 8px;
841
+ background: var(--m-bg-input);
842
+ color: var(--m-text);
843
+ font: inherit;
844
+ font-size: 13px;
845
+ resize: vertical;
846
+ outline: none;
847
+ }
848
+
849
+ .chat-question-custom:focus-visible {
850
+ border-color: var(--m-accent);
851
+ }
852
+
853
+ .chat-question-submit {
854
+ height: 36px;
855
+ border: none;
856
+ border-radius: 8px;
857
+ background: var(--m-accent);
858
+ color: #fff;
859
+ font: inherit;
860
+ font-size: 13px;
861
+ font-weight: 600;
862
+ cursor: pointer;
863
+ }
864
+
865
+ .chat-question-submit:disabled {
866
+ opacity: 0.6;
867
+ }
868
+
869
+
616
870
  /* ── composer toolbar (model / permission chips) ─────────────────────── */
617
871
 
618
872
  .chat-tools {
@@ -1053,10 +1307,16 @@ body {
1053
1307
  .sheet-confirm-danger,
1054
1308
  .sheet-toggle-switch,
1055
1309
  .sheet-toggle-switch-knob,
1056
- .chat-input {
1310
+ .chat-input,
1311
+ .chat-turn-status,
1312
+ .chat-turn-dots span {
1057
1313
  animation: none;
1058
1314
  transition: none;
1059
1315
  }
1316
+ .chat-turn-status {
1317
+ background: none;
1318
+ -webkit-text-fill-color: var(--m-accent);
1319
+ }
1060
1320
  }
1061
1321
 
1062
1322
  /* ── installed-app pairing ───────────────────────────────────────────── */
@@ -1106,4 +1366,64 @@ body {
1106
1366
  .mobile-pairSubmit {
1107
1367
  width: 100%;
1108
1368
  }
1369
+
1370
+ /* ── directory browser ─────────────────────────────────────────────── */
1371
+
1372
+ .dir-browser {
1373
+ display: flex;
1374
+ flex-direction: column;
1375
+ }
1376
+
1377
+ .dir-crumbs {
1378
+ display: flex;
1379
+ align-items: center;
1380
+ overflow-x: auto;
1381
+ padding: 12px 16px;
1382
+ background: var(--m-bg-raised);
1383
+ border-bottom: 1px solid var(--m-border);
1384
+ white-space: nowrap;
1385
+ }
1386
+
1387
+ .dir-crumbs::-webkit-scrollbar {
1388
+ display: none;
1389
+ }
1390
+
1391
+ .dir-crumb {
1392
+ border: none;
1393
+ background: transparent;
1394
+ padding: 4px 6px;
1395
+ color: var(--m-text);
1396
+ font-size: 14px;
1397
+ cursor: pointer;
1398
+ }
1399
+
1400
+ .dir-crumb:active {
1401
+ background: var(--m-bg-input);
1402
+ border-radius: 4px;
1403
+ }
1404
+
1405
+ .dir-crumb-separator {
1406
+ color: var(--m-text-tertiary);
1407
+ margin: 0 4px;
1408
+ font-size: 14px;
1409
+ }
1410
+
1411
+ .dir-entry {
1412
+ padding-left: 16px;
1413
+ }
1414
+
1415
+ .dir-entry-hidden {
1416
+ opacity: 0.5;
1417
+ }
1418
+
1419
+ .dir-select {
1420
+ padding: 16px;
1421
+ background: var(--m-bg);
1422
+ border-top: 1px solid var(--m-border);
1423
+ }
1424
+
1425
+ .dir-empty {
1426
+ padding: 40px 0;
1427
+ }
1109
1428
  `
1429
+
@@ -0,0 +1,231 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * ChatView streaming markdown throttle: a pending assistant message must not
4
+ * re-parse its whole accumulated text on every chunk. Streaming text
5
+ * re-parses at most once per STREAM_RENDER_INTERVAL_MS, shows the last
6
+ * parsed result in between, and renders the exact final HTML (byte-identical
7
+ * to the plain renderMarkdown output) the moment the turn closes.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { act, cleanup, render } from '@testing-library/react'
11
+ import { ChatView, STREAM_RENDER_INTERVAL_MS } from './ChatView.tsx'
12
+ import { type SessionView } from './App.tsx'
13
+ import { renderMarkdown } from '../markdown.ts'
14
+ import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
15
+ import type { HistoryPage } from '../api.ts'
16
+ import type { WireEvent } from '../messages.ts'
17
+
18
+ // The api module is fully mocked; App.tsx's history wrapper is overridden to
19
+ // feed a fixed empty history page, its pure helpers stay real.
20
+ vi.mock('../api.ts', () => ({
21
+ fetchMobilePreferences: vi.fn(),
22
+ models: vi.fn(),
23
+ selectModel: vi.fn(),
24
+ sendCommand: vi.fn(),
25
+ }))
26
+ vi.mock('./App.tsx', async importOriginal => {
27
+ const actual = await importOriginal<typeof import('./App.tsx')>()
28
+ return {
29
+ ...actual,
30
+ loadHistory: vi.fn(),
31
+ prompt: vi.fn(async () => {}),
32
+ }
33
+ })
34
+ // Wrap the real renderer with a call counter: the throttle must cut the
35
+ // number of full-text parses during a chunk stream (each parse is O(n)).
36
+ vi.mock('../markdown.ts', async importOriginal => {
37
+ const actual = await importOriginal<typeof import('../markdown.ts')>()
38
+ return { ...actual, renderMarkdown: vi.fn(actual.renderMarkdown) }
39
+ })
40
+ import { fetchMobilePreferences, models } from '../api.ts'
41
+ import { loadHistory } from './App.tsx'
42
+
43
+ const loadHistoryMock = vi.mocked(loadHistory)
44
+ const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
45
+ const modelsMock = vi.mocked(models)
46
+ const renderMarkdownMock = vi.mocked(renderMarkdown)
47
+
48
+ const session: SessionView = {
49
+ sessionId: 's-1',
50
+ title: '测试会话',
51
+ updatedAt: 1_700_000_000_000,
52
+ running: false,
53
+ blank: false,
54
+ }
55
+
56
+ /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
57
+ class FakeMux {
58
+ listeners = new Set<(frame: unknown) => void>()
59
+ onFrame(listener: (frame: unknown) => void): () => void {
60
+ this.listeners.add(listener)
61
+ return () => { this.listeners.delete(listener) }
62
+ }
63
+ emit(frame: unknown): void {
64
+ for (const listener of this.listeners) listener(frame)
65
+ }
66
+ }
67
+
68
+ /** One history entry wrapping a WireEvent (host history-page shape). */
69
+ function makeEntry(type: string, data: unknown, seq: number): { event: WireEvent } {
70
+ return { event: { type, seq, time: seq * 1_000, data } }
71
+ }
72
+
73
+ /** Build an empty history page (the live stream supplies all content). */
74
+ function historyPage(): HistoryPage {
75
+ return { events: [] as never, hasMore: false } as HistoryPage
76
+ }
77
+
78
+ /**
79
+ * Emit one live text-delta chunk bound to the stable message id 'a-1' (the
80
+ * mobile wire shape). A stable id keeps the message row mounted across the
81
+ * fold, so the tests observe the throttle effect path only.
82
+ */
83
+ function chunk(mux: FakeMux, text: string, seq: number): void {
84
+ mux.emit({
85
+ type: 'session/event',
86
+ sessionId: 's-1',
87
+ event: makeEntry('assistant/chunk', { messageId: 'a-1', turn: 0, step: 0, text }, seq).event,
88
+ })
89
+ }
90
+
91
+ /** Emit the final assistant message (authoritative text, closes the turn). */
92
+ function finalMessage(mux: FakeMux, text: string, seq: number): void {
93
+ mux.emit({
94
+ type: 'session/event',
95
+ sessionId: 's-1',
96
+ event: makeEntry('assistant/message', {
97
+ turn: 0,
98
+ step: 0,
99
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text }] },
100
+ }, seq).event,
101
+ })
102
+ }
103
+
104
+ /** Round-trip HTML through the DOM so a byte comparison is symmetric. */
105
+ function normalize(html: string): string {
106
+ const element = document.createElement('div')
107
+ element.innerHTML = html
108
+ return element.innerHTML
109
+ }
110
+
111
+ /** The assistant message's markdown body (dangerouslySetInnerHTML target). */
112
+ function body(): HTMLElement {
113
+ const element = document.querySelector('.chat-msg.chat-msg-assistant .chat-md-body')
114
+ if (element === null) throw new Error('assistant markdown body not found')
115
+ return element as HTMLElement
116
+ }
117
+
118
+ beforeEach(() => {
119
+ fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true })
120
+ modelsMock.mockResolvedValue({
121
+ current: { provider: 'fx', model: 'fx-1' },
122
+ routable: true,
123
+ groups: [],
124
+ failures: [],
125
+ } satisfies SessionModels)
126
+ loadHistoryMock.mockResolvedValue(historyPage())
127
+ vi.useFakeTimers()
128
+ })
129
+
130
+ afterEach(() => {
131
+ cleanup()
132
+ vi.useRealTimers()
133
+ vi.clearAllMocks()
134
+ vi.restoreAllMocks()
135
+ })
136
+
137
+ describe('ChatView streaming markdown throttle', () => {
138
+ it('re-parses pending text at most once per interval and catches up byte-exactly', async () => {
139
+ const mux = new FakeMux()
140
+ await act(async () => {
141
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
142
+ })
143
+
144
+ const parts = [
145
+ '第一段 **加粗** 文本与 `行内代码`。\n\n',
146
+ '第二段 *斜体* 与 <标签> 内容。\n\n',
147
+ '第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
148
+ ]
149
+ const full = parts.join('')
150
+
151
+ // The first chunk mounts the pending message; the mount render parses it.
152
+ await act(async () => { chunk(mux, parts[0] ?? '', 6) })
153
+ const afterMount = renderMarkdownMock.mock.calls.length
154
+ expect(afterMount).toBeGreaterThanOrEqual(1)
155
+
156
+ // A burst of further chunks does not re-parse per chunk: at most one
157
+ // leading parse may run (if the wall-clock gap to the burst exceeded the
158
+ // window), but never one parse per chunk.
159
+ await act(async () => {
160
+ chunk(mux, parts[1] ?? '', 7)
161
+ chunk(mux, parts[2] ?? '', 8)
162
+ })
163
+ expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(2)
164
+ // During the window the last parsed result stays visible: the newest
165
+ // chunk's text has not reached the DOM yet.
166
+ expect(body().innerHTML).not.toContain('第三段')
167
+
168
+ // The trailing render inside the window catches up to the newest text,
169
+ // byte-identical to a full renderMarkdown of the accumulated text.
170
+ await act(async () => {
171
+ vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
172
+ })
173
+ expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
174
+ expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(3)
175
+ })
176
+
177
+ it('renders the exact final HTML immediately when the turn closes', async () => {
178
+ const mux = new FakeMux()
179
+ await act(async () => {
180
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
181
+ })
182
+
183
+ // The final event carries the authoritative text and closes the turn
184
+ // (pending -> false) in the same synchronous burst as the chunks.
185
+ const finalText = '最终 **整篇** `渲染` 与 [链接](https://example.com/x) 文本。'
186
+ await act(async () => {
187
+ chunk(mux, '流式前缀', 6)
188
+ chunk(mux, '中间过程', 7)
189
+ finalMessage(mux, finalText, 8)
190
+ })
191
+
192
+ // No timer advance needed: the closed turn already renders the exact
193
+ // final HTML (terminal messages keep the immediate-render behavior).
194
+ expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(finalText)))
195
+ })
196
+
197
+ it('keeps the streaming render fresh after window edges and skips the identical final parse', async () => {
198
+ const mux = new FakeMux()
199
+ await act(async () => {
200
+ render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
201
+ })
202
+
203
+ const first = '第一期内容。'
204
+ const second = '第二期内容,包含 `code`。'
205
+ const full = first + second
206
+
207
+ // The chunk and the window edge in one tick: the trailing render fires
208
+ // inside the window and shows the current text.
209
+ await act(async () => {
210
+ chunk(mux, first, 6)
211
+ vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
212
+ })
213
+ expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(first)))
214
+
215
+ // A later chunk schedules a new trailing render; before it fires the last
216
+ // parsed result is still on screen.
217
+ await act(async () => { chunk(mux, second, 7) })
218
+ expect(body().innerHTML).not.toContain('第二期内容')
219
+ await act(async () => {
220
+ vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5)
221
+ })
222
+ expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
223
+
224
+ // Closing the turn with the exact text already rendered must not re-parse
225
+ // (the row already holds the byte-identical final HTML).
226
+ const beforeFinal = renderMarkdownMock.mock.calls.length
227
+ await act(async () => { finalMessage(mux, full, 9) })
228
+ expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0)
229
+ expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)))
230
+ })
231
+ })