@linxin666/dsh-remote-web-ui 0.2.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +41 -8
- package/README.zh.md +7 -2
- package/lib/client.js +65 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +645 -61
- package/lib/mobile.js +874 -64
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +35 -5
- package/lib/types/client/remote-channel.d.ts +4 -5
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +13 -28
- package/lib/types/http.d.ts +37 -8
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +76 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +25 -1
- package/lib/types/mobile/api.d.ts +57 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +29 -0
- package/lib/types/mobile/markdown.d.ts +7 -1
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +16 -7
- package/lib/types/mobile/markdown.test.js +8 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +325 -6
- package/lib/types/mobile/views/ChatView.d.ts +10 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +205 -8
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +46 -5
- package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
- package/lib/types/mobile-api.d.ts +9 -3
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +61 -16
- package/lib/types/mobile-pending.d.ts +38 -0
- package/lib/types/mobile-pending.d.ts.map +1 -0
- package/lib/types/mobile-pending.js +59 -0
- package/lib/types/paired-model-catalog.d.ts +17 -0
- package/lib/types/paired-model-catalog.d.ts.map +1 -0
- package/lib/types/paired-model-catalog.js +252 -0
- package/lib/types/pairing.d.ts +13 -4
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -5
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.d.ts +26 -0
- package/lib/types/remote-channel-boot.d.ts.map +1 -0
- package/lib/types/remote-channel-boot.js +128 -0
- package/lib/types/remote-channel-rules.d.ts +40 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -0
- package/lib/types/remote-channel-rules.js +32 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +12 -23
- package/lib/types/uuid-polyfill.d.ts +10 -0
- package/lib/types/uuid-polyfill.d.ts.map +1 -0
- package/lib/types/uuid-polyfill.js +27 -0
- package/package.json +1 -1
- package/src/client/index.ts +41 -5
- package/src/client/remote-channel.ts +18 -28
- package/src/http.ts +87 -12
- package/src/index.ts +27 -2
- package/src/mobile/api.ts +91 -0
- package/src/mobile/markdown.test.ts +9 -0
- package/src/mobile/markdown.ts +17 -7
- package/src/mobile/mobile-styles.ts +326 -6
- package/src/mobile/views/ChatView.stream.test.tsx +231 -0
- package/src/mobile/views/ChatView.tsx +328 -10
- package/src/mobile/views/WorkspaceView.test.tsx +163 -2
- package/src/mobile/views/WorkspaceView.tsx +152 -26
- package/src/mobile-api.ts +59 -17
- package/src/mobile-pending.ts +88 -0
- package/src/paired-model-catalog.ts +224 -0
- package/src/pairing.ts +13 -5
- package/src/remote-api.ts +0 -1
- package/src/remote-channel-boot.ts +131 -0
- package/src/remote-channel-rules.ts +62 -0
- package/src/routes.ts +13 -24
- package/src/uuid-polyfill.ts +28 -0
|
@@ -14,7 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
15
|
import { loadHistory, prompt } from "./App.js";
|
|
16
16
|
import { errorText, formatTime, staleHostHint } from "./App.js";
|
|
17
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand } from "../api.js";
|
|
17
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, fetchPending, respondApproval, respondQuestion } from "../api.js";
|
|
18
18
|
import { EventFolder, foldEvents } from "../messages.js";
|
|
19
19
|
import { renderMarkdown } from "../markdown.js";
|
|
20
20
|
import { ThemeToggle } from "../theme-toggle.js";
|
|
@@ -138,6 +138,12 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
138
138
|
* the legacy Enter-to-send behavior until the preference loads).
|
|
139
139
|
*/
|
|
140
140
|
const [mobileEnterToSend, setMobileEnterToSend] = useState(true);
|
|
141
|
+
/** Whether the assistant is currently generating (turn/start..turn/end). */
|
|
142
|
+
const [running, setRunning] = useState(false);
|
|
143
|
+
/** Pending tool approvals awaiting user decision (#1025). */
|
|
144
|
+
const [pendingApprovals, setPendingApprovals] = useState([]);
|
|
145
|
+
/** Pending questions awaiting user answer (#1025). */
|
|
146
|
+
const [pendingQuestions, setPendingQuestions] = useState([]);
|
|
141
147
|
// Read-only mobile display preferences ride the plugin's local
|
|
142
148
|
// `/m/api` method; a failure keeps the default (Enter sends).
|
|
143
149
|
useEffect(() => {
|
|
@@ -235,6 +241,13 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
235
241
|
if (frame.sessionId !== session.sessionId)
|
|
236
242
|
return;
|
|
237
243
|
const event = frame.event;
|
|
244
|
+
// Track the turn running state for the "outputting" indicator (#1017).
|
|
245
|
+
if (typeof event.type === 'string') {
|
|
246
|
+
if (event.type === 'turn/start')
|
|
247
|
+
setRunning(true);
|
|
248
|
+
if (event.type === 'turn/end')
|
|
249
|
+
setRunning(false);
|
|
250
|
+
}
|
|
238
251
|
if (tailLoadingRef.current) {
|
|
239
252
|
if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
|
|
240
253
|
// Bound the tail-load window: drop the oldest buffered event and
|
|
@@ -260,9 +273,58 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
260
273
|
&& frame.sessionId === session.sessionId
|
|
261
274
|
&& frame.key === 'permissions') {
|
|
262
275
|
setPermissions(parsePermissionSelect(frame.value));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Approval/question frames for this session (#1025).
|
|
279
|
+
if (!('sessionId' in frame) || frame.sessionId !== session.sessionId)
|
|
280
|
+
return;
|
|
281
|
+
if (frame.type === 'approval/requested') {
|
|
282
|
+
setPendingApprovals(previous => {
|
|
283
|
+
if (previous.some(a => a.approvalId === frame.approvalId))
|
|
284
|
+
return previous;
|
|
285
|
+
return [...previous, {
|
|
286
|
+
approvalId: frame.approvalId,
|
|
287
|
+
toolName: frame.toolName,
|
|
288
|
+
callId: frame.callId,
|
|
289
|
+
reason: frame.reason,
|
|
290
|
+
}];
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (frame.type === 'approval/resolved') {
|
|
295
|
+
setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (frame.type === 'question/requested') {
|
|
299
|
+
const items = frame.questions;
|
|
300
|
+
setPendingQuestions(items);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (frame.type === 'question/resolved') {
|
|
304
|
+
setPendingQuestions([]);
|
|
305
|
+
return;
|
|
263
306
|
}
|
|
264
307
|
});
|
|
265
308
|
}, [mux, session.sessionId]);
|
|
309
|
+
// Weak-network polling fallback: when the assistant is running, poll for
|
|
310
|
+
// pending approvals/questions every 1.5 s so the phone can act even if the
|
|
311
|
+
// SSE channel drops frames (#1025).
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!running)
|
|
314
|
+
return;
|
|
315
|
+
let cancelled = false;
|
|
316
|
+
const tick = () => {
|
|
317
|
+
void fetchPending(session.sessionId).then((state) => {
|
|
318
|
+
if (cancelled)
|
|
319
|
+
return;
|
|
320
|
+
setPendingApprovals(state.approvals);
|
|
321
|
+
setPendingQuestions(state.questions);
|
|
322
|
+
}, () => { });
|
|
323
|
+
};
|
|
324
|
+
tick();
|
|
325
|
+
const timer = setInterval(tick, 1500);
|
|
326
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
327
|
+
}, [running, session.sessionId]);
|
|
266
328
|
const scrollToBottom = useCallback(() => {
|
|
267
329
|
const el = scrollRef.current;
|
|
268
330
|
if (el === undefined)
|
|
@@ -359,7 +421,7 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
359
421
|
}
|
|
360
422
|
return undefined;
|
|
361
423
|
}, [messages]);
|
|
362
|
-
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" })] }), _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) => {
|
|
424
|
+
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) => {
|
|
363
425
|
if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
|
|
364
426
|
event.preventDefault();
|
|
365
427
|
void send();
|
|
@@ -384,7 +446,7 @@ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystem
|
|
|
384
446
|
return null;
|
|
385
447
|
}
|
|
386
448
|
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'
|
|
387
|
-
? _jsx(MarkdownText, { text: message.text })
|
|
449
|
+
? _jsx(MarkdownText, { text: message.text, pending: message.pending === true })
|
|
388
450
|
: _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) })] }));
|
|
389
451
|
});
|
|
390
452
|
/** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
|
|
@@ -393,12 +455,22 @@ function ReasoningDisclosure({ text, pending }) {
|
|
|
393
455
|
const summary = pending ? lastLine(text) : firstMeaningfulLine(text);
|
|
394
456
|
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 })] }));
|
|
395
457
|
}
|
|
396
|
-
/** Collapsed-by-default tool-call disclosure: summary
|
|
458
|
+
/** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
|
|
397
459
|
function ToolDisclosure({ tools }) {
|
|
398
460
|
const [open, setOpen] = useState(false);
|
|
399
|
-
const
|
|
400
|
-
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:
|
|
461
|
+
const uniqueNames = [...new Set(tools.map(tool => tool.name))];
|
|
462
|
+
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}`))) }))] }));
|
|
401
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Minimum interval between full markdown re-parses of a live (pending)
|
|
466
|
+
* assistant message. Every streamed chunk replaces the message object, and
|
|
467
|
+
* re-parsing the whole accumulated text per chunk turns a long reply into
|
|
468
|
+
* O(n^2) work on mobile. Pending text keeps the last parsed result visible
|
|
469
|
+
* and re-parses at most once per interval; the moment the turn closes the
|
|
470
|
+
* final text parses immediately, so terminal messages render exactly as
|
|
471
|
+
* before.
|
|
472
|
+
*/
|
|
473
|
+
export const STREAM_RENDER_INTERVAL_MS = 120;
|
|
402
474
|
/**
|
|
403
475
|
* Assistant text rendered as GFM markdown (escape-first, protocol
|
|
404
476
|
* allow-list — see markdown.ts). Long replies collapse by clamping the
|
|
@@ -406,9 +478,60 @@ function ToolDisclosure({ tools }) {
|
|
|
406
478
|
* fences or tables never leak malformed markup into the DOM. User
|
|
407
479
|
* messages stay plain text (CollapsibleText).
|
|
408
480
|
*/
|
|
409
|
-
function MarkdownText({ text }) {
|
|
481
|
+
function MarkdownText({ text, pending }) {
|
|
410
482
|
const [open, setOpen] = useState(false);
|
|
411
|
-
const html =
|
|
483
|
+
const [html, setHtml] = useState(() => renderMarkdown(text));
|
|
484
|
+
/** Text of the last render actually applied to `html`. */
|
|
485
|
+
const renderedTextRef = useRef(text);
|
|
486
|
+
/** Newest streamed text, read by the trailing render at fire time. */
|
|
487
|
+
const latestTextRef = useRef(text);
|
|
488
|
+
/** Timestamp of the last applied parse (throttle window start). */
|
|
489
|
+
const lastRenderAtRef = useRef(performance.now());
|
|
490
|
+
const timerRef = useRef(undefined);
|
|
491
|
+
// Throttled parse for a live stream: skip parses while the newest text is
|
|
492
|
+
// already rendered, parse immediately once the throttle window elapsed,
|
|
493
|
+
// otherwise schedule one trailing render that picks up the newest text.
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
latestTextRef.current = text;
|
|
496
|
+
if (!pending) {
|
|
497
|
+
// Turn closed: terminal messages are never throttled, so cancel any
|
|
498
|
+
// scheduled stream render and parse the final text immediately.
|
|
499
|
+
if (timerRef.current !== undefined) {
|
|
500
|
+
clearTimeout(timerRef.current);
|
|
501
|
+
timerRef.current = undefined;
|
|
502
|
+
}
|
|
503
|
+
if (text === renderedTextRef.current)
|
|
504
|
+
return;
|
|
505
|
+
lastRenderAtRef.current = performance.now();
|
|
506
|
+
renderedTextRef.current = text;
|
|
507
|
+
setHtml(renderMarkdown(text));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (text === renderedTextRef.current)
|
|
511
|
+
return;
|
|
512
|
+
const elapsed = performance.now() - lastRenderAtRef.current;
|
|
513
|
+
if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
|
|
514
|
+
lastRenderAtRef.current = performance.now();
|
|
515
|
+
renderedTextRef.current = text;
|
|
516
|
+
setHtml(renderMarkdown(text));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (timerRef.current === undefined) {
|
|
520
|
+
timerRef.current = setTimeout(() => {
|
|
521
|
+
timerRef.current = undefined;
|
|
522
|
+
lastRenderAtRef.current = performance.now();
|
|
523
|
+
renderedTextRef.current = latestTextRef.current;
|
|
524
|
+
setHtml(renderMarkdown(latestTextRef.current));
|
|
525
|
+
}, STREAM_RENDER_INTERVAL_MS - elapsed);
|
|
526
|
+
}
|
|
527
|
+
}, [text, pending]);
|
|
528
|
+
// Cancel the trailing stream render if the row unmounts mid-stream.
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
return () => {
|
|
531
|
+
if (timerRef.current !== undefined)
|
|
532
|
+
clearTimeout(timerRef.current);
|
|
533
|
+
};
|
|
534
|
+
}, []);
|
|
412
535
|
const long = text.length > LONG_TEXT_LIMIT;
|
|
413
536
|
const collapsed = long && !open;
|
|
414
537
|
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 + ' 字)' }))] }));
|
|
@@ -547,3 +670,77 @@ function PermissionSheet({ sessionId, value, onChanged, onClose }) {
|
|
|
547
670
|
function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystemMessages, onClose }) {
|
|
548
671
|
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 }) })] })] }) }));
|
|
549
672
|
}
|
|
673
|
+
/* ── approval / question panels (#1025) ──────────────────────────────── */
|
|
674
|
+
/** One pending tool approval card with allow/reject actions. */
|
|
675
|
+
function ApprovalPanel({ approval, sessionId, onResolved }) {
|
|
676
|
+
const [busy, setBusy] = useState(false);
|
|
677
|
+
const [panelError, setPanelError] = useState(undefined);
|
|
678
|
+
const act = (outcome) => {
|
|
679
|
+
if (busy)
|
|
680
|
+
return;
|
|
681
|
+
setBusy(true);
|
|
682
|
+
setPanelError(undefined);
|
|
683
|
+
void respondApproval(sessionId, approval.approvalId, outcome).then(() => { onResolved(approval.approvalId); }, (reason) => {
|
|
684
|
+
setBusy(false);
|
|
685
|
+
setPanelError(reason instanceof Error ? reason.message : String(reason));
|
|
686
|
+
});
|
|
687
|
+
};
|
|
688
|
+
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" })] })] }));
|
|
689
|
+
}
|
|
690
|
+
/** Question panel: renders one or more questions with option pickers and a submit button. */
|
|
691
|
+
function QuestionPanel({ questions, sessionId, onResolved }) {
|
|
692
|
+
const [selections, setSelections] = useState(() => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])));
|
|
693
|
+
const [busy, setBusy] = useState(false);
|
|
694
|
+
const [panelError, setPanelError] = useState(undefined);
|
|
695
|
+
const toggle = (questionId, label, multi) => {
|
|
696
|
+
setSelections(previous => {
|
|
697
|
+
const next = new Map(previous);
|
|
698
|
+
const entry = next.get(questionId) ?? { selected: [], custom: '' };
|
|
699
|
+
if (multi) {
|
|
700
|
+
const set = new Set(entry.selected);
|
|
701
|
+
if (set.has(label))
|
|
702
|
+
set.delete(label);
|
|
703
|
+
else
|
|
704
|
+
set.add(label);
|
|
705
|
+
next.set(questionId, { ...entry, selected: [...set] });
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
next.set(questionId, { ...entry, selected: [label] });
|
|
709
|
+
}
|
|
710
|
+
return next;
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
const setCustom = (questionId, value) => {
|
|
714
|
+
setSelections(previous => {
|
|
715
|
+
const next = new Map(previous);
|
|
716
|
+
const entry = next.get(questionId) ?? { selected: [], custom: '' };
|
|
717
|
+
next.set(questionId, { ...entry, custom: value });
|
|
718
|
+
return next;
|
|
719
|
+
});
|
|
720
|
+
};
|
|
721
|
+
const submit = () => {
|
|
722
|
+
if (busy)
|
|
723
|
+
return;
|
|
724
|
+
setBusy(true);
|
|
725
|
+
setPanelError(undefined);
|
|
726
|
+
const answers = questions.map(q => {
|
|
727
|
+
const entry = selections.get(q.id) ?? { selected: [], custom: '' };
|
|
728
|
+
return {
|
|
729
|
+
id: q.id,
|
|
730
|
+
selected: entry.selected,
|
|
731
|
+
...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
void respondQuestion(sessionId, answers).then(() => { onResolved(); }, (reason) => {
|
|
735
|
+
setBusy(false);
|
|
736
|
+
setPanelError(reason instanceof Error ? reason.message : String(reason));
|
|
737
|
+
});
|
|
738
|
+
};
|
|
739
|
+
return (_jsxs("div", { className: "chat-question-panel", role: "form", "aria-label": "\u95EE\u9898", children: [questions.map(q => {
|
|
740
|
+
const entry = selections.get(q.id) ?? { selected: [], custom: '' };
|
|
741
|
+
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 => {
|
|
742
|
+
const checked = entry.selected.includes(option.label);
|
|
743
|
+
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));
|
|
744
|
+
}) })), _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));
|
|
745
|
+
}), panelError !== undefined && _jsx("p", { className: "chat-approval-error", children: panelError }), _jsx("button", { type: "button", className: "chat-question-submit", disabled: busy, onClick: submit, children: busy ? '提交中…' : '提交回答' })] }));
|
|
746
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ChatView.stream.test.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.stream.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
/**
|
|
4
|
+
* ChatView streaming markdown throttle: a pending assistant message must not
|
|
5
|
+
* re-parse its whole accumulated text on every chunk. Streaming text
|
|
6
|
+
* re-parses at most once per STREAM_RENDER_INTERVAL_MS, shows the last
|
|
7
|
+
* parsed result in between, and renders the exact final HTML (byte-identical
|
|
8
|
+
* to the plain renderMarkdown output) the moment the turn closes.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
12
|
+
import { ChatView, STREAM_RENDER_INTERVAL_MS } from "./ChatView.js";
|
|
13
|
+
import { renderMarkdown } from "../markdown.js";
|
|
14
|
+
// The api module is fully mocked; App.tsx's history wrapper is overridden to
|
|
15
|
+
// feed a fixed empty history page, its pure helpers stay real.
|
|
16
|
+
vi.mock('../api.ts', () => ({
|
|
17
|
+
fetchMobilePreferences: vi.fn(),
|
|
18
|
+
models: vi.fn(),
|
|
19
|
+
selectModel: vi.fn(),
|
|
20
|
+
sendCommand: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('./App.tsx', async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal();
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
loadHistory: vi.fn(),
|
|
27
|
+
prompt: vi.fn(async () => { }),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
// Wrap the real renderer with a call counter: the throttle must cut the
|
|
31
|
+
// number of full-text parses during a chunk stream (each parse is O(n)).
|
|
32
|
+
vi.mock('../markdown.ts', async (importOriginal) => {
|
|
33
|
+
const actual = await importOriginal();
|
|
34
|
+
return { ...actual, renderMarkdown: vi.fn(actual.renderMarkdown) };
|
|
35
|
+
});
|
|
36
|
+
import { fetchMobilePreferences, models } from "../api.js";
|
|
37
|
+
import { loadHistory } from "./App.js";
|
|
38
|
+
const loadHistoryMock = vi.mocked(loadHistory);
|
|
39
|
+
const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences);
|
|
40
|
+
const modelsMock = vi.mocked(models);
|
|
41
|
+
const renderMarkdownMock = vi.mocked(renderMarkdown);
|
|
42
|
+
const session = {
|
|
43
|
+
sessionId: 's-1',
|
|
44
|
+
title: '测试会话',
|
|
45
|
+
updatedAt: 1_700_000_000_000,
|
|
46
|
+
running: false,
|
|
47
|
+
blank: false,
|
|
48
|
+
};
|
|
49
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
50
|
+
class FakeMux {
|
|
51
|
+
listeners = new Set();
|
|
52
|
+
onFrame(listener) {
|
|
53
|
+
this.listeners.add(listener);
|
|
54
|
+
return () => { this.listeners.delete(listener); };
|
|
55
|
+
}
|
|
56
|
+
emit(frame) {
|
|
57
|
+
for (const listener of this.listeners)
|
|
58
|
+
listener(frame);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** One history entry wrapping a WireEvent (host history-page shape). */
|
|
62
|
+
function makeEntry(type, data, seq) {
|
|
63
|
+
return { event: { type, seq, time: seq * 1_000, data } };
|
|
64
|
+
}
|
|
65
|
+
/** Build an empty history page (the live stream supplies all content). */
|
|
66
|
+
function historyPage() {
|
|
67
|
+
return { events: [], hasMore: false };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Emit one live text-delta chunk bound to the stable message id 'a-1' (the
|
|
71
|
+
* mobile wire shape). A stable id keeps the message row mounted across the
|
|
72
|
+
* fold, so the tests observe the throttle effect path only.
|
|
73
|
+
*/
|
|
74
|
+
function chunk(mux, text, seq) {
|
|
75
|
+
mux.emit({
|
|
76
|
+
type: 'session/event',
|
|
77
|
+
sessionId: 's-1',
|
|
78
|
+
event: makeEntry('assistant/chunk', { messageId: 'a-1', turn: 0, step: 0, text }, seq).event,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Emit the final assistant message (authoritative text, closes the turn). */
|
|
82
|
+
function finalMessage(mux, text, seq) {
|
|
83
|
+
mux.emit({
|
|
84
|
+
type: 'session/event',
|
|
85
|
+
sessionId: 's-1',
|
|
86
|
+
event: makeEntry('assistant/message', {
|
|
87
|
+
turn: 0,
|
|
88
|
+
step: 0,
|
|
89
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text }] },
|
|
90
|
+
}, seq).event,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Round-trip HTML through the DOM so a byte comparison is symmetric. */
|
|
94
|
+
function normalize(html) {
|
|
95
|
+
const element = document.createElement('div');
|
|
96
|
+
element.innerHTML = html;
|
|
97
|
+
return element.innerHTML;
|
|
98
|
+
}
|
|
99
|
+
/** The assistant message's markdown body (dangerouslySetInnerHTML target). */
|
|
100
|
+
function body() {
|
|
101
|
+
const element = document.querySelector('.chat-msg.chat-msg-assistant .chat-md-body');
|
|
102
|
+
if (element === null)
|
|
103
|
+
throw new Error('assistant markdown body not found');
|
|
104
|
+
return element;
|
|
105
|
+
}
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true });
|
|
108
|
+
modelsMock.mockResolvedValue({
|
|
109
|
+
current: { provider: 'fx', model: 'fx-1' },
|
|
110
|
+
routable: true,
|
|
111
|
+
groups: [],
|
|
112
|
+
failures: [],
|
|
113
|
+
});
|
|
114
|
+
loadHistoryMock.mockResolvedValue(historyPage());
|
|
115
|
+
vi.useFakeTimers();
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
cleanup();
|
|
119
|
+
vi.useRealTimers();
|
|
120
|
+
vi.clearAllMocks();
|
|
121
|
+
vi.restoreAllMocks();
|
|
122
|
+
});
|
|
123
|
+
describe('ChatView streaming markdown throttle', () => {
|
|
124
|
+
it('re-parses pending text at most once per interval and catches up byte-exactly', async () => {
|
|
125
|
+
const mux = new FakeMux();
|
|
126
|
+
await act(async () => {
|
|
127
|
+
render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
|
|
128
|
+
});
|
|
129
|
+
const parts = [
|
|
130
|
+
'第一段 **加粗** 文本与 `行内代码`。\n\n',
|
|
131
|
+
'第二段 *斜体* 与 <标签> 内容。\n\n',
|
|
132
|
+
'第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
|
|
133
|
+
];
|
|
134
|
+
const full = parts.join('');
|
|
135
|
+
// The first chunk mounts the pending message; the mount render parses it.
|
|
136
|
+
await act(async () => { chunk(mux, parts[0] ?? '', 6); });
|
|
137
|
+
const afterMount = renderMarkdownMock.mock.calls.length;
|
|
138
|
+
expect(afterMount).toBeGreaterThanOrEqual(1);
|
|
139
|
+
// A burst of further chunks does not re-parse per chunk: at most one
|
|
140
|
+
// leading parse may run (if the wall-clock gap to the burst exceeded the
|
|
141
|
+
// window), but never one parse per chunk.
|
|
142
|
+
await act(async () => {
|
|
143
|
+
chunk(mux, parts[1] ?? '', 7);
|
|
144
|
+
chunk(mux, parts[2] ?? '', 8);
|
|
145
|
+
});
|
|
146
|
+
expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(2);
|
|
147
|
+
// During the window the last parsed result stays visible: the newest
|
|
148
|
+
// chunk's text has not reached the DOM yet.
|
|
149
|
+
expect(body().innerHTML).not.toContain('第三段');
|
|
150
|
+
// The trailing render inside the window catches up to the newest text,
|
|
151
|
+
// byte-identical to a full renderMarkdown of the accumulated text.
|
|
152
|
+
await act(async () => {
|
|
153
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5);
|
|
154
|
+
});
|
|
155
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)));
|
|
156
|
+
expect(renderMarkdownMock.mock.calls.length - afterMount).toBeLessThan(3);
|
|
157
|
+
});
|
|
158
|
+
it('renders the exact final HTML immediately when the turn closes', async () => {
|
|
159
|
+
const mux = new FakeMux();
|
|
160
|
+
await act(async () => {
|
|
161
|
+
render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
|
|
162
|
+
});
|
|
163
|
+
// The final event carries the authoritative text and closes the turn
|
|
164
|
+
// (pending -> false) in the same synchronous burst as the chunks.
|
|
165
|
+
const finalText = '最终 **整篇** `渲染` 与 [链接](https://example.com/x) 文本。';
|
|
166
|
+
await act(async () => {
|
|
167
|
+
chunk(mux, '流式前缀', 6);
|
|
168
|
+
chunk(mux, '中间过程', 7);
|
|
169
|
+
finalMessage(mux, finalText, 8);
|
|
170
|
+
});
|
|
171
|
+
// No timer advance needed: the closed turn already renders the exact
|
|
172
|
+
// final HTML (terminal messages keep the immediate-render behavior).
|
|
173
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(finalText)));
|
|
174
|
+
});
|
|
175
|
+
it('keeps the streaming render fresh after window edges and skips the identical final parse', async () => {
|
|
176
|
+
const mux = new FakeMux();
|
|
177
|
+
await act(async () => {
|
|
178
|
+
render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
|
|
179
|
+
});
|
|
180
|
+
const first = '第一期内容。';
|
|
181
|
+
const second = '第二期内容,包含 `code`。';
|
|
182
|
+
const full = first + second;
|
|
183
|
+
// The chunk and the window edge in one tick: the trailing render fires
|
|
184
|
+
// inside the window and shows the current text.
|
|
185
|
+
await act(async () => {
|
|
186
|
+
chunk(mux, first, 6);
|
|
187
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5);
|
|
188
|
+
});
|
|
189
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(first)));
|
|
190
|
+
// A later chunk schedules a new trailing render; before it fires the last
|
|
191
|
+
// parsed result is still on screen.
|
|
192
|
+
await act(async () => { chunk(mux, second, 7); });
|
|
193
|
+
expect(body().innerHTML).not.toContain('第二期内容');
|
|
194
|
+
await act(async () => {
|
|
195
|
+
vi.advanceTimersByTime(STREAM_RENDER_INTERVAL_MS + 5);
|
|
196
|
+
});
|
|
197
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)));
|
|
198
|
+
// Closing the turn with the exact text already rendered must not re-parse
|
|
199
|
+
// (the row already holds the byte-identical final HTML).
|
|
200
|
+
const beforeFinal = renderMarkdownMock.mock.calls.length;
|
|
201
|
+
await act(async () => { finalMessage(mux, full, 9); });
|
|
202
|
+
expect(renderMarkdownMock.mock.calls.length - beforeFinal).toBe(0);
|
|
203
|
+
expect(normalize(body().innerHTML)).toBe(normalize(renderMarkdown(full)));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;
|
|
1
|
+
{"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;AAwHjG,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,yCAAyC;IACzC,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,CAAA;CACtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,kBAAkB,EAAE,MAAM,EAAE,EAAE,kBAAkB,+BAuG/E"}
|
|
@@ -6,9 +6,49 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* until a workspace is opened).
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useState } from 'react';
|
|
9
|
-
import { listWorkspaces } from "../api.js";
|
|
9
|
+
import { listWorkspaces, listDirectory, createWorkspace } from "../api.js";
|
|
10
10
|
import { errorText } from "./App.js";
|
|
11
11
|
import { ThemeToggle } from "../theme-toggle.js";
|
|
12
|
+
/**
|
|
13
|
+
* Render the directory browser for creating a new workspace.
|
|
14
|
+
*/
|
|
15
|
+
function DirectoryBrowser({ onCancel, onPick }) {
|
|
16
|
+
const [listing, setListing] = useState(undefined);
|
|
17
|
+
const [error, setError] = useState(undefined);
|
|
18
|
+
const [creating, setCreating] = useState(false);
|
|
19
|
+
const [path, setPath] = useState(undefined);
|
|
20
|
+
const [reload, setReload] = useState(0);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
setError(undefined);
|
|
24
|
+
setListing(undefined);
|
|
25
|
+
void listDirectory(path).then((res) => {
|
|
26
|
+
if (cancelled)
|
|
27
|
+
return;
|
|
28
|
+
setListing(res);
|
|
29
|
+
}, (reason) => {
|
|
30
|
+
if (cancelled)
|
|
31
|
+
return;
|
|
32
|
+
setError(errorText(reason));
|
|
33
|
+
});
|
|
34
|
+
return () => { cancelled = true; };
|
|
35
|
+
}, [path, reload]);
|
|
36
|
+
const handleCreate = async () => {
|
|
37
|
+
if (listing === undefined || creating)
|
|
38
|
+
return;
|
|
39
|
+
setCreating(true);
|
|
40
|
+
setError(undefined);
|
|
41
|
+
try {
|
|
42
|
+
const result = await createWorkspace(listing.path);
|
|
43
|
+
onPick(result.workspace);
|
|
44
|
+
}
|
|
45
|
+
catch (reason) {
|
|
46
|
+
setCreating(false);
|
|
47
|
+
setError(errorText(reason));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return (_jsxs("div", { className: "mobile dir-browser", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-headerAction", onClick: onCancel, children: "\u8FD4\u56DE" }), _jsx("h1", { className: "mobile-title", children: "\u9009\u62E9\u76EE\u5F55" })] }), listing !== undefined && (_jsx("div", { className: "dir-crumbs", children: listing.crumbs.map((crumb, idx) => (_jsxs("span", { children: [_jsx("button", { type: "button", className: "dir-crumb", onClick: () => setPath(crumb.path), children: crumb.name || '/' }), idx < listing.crumbs.length - 1 && _jsx("span", { className: "dir-crumb-separator", children: "/" })] }, crumb.path))) })), error !== undefined ? (_jsxs("div", { className: "mobile-empty", children: [_jsx("p", { className: "mobile-error", children: error }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => setReload(n => n + 1), children: "\u91CD\u8BD5" })] })) : listing === undefined ? (_jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u52A0\u8F7D\u4E2D\u2026" }) })) : (_jsx("ul", { className: "mobile-list", children: listing.entries.length === 0 ? (_jsx("div", { className: "mobile-empty dir-empty", children: _jsx("p", { className: "mobile-muted", children: "\u7A7A\u76EE\u5F55" }) })) : (listing.entries.map((entry) => (_jsx("li", { children: _jsx("button", { type: "button", className: `mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`, onClick: () => setPath(entry.path), children: _jsxs("span", { className: "mobile-rowTitle", children: ["\uD83D\uDCC1 ", entry.name] }) }) }, entry.path)))) })), _jsx("div", { className: "dir-select", children: _jsx("button", { type: "button", className: "mobile-button", disabled: listing === undefined || creating, onClick: () => void handleCreate(), children: creating ? '创建中…' : '选择此目录' }) })] }));
|
|
51
|
+
}
|
|
12
52
|
/**
|
|
13
53
|
* Render the workspace roster.
|
|
14
54
|
* @param props - the pick action.
|
|
@@ -19,6 +59,7 @@ export function WorkspaceView({ initialWorkspaceId, onPick }) {
|
|
|
19
59
|
const [error, setError] = useState(undefined);
|
|
20
60
|
// Bumped by the retry button to re-run the roster fetch effect.
|
|
21
61
|
const [reload, setReload] = useState(0);
|
|
62
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
22
63
|
useEffect(() => {
|
|
23
64
|
let cancelled = false;
|
|
24
65
|
void listWorkspaces().then((rows) => {
|
|
@@ -39,14 +80,14 @@ export function WorkspaceView({ initialWorkspaceId, onPick }) {
|
|
|
39
80
|
});
|
|
40
81
|
return () => { cancelled = true; };
|
|
41
82
|
}, [initialWorkspaceId, onPick, reload]);
|
|
83
|
+
if (isCreating) {
|
|
84
|
+
return (_jsx(DirectoryBrowser, { onCancel: () => setIsCreating(false), onPick: onPick }));
|
|
85
|
+
}
|
|
42
86
|
if (error !== undefined) {
|
|
43
87
|
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsxs("div", { className: "mobile-empty", children: [_jsxs("p", { className: "mobile-error", children: ["\u52A0\u8F7D\u5931\u8D25\uFF1A", error] }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => { setError(undefined); setItems(undefined); setReload(n => n + 1); }, children: "\u91CD\u8BD5" })] })] }));
|
|
44
88
|
}
|
|
45
89
|
if (items === undefined) {
|
|
46
90
|
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u52A0\u8F7D\u4E2D\u2026" }) })] }));
|
|
47
91
|
}
|
|
48
|
-
|
|
49
|
-
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u6682\u65E0\u5DE5\u4F5C\u533A" }) })] }));
|
|
50
|
-
}
|
|
51
|
-
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsx("ul", { className: "mobile-list", children: items.map(workspace => (_jsx("li", { children: _jsxs("button", { type: "button", className: "mobile-row", onClick: () => { onPick(workspace); }, children: [_jsx("span", { className: "mobile-rowTitle", children: workspace.title }), _jsx("span", { className: "mobile-rowMeta", children: workspace.path }), _jsx("span", { className: "mobile-chevron", children: "\u203A" })] }) }, workspace.workspaceId))) })] }));
|
|
92
|
+
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), items.length === 0 ? (_jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u6682\u65E0\u5DE5\u4F5C\u533A" }) })) : (_jsx("ul", { className: "mobile-list", children: items.map(workspace => (_jsx("li", { children: _jsxs("button", { type: "button", className: "mobile-row", onClick: () => { onPick(workspace); }, children: [_jsx("span", { className: "mobile-rowTitle", children: workspace.title }), _jsx("span", { className: "mobile-rowMeta", children: workspace.path }), _jsx("span", { className: "mobile-chevron", children: "\u203A" })] }) }, workspace.workspaceId))) })), _jsx("div", { style: { padding: '16px' }, children: _jsx("button", { type: "button", className: "mobile-button", onClick: () => setIsCreating(true), children: "+ \u65B0\u5EFA\u5DE5\u4F5C\u533A" }) })] }));
|
|
52
93
|
}
|