@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,773 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * Chat level: one session. Loads the history tail page on open, appends
4
- * pages upward (loadOlder), folds live mux frames in as they arrive, and
5
- * sends prompts through session.prompt.
6
- *
7
- * Rendering mirrors the desktop web UI's fold discipline on a small screen:
8
- * - reasoning text hides behind a collapsed "深度思考" disclosure,
9
- * - tool calls behind a collapsed tool disclosure (name + arguments),
10
- * - very long assistant text collapses with an explicit expand toggle,
11
- * - a toolbar above the composer carries the model (+ thinking effort) and
12
- * permission pickers, both as bottom sheets.
13
- */
14
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
- import { loadHistory, prompt } from "./App.js";
16
- import { errorText, formatTime, staleHostHint } from "./App.js";
17
- import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending, respondApproval, respondQuestion } from "../api.js";
18
- import { EventFolder, foldEvents } from "../messages.js";
19
- import { renderMarkdown } from "../markdown.js";
20
- import { ThemeToggle } from "../theme-toggle.js";
21
- /**
22
- * Hard cap on live events buffered while the initial history tail page is in
23
- * flight. Beyond this the oldest buffered event is dropped and a follow-up
24
- * history tail re-pull closes the seam.
25
- */
26
- export const MAX_TAIL_BUFFER_EVENTS = 500;
27
- /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
28
- const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls';
29
- /** localStorage key for the injected-system-message display toggle. */
30
- const SHOW_SYSTEM_MESSAGES_KEY = 'dsh.mobile.showSystemMessages';
31
- /** Read a boolean from localStorage defensively; falls back to the default. */
32
- function readStoredBoolean(key, fallback) {
33
- try {
34
- const raw = localStorage.getItem(key);
35
- if (raw === null)
36
- return fallback;
37
- return raw === '1' || raw.toLowerCase() === 'true';
38
- }
39
- catch {
40
- return fallback;
41
- }
42
- }
43
- /** Persist a boolean toggle; storage failures are ignored (feature stays non-persistent). */
44
- function writeStoredBoolean(key, value) {
45
- try {
46
- localStorage.setItem(key, value ? '1' : '0');
47
- }
48
- catch {
49
- /* quota / privacy mode: non-persistent is acceptable */
50
- }
51
- }
52
- /** Extract the raw event from one history entry (the fold consumes events only). */
53
- function eventOf(entry) {
54
- return entry.event;
55
- }
56
- /** Defensive runtime guard for projection payloads. */
57
- function isRecord(value) {
58
- return typeof value === 'object' && value !== null && !Array.isArray(value);
59
- }
60
- /** Parse the wire `permissions` projection defensively; undefined when absent. */
61
- function parsePermissionSelect(value) {
62
- if (!isRecord(value))
63
- return undefined;
64
- const rawOptions = Array.isArray(value['options']) ? value['options'] : [];
65
- const options = [];
66
- for (const raw of rawOptions) {
67
- if (!isRecord(raw))
68
- continue;
69
- const optionValue = typeof raw['value'] === 'string' ? raw['value'] : undefined;
70
- const name = typeof raw['name'] === 'string' ? raw['name'] : undefined;
71
- if (optionValue === undefined || name === undefined)
72
- continue;
73
- options.push({
74
- value: optionValue,
75
- name,
76
- ...(typeof raw['description'] === 'string' ? { description: raw['description'] } : {}),
77
- });
78
- }
79
- const currentValue = typeof value['currentValue'] === 'string' ? value['currentValue'] : undefined;
80
- if (currentValue === undefined || options.length === 0)
81
- return undefined;
82
- return { options, currentValue };
83
- }
84
- /** One display-name transform for kebab-case machine names (web-UI parity). */
85
- function displayName(name) {
86
- if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
87
- return name;
88
- return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
89
- }
90
- /** First non-empty line of reasoning text (the collapsed summary). */
91
- function firstMeaningfulLine(text) {
92
- const trimmed = text.trim();
93
- if (trimmed === '')
94
- return '';
95
- const newline = trimmed.indexOf('\n');
96
- return newline === -1 ? trimmed : trimmed.slice(0, newline);
97
- }
98
- /**
99
- * Render one session's chat.
100
- * @param props - the session, the mux client, and the back action.
101
- * @returns the chat surface.
102
- */
103
- export function ChatView({ session, mux, onBack }) {
104
- const [messages, setMessages] = useState([]);
105
- const [hasOlder, setHasOlder] = useState(false);
106
- const [loading, setLoading] = useState(true);
107
- const [error, setError] = useState(undefined);
108
- const [input, setInput] = useState('');
109
- const [sending, setSending] = useState(false);
110
- const scrollRef = useRef(undefined);
111
- const pendingRef = useRef(false);
112
- /**
113
- * True while the initial tail page is in flight. Live events arriving in
114
- * that window go to {@link liveBufferRef} instead of the message list: the
115
- * tail load replaces the list wholesale, so a directly folded event would
116
- * flash once, be discarded by the snapshot, and then be skipped forever by
117
- * the seq watermark.
118
- */
119
- const tailLoadingRef = useRef(true);
120
- /** Live session events buffered while the initial tail page loads. */
121
- const liveBufferRef = useRef([]);
122
- /** Incremental folder for this session's stream (indexes stay hot across events). */
123
- const folderRef = useRef(undefined);
124
- /** True once the live buffer hit its cap (oldest events were dropped). */
125
- const liveBufferOverflowRef = useRef(false);
126
- /** The session's permission select (absent = capability not composed). */
127
- const [permissions, setPermissions] = useState(undefined);
128
- /** The current model selection for the toolbar chip (best-effort label). */
129
- const [currentModel, setCurrentModel] = useState(undefined);
130
- /** Which bottom sheet is open. */
131
- const [sheet, setSheet] = useState(null);
132
- /** Show tool-call disclosures (default on, persisted on the /m origin). */
133
- const [showToolCalls, setShowToolCalls] = useState(() => readStoredBoolean(SHOW_TOOL_CALLS_KEY, true));
134
- /** Show injected system messages (default off, persisted on the /m origin). */
135
- const [showSystemMessages, setShowSystemMessages] = useState(() => readStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, false));
136
- /**
137
- * Composer preference from the plugin's host settings (default true keeps
138
- * the legacy Enter-to-send behavior until the preference loads).
139
- */
140
- const [mobileEnterToSend, setMobileEnterToSend] = useState(true);
141
- /** Whether the assistant is currently generating (turn/start..turn/end). */
142
- const [running, setRunning] = useState(false);
143
- /** Whether a stop request is in flight (guards the composer's stop button). */
144
- const [stopping, setStopping] = useState(false);
145
- /** Pending tool approvals awaiting user decision (#1025). */
146
- const [pendingApprovals, setPendingApprovals] = useState([]);
147
- /** Pending questions awaiting user answer (#1025). */
148
- const [pendingQuestions, setPendingQuestions] = useState([]);
149
- // Read-only mobile display preferences ride the plugin's local
150
- // `/m/api` method; a failure keeps the default (Enter sends).
151
- useEffect(() => {
152
- let cancelled = false;
153
- void fetchMobilePreferences().then((preferences) => {
154
- if (!cancelled)
155
- setMobileEnterToSend(preferences.mobileEnterToSend !== false);
156
- }, () => { });
157
- return () => { cancelled = true; };
158
- }, []);
159
- // Tail page on open (content loads only when the session is opened).
160
- useEffect(() => {
161
- let cancelled = false;
162
- const controller = new AbortController();
163
- // A stuck history load must not keep the chat empty (or the live buffer
164
- // growing) forever: abort it and surface the transport error.
165
- const timeout = setTimeout(() => {
166
- controller.abort(new DOMException('history load timed out', 'TimeoutError'));
167
- }, 15_000);
168
- tailLoadingRef.current = true;
169
- liveBufferRef.current = [];
170
- liveBufferOverflowRef.current = false;
171
- folderRef.current = undefined;
172
- setLoading(true);
173
- setError(undefined);
174
- setMessages([]);
175
- void loadHistory(session.sessionId, undefined, controller.signal).then((page) => {
176
- if (cancelled)
177
- return;
178
- // Buffered live events re-fold on top of the snapshot; the watermark
179
- // drops any the snapshot already includes, so nothing is lost or doubled.
180
- const buffered = liveBufferRef.current;
181
- liveBufferRef.current = [];
182
- tailLoadingRef.current = false;
183
- const folder = new EventFolder(foldEvents(page.events.map(eventOf)));
184
- folderRef.current = folder;
185
- setMessages(folder.fold(buffered));
186
- setHasOlder(page.hasMore);
187
- setLoading(false);
188
- // The history-tail projection baseline seeds the permission picker.
189
- // The `permissions` key is declared by the deployment's permission
190
- // plugin (augmentation), so the base SDK map is indexed loosely.
191
- const projections = page.projections?.values;
192
- setPermissions(parsePermissionSelect(projections?.['permissions']));
193
- // The buffer overflowed while waiting (oldest events were dropped), so
194
- // re-pull the freshest history page to close the gap on top of what is
195
- // already rendered. Best-effort: a failure here only logs, it must not
196
- // replace the loaded state with an error.
197
- if (liveBufferOverflowRef.current) {
198
- void loadHistory(session.sessionId, undefined, controller.signal).then((fresh) => {
199
- if (cancelled)
200
- return;
201
- const folder = folderRef.current;
202
- const freshEvents = fresh.events.map(eventOf);
203
- setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents));
204
- liveBufferOverflowRef.current = false;
205
- }, (reason) => {
206
- if (cancelled)
207
- return;
208
- console.warn('history tail re-pull after live buffer overflow failed', reason);
209
- });
210
- }
211
- }, (reason) => {
212
- if (cancelled)
213
- return;
214
- // Load failed: flush the buffer so the live stream still renders.
215
- const buffered = liveBufferRef.current;
216
- liveBufferRef.current = [];
217
- tailLoadingRef.current = false;
218
- if (buffered.length > 0) {
219
- const folder = folderRef.current;
220
- setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered));
221
- }
222
- setError(errorText(reason));
223
- setLoading(false);
224
- });
225
- // Best-effort current-model label for the toolbar chip; the sheet
226
- // always re-reads a fresh directory on open.
227
- void models(session.sessionId).then((directory) => {
228
- if (!cancelled)
229
- setCurrentModel(directory.current);
230
- }, () => { });
231
- return () => {
232
- cancelled = true;
233
- clearTimeout(timeout);
234
- controller.abort();
235
- };
236
- }, [session.sessionId]);
237
- // Live frames: fold session events for this session in as they arrive.
238
- useEffect(() => {
239
- if (mux === undefined)
240
- return;
241
- return mux.onFrame((frame) => {
242
- if (frame.type === 'session/event') {
243
- if (frame.sessionId !== session.sessionId)
244
- return;
245
- const event = frame.event;
246
- // Track the turn running state for the "outputting" indicator (#1017).
247
- if (typeof event.type === 'string') {
248
- if (event.type === 'turn/start')
249
- setRunning(true);
250
- if (event.type === 'turn/end')
251
- setRunning(false);
252
- }
253
- if (tailLoadingRef.current) {
254
- if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
255
- // Bound the tail-load window: drop the oldest buffered event and
256
- // remember that a follow-up history re-pull is needed. Warn once
257
- // per load, not for every subsequent overflow.
258
- liveBufferRef.current.shift();
259
- if (!liveBufferOverflowRef.current) {
260
- console.warn(`history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`);
261
- liveBufferOverflowRef.current = true;
262
- }
263
- }
264
- liveBufferRef.current.push(event);
265
- return;
266
- }
267
- setMessages(previous => {
268
- const folder = folderRef.current;
269
- return folder === undefined ? foldEvents([event], previous) : folder.fold([event]);
270
- });
271
- return;
272
- }
273
- // Live projection pushes keep the permission picker current.
274
- if (frame.type === 'session/projection'
275
- && frame.sessionId === session.sessionId
276
- && frame.key === 'permissions') {
277
- setPermissions(parsePermissionSelect(frame.value));
278
- return;
279
- }
280
- // Approval/question frames for this session (#1025).
281
- if (!('sessionId' in frame) || frame.sessionId !== session.sessionId)
282
- return;
283
- if (frame.type === 'approval/requested') {
284
- setPendingApprovals(previous => {
285
- if (previous.some(a => a.approvalId === frame.approvalId))
286
- return previous;
287
- return [...previous, {
288
- approvalId: frame.approvalId,
289
- toolName: frame.toolName,
290
- callId: frame.callId,
291
- reason: frame.reason,
292
- }];
293
- });
294
- return;
295
- }
296
- if (frame.type === 'approval/resolved') {
297
- setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId));
298
- return;
299
- }
300
- if (frame.type === 'question/requested') {
301
- const items = frame.questions;
302
- setPendingQuestions(items);
303
- return;
304
- }
305
- if (frame.type === 'question/resolved') {
306
- setPendingQuestions([]);
307
- return;
308
- }
309
- });
310
- }, [mux, session.sessionId]);
311
- // Weak-network polling fallback: when the assistant is running, poll for
312
- // pending approvals/questions every 1.5 s so the phone can act even if the
313
- // SSE channel drops frames (#1025).
314
- useEffect(() => {
315
- if (!running)
316
- return;
317
- let cancelled = false;
318
- const tick = () => {
319
- void fetchPending(session.sessionId).then((state) => {
320
- if (cancelled)
321
- return;
322
- setPendingApprovals(state.approvals);
323
- setPendingQuestions(state.questions);
324
- }, () => { });
325
- };
326
- tick();
327
- const timer = setInterval(tick, 1500);
328
- return () => { cancelled = true; clearInterval(timer); };
329
- }, [running, session.sessionId]);
330
- const scrollToBottom = useCallback(() => {
331
- const el = scrollRef.current;
332
- if (el === undefined)
333
- return;
334
- el.scrollTop = el.scrollHeight;
335
- }, []);
336
- // Track the last message's fold key so scrolling only fires when the
337
- // newest message actually changes (seq bump and/or pending flip). Runs
338
- // after React has committed the render, so scrollHeight reflects the
339
- // freshly appended content.
340
- const lastMessageKeyRef = useRef(undefined);
341
- // Keep the newest content visible. This covers the initial tail page (the
342
- // effect runs after commit, fixing the stale scrollHeight from the old
343
- // open-time scrollToBottom), live streaming chunks on the pending message,
344
- // and finalized/appended messages. Prepending older pages via loadOlder
345
- // leaves the last message untouched, so it never disturbs the scroll position.
346
- useEffect(() => {
347
- const last = messages[messages.length - 1];
348
- if (last === undefined)
349
- return;
350
- const key = last.seq + ':' + (last.pending === true ? 'p' : 'f');
351
- if (key === lastMessageKeyRef.current)
352
- return;
353
- lastMessageKeyRef.current = key;
354
- scrollToBottom();
355
- }, [messages, scrollToBottom]);
356
- /** Load one older page and prepend it. The fold is directional (incremental
357
- * tails only), so the older page folds standalone and concatenates ahead —
358
- * host page boundaries never cut a message, so the seam is exact. */
359
- const loadOlder = useCallback(() => {
360
- if (pendingRef.current)
361
- return;
362
- pendingRef.current = true;
363
- setLoading(true);
364
- const first = messages[0];
365
- if (first === undefined) {
366
- pendingRef.current = false;
367
- setLoading(false);
368
- return;
369
- }
370
- void loadHistory(session.sessionId, first.seq).then((page) => {
371
- pendingRef.current = false;
372
- setLoading(false);
373
- const older = foldEvents(page.events.map(eventOf));
374
- const folder = folderRef.current;
375
- if (folder === undefined) {
376
- setMessages(previous => [...older, ...previous]);
377
- }
378
- else {
379
- folder.prepend(older);
380
- setMessages(folder.snapshot());
381
- }
382
- setHasOlder(page.hasMore);
383
- }, (reason) => {
384
- pendingRef.current = false;
385
- setLoading(false);
386
- setError(errorText(reason));
387
- });
388
- }, [session.sessionId, messages]);
389
- /** Send the drafted prompt (the echoed user/message arrives over mux). */
390
- const send = useCallback(() => {
391
- const text = input.trim();
392
- if (text === '' || sending)
393
- return;
394
- setSending(true);
395
- void prompt(session.sessionId, text).then(() => {
396
- setSending(false);
397
- setInput('');
398
- }, (reason) => {
399
- setSending(false);
400
- setError(errorText(reason));
401
- });
402
- }, [input, sending, session.sessionId]);
403
- /**
404
- * Stop the active turn (desktop parity: the composer's primary button
405
- * becomes a stop button while running). The turn/end frame arriving over
406
- * mux flips the button back; a failed request surfaces through the chat
407
- * error line.
408
- */
409
- const stopTurn = useCallback(() => {
410
- if (stopping)
411
- return;
412
- setStopping(true);
413
- void cancelSession(session.sessionId).then(() => { setStopping(false); }, (reason) => {
414
- setStopping(false);
415
- setError(errorText(reason));
416
- });
417
- }, [stopping, session.sessionId]);
418
- const modelLabel = currentModel?.model ?? '模型';
419
- const permissionLabel = permissions === undefined
420
- ? undefined
421
- : permissions.options.find(option => option.value === permissions.currentValue)?.name
422
- ?? displayName(permissions.currentValue);
423
- // Context usage chip: the most recent assistant message carrying both usage and
424
- // a positive context window drives the percentage. Scanned from the end so a
425
- // newer answer (whose usage may be the last one reported) takes precedence.
426
- const contextUsage = useMemo(() => {
427
- for (let index = messages.length - 1; index >= 0; index--) {
428
- const message = messages[index];
429
- const usage = message.usage;
430
- if (message.kind !== 'assistant' || usage === undefined)
431
- continue;
432
- const window = message.contextWindow;
433
- if (window === undefined || window <= 0)
434
- continue;
435
- const tokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
436
- const pct = Math.round(tokens / window * 100);
437
- return { pct };
438
- }
439
- return undefined;
440
- }, [messages]);
441
- return (_jsxs("div", { className: "chat", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-back", "aria-label": "\u8FD4\u56DE", onClick: onBack, children: "\u2039" }), _jsx("h1", { className: "mobile-title mobile-titleInline", children: session.title }), _jsx(ThemeToggle, {})] }), error !== undefined && _jsx("p", { className: "mobile-error mobile-pad", children: error }), _jsxs("div", { className: "chat-scroll", ref: ref => { scrollRef.current = ref ?? undefined; }, children: [hasOlder && (_jsx("button", { type: "button", className: "chat-load-older", disabled: loading, onClick: () => { void loadOlder(); }, children: loading ? '加载中…' : '加载更早的消息' })), messages.map(message => (_jsx(MessageRow, { message: message, showToolCalls: showToolCalls, showSystemMessages: showSystemMessages }, message.id))), loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u52A0\u8F7D\u4E2D\u2026" }), !loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u8FD8\u6CA1\u6709\u6D88\u606F\uFF0C\u53D1\u4E00\u53E5\u8BDD\u5F00\u59CB\u5427" }), running && (_jsxs("div", { className: "chat-turn-status", role: "status", "aria-label": "\u8F93\u51FA\u4E2D", children: ["\u8F93\u51FA\u4E2D", _jsxs("span", { className: "chat-turn-dots", "aria-hidden": true, children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] })] })), pendingApprovals.map(approval => (_jsx(ApprovalPanel, { approval: approval, sessionId: session.sessionId, onResolved: (id) => { setPendingApprovals(prev => prev.filter(a => a.approvalId !== id)); } }, approval.approvalId))), pendingQuestions.length > 0 && (_jsx(QuestionPanel, { questions: pendingQuestions, sessionId: session.sessionId, onResolved: () => { setPendingQuestions([]); } }))] }), _jsxs("div", { className: "chat-tools", children: [_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('model'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6A21\u578B" }), _jsx("span", { className: "chat-chip-value", children: modelLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), _jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('display'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u663E\u793A" }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), permissionLabel !== undefined && (_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('permission'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6743\u9650" }), _jsx("span", { className: "chat-chip-value", children: permissionLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] })), contextUsage !== undefined && (_jsxs("div", { className: "chat-context" + (contextUsage.pct >= 80 ? " chat-context-warn" : ""), children: ["\u4E0A\u4E0B\u6587 ", contextUsage.pct, "%"] }))] }), _jsxs("div", { className: "chat-inputbar", children: [_jsx("textarea", { className: "chat-input", rows: 1, value: input, placeholder: mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…', enterKeyHint: mobileEnterToSend ? 'send' : 'enter', onChange: (event) => { setInput(event.target.value); }, onKeyDown: (event) => {
442
- if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
443
- event.preventDefault();
444
- void send();
445
- }
446
- } }), _jsx("button", { type: "button", className: running ? 'chat-send chat-send-stop' : 'chat-send', ...(running ? { 'aria-label': stopping ? '停止中' : '停止' } : {}), disabled: running ? stopping : sending || input.trim() === '', onClick: () => { if (running)
447
- void stopTurn();
448
- else
449
- void send(); }, children: running ? (_jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", focusable: "false", children: _jsx("rect", { x: "3", y: "3", width: "10", height: "10", rx: "3", fill: "currentColor" }) })) : sending ? '发送中…' : '发送' })] }), sheet === 'model' && (_jsx(ModelSheet, { sessionId: session.sessionId, current: currentModel, onCurrent: (selection) => { setCurrentModel(selection); }, onClose: () => { setSheet(null); } })), sheet === 'permission' && permissions !== undefined && (_jsx(PermissionSheet, { sessionId: session.sessionId, value: permissions, onChanged: (value) => {
450
- setPermissions(previous => previous === undefined ? previous : { ...previous, currentValue: value });
451
- }, onClose: () => { setSheet(null); } })), sheet === 'display' && (_jsx(DisplaySheet, { showToolCalls: showToolCalls, showSystemMessages: showSystemMessages, onToolCalls: (value) => { setShowToolCalls(value); writeStoredBoolean(SHOW_TOOL_CALLS_KEY, value); }, onSystemMessages: (value) => { setShowSystemMessages(value); writeStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, value); }, onClose: () => { setSheet(null); } }))] }));
452
- }
453
- /* ── message rows ─────────────────────────────────────────────────────── */
454
- /**
455
- * One rendered message row (user bubble or assistant bubble with folds).
456
- * Memoized: live streaming updates exactly one message object per frame, so
457
- * unchanged rows skip re-rendering their markdown/sub-components.
458
- */
459
- const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }) {
460
- // Injected user messages (sourceKind defined and not 'user') hide behind
461
- // the system-message toggle.
462
- if (message.kind === 'user'
463
- && message.sourceKind !== undefined
464
- && message.sourceKind !== 'user'
465
- && !showSystemMessages) {
466
- return null;
467
- }
468
- const hasReasoning = message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '';
469
- const hasTools = showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0;
470
- const hasText = message.text !== '';
471
- const hasFailTag = message.failed === true;
472
- if (!hasReasoning && !hasTools && !hasText && !hasFailTag) {
473
- return null;
474
- }
475
- return (_jsxs("div", { className: `chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`, children: [message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (_jsx(ReasoningDisclosure, { text: message.reasoning, pending: message.pending === true })), showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (_jsx(ToolDisclosure, { tools: message.tools })), message.kind === 'assistant'
476
- ? _jsx(MarkdownText, { text: message.text, pending: message.pending === true })
477
- : _jsx(CollapsibleText, { text: message.text }), message.failed === true && _jsx("span", { className: "chat-msg-failtag", children: "\u672C\u6B21\u56DE\u590D\u5931\u8D25" }), _jsx("span", { className: "chat-msg-time", children: formatTime(message.time) })] }));
478
- });
479
- /** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
480
- function ReasoningDisclosure({ text, pending }) {
481
- const [open, setOpen] = useState(false);
482
- const summary = pending ? lastLine(text) : firstMeaningfulLine(text);
483
- return (_jsxs("div", { className: `chat-disclosure chat-reasoning${open ? ' chat-disclosure-open' : ''}`, "data-pending": pending || undefined, children: [_jsxs("button", { type: "button", className: "chat-disclosure-head", "aria-expanded": open, onClick: () => { setOpen(value => !value); }, children: [_jsx("span", { className: "chat-disclosure-caret", "aria-hidden": true, children: "\u203A" }), _jsx("span", { className: "chat-disclosure-label", children: pending ? '思考中…' : '深度思考' }), !open && _jsx("span", { className: "chat-disclosure-summary", children: summary })] }), open && _jsx("div", { className: "chat-disclosure-body", children: text })] }));
484
- }
485
- /** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
486
- function ToolDisclosure({ tools }) {
487
- const [open, setOpen] = useState(false);
488
- const uniqueNames = [...new Set(tools.map(tool => tool.name))];
489
- return (_jsxs("div", { className: `chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`, children: [_jsxs("button", { type: "button", className: "chat-disclosure-head", "aria-expanded": open, onClick: () => { setOpen(value => !value); }, children: [_jsx("span", { className: "chat-disclosure-caret", "aria-hidden": true, children: "\u203A" }), _jsx("span", { className: "chat-disclosure-label", children: "\u5DE5\u5177" }), !open && (_jsx("span", { className: "chat-disclosure-summary chat-tool-pills", children: uniqueNames.map(name => (_jsx("span", { className: "chat-tool-pill", children: name }, name))) })), _jsxs("span", { className: "chat-disclosure-count", children: [tools.length, " \u6B21"] })] }), open && (_jsx("div", { className: "chat-disclosure-body chat-tools-body", children: tools.map((tool, index) => (_jsxs("div", { className: "chat-tool-card", children: [_jsx("span", { className: "chat-tool-pill", children: tool.name }), tool.arguments !== undefined && (_jsx("pre", { className: "chat-tool-args", children: tool.arguments }))] }, `${tool.callId}-${index}`))) }))] }));
490
- }
491
- /**
492
- * Minimum interval between full markdown re-parses of a live (pending)
493
- * assistant message. Every streamed chunk replaces the message object, and
494
- * re-parsing the whole accumulated text per chunk turns a long reply into
495
- * O(n^2) work on mobile. Pending text keeps the last parsed result visible
496
- * and re-parses at most once per interval; the moment the turn closes the
497
- * final text parses immediately, so terminal messages render exactly as
498
- * before.
499
- */
500
- export const STREAM_RENDER_INTERVAL_MS = 120;
501
- /**
502
- * Assistant text rendered as GFM markdown (escape-first, protocol
503
- * allow-list — see markdown.ts). Long replies collapse by clamping the
504
- * rendered block height instead of slicing the source, so half-cut code
505
- * fences or tables never leak malformed markup into the DOM. User
506
- * messages stay plain text (CollapsibleText).
507
- */
508
- function MarkdownText({ text, pending }) {
509
- const [open, setOpen] = useState(false);
510
- const [html, setHtml] = useState(() => renderMarkdown(text));
511
- /** Text of the last render actually applied to `html`. */
512
- const renderedTextRef = useRef(text);
513
- /** Newest streamed text, read by the trailing render at fire time. */
514
- const latestTextRef = useRef(text);
515
- /** Timestamp of the last applied parse (throttle window start). */
516
- const lastRenderAtRef = useRef(performance.now());
517
- const timerRef = useRef(undefined);
518
- // Throttled parse for a live stream: skip parses while the newest text is
519
- // already rendered, parse immediately once the throttle window elapsed,
520
- // otherwise schedule one trailing render that picks up the newest text.
521
- useEffect(() => {
522
- latestTextRef.current = text;
523
- if (!pending) {
524
- // Turn closed: terminal messages are never throttled, so cancel any
525
- // scheduled stream render and parse the final text immediately.
526
- if (timerRef.current !== undefined) {
527
- clearTimeout(timerRef.current);
528
- timerRef.current = undefined;
529
- }
530
- if (text === renderedTextRef.current)
531
- return;
532
- lastRenderAtRef.current = performance.now();
533
- renderedTextRef.current = text;
534
- setHtml(renderMarkdown(text));
535
- return;
536
- }
537
- if (text === renderedTextRef.current)
538
- return;
539
- const elapsed = performance.now() - lastRenderAtRef.current;
540
- if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
541
- lastRenderAtRef.current = performance.now();
542
- renderedTextRef.current = text;
543
- setHtml(renderMarkdown(text));
544
- return;
545
- }
546
- if (timerRef.current === undefined) {
547
- timerRef.current = setTimeout(() => {
548
- timerRef.current = undefined;
549
- lastRenderAtRef.current = performance.now();
550
- renderedTextRef.current = latestTextRef.current;
551
- setHtml(renderMarkdown(latestTextRef.current));
552
- }, STREAM_RENDER_INTERVAL_MS - elapsed);
553
- }
554
- }, [text, pending]);
555
- // Cancel the trailing stream render if the row unmounts mid-stream.
556
- useEffect(() => {
557
- return () => {
558
- if (timerRef.current !== undefined)
559
- clearTimeout(timerRef.current);
560
- };
561
- }, []);
562
- const long = !pending && text.length > LONG_TEXT_LIMIT;
563
- const collapsed = long && !open;
564
- return (_jsxs("div", { className: 'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : ''), children: [_jsx("div", { className: "chat-md-body", dangerouslySetInnerHTML: { __html: html } }), long && (_jsx("button", { type: "button", className: "chat-msg-toggle", onClick: () => { setOpen(value => !value); }, children: open ? '收起' : '展开全文(' + text.length + ' 字)' }))] }));
565
- }
566
- /** Long assistant text collapses behind an explicit expand toggle. */
567
- function CollapsibleText({ text }) {
568
- const [open, setOpen] = useState(false);
569
- if (text.length <= LONG_TEXT_LIMIT) {
570
- return _jsx("span", { className: "chat-msg-text", children: text });
571
- }
572
- const shown = open ? text : text.slice(0, LONG_TEXT_PREVIEW);
573
- return (_jsxs("span", { className: "chat-msg-text", children: [shown, !open ? '…' : '', _jsx("button", { type: "button", className: "chat-msg-toggle", onClick: () => { setOpen(value => !value); }, children: open ? '收起' : `展开全文(${text.length} 字)` })] }));
574
- }
575
- export const LONG_TEXT_LIMIT = 6000;
576
- const LONG_TEXT_PREVIEW = 800;
577
- /** Latest non-empty line of a streaming reasoning buffer. */
578
- function lastLine(text) {
579
- const trimmed = text.trimEnd();
580
- if (trimmed === '')
581
- return '';
582
- const newline = trimmed.lastIndexOf('\n');
583
- const line = newline === -1 ? trimmed : trimmed.slice(newline + 1);
584
- return line.trim() === '' ? '' : line;
585
- }
586
- /* ── bottom sheets ───────────────────────────────────────────────────── */
587
- /** Shared bottom-sheet chrome (backdrop + slide-up panel). */
588
- function Sheet({ title, onClose, children }) {
589
- return (_jsx("div", { className: "sheet-backdrop", onClick: onClose, children: _jsxs("div", { className: "sheet", role: "dialog", "aria-modal": "true", "aria-label": title, onClick: (event) => { event.stopPropagation(); }, children: [_jsx("div", { className: "sheet-handle", "aria-hidden": true }), _jsx("div", { className: "sheet-title", children: title }), _jsx("div", { className: "sheet-body", children: children })] }) }));
590
- }
591
- /** The model + thinking-effort picker (fresh advisory directory per open). */
592
- function ModelSheet({ sessionId, current, onCurrent, onClose }) {
593
- const [state, setState] = useState({ status: 'loading' });
594
- const [busy, setBusy] = useState(false);
595
- const [error, setError] = useState(undefined);
596
- const load = useCallback(() => {
597
- setState({ status: 'loading' });
598
- void models(sessionId).then(data => { setState({ status: 'ready', data }); }, (reason) => { setState({ status: 'error', message: errorText(reason) }); });
599
- }, [sessionId]);
600
- useEffect(() => { load(); }, [load]);
601
- /** Select model/effort and close on success (one-shot action per sheet). */
602
- const apply = useCallback((selection) => {
603
- if (busy)
604
- return;
605
- setBusy(true);
606
- setError(undefined);
607
- void selectModel(sessionId, selection).then((result) => {
608
- setBusy(false);
609
- onCurrent(result.selected);
610
- onClose();
611
- }, (reason) => {
612
- setBusy(false);
613
- setError(errorText(reason));
614
- });
615
- }, [busy, sessionId, onCurrent, onClose]);
616
- if (state.status === 'loading') {
617
- return (_jsx(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: _jsx("div", { className: "sheet-status", children: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u76EE\u5F55\u2026" }) }));
618
- }
619
- if (state.status === 'error') {
620
- return (_jsx(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: _jsxs("div", { className: "sheet-status sheet-status-error", children: [_jsx("span", { children: state.message }), staleHostHint(state.message) !== undefined && _jsx("span", { className: "sheet-hint", children: staleHostHint(state.message) }), _jsx("button", { type: "button", className: "chat-load-older", onClick: load, children: "\u91CD\u8BD5" })] }) }));
621
- }
622
- const { data } = state;
623
- const selected = current ?? data.current;
624
- const choices = data.groups.flatMap(group => group.models.map(model => ({ group, model })));
625
- const currentChoice = choices.find(choice => choice.group.id === selected.provider && choice.model.id === selected.model);
626
- const reasoning = currentChoice?.model.reasoning;
627
- const effectiveEffort = selected.reasoningEffort ?? reasoning?.defaultEffort;
628
- const effortChoices = reasoning === undefined
629
- ? []
630
- : [
631
- ...(reasoning.defaultEffort === undefined
632
- ? [{ key: 'provider-default', effort: undefined, label: '跟随模型默认' }]
633
- : []),
634
- ...reasoning.efforts.map(effort => ({
635
- key: `effort:${effort.id}`,
636
- effort: effort.id,
637
- label: effort.name,
638
- description: effort.description,
639
- })),
640
- ];
641
- return (_jsxs(Sheet, { title: "\u6A21\u578B\u4E0E\u601D\u8003\u5F3A\u5EA6", onClose: onClose, children: [error !== undefined && _jsx("p", { className: "sheet-error", children: error }), error !== undefined && staleHostHint(error) !== undefined && _jsx("p", { className: "sheet-hint", children: staleHostHint(error) }), data.failures.map(failure => (_jsxs("p", { className: "sheet-error", children: [failure.name, ": ", failure.message] }, failure.id))), data.groups.length === 0 && choices.length === 0 && (_jsx("div", { className: "sheet-status", children: "\u6CA1\u6709\u53EF\u7528\u7684\u6A21\u578B" })), data.groups.map(group => (_jsxs("div", { className: "sheet-section", children: [_jsx("div", { className: "sheet-section-title", children: group.name }), group.models.map(model => {
642
- const isSelected = selected.provider === group.id && selected.model === model.id;
643
- return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => {
644
- apply({
645
- provider: group.id,
646
- model: model.id,
647
- ...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
648
- });
649
- }, children: [_jsxs("span", { className: "sheet-option-copy", children: [_jsx("span", { className: "sheet-option-title", children: model.name }), model.description !== undefined && _jsx("span", { className: "sheet-option-desc", children: model.description })] }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, model.id));
650
- })] }, group.id))), effortChoices.length > 0 && (_jsxs("div", { className: "sheet-section", children: [_jsx("div", { className: "sheet-section-title", children: "\u601D\u8003\u5F3A\u5EA6" }), effortChoices.map(choice => {
651
- const isSelected = effectiveEffort === choice.effort;
652
- return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => { apply({ provider: selected.provider, model: selected.model, ...(choice.effort !== undefined ? { reasoningEffort: choice.effort } : {}) }); }, children: [_jsx("span", { className: "sheet-option-copy", children: _jsx("span", { className: "sheet-option-title", children: choice.label }) }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, choice.key));
653
- })] }))] }));
654
- }
655
- /** The permission-preset picker; full access needs an explicit confirm. */
656
- function PermissionSheet({ sessionId, value, onChanged, onClose }) {
657
- const [confirming, setConfirming] = useState(null);
658
- const [busy, setBusy] = useState(false);
659
- const [error, setError] = useState(undefined);
660
- /** Submit `/permission <value>` as a slash command (mode-agnostic). */
661
- const submit = useCallback((next) => {
662
- if (busy)
663
- return;
664
- setBusy(true);
665
- setError(undefined);
666
- void sendCommand(sessionId, `/permission ${next}`).then(() => {
667
- setBusy(false);
668
- setConfirming(null);
669
- onChanged(next);
670
- onClose();
671
- }, (reason) => {
672
- setBusy(false);
673
- setConfirming(null);
674
- setError(errorText(reason));
675
- });
676
- }, [busy, sessionId, onChanged, onClose]);
677
- const choose = (next) => {
678
- if (next === value.currentValue) {
679
- onClose();
680
- return;
681
- }
682
- if (next === 'danger-full-access') {
683
- setConfirming(next);
684
- return;
685
- }
686
- submit(next);
687
- };
688
- if (confirming !== null) {
689
- return (_jsxs(Sheet, { title: "\u786E\u8BA4\u5B8C\u5168\u6743\u9650", onClose: () => { setConfirming(null); }, children: [_jsx("p", { className: "sheet-confirm-desc", children: "\u5F00\u542F\u5B8C\u5168\u6743\u9650\u540E\uFF0C\u8FDC\u7A0B\u4F1A\u8BDD\u53EF\u4EE5\u5728\u5DE5\u4F5C\u533A\u5185\u6267\u884C\u4EFB\u610F\u64CD\u4F5C\uFF08\u5305\u62EC\u8FD0\u884C\u547D\u4EE4\u3001\u4FEE\u6539\u6240\u6709\u6587\u4EF6\u4E0E\u8BBF\u95EE\u51ED\u8BC1\uFF09\u3002 \u4EC5\u5728\u60A8\u4FE1\u4EFB\u5F53\u524D\u8BBE\u5907\u548C\u7F51\u7EDC\u65F6\u5F00\u542F\u3002" }), error !== undefined && _jsx("p", { className: "sheet-error", children: error }), _jsxs("div", { className: "sheet-confirm-actions", children: [_jsx("button", { type: "button", className: "mobile-button", disabled: busy, onClick: () => { setConfirming(null); }, children: "\u53D6\u6D88" }), _jsx("button", { type: "button", className: "sheet-confirm-danger", disabled: busy, onClick: () => { submit(confirming); }, children: busy ? '提交中…' : '确认开启' })] })] }));
690
- }
691
- return (_jsxs(Sheet, { title: "\u6743\u9650", onClose: onClose, children: [error !== undefined && _jsx("p", { className: "sheet-error", children: error }), value.options.map(option => {
692
- const isSelected = option.value === value.currentValue;
693
- return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => { choose(option.value); }, children: [_jsxs("span", { className: "sheet-option-copy", children: [_jsx("span", { className: "sheet-option-title", children: option.name }), option.description !== undefined && _jsx("span", { className: "sheet-option-desc", children: option.description })] }), isSelected && _jsx("span", { className: "sheet-option-check", "aria-hidden": true, children: "\u221A" })] }, option.value));
694
- })] }));
695
- }
696
- /** The display-options sheet: tool calls and injected system messages toggles. */
697
- function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystemMessages, onClose }) {
698
- return (_jsx(Sheet, { title: "\u663E\u793A", onClose: onClose, children: _jsxs("div", { role: "group", "aria-label": "\u663E\u793A\u9009\u9879", children: [_jsxs("div", { className: "sheet-toggle-row", children: [_jsxs("div", { className: "sheet-toggle-copy", children: [_jsx("span", { className: "sheet-toggle-title", children: "\u5DE5\u5177\u8C03\u7528" }), _jsx("span", { className: "sheet-toggle-desc", children: "\u663E\u793A\u52A9\u624B\u4F7F\u7528\u7684\u5DE5\u5177\u8C03\u7528" })] }), _jsx("button", { type: "button", role: "switch", "aria-label": "\u5DE5\u5177\u8C03\u7528", "aria-checked": showToolCalls, className: `sheet-toggle-switch${showToolCalls ? ' sheet-toggle-switch-on' : ''}`, onClick: () => { onToolCalls(!showToolCalls); }, children: _jsx("span", { className: "sheet-toggle-switch-knob", "aria-hidden": true }) })] }), _jsxs("div", { className: "sheet-toggle-row", children: [_jsxs("div", { className: "sheet-toggle-copy", children: [_jsx("span", { className: "sheet-toggle-title", children: "\u7CFB\u7EDF\u63D0\u793A\u8BCD" }), _jsx("span", { className: "sheet-toggle-desc", children: "\u663E\u793A\u6CE8\u5165\u5230\u5BF9\u8BDD\u4E2D\u7684\u7CFB\u7EDF\u6D88\u606F" })] }), _jsx("button", { type: "button", role: "switch", "aria-label": "\u7CFB\u7EDF\u63D0\u793A\u8BCD", "aria-checked": showSystemMessages, className: `sheet-toggle-switch${showSystemMessages ? ' sheet-toggle-switch-on' : ''}`, onClick: () => { onSystemMessages(!showSystemMessages); }, children: _jsx("span", { className: "sheet-toggle-switch-knob", "aria-hidden": true }) })] })] }) }));
699
- }
700
- /* ── approval / question panels (#1025) ──────────────────────────────── */
701
- /** One pending tool approval card with allow/reject actions. */
702
- function ApprovalPanel({ approval, sessionId, onResolved }) {
703
- const [busy, setBusy] = useState(false);
704
- const [panelError, setPanelError] = useState(undefined);
705
- const act = (outcome) => {
706
- if (busy)
707
- return;
708
- setBusy(true);
709
- setPanelError(undefined);
710
- void respondApproval(sessionId, approval.approvalId, outcome).then(() => { onResolved(approval.approvalId); }, (reason) => {
711
- setBusy(false);
712
- setPanelError(reason instanceof Error ? reason.message : String(reason));
713
- });
714
- };
715
- return (_jsxs("div", { className: "chat-approval-panel", role: "alert", children: [_jsxs("div", { className: "chat-approval-header", children: [_jsx("span", { className: "chat-tool-pill", children: approval.toolName }), approval.reason !== undefined && (_jsx("span", { className: "chat-approval-reason", children: approval.reason }))] }), panelError !== undefined && _jsx("p", { className: "chat-approval-error", children: panelError }), _jsxs("div", { className: "chat-approval-actions", children: [_jsx("button", { type: "button", className: "chat-approval-allow", disabled: busy, onClick: () => { act('allowed-once'); }, children: busy ? '提交中…' : '允许一次' }), _jsx("button", { type: "button", className: "chat-approval-reject", disabled: busy, onClick: () => { act('rejected'); }, children: "\u62D2\u7EDD" })] })] }));
716
- }
717
- /** Question panel: renders one or more questions with option pickers and a submit button. */
718
- function QuestionPanel({ questions, sessionId, onResolved }) {
719
- const [selections, setSelections] = useState(() => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])));
720
- const [busy, setBusy] = useState(false);
721
- const [panelError, setPanelError] = useState(undefined);
722
- const toggle = (questionId, label, multi) => {
723
- setSelections(previous => {
724
- const next = new Map(previous);
725
- const entry = next.get(questionId) ?? { selected: [], custom: '' };
726
- if (multi) {
727
- const set = new Set(entry.selected);
728
- if (set.has(label))
729
- set.delete(label);
730
- else
731
- set.add(label);
732
- next.set(questionId, { ...entry, selected: [...set] });
733
- }
734
- else {
735
- next.set(questionId, { ...entry, selected: [label] });
736
- }
737
- return next;
738
- });
739
- };
740
- const setCustom = (questionId, value) => {
741
- setSelections(previous => {
742
- const next = new Map(previous);
743
- const entry = next.get(questionId) ?? { selected: [], custom: '' };
744
- next.set(questionId, { ...entry, custom: value });
745
- return next;
746
- });
747
- };
748
- const submit = () => {
749
- if (busy)
750
- return;
751
- setBusy(true);
752
- setPanelError(undefined);
753
- const answers = questions.map(q => {
754
- const entry = selections.get(q.id) ?? { selected: [], custom: '' };
755
- return {
756
- id: q.id,
757
- selected: entry.selected,
758
- ...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
759
- };
760
- });
761
- void respondQuestion(sessionId, answers).then(() => { onResolved(); }, (reason) => {
762
- setBusy(false);
763
- setPanelError(reason instanceof Error ? reason.message : String(reason));
764
- });
765
- };
766
- return (_jsxs("div", { className: "chat-question-panel", role: "form", "aria-label": "\u95EE\u9898", children: [questions.map(q => {
767
- const entry = selections.get(q.id) ?? { selected: [], custom: '' };
768
- return (_jsxs("div", { className: "chat-question-group", children: [q.header !== undefined && _jsx("div", { className: "chat-question-header", children: q.header }), _jsx("div", { className: "chat-question-text", children: q.question }), q.detail !== undefined && _jsx("div", { className: "chat-question-detail", children: q.detail }), q.options !== undefined && q.options.length > 0 && (_jsx("div", { className: "chat-question-options", role: "group", "aria-label": q.question, children: q.options.map(option => {
769
- const checked = entry.selected.includes(option.label);
770
- return (_jsxs("label", { className: `chat-question-option${checked ? ' chat-question-option-selected' : ''}`, children: [_jsx("input", { type: q.multiSelect ? 'checkbox' : 'radio', name: `q-${q.id}`, checked: checked, onChange: () => { toggle(q.id, option.label, q.multiSelect === true); } }), _jsx("span", { className: "chat-question-option-label", children: option.label }), option.description !== undefined && (_jsx("span", { className: "chat-question-option-desc", children: option.description }))] }, option.label));
771
- }) })), _jsx("textarea", { className: "chat-question-custom", placeholder: "\u81EA\u5B9A\u4E49\u56DE\u7B54\uFF08\u53EF\u9009\uFF09", rows: 2, value: entry.custom, onChange: (e) => { setCustom(q.id, e.target.value); } })] }, q.id));
772
- }), panelError !== undefined && _jsx("p", { className: "chat-approval-error", children: panelError }), _jsx("button", { type: "button", className: "chat-question-submit", disabled: busy, onClick: submit, children: busy ? '提交中…' : '提交回答' })] }));
773
- }