@linxin666/dsh-remote-web-ui 0.2.8 → 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.
Files changed (86) hide show
  1. package/LICENSE +202 -28
  2. package/README.i18n.yaml +2 -2
  3. package/README.md +51 -15
  4. package/README.zh.md +9 -4
  5. package/lib/client.js +65 -34
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +667 -77
  8. package/lib/mobile.js +874 -64
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/index.d.ts.map +1 -1
  11. package/lib/types/client/index.js +35 -5
  12. package/lib/types/client/remote-channel.d.ts +4 -5
  13. package/lib/types/client/remote-channel.d.ts.map +1 -1
  14. package/lib/types/client/remote-channel.js +13 -28
  15. package/lib/types/http.d.ts +37 -8
  16. package/lib/types/http.d.ts.map +1 -1
  17. package/lib/types/http.js +76 -11
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +40 -5
  20. package/lib/types/mobile/api.d.ts +57 -0
  21. package/lib/types/mobile/api.d.ts.map +1 -1
  22. package/lib/types/mobile/api.js +29 -0
  23. package/lib/types/mobile/markdown.d.ts +7 -1
  24. package/lib/types/mobile/markdown.d.ts.map +1 -1
  25. package/lib/types/mobile/markdown.js +16 -7
  26. package/lib/types/mobile/markdown.test.js +8 -0
  27. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  28. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  29. package/lib/types/mobile/mobile-styles.js +325 -6
  30. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  31. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  32. package/lib/types/mobile/views/ChatView.js +205 -8
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  35. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  36. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  37. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  38. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  39. package/lib/types/mobile-api.d.ts +9 -3
  40. package/lib/types/mobile-api.d.ts.map +1 -1
  41. package/lib/types/mobile-api.js +61 -16
  42. package/lib/types/mobile-pending.d.ts +38 -0
  43. package/lib/types/mobile-pending.d.ts.map +1 -0
  44. package/lib/types/mobile-pending.js +59 -0
  45. package/lib/types/paired-model-catalog.d.ts +17 -0
  46. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  47. package/lib/types/paired-model-catalog.js +252 -0
  48. package/lib/types/pairing.d.ts +13 -4
  49. package/lib/types/pairing.d.ts.map +1 -1
  50. package/lib/types/pairing.js +13 -5
  51. package/lib/types/remote-api.d.ts +13 -4
  52. package/lib/types/remote-api.d.ts.map +1 -1
  53. package/lib/types/remote-api.js +27 -17
  54. package/lib/types/remote-channel-boot.d.ts +26 -0
  55. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  56. package/lib/types/remote-channel-boot.js +128 -0
  57. package/lib/types/remote-channel-rules.d.ts +40 -0
  58. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  59. package/lib/types/remote-channel-rules.js +32 -0
  60. package/lib/types/routes.d.ts.map +1 -1
  61. package/lib/types/routes.js +12 -23
  62. package/lib/types/uuid-polyfill.d.ts +10 -0
  63. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  64. package/lib/types/uuid-polyfill.js +27 -0
  65. package/package.json +1 -1
  66. package/src/client/index.ts +41 -5
  67. package/src/client/remote-channel.ts +18 -28
  68. package/src/http.ts +87 -12
  69. package/src/index.ts +42 -6
  70. package/src/mobile/api.ts +91 -0
  71. package/src/mobile/markdown.test.ts +9 -0
  72. package/src/mobile/markdown.ts +17 -7
  73. package/src/mobile/mobile-styles.ts +326 -6
  74. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  75. package/src/mobile/views/ChatView.tsx +328 -10
  76. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  77. package/src/mobile/views/WorkspaceView.tsx +152 -26
  78. package/src/mobile-api.ts +59 -17
  79. package/src/mobile-pending.ts +88 -0
  80. package/src/paired-model-catalog.ts +224 -0
  81. package/src/pairing.ts +13 -5
  82. package/src/remote-api.ts +34 -18
  83. package/src/remote-channel-boot.ts +131 -0
  84. package/src/remote-channel-rules.ts +62 -0
  85. package/src/routes.ts +13 -24
  86. 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 row + expandable details. */
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 names = [...new Set(tools.map(tool => tool.name))].join(' / ');
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: 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}`))) }))] }));
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 = useMemo(() => renderMarkdown(text), [text]);
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ChatView.stream.test.d.ts.map
@@ -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;AAKjG,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,+BA4F/E"}
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
- if (items.length === 0) {
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
  }