@linxin666/dsh-remote-web-ui 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +4 -1
  2. package/lib/client.js.map +1 -0
  3. package/lib/mobile.js +15467 -0
  4. package/lib/mobile.js.map +1 -0
  5. package/lib/types/client/PairFailedNotice.js +24 -0
  6. package/lib/types/client/PairFailedNotice.js.map +1 -0
  7. package/lib/types/client/PhoneIcon.js +9 -0
  8. package/lib/types/client/PhoneIcon.js.map +1 -0
  9. package/lib/types/client/PluginSettingsCard.js +39 -0
  10. package/lib/types/client/PluginSettingsCard.js.map +1 -0
  11. package/lib/types/client/RemoteEntry.js +162 -0
  12. package/lib/types/client/RemoteEntry.js.map +1 -0
  13. package/lib/types/client/RemotePanel.js +34 -0
  14. package/lib/types/client/RemotePanel.js.map +1 -0
  15. package/lib/types/client/RemoteSettingsCard.js +59 -0
  16. package/lib/types/client/RemoteSettingsCard.js.map +1 -0
  17. package/lib/types/client/deep-link.js +118 -0
  18. package/lib/types/client/deep-link.js.map +1 -0
  19. package/lib/types/client/index.js +111 -0
  20. package/lib/types/client/index.js.map +1 -0
  21. package/lib/types/client/locales.js +141 -0
  22. package/lib/types/client/locales.js.map +1 -0
  23. package/lib/types/client/pair-api.js +123 -0
  24. package/lib/types/client/pair-api.js.map +1 -0
  25. package/lib/types/client/settings-form.js +211 -0
  26. package/lib/types/client/settings-form.js.map +1 -0
  27. package/lib/types/gate.js +121 -0
  28. package/lib/types/gate.js.map +1 -0
  29. package/lib/types/index.js +227 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/lib/types/invariant.js +23 -0
  32. package/lib/types/invariant.js.map +1 -0
  33. package/lib/types/lan.js +19 -0
  34. package/lib/types/lan.js.map +1 -0
  35. package/lib/types/mobile/api.js +60 -0
  36. package/lib/types/mobile/api.js.map +1 -0
  37. package/lib/types/mobile/index.js +25 -0
  38. package/lib/types/mobile/index.js.map +1 -0
  39. package/lib/types/mobile/messages.js +449 -0
  40. package/lib/types/mobile/messages.js.map +1 -0
  41. package/lib/types/mobile/messages.test.js +189 -0
  42. package/lib/types/mobile/messages.test.js.map +1 -0
  43. package/lib/types/mobile/mobile-styles.js +853 -0
  44. package/lib/types/mobile/mobile-styles.js.map +1 -0
  45. package/lib/types/mobile/mobile-theme.js +70 -0
  46. package/lib/types/mobile/mobile-theme.test.js +71 -0
  47. package/lib/types/mobile/mux.js +110 -0
  48. package/lib/types/mobile/mux.js.map +1 -0
  49. package/lib/types/mobile/rpc.js +83 -0
  50. package/lib/types/mobile/rpc.js.map +1 -0
  51. package/lib/types/mobile/theme-toggle.js +26 -0
  52. package/lib/types/mobile/views/App.js +102 -0
  53. package/lib/types/mobile/views/App.js.map +1 -0
  54. package/lib/types/mobile/views/ChatView.js +348 -0
  55. package/lib/types/mobile/views/ChatView.js.map +1 -0
  56. package/lib/types/mobile/views/ChatView.test.js +215 -0
  57. package/lib/types/mobile/views/SessionListView.js +108 -0
  58. package/lib/types/mobile/views/SessionListView.js.map +1 -0
  59. package/lib/types/mobile/views/SessionListView.test.js +110 -0
  60. package/lib/types/mobile/views/WorkspaceView.js +43 -0
  61. package/lib/types/mobile/views/WorkspaceView.js.map +1 -0
  62. package/lib/types/mobile-api.js +263 -0
  63. package/lib/types/mobile-api.js.map +1 -0
  64. package/lib/types/mobile-routes.js +71 -0
  65. package/lib/types/mobile-routes.js.map +1 -0
  66. package/lib/types/pairing.js +285 -0
  67. package/lib/types/pairing.js.map +1 -0
  68. package/lib/types/routes.js +327 -0
  69. package/lib/types/routes.js.map +1 -0
  70. package/lib/types/tunnel.js +222 -0
  71. package/lib/types/tunnel.js.map +1 -0
  72. package/package.json +9 -6
@@ -0,0 +1,348 @@
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 { useCallback, useEffect, useRef, useState } from 'react';
15
+ import { loadHistory, prompt } from "./App.js";
16
+ import { errorText, formatTime, staleHostHint } from "./App.js";
17
+ import { models, selectModel, sendCommand } from "../api.js";
18
+ import { foldEvents } from "../messages.js";
19
+ import { ThemeToggle } from "../theme-toggle.js";
20
+ /** Extract the raw event from one history entry (the fold consumes events only). */
21
+ function eventOf(entry) {
22
+ return entry.event;
23
+ }
24
+ /** Defensive runtime guard for projection payloads. */
25
+ function isRecord(value) {
26
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
27
+ }
28
+ /** Parse the wire `permissions` projection defensively; undefined when absent. */
29
+ function parsePermissionSelect(value) {
30
+ if (!isRecord(value))
31
+ return undefined;
32
+ const rawOptions = Array.isArray(value['options']) ? value['options'] : [];
33
+ const options = [];
34
+ for (const raw of rawOptions) {
35
+ if (!isRecord(raw))
36
+ continue;
37
+ const optionValue = typeof raw['value'] === 'string' ? raw['value'] : undefined;
38
+ const name = typeof raw['name'] === 'string' ? raw['name'] : undefined;
39
+ if (optionValue === undefined || name === undefined)
40
+ continue;
41
+ options.push({
42
+ value: optionValue,
43
+ name,
44
+ ...(typeof raw['description'] === 'string' ? { description: raw['description'] } : {}),
45
+ });
46
+ }
47
+ const currentValue = typeof value['currentValue'] === 'string' ? value['currentValue'] : undefined;
48
+ if (currentValue === undefined || options.length === 0)
49
+ return undefined;
50
+ return { options, currentValue };
51
+ }
52
+ /** One display-name transform for kebab-case machine names (web-UI parity). */
53
+ function displayName(name) {
54
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
55
+ return name;
56
+ return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
57
+ }
58
+ /** First non-empty line of reasoning text (the collapsed summary). */
59
+ function firstMeaningfulLine(text) {
60
+ const trimmed = text.trim();
61
+ if (trimmed === '')
62
+ return '';
63
+ const newline = trimmed.indexOf('\n');
64
+ return newline === -1 ? trimmed : trimmed.slice(0, newline);
65
+ }
66
+ /**
67
+ * Render one session's chat.
68
+ * @param props - the session, the mux client, and the back action.
69
+ * @returns the chat surface.
70
+ */
71
+ export function ChatView({ session, mux, onBack }) {
72
+ const [messages, setMessages] = useState([]);
73
+ const [hasOlder, setHasOlder] = useState(false);
74
+ const [loading, setLoading] = useState(true);
75
+ const [error, setError] = useState(undefined);
76
+ const [input, setInput] = useState('');
77
+ const [sending, setSending] = useState(false);
78
+ const scrollRef = useRef(undefined);
79
+ const pendingRef = useRef(false);
80
+ /** The session's permission select (absent = capability not composed). */
81
+ const [permissions, setPermissions] = useState(undefined);
82
+ /** The current model selection for the toolbar chip (best-effort label). */
83
+ const [currentModel, setCurrentModel] = useState(undefined);
84
+ /** Which bottom sheet is open. */
85
+ const [sheet, setSheet] = useState(null);
86
+ // Tail page on open (content loads only when the session is opened).
87
+ useEffect(() => {
88
+ let cancelled = false;
89
+ setLoading(true);
90
+ setError(undefined);
91
+ setMessages([]);
92
+ void loadHistory(session.sessionId).then((page) => {
93
+ if (cancelled)
94
+ return;
95
+ setMessages(foldEvents(page.events.map(eventOf)));
96
+ setHasOlder(page.hasMore);
97
+ setLoading(false);
98
+ // The history-tail projection baseline seeds the permission picker.
99
+ // The `permissions` key is declared by the deployment's permission
100
+ // plugin (augmentation), so the base SDK map is indexed loosely.
101
+ const projections = page.projections?.values;
102
+ setPermissions(parsePermissionSelect(projections?.['permissions']));
103
+ scrollToBottom();
104
+ }, (reason) => {
105
+ if (cancelled)
106
+ return;
107
+ setError(errorText(reason));
108
+ setLoading(false);
109
+ });
110
+ // Best-effort current-model label for the toolbar chip; the sheet
111
+ // always re-reads a fresh directory on open.
112
+ void models(session.sessionId).then((directory) => {
113
+ if (!cancelled)
114
+ setCurrentModel(directory.current);
115
+ }, () => { });
116
+ return () => { cancelled = true; };
117
+ }, [session.sessionId]);
118
+ // Live frames: fold session events for this session in as they arrive.
119
+ useEffect(() => {
120
+ if (mux === undefined)
121
+ return;
122
+ return mux.onFrame((frame) => {
123
+ if (frame.type === 'session/event') {
124
+ if (frame.sessionId !== session.sessionId)
125
+ return;
126
+ setMessages(previous => foldEvents([frame.event], previous));
127
+ return;
128
+ }
129
+ // Live projection pushes keep the permission picker current.
130
+ if (frame.type === 'session/projection'
131
+ && frame.sessionId === session.sessionId
132
+ && frame.key === 'permissions') {
133
+ setPermissions(parsePermissionSelect(frame.value));
134
+ }
135
+ });
136
+ }, [mux, session.sessionId]);
137
+ // Keep the newest content visible while streaming.
138
+ useEffect(() => {
139
+ if (messages.some(message => message.pending === true))
140
+ scrollToBottom();
141
+ }, [messages]);
142
+ const scrollToBottom = useCallback(() => {
143
+ const el = scrollRef.current;
144
+ if (el === undefined)
145
+ return;
146
+ el.scrollTop = el.scrollHeight;
147
+ }, []);
148
+ /** Load one older page and prepend it. The fold is directional (incremental
149
+ * tails only), so the older page folds standalone and concatenates ahead —
150
+ * host page boundaries never cut a message, so the seam is exact. */
151
+ const loadOlder = useCallback(() => {
152
+ if (pendingRef.current)
153
+ return;
154
+ pendingRef.current = true;
155
+ setLoading(true);
156
+ const first = messages[0];
157
+ if (first === undefined) {
158
+ pendingRef.current = false;
159
+ setLoading(false);
160
+ return;
161
+ }
162
+ void loadHistory(session.sessionId, first.seq).then((page) => {
163
+ pendingRef.current = false;
164
+ setLoading(false);
165
+ const older = foldEvents(page.events.map(eventOf));
166
+ setMessages(previous => [...older, ...previous]);
167
+ setHasOlder(page.hasMore);
168
+ }, (reason) => {
169
+ pendingRef.current = false;
170
+ setLoading(false);
171
+ setError(errorText(reason));
172
+ });
173
+ }, [session.sessionId, messages]);
174
+ /** Send the drafted prompt (the echoed user/message arrives over mux). */
175
+ const send = useCallback(() => {
176
+ const text = input.trim();
177
+ if (text === '' || sending)
178
+ return;
179
+ setSending(true);
180
+ void prompt(session.sessionId, text).then(() => {
181
+ setSending(false);
182
+ setInput('');
183
+ }, (reason) => {
184
+ setSending(false);
185
+ setError(errorText(reason));
186
+ });
187
+ }, [input, sending, session.sessionId]);
188
+ const modelLabel = currentModel?.model ?? '模型';
189
+ const permissionLabel = permissions === undefined
190
+ ? undefined
191
+ : permissions.options.find(option => option.value === permissions.currentValue)?.name
192
+ ?? displayName(permissions.currentValue);
193
+ 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 }, 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" })] }), _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" })] }), 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" })] }))] }), _jsxs("div", { className: "chat-inputbar", children: [_jsx("textarea", { className: "chat-input", rows: 1, value: input, placeholder: "\u8F93\u5165\u6D88\u606F\uFF0CEnter \u53D1\u9001\u2026", enterKeyHint: "send", onChange: (event) => { setInput(event.target.value); }, onKeyDown: (event) => {
194
+ if (event.key === 'Enter' && !event.shiftKey) {
195
+ event.preventDefault();
196
+ void send();
197
+ }
198
+ } }), _jsx("button", { type: "button", className: "chat-send", disabled: sending || input.trim() === '', onClick: () => { void send(); }, children: 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) => {
199
+ setPermissions(previous => previous === undefined ? previous : { ...previous, currentValue: value });
200
+ }, onClose: () => { setSheet(null); } }))] }));
201
+ }
202
+ /* ── message rows ─────────────────────────────────────────────────────── */
203
+ /** One rendered message row (user bubble or assistant bubble with folds). */
204
+ function MessageRow({ message }) {
205
+ 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 })), message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (_jsx(ToolDisclosure, { tools: message.tools })), _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) })] }));
206
+ }
207
+ /** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
208
+ function ReasoningDisclosure({ text, pending }) {
209
+ const [open, setOpen] = useState(false);
210
+ const summary = pending ? lastLine(text) : firstMeaningfulLine(text);
211
+ 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 })] }));
212
+ }
213
+ /** Collapsed-by-default tool-call disclosure: summary row + expandable details. */
214
+ function ToolDisclosure({ tools }) {
215
+ const [open, setOpen] = useState(false);
216
+ const names = [...new Set(tools.map(tool => tool.name))].join(' / ');
217
+ 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", children: names }), _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-item", children: [_jsx("span", { className: "chat-tool-name", children: tool.name }), tool.arguments !== undefined && _jsx("pre", { className: "chat-tool-args", children: tool.arguments })] }, `${tool.callId}-${index}`))) }))] }));
218
+ }
219
+ /** Long assistant text collapses behind an explicit expand toggle. */
220
+ function CollapsibleText({ text }) {
221
+ const [open, setOpen] = useState(false);
222
+ if (text.length <= LONG_TEXT_LIMIT) {
223
+ return _jsx("span", { className: "chat-msg-text", children: text });
224
+ }
225
+ const shown = open ? text : text.slice(0, LONG_TEXT_PREVIEW);
226
+ 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} 字)` })] }));
227
+ }
228
+ const LONG_TEXT_LIMIT = 1600;
229
+ const LONG_TEXT_PREVIEW = 800;
230
+ /** Latest non-empty line of a streaming reasoning buffer. */
231
+ function lastLine(text) {
232
+ const trimmed = text.trimEnd();
233
+ if (trimmed === '')
234
+ return '';
235
+ const newline = trimmed.lastIndexOf('\n');
236
+ const line = newline === -1 ? trimmed : trimmed.slice(newline + 1);
237
+ return line.trim() === '' ? '' : line;
238
+ }
239
+ /* ── bottom sheets ───────────────────────────────────────────────────── */
240
+ /** Shared bottom-sheet chrome (backdrop + slide-up panel). */
241
+ function Sheet({ title, onClose, children }) {
242
+ 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 })] }) }));
243
+ }
244
+ /** The model + thinking-effort picker (fresh advisory directory per open). */
245
+ function ModelSheet({ sessionId, current, onCurrent, onClose }) {
246
+ const [state, setState] = useState({ status: 'loading' });
247
+ const [busy, setBusy] = useState(false);
248
+ const [error, setError] = useState(undefined);
249
+ const load = useCallback(() => {
250
+ setState({ status: 'loading' });
251
+ void models(sessionId).then(data => { setState({ status: 'ready', data }); }, (reason) => { setState({ status: 'error', message: errorText(reason) }); });
252
+ }, [sessionId]);
253
+ useEffect(() => { load(); }, [load]);
254
+ /** Select model/effort and close on success (one-shot action per sheet). */
255
+ const apply = useCallback((selection) => {
256
+ if (busy)
257
+ return;
258
+ setBusy(true);
259
+ setError(undefined);
260
+ void selectModel(sessionId, selection).then((result) => {
261
+ setBusy(false);
262
+ onCurrent(result.selected);
263
+ onClose();
264
+ }, (reason) => {
265
+ setBusy(false);
266
+ setError(errorText(reason));
267
+ });
268
+ }, [busy, sessionId, onCurrent, onClose]);
269
+ if (state.status === 'loading') {
270
+ 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" }) }));
271
+ }
272
+ if (state.status === 'error') {
273
+ 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" })] }) }));
274
+ }
275
+ const { data } = state;
276
+ const selected = current ?? data.current;
277
+ const choices = data.groups.flatMap(group => group.models.map(model => ({ group, model })));
278
+ const currentChoice = choices.find(choice => choice.group.id === selected.provider && choice.model.id === selected.model);
279
+ const reasoning = currentChoice?.model.reasoning;
280
+ const effectiveEffort = selected.reasoningEffort ?? reasoning?.defaultEffort;
281
+ const effortChoices = reasoning === undefined
282
+ ? []
283
+ : [
284
+ ...(reasoning.defaultEffort === undefined
285
+ ? [{ key: 'provider-default', effort: undefined, label: '跟随模型默认' }]
286
+ : []),
287
+ ...reasoning.efforts.map(effort => ({
288
+ key: `effort:${effort.id}`,
289
+ effort: effort.id,
290
+ label: effort.name,
291
+ description: effort.description,
292
+ })),
293
+ ];
294
+ 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 => {
295
+ const isSelected = selected.provider === group.id && selected.model === model.id;
296
+ return (_jsxs("button", { type: "button", className: `sheet-option${isSelected ? ' sheet-option-selected' : ''}`, disabled: busy, onClick: () => {
297
+ apply({
298
+ provider: group.id,
299
+ model: model.id,
300
+ ...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
301
+ });
302
+ }, 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));
303
+ })] }, 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 => {
304
+ const isSelected = effectiveEffort === choice.effort;
305
+ 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));
306
+ })] }))] }));
307
+ }
308
+ /** The permission-preset picker; full access needs an explicit confirm. */
309
+ function PermissionSheet({ sessionId, value, onChanged, onClose }) {
310
+ const [confirming, setConfirming] = useState(null);
311
+ const [busy, setBusy] = useState(false);
312
+ const [error, setError] = useState(undefined);
313
+ /** Submit `/permission <value>` as a slash command (mode-agnostic). */
314
+ const submit = useCallback((next) => {
315
+ if (busy)
316
+ return;
317
+ setBusy(true);
318
+ setError(undefined);
319
+ void sendCommand(sessionId, `/permission ${next}`).then(() => {
320
+ setBusy(false);
321
+ setConfirming(null);
322
+ onChanged(next);
323
+ onClose();
324
+ }, (reason) => {
325
+ setBusy(false);
326
+ setConfirming(null);
327
+ setError(errorText(reason));
328
+ });
329
+ }, [busy, sessionId, onChanged, onClose]);
330
+ const choose = (next) => {
331
+ if (next === value.currentValue) {
332
+ onClose();
333
+ return;
334
+ }
335
+ if (next === 'danger-full-access') {
336
+ setConfirming(next);
337
+ return;
338
+ }
339
+ submit(next);
340
+ };
341
+ if (confirming !== null) {
342
+ 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 ? '提交中…' : '确认开启' })] })] }));
343
+ }
344
+ return (_jsxs(Sheet, { title: "\u6743\u9650", onClose: onClose, children: [error !== undefined && _jsx("p", { className: "sheet-error", children: error }), value.options.map(option => {
345
+ const isSelected = option.value === value.currentValue;
346
+ 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));
347
+ })] }));
348
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ChatView.js","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,215 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
+ import { ChatView } 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
+ models: vi.fn(),
11
+ selectModel: vi.fn(),
12
+ sendCommand: vi.fn(),
13
+ }));
14
+ vi.mock('./App.tsx', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return {
17
+ ...actual,
18
+ loadHistory: vi.fn(),
19
+ prompt: vi.fn(async () => { }),
20
+ };
21
+ });
22
+ import { models, selectModel, sendCommand } from "../api.js";
23
+ import { loadHistory } from "./App.js";
24
+ const session = {
25
+ sessionId: 's-1',
26
+ title: '测试会话',
27
+ updatedAt: 1_700_000_000_000,
28
+ running: false,
29
+ blank: false,
30
+ };
31
+ /** Assemble one history entry wrapping a WireEvent (host history-page shape). */
32
+ function makeEntry(type, data, seq) {
33
+ return { event: { type, seq, time: seq * 1_000, data } };
34
+ }
35
+ /** Build a history page from loose wire events (the host union is strict). */
36
+ function historyPage(events, extra = {}) {
37
+ return { events: events, hasMore: false, ...extra };
38
+ }
39
+ /** A full turn: user message, reasoning + text chunks, tool calls, final message. */
40
+ function turnEvents() {
41
+ return [
42
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改一下代码' }] }, 0),
43
+ makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: '先看结构' } }, 1),
44
+ makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: '\n再看细节' } }, 2),
45
+ makeEntry('assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 1, text: '正在处理' } }, 3),
46
+ makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{"cmd":"ls"}' }, 4),
47
+ makeEntry('assistant/message', {
48
+ turn: 0,
49
+ step: 0,
50
+ message: {
51
+ id: 'a-1',
52
+ role: 'assistant',
53
+ content: [
54
+ { type: 'reasoning', text: '先看结构\n再看细节' },
55
+ { type: 'text', text: '已完成修改' },
56
+ ],
57
+ },
58
+ }, 5),
59
+ ];
60
+ }
61
+ const modelsMock = vi.mocked(models);
62
+ const selectModelMock = vi.mocked(selectModel);
63
+ const sendCommandMock = vi.mocked(sendCommand);
64
+ const loadHistoryMock = vi.mocked(loadHistory);
65
+ beforeEach(() => {
66
+ modelsMock.mockResolvedValue({
67
+ current: { provider: 'fx', model: 'fx-1' },
68
+ routable: true,
69
+ groups: [
70
+ {
71
+ id: 'fx',
72
+ name: 'FX',
73
+ models: [
74
+ { id: 'fx-1', name: 'FX 标准' },
75
+ { id: 'fx-2', name: 'FX 深度', reasoning: { efforts: [{ id: 'high', name: '高' }], defaultEffort: 'high' } },
76
+ ],
77
+ },
78
+ ],
79
+ failures: [],
80
+ });
81
+ selectModelMock.mockResolvedValue({ selected: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' } });
82
+ sendCommandMock.mockResolvedValue({});
83
+ });
84
+ afterEach(() => {
85
+ cleanup();
86
+ vi.clearAllMocks();
87
+ });
88
+ describe('ChatView message folds', () => {
89
+ it('hides reasoning behind a collapsed disclosure and expands on tap', async () => {
90
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
91
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
92
+ // The folded turn renders: user bubble, assistant text, disclosures.
93
+ expect(await screen.findByText('改一下代码')).toBeTruthy();
94
+ expect(await screen.findByText('已完成修改')).toBeTruthy();
95
+ const head = await screen.findByRole('button', { name: /深度思考/ });
96
+ expect(head.getAttribute('aria-expanded')).toBe('false');
97
+ // Only the one-line summary shows while collapsed; the body stays hidden.
98
+ expect(await screen.findByText('先看结构')).toBeTruthy();
99
+ expect(screen.queryByText(/再看细节/)).toBeNull();
100
+ fireEvent.click(head);
101
+ expect(head.getAttribute('aria-expanded')).toBe('true');
102
+ expect(await screen.findByText(/再看细节/)).toBeTruthy();
103
+ });
104
+ it('keeps the tool disclosure collapsed with a summary, then reveals arguments', async () => {
105
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
106
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
107
+ const head = await screen.findByRole('button', { name: /工具/ });
108
+ expect(head.getAttribute('aria-expanded')).toBe('false');
109
+ expect(screen.queryByText('{"cmd":"ls"}')).toBeNull();
110
+ fireEvent.click(head);
111
+ expect(head.getAttribute('aria-expanded')).toBe('true');
112
+ expect(await screen.findByText('{"cmd":"ls"}')).toBeTruthy();
113
+ expect(screen.getByText('bash')).toBeTruthy();
114
+ });
115
+ it('shows the permission chip from the history-tail projection and applies via /permission', async () => {
116
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), {
117
+ projections: {
118
+ asOfSeq: 4,
119
+ values: {
120
+ permissions: {
121
+ options: [
122
+ { value: 'read-only', name: '只读' },
123
+ { value: 'workspace-write', name: '读写工作区' },
124
+ ],
125
+ currentValue: 'read-only',
126
+ },
127
+ },
128
+ },
129
+ }));
130
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
131
+ const chip = await screen.findByRole('button', { name: /只读/ });
132
+ fireEvent.click(chip);
133
+ // The sheet lists the presets; picking one dispatches the slash command.
134
+ const writeOption = await screen.findByRole('button', { name: /读写工作区/ });
135
+ fireEvent.click(writeOption);
136
+ await waitFor(() => {
137
+ expect(sendCommandMock).toHaveBeenCalledWith('s-1', '/permission workspace-write');
138
+ });
139
+ });
140
+ it('requires an explicit confirm before enabling full access', async () => {
141
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), {
142
+ projections: {
143
+ asOfSeq: 4,
144
+ values: {
145
+ permissions: {
146
+ options: [{ value: 'danger-full-access', name: '完全权限' }],
147
+ currentValue: 'workspace-write',
148
+ },
149
+ },
150
+ },
151
+ }));
152
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
153
+ // The chip shows the derived label for the unmatched current value.
154
+ fireEvent.click(await screen.findByRole('button', { name: /Workspace Write/ }));
155
+ // Picking full access opens the confirmation sheet instead of submitting.
156
+ fireEvent.click(await screen.findByRole('button', { name: /完全权限/ }));
157
+ expect(await screen.findByText(/确认完全权限/)).toBeTruthy();
158
+ expect(sendCommandMock).not.toHaveBeenCalled();
159
+ // Cancelling dispatches nothing; opening again and confirming submits.
160
+ fireEvent.click(screen.getByRole('button', { name: /取消/ }));
161
+ fireEvent.click(screen.getByRole('button', { name: /完全权限/ }));
162
+ fireEvent.click(await screen.findByRole('button', { name: /确认开启/ }));
163
+ await waitFor(() => {
164
+ expect(sendCommandMock).toHaveBeenCalledWith('s-1', '/permission danger-full-access');
165
+ });
166
+ });
167
+ });
168
+ describe('ChatView model sheet', () => {
169
+ it('labels the toolbar chip with the current model and selects a new one', async () => {
170
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
171
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
172
+ const chip = await screen.findByRole('button', { name: /模型/ });
173
+ expect(chip.textContent).toContain('fx-1');
174
+ fireEvent.click(chip);
175
+ const deep = await screen.findByRole('button', { name: /FX 深度/ });
176
+ fireEvent.click(deep);
177
+ await waitFor(() => {
178
+ expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' });
179
+ });
180
+ });
181
+ it('offers effort choices for the current model and submits the picked effort', async () => {
182
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
183
+ // The current model already is the effort-capable one.
184
+ modelsMock.mockResolvedValue({
185
+ current: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' },
186
+ routable: true,
187
+ groups: [
188
+ {
189
+ id: 'fx',
190
+ name: 'FX',
191
+ models: [
192
+ { id: 'fx-1', name: 'FX 标准' },
193
+ { id: 'fx-2', name: 'FX 深度', reasoning: { efforts: [{ id: 'high', name: '高' }], defaultEffort: 'high' } },
194
+ ],
195
+ },
196
+ ],
197
+ failures: [],
198
+ });
199
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
200
+ fireEvent.click(await screen.findByRole('button', { name: /模型/ }));
201
+ const effort = await screen.findByRole('button', { name: /^高/ });
202
+ fireEvent.click(effort);
203
+ await waitFor(() => {
204
+ expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' });
205
+ });
206
+ });
207
+ it('explains a transport 403 on the model channel as a stale host', async () => {
208
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
209
+ modelsMock.mockRejectedValue(new Error('HTTP 403'));
210
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
211
+ fireEvent.click(await screen.findByRole('button', { name: /模型/ }));
212
+ expect(await screen.findByText(/HTTP 403/)).toBeTruthy();
213
+ expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
214
+ });
215
+ });