@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17621
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -677
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,718 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- // @vitest-environment jsdom
3
- /** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
4
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
- import { ChatView, MAX_TAIL_BUFFER_EVENTS, LONG_TEXT_LIMIT } from "./ChatView.js";
7
- // The api module is fully mocked; App.tsx's history wrapper is overridden to
8
- // feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
9
- vi.mock('../api.ts', () => ({
10
- fetchMobilePreferences: vi.fn(),
11
- models: vi.fn(),
12
- selectModel: vi.fn(),
13
- sendCommand: vi.fn(),
14
- cancelSession: vi.fn(),
15
- fetchPending: vi.fn(),
16
- }));
17
- vi.mock('./App.tsx', async (importOriginal) => {
18
- const actual = await importOriginal();
19
- return {
20
- ...actual,
21
- loadHistory: vi.fn(),
22
- prompt: vi.fn(async () => { }),
23
- };
24
- });
25
- import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending } from "../api.js";
26
- import { loadHistory, prompt } from "./App.js";
27
- const session = {
28
- sessionId: 's-1',
29
- title: '测试会话',
30
- updatedAt: 1_700_000_000_000,
31
- running: false,
32
- blank: false,
33
- };
34
- /** Assemble one history entry wrapping a WireEvent (host history-page shape). */
35
- function makeEntry(type, data, seq) {
36
- return { event: { type, seq, time: seq * 1_000, data } };
37
- }
38
- /** Build a history page from loose wire events (the host union is strict). */
39
- function historyPage(events, extra = {}) {
40
- return { events: events, hasMore: false, ...extra };
41
- }
42
- /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
43
- class FakeMux {
44
- listeners = new Set();
45
- onFrame(listener) {
46
- this.listeners.add(listener);
47
- return () => { this.listeners.delete(listener); };
48
- }
49
- emit(frame) {
50
- for (const listener of this.listeners)
51
- listener(frame);
52
- }
53
- }
54
- /** A full turn: user message, reasoning + text chunks, tool calls, final message. */
55
- function turnEvents() {
56
- return [
57
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改一下代码' }] }, 0),
58
- makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: '先看结构' } }, 1),
59
- makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: '\n再看细节' } }, 2),
60
- makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 1, text: '正在处理' } }, 3),
61
- makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{"cmd":"ls"}' }, 4),
62
- makeEntry('assistant/message', {
63
- turn: 0,
64
- step: 0,
65
- message: {
66
- id: 'a-1',
67
- role: 'assistant',
68
- content: [
69
- { type: 'reasoning', text: '先看结构\n再看细节' },
70
- { type: 'text', text: '已完成修改' },
71
- ],
72
- },
73
- }, 5),
74
- ];
75
- }
76
- const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences);
77
- const modelsMock = vi.mocked(models);
78
- const selectModelMock = vi.mocked(selectModel);
79
- const sendCommandMock = vi.mocked(sendCommand);
80
- const cancelSessionMock = vi.mocked(cancelSession);
81
- const fetchPendingMock = vi.mocked(fetchPending);
82
- const loadHistoryMock = vi.mocked(loadHistory);
83
- const promptMock = vi.mocked(prompt);
84
- beforeEach(() => {
85
- fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true });
86
- promptMock.mockResolvedValue(undefined);
87
- modelsMock.mockResolvedValue({
88
- current: { provider: 'fx', model: 'fx-1' },
89
- routable: true,
90
- groups: [
91
- {
92
- id: 'fx',
93
- name: 'FX',
94
- models: [
95
- { id: 'fx-1', name: 'FX 标准' },
96
- { id: 'fx-2', name: 'FX 深度', reasoning: { efforts: [{ id: 'high', name: '高' }], defaultEffort: 'high' } },
97
- ],
98
- },
99
- ],
100
- failures: [],
101
- });
102
- selectModelMock.mockResolvedValue({ selected: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' } });
103
- sendCommandMock.mockResolvedValue({});
104
- cancelSessionMock.mockResolvedValue({ accepted: true });
105
- fetchPendingMock.mockResolvedValue({ approvals: [], questions: [] });
106
- });
107
- afterEach(() => {
108
- cleanup();
109
- vi.clearAllMocks();
110
- vi.restoreAllMocks();
111
- });
112
- describe('ChatView message folds', () => {
113
- it('hides reasoning behind a collapsed disclosure and expands on tap', async () => {
114
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
115
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
116
- // The folded turn renders: user bubble, assistant text, disclosures.
117
- expect(await screen.findByText('改一下代码')).toBeTruthy();
118
- expect(await screen.findByText('已完成修改')).toBeTruthy();
119
- const head = await screen.findByRole('button', { name: /深度思考/ });
120
- expect(head.getAttribute('aria-expanded')).toBe('false');
121
- // Only the one-line summary shows while collapsed; the body stays hidden.
122
- expect(await screen.findByText('先看结构')).toBeTruthy();
123
- expect(screen.queryByText(/再看细节/)).toBeNull();
124
- fireEvent.click(head);
125
- expect(head.getAttribute('aria-expanded')).toBe('true');
126
- expect(await screen.findByText(/再看细节/)).toBeTruthy();
127
- });
128
- it('keeps the tool disclosure collapsed with a summary, then reveals arguments', async () => {
129
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
130
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
131
- const head = await screen.findByRole('button', { name: /工具/ });
132
- expect(head.getAttribute('aria-expanded')).toBe('false');
133
- expect(screen.queryByText('{"cmd":"ls"}')).toBeNull();
134
- fireEvent.click(head);
135
- expect(head.getAttribute('aria-expanded')).toBe('true');
136
- expect(await screen.findByText('{"cmd":"ls"}')).toBeTruthy();
137
- expect(screen.getByText('bash')).toBeTruthy();
138
- });
139
- it('shows the permission chip from the history-tail projection and applies via /permission', async () => {
140
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), {
141
- projections: {
142
- asOfSeq: 4,
143
- values: {
144
- permissions: {
145
- options: [
146
- { value: 'read-only', name: '只读' },
147
- { value: 'workspace-write', name: '读写工作区' },
148
- ],
149
- currentValue: 'read-only',
150
- },
151
- },
152
- },
153
- }));
154
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
155
- const chip = await screen.findByRole('button', { name: /只读/ });
156
- fireEvent.click(chip);
157
- // The sheet lists the presets; picking one dispatches the slash command.
158
- const writeOption = await screen.findByRole('button', { name: /读写工作区/ });
159
- fireEvent.click(writeOption);
160
- await waitFor(() => {
161
- expect(sendCommandMock).toHaveBeenCalledWith('s-1', '/permission workspace-write');
162
- });
163
- });
164
- it('requires an explicit confirm before enabling full access', async () => {
165
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), {
166
- projections: {
167
- asOfSeq: 4,
168
- values: {
169
- permissions: {
170
- options: [{ value: 'danger-full-access', name: '完全权限' }],
171
- currentValue: 'workspace-write',
172
- },
173
- },
174
- },
175
- }));
176
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
177
- // The chip shows the derived label for the unmatched current value.
178
- fireEvent.click(await screen.findByRole('button', { name: /Workspace Write/ }));
179
- // Picking full access opens the confirmation sheet instead of submitting.
180
- fireEvent.click(await screen.findByRole('button', { name: /完全权限/ }));
181
- expect(await screen.findByText(/确认完全权限/)).toBeTruthy();
182
- expect(sendCommandMock).not.toHaveBeenCalled();
183
- // Cancelling dispatches nothing; opening again and confirming submits.
184
- fireEvent.click(screen.getByRole('button', { name: /取消/ }));
185
- fireEvent.click(screen.getByRole('button', { name: /完全权限/ }));
186
- fireEvent.click(await screen.findByRole('button', { name: /确认开启/ }));
187
- await waitFor(() => {
188
- expect(sendCommandMock).toHaveBeenCalledWith('s-1', '/permission danger-full-access');
189
- });
190
- });
191
- });
192
- describe('ChatView initial-load race', () => {
193
- /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
194
- class FakeMux {
195
- listeners = new Set();
196
- onFrame(listener) {
197
- this.listeners.add(listener);
198
- return () => { this.listeners.delete(listener); };
199
- }
200
- emit(frame) {
201
- for (const listener of this.listeners)
202
- listener(frame);
203
- }
204
- }
205
- it('keeps live events that arrive while the tail page is still loading', async () => {
206
- let resolveHistory = () => { };
207
- loadHistoryMock.mockReturnValue(new Promise((resolve) => { resolveHistory = resolve; }));
208
- const mux = new FakeMux();
209
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
210
- // A live turn starts before the snapshot resolves: chunk, tool call, final.
211
- await act(async () => {
212
- mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', text: '正在' } }, 6).event });
213
- mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('tool/call', { turn: 1, step: 0, callId: 'c9', name: 'bash', arguments: '{"cmd":"ls"}' }, 7).event });
214
- mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/message', { turn: 1, step: 0, message: { id: 'a-9', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] } }, 8).event });
215
- });
216
- // The snapshot predates those events; resolving it must not drop them.
217
- await act(async () => { resolveHistory(historyPage(turnEvents())); });
218
- expect(await screen.findByText('实时新消息')).toBeTruthy();
219
- // The history turn's tool disclosure plus the live one both render.
220
- expect((await screen.findAllByRole('button', { name: /工具/ })).length).toBe(2);
221
- });
222
- it('caps the tail-load live buffer and re-pulls the history tail after an overflow', async () => {
223
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
224
- let resolveHistory = () => { };
225
- loadHistoryMock
226
- .mockReturnValueOnce(new Promise((resolve) => { resolveHistory = resolve; }))
227
- // The overflow follow-up load resolves a page newer than the buffered burst.
228
- .mockResolvedValueOnce(historyPage([
229
- makeEntry('assistant/message', {
230
- id: 'a-refetch',
231
- role: 'assistant',
232
- content: [{ type: 'text', text: '补拉恢复' }],
233
- }, 700),
234
- ]));
235
- const mux = new FakeMux();
236
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
237
- // 501 live final messages arrive before the snapshot resolves: the first one
238
- // is dropped by the 500-event cap, the remaining 500 stay buffered.
239
- await act(async () => {
240
- for (let index = 0; index <= MAX_TAIL_BUFFER_EVENTS; index++) {
241
- mux.emit({
242
- type: 'session/event',
243
- sessionId: 's-1',
244
- event: makeEntry('assistant/message', {
245
- id: `a-burst-${index}`,
246
- role: 'assistant',
247
- content: [{ type: 'text', text: `突发消息 ${index}` }],
248
- }, 100 + index).event,
249
- });
250
- }
251
- });
252
- // The overflow is logged exactly once, and the cap mentions the limit.
253
- expect(warnSpy).toHaveBeenCalledTimes(1);
254
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain(String(MAX_TAIL_BUFFER_EVENTS));
255
- await act(async () => { resolveHistory(historyPage(turnEvents())); });
256
- // The capped buffer keeps the render bounded: the dropped oldest burst
257
- // message is gone, while the newest buffered one survives the snapshot fold.
258
- expect(await screen.findByText('突发消息 500')).toBeTruthy();
259
- expect(screen.queryByText('突发消息 0')).toBeNull();
260
- // The overflow triggered exactly one follow-up tail load, still carrying the
261
- // same abort signal as the initial load.
262
- await waitFor(() => { expect(loadHistoryMock).toHaveBeenCalledTimes(2); });
263
- expect(loadHistoryMock.mock.calls[1]?.[0]).toBe('s-1');
264
- expect(loadHistoryMock.mock.calls[1]?.[1]).toBeUndefined();
265
- expect(loadHistoryMock.mock.calls[1]?.[2]).toBeInstanceOf(AbortSignal);
266
- expect(loadHistoryMock.mock.calls[1]?.[2]).toBe(loadHistoryMock.mock.calls[0]?.[2]);
267
- // The re-pulled page folds into the already-rendered messages.
268
- expect(await screen.findByText('补拉恢复')).toBeTruthy();
269
- });
270
- it('passes an AbortSignal to loadHistory and aborts it on unmount', async () => {
271
- let capturedSignal;
272
- loadHistoryMock.mockImplementation((_sessionId, _beforeSeq, signal) => {
273
- capturedSignal = signal;
274
- return Promise.resolve(historyPage(turnEvents()));
275
- });
276
- const view = render(_jsx(ChatView, { session: session, onBack: () => { } }));
277
- expect(await screen.findByText('已完成修改')).toBeTruthy();
278
- expect(loadHistoryMock).toHaveBeenCalledTimes(1);
279
- expect(loadHistoryMock.mock.calls[0]?.[0]).toBe('s-1');
280
- expect(loadHistoryMock.mock.calls[0]?.[1]).toBeUndefined();
281
- expect(loadHistoryMock.mock.calls[0]?.[2]).toBeInstanceOf(AbortSignal);
282
- expect(capturedSignal).toBeInstanceOf(AbortSignal);
283
- expect(capturedSignal?.aborted).toBe(false);
284
- view.unmount();
285
- expect(capturedSignal?.aborted).toBe(true);
286
- });
287
- });
288
- describe('ChatView model sheet', () => {
289
- it('labels the toolbar chip with the current model and selects a new one', async () => {
290
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
291
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
292
- const chip = await screen.findByRole('button', { name: /模型/ });
293
- expect(chip.textContent).toContain('fx-1');
294
- fireEvent.click(chip);
295
- const deep = await screen.findByRole('button', { name: /FX 深度/ });
296
- fireEvent.click(deep);
297
- await waitFor(() => {
298
- expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' });
299
- });
300
- });
301
- it('offers effort choices for the current model and submits the picked effort', async () => {
302
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
303
- // The current model already is the effort-capable one.
304
- modelsMock.mockResolvedValue({
305
- current: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' },
306
- routable: true,
307
- groups: [
308
- {
309
- id: 'fx',
310
- name: 'FX',
311
- models: [
312
- { id: 'fx-1', name: 'FX 标准' },
313
- { id: 'fx-2', name: 'FX 深度', reasoning: { efforts: [{ id: 'high', name: '高' }], defaultEffort: 'high' } },
314
- ],
315
- },
316
- ],
317
- failures: [],
318
- });
319
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
320
- fireEvent.click(await screen.findByRole('button', { name: /模型/ }));
321
- const effort = await screen.findByRole('button', { name: /^高/ });
322
- fireEvent.click(effort);
323
- await waitFor(() => {
324
- expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' });
325
- });
326
- });
327
- it('explains a transport 403 on the model channel as a stale host', async () => {
328
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
329
- modelsMock.mockRejectedValue(new Error('HTTP 403'));
330
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
331
- fireEvent.click(await screen.findByRole('button', { name: /模型/ }));
332
- expect(await screen.findByText(/HTTP 403/)).toBeTruthy();
333
- expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
334
- });
335
- });
336
- describe('ChatView composer', () => {
337
- const inputBox = () => screen.getByRole('textbox');
338
- /** Dispatch one keydown through the React tree and return the real event. */
339
- const pressEnter = (input, shiftKey = false) => {
340
- const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, shiftKey });
341
- input.dispatchEvent(event);
342
- return event;
343
- };
344
- it('sends on Enter by default and keeps Shift+Enter inserting a newline', async () => {
345
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
346
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
347
- await screen.findByText('已完成修改');
348
- const input = inputBox();
349
- expect(input.getAttribute('enterKeyHint')).toBe('send');
350
- expect(input.getAttribute('placeholder')).toContain('Enter 发送');
351
- fireEvent.change(input, { target: { value: '第一行' } });
352
- const enter = pressEnter(input);
353
- expect(enter.defaultPrevented).toBe(true);
354
- await waitFor(() => {
355
- expect(promptMock).toHaveBeenCalledWith('s-1', '第一行');
356
- });
357
- // Shift+Enter stays a newline gesture and never sends.
358
- promptMock.mockClear();
359
- const shifted = pressEnter(input, true);
360
- expect(shifted.defaultPrevented).toBe(false);
361
- expect(promptMock).not.toHaveBeenCalled();
362
- });
363
- it('inserts a newline on Enter and sends only from the button when the preference is false', async () => {
364
- fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: false });
365
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
366
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
367
- await screen.findByText('已完成修改');
368
- const input = inputBox();
369
- await waitFor(() => { expect(input.getAttribute('enterKeyHint')).toBe('enter'); });
370
- expect(input.getAttribute('placeholder')).not.toContain('Enter 发送');
371
- // The handler no longer prevents Enter, so the browser's default inserts
372
- // a newline (emulated here through the controlled value) and no send fires.
373
- fireEvent.change(input, { target: { value: '第一行' } });
374
- const enter = pressEnter(input);
375
- expect(enter.defaultPrevented).toBe(false);
376
- fireEvent.change(input, { target: { value: '第一行\n' } });
377
- expect(input.value).toBe('第一行\n');
378
- expect(promptMock).not.toHaveBeenCalled();
379
- // The send button still sends the full multi-line draft.
380
- fireEvent.change(input, { target: { value: '第一行\n第二行' } });
381
- fireEvent.click(screen.getByRole('button', { name: '发送' }));
382
- await waitFor(() => {
383
- expect(promptMock).toHaveBeenCalledWith('s-1', '第一行\n第二行');
384
- });
385
- // Shift+Enter keeps inserting a newline in either mode.
386
- promptMock.mockClear();
387
- const shifted = pressEnter(input, true);
388
- expect(shifted.defaultPrevented).toBe(false);
389
- expect(promptMock).not.toHaveBeenCalled();
390
- });
391
- });
392
- describe('ChatView scrolling', () => {
393
- // Controllable scrollHeight + a write log for the chat-scroll element. The
394
- // accessors live on Element.prototype, so patching them here lets every
395
- // scrollToBottom assignment drive a deterministic assertion regardless of
396
- // when the effect runs relative to mount.
397
- let scrollHeightMock = 0;
398
- let scrollWrites = [];
399
- const origScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight');
400
- const origScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
401
- /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
402
- class FakeMux {
403
- listeners = new Set();
404
- onFrame(listener) {
405
- this.listeners.add(listener);
406
- return () => { this.listeners.delete(listener); };
407
- }
408
- emit(frame) {
409
- for (const listener of this.listeners)
410
- listener(frame);
411
- }
412
- }
413
- beforeAll(() => {
414
- Object.defineProperty(Element.prototype, 'scrollHeight', {
415
- configurable: true,
416
- get() { return scrollHeightMock; },
417
- });
418
- Object.defineProperty(Element.prototype, 'scrollTop', {
419
- configurable: true,
420
- get() { const stored = this['__scrollTop']; return typeof stored === 'number' ? stored : 0; },
421
- set(value) { scrollWrites.push(value); this['__scrollTop'] = value; },
422
- });
423
- });
424
- afterAll(() => {
425
- if (origScrollHeight)
426
- Object.defineProperty(Element.prototype, 'scrollHeight', origScrollHeight);
427
- if (origScrollTop)
428
- Object.defineProperty(Element.prototype, 'scrollTop', origScrollTop);
429
- });
430
- beforeEach(() => { scrollHeightMock = 0; scrollWrites = []; });
431
- /** A final assistant/message event (non-pending) appended live after the history turn. */
432
- const liveFinalEvent = (seq) => makeEntry('assistant/message', {
433
- turn: 1,
434
- step: 0,
435
- message: { id: 'a-2', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] },
436
- }, seq);
437
- it('positions to the latest message when a session is opened', async () => {
438
- scrollHeightMock = 400;
439
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
440
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
441
- // The tail page renders, then the commit-time effect pins scrollTop to the tail.
442
- expect(await screen.findByText('已完成修改')).toBeTruthy();
443
- expect(scrollWrites.at(-1)).toBe(400);
444
- });
445
- it('auto-scrolls to the bottom when a new live message arrives', async () => {
446
- scrollHeightMock = 400;
447
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
448
- const mux = new FakeMux();
449
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
450
- await screen.findByText('已完成修改');
451
- expect(scrollWrites.at(-1)).toBe(400);
452
- // A real-time message grows content; the newly appended (non-pending) last
453
- // message must still pull the view down to the new bottom.
454
- scrollHeightMock = 800;
455
- await act(async () => {
456
- mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
457
- });
458
- expect(await screen.findByText('实时新消息')).toBeTruthy();
459
- expect(scrollWrites.at(-1)).toBe(800);
460
- });
461
- it('keeps the current scroll position when older messages are loaded', async () => {
462
- scrollHeightMock = 400;
463
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), { hasMore: true }));
464
- const mux = new FakeMux();
465
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
466
- await screen.findByText('已完成修改');
467
- // Move to the bottom (streaming), as a stable baseline to preserve.
468
- scrollHeightMock = 800;
469
- await act(async () => {
470
- mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
471
- });
472
- await screen.findByText('实时新消息');
473
- const writesBefore = scrollWrites.length;
474
- expect(scrollWrites.at(-1)).toBe(800);
475
- // Prepend an older page; the last message is untouched, so no re-scroll fires.
476
- scrollHeightMock = 900;
477
- loadHistoryMock.mockResolvedValueOnce(historyPage(turnEvents(), { hasMore: false }));
478
- fireEvent.click(screen.getByRole('button', { name: /加载更早的消息/ }));
479
- await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
480
- });
481
- });
482
- describe('ChatView display toggles and context usage', () => {
483
- /** jsdom in this setup ships a bare localStorage object; install a real fake. */
484
- const makeStorage = () => {
485
- const map = new Map();
486
- return {
487
- getItem: (key) => map.get(key) ?? null,
488
- setItem: (key, value) => { map.set(key, value); },
489
- removeItem: (key) => { map.delete(key); },
490
- clear: () => { map.clear(); },
491
- key: (index) => [...map.keys()][index] ?? null,
492
- get length() { return map.size; },
493
- };
494
- };
495
- /** A user/message whose source.kind is 'plugin' (injected system message). */
496
- const systemEvents = () => [
497
- makeEntry('user/message', {
498
- id: 'u-plugin',
499
- role: 'user',
500
- content: [{ type: 'text', text: '系统注入消息' }],
501
- source: { kind: 'plugin', name: 'react-extension' },
502
- }, 0),
503
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
504
- ];
505
- const toolEvents = () => [
506
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
507
- makeEntry('assistant/message', {
508
- turn: 0,
509
- step: 0,
510
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
511
- }, 1),
512
- makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
513
- ];
514
- beforeEach(() => {
515
- vi.stubGlobal('localStorage', makeStorage());
516
- });
517
- afterEach(() => {
518
- vi.unstubAllGlobals();
519
- });
520
- it('hides injected user messages by default and reveals them via the display sheet', async () => {
521
- loadHistoryMock.mockResolvedValue(historyPage(systemEvents()));
522
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
523
- // The plugin-injected message is hidden by default; the real user message shows.
524
- expect(await screen.findByText('普通消息')).toBeTruthy();
525
- expect(screen.queryByText('系统注入消息')).toBeNull();
526
- // Open the display sheet and flip the system-message switch on.
527
- fireEvent.click(screen.getByRole('button', { name: /显示/ }));
528
- const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' });
529
- expect(systemSwitch.getAttribute('aria-checked')).toBe('false');
530
- fireEvent.click(systemSwitch);
531
- expect(await screen.findByText('系统注入消息')).toBeTruthy();
532
- // Flip it back off: the injected row disappears again.
533
- const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' });
534
- expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true');
535
- fireEvent.click(systemSwitchAfter);
536
- expect(screen.queryByText('系统注入消息')).toBeNull();
537
- });
538
- it('hides the tool disclosure when the tool-call toggle is off', async () => {
539
- loadHistoryMock.mockResolvedValue(historyPage(toolEvents()));
540
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
541
- // Tool disclosure visible by default.
542
- expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy();
543
- fireEvent.click(screen.getByRole('button', { name: /显示/ }));
544
- const toolSwitch = await screen.findByRole('switch', { name: '工具调用' });
545
- expect(toolSwitch.getAttribute('aria-checked')).toBe('true');
546
- fireEvent.click(toolSwitch);
547
- // The disclosure is gone while reasoning/text remain.
548
- expect(screen.queryByRole('button', { name: /工具/ })).toBeNull();
549
- expect(screen.getByText('完成')).toBeTruthy();
550
- });
551
- it('renders the context usage chip from request/context plus assistant usage', async () => {
552
- loadHistoryMock.mockResolvedValue(historyPage([
553
- makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
554
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
555
- makeEntry('assistant/message', {
556
- turn: 0,
557
- step: 0,
558
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
559
- usage: { inputTokens: 30_000, outputTokens: 1_000 },
560
- }, 2),
561
- ]));
562
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
563
- expect(await screen.findByText('上下文 30%')).toBeTruthy();
564
- });
565
- it('adds the warn class when context usage is at or above 80%', async () => {
566
- loadHistoryMock.mockResolvedValue(historyPage([
567
- makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
568
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
569
- makeEntry('assistant/message', {
570
- turn: 0,
571
- step: 0,
572
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
573
- usage: { inputTokens: 80_000, outputTokens: 0 },
574
- }, 2),
575
- ]));
576
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
577
- const chip = await screen.findByText('上下文 80%');
578
- expect(chip.className).toContain('chat-context-warn');
579
- });
580
- it('renders no context chip when there is no usage/context data', async () => {
581
- loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
582
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
583
- await screen.findByText('已完成修改');
584
- expect(screen.queryByText(/上下文/)).toBeNull();
585
- });
586
- });
587
- describe('ChatView stop button (#1041)', () => {
588
- /** Emit one session lifecycle frame for the chat's session. */
589
- function emitSessionEvent(mux, type, seq) {
590
- act(() => {
591
- mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry(type, {}, seq).event });
592
- });
593
- }
594
- it('switches the composer primary to a stop button while running and cancels the turn', async () => {
595
- loadHistoryMock.mockResolvedValue(historyPage([]));
596
- const mux = new FakeMux();
597
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
598
- // Idle: the primary button is the (empty-draft disabled) send button.
599
- const sendButton = await screen.findByRole('button', { name: '发送' });
600
- expect(sendButton.disabled).toBe(true);
601
- // Turn starts: the primary becomes an enabled stop button (square icon).
602
- emitSessionEvent(mux, 'turn/start', 1);
603
- const stopButton = (await screen.findByRole('button', { name: '停止' }));
604
- expect(stopButton.disabled).toBe(false);
605
- fireEvent.click(stopButton);
606
- await waitFor(() => expect(cancelSessionMock).toHaveBeenCalledWith('s-1'));
607
- // Turn ends: the primary flips back to send.
608
- emitSessionEvent(mux, 'turn/end', 2);
609
- expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy();
610
- });
611
- it('disables the stop button while the cancel request is in flight', async () => {
612
- loadHistoryMock.mockResolvedValue(historyPage([]));
613
- let resolveCancel;
614
- cancelSessionMock.mockReturnValue(new Promise((resolve) => {
615
- resolveCancel = () => { resolve({ accepted: true }); };
616
- }));
617
- const mux = new FakeMux();
618
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
619
- await screen.findByRole('button', { name: '发送' });
620
- emitSessionEvent(mux, 'turn/start', 1);
621
- const stopButton = (await screen.findByRole('button', { name: '停止' }));
622
- fireEvent.click(stopButton);
623
- // In flight: disabled and re-labeled; a second tap cannot double-submit.
624
- const inflight = (await screen.findByRole('button', { name: '停止中' }));
625
- expect(inflight.disabled).toBe(true);
626
- fireEvent.click(inflight);
627
- expect(cancelSessionMock).toHaveBeenCalledTimes(1);
628
- await act(async () => { resolveCancel?.(); });
629
- emitSessionEvent(mux, 'turn/end', 2);
630
- expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy();
631
- });
632
- it('surfaces a cancel failure through the chat error line', async () => {
633
- loadHistoryMock.mockResolvedValue(historyPage([]));
634
- cancelSessionMock.mockRejectedValue(new Error('cancel exploded'));
635
- const mux = new FakeMux();
636
- render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
637
- await screen.findByRole('button', { name: '发送' });
638
- emitSessionEvent(mux, 'turn/start', 1);
639
- fireEvent.click(await screen.findByRole('button', { name: '停止' }));
640
- expect(await screen.findByText(/cancel exploded/)).toBeTruthy();
641
- });
642
- });
643
- describe('ChatView message visibility and long text folding (#1065)', () => {
644
- const toolOnlyEvents = () => [
645
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '查询文件' }] }, 0),
646
- makeEntry('assistant/message', {
647
- turn: 0,
648
- step: 0,
649
- message: { id: 'a-1', role: 'assistant', content: [] },
650
- }, 1),
651
- makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'read_file', arguments: '{"path":"/a"}' }, 2),
652
- ];
653
- it('hides assistant message completely (no air bubble) when only tool calls exist and tool toggle is off', async () => {
654
- loadHistoryMock.mockResolvedValue(historyPage(toolOnlyEvents()));
655
- const { container } = render(_jsx(ChatView, { session: session, onBack: () => { } }));
656
- expect(await screen.findByText('查询文件')).toBeTruthy();
657
- // By default showToolCalls is true, tool disclosure is visible
658
- expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy();
659
- // Turn off tool-calls toggle
660
- fireEvent.click(screen.getByRole('button', { name: /显示/ }));
661
- const toolSwitch = await screen.findByRole('switch', { name: '工具调用' });
662
- fireEvent.click(toolSwitch);
663
- // The tool disclosure is gone, and the entire assistant message bubble is not rendered (no air bubble)
664
- expect(screen.queryByRole('button', { name: /工具/ })).toBeNull();
665
- const msgElements = container.querySelectorAll('.chat-msg');
666
- expect(msgElements.length).toBe(1);
667
- expect(msgElements[0]?.classList.contains('chat-msg-user')).toBe(true);
668
- });
669
- it('renders failed tag even if assistant message has no text or reasoning', async () => {
670
- loadHistoryMock.mockResolvedValue(historyPage([
671
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '测试失败' }] }, 0),
672
- makeEntry('assistant/message', {
673
- turn: 0,
674
- step: 0,
675
- message: { id: 'a-1', role: 'assistant', content: [] },
676
- }, 1),
677
- makeEntry('turn/end', { turn: 0, reason: { kind: 'error', message: 'timeout' } }, 2),
678
- ]));
679
- render(_jsx(ChatView, { session: session, onBack: () => { } }));
680
- expect(await screen.findByText('测试失败')).toBeTruthy();
681
- expect(await screen.findByText('本次回复失败')).toBeTruthy();
682
- });
683
- it('collapses terminal assistant text exceeding LONG_TEXT_LIMIT and toggles open/close', async () => {
684
- const longText = 'A'.repeat(LONG_TEXT_LIMIT + 100);
685
- loadHistoryMock.mockResolvedValue(historyPage([
686
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成长文本' }] }, 0),
687
- makeEntry('assistant/message', {
688
- turn: 0,
689
- step: 0,
690
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: longText }] },
691
- }, 1),
692
- ]));
693
- const { container } = render(_jsx(ChatView, { session: session, onBack: () => { } }));
694
- expect(await screen.findByText('生成长文本')).toBeTruthy();
695
- const toggleButton = await screen.findByRole('button', { name: new RegExp(`展开全文(${LONG_TEXT_LIMIT + 100} 字)`) });
696
- expect(toggleButton).toBeTruthy();
697
- expect(container.querySelector('.chat-md-collapsed')).not.toBeNull();
698
- // Expand
699
- fireEvent.click(toggleButton);
700
- expect(await screen.findByRole('button', { name: '收起' })).toBeTruthy();
701
- expect(container.querySelector('.chat-md-collapsed')).toBeNull();
702
- });
703
- it('does not collapse terminal assistant text within LONG_TEXT_LIMIT', async () => {
704
- const shortText = 'B'.repeat(LONG_TEXT_LIMIT);
705
- loadHistoryMock.mockResolvedValue(historyPage([
706
- makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成中等文本' }] }, 0),
707
- makeEntry('assistant/message', {
708
- turn: 0,
709
- step: 0,
710
- message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: shortText }] },
711
- }, 1),
712
- ]));
713
- const { container } = render(_jsx(ChatView, { session: session, onBack: () => { } }));
714
- expect(await screen.findByText('生成中等文本')).toBeTruthy();
715
- expect(screen.queryByRole('button', { name: /展开全文/ })).toBeNull();
716
- expect(container.querySelector('.chat-md-collapsed')).toBeNull();
717
- });
718
- });