@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.
Files changed (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -8
  3. package/README.zh.md +7 -2
  4. package/lib/client.js +65 -34
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +645 -61
  7. package/lib/mobile.js +874 -64
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/index.js +35 -5
  11. package/lib/types/client/remote-channel.d.ts +4 -5
  12. package/lib/types/client/remote-channel.d.ts.map +1 -1
  13. package/lib/types/client/remote-channel.js +13 -28
  14. package/lib/types/http.d.ts +37 -8
  15. package/lib/types/http.d.ts.map +1 -1
  16. package/lib/types/http.js +76 -11
  17. package/lib/types/index.d.ts.map +1 -1
  18. package/lib/types/index.js +25 -1
  19. package/lib/types/mobile/api.d.ts +57 -0
  20. package/lib/types/mobile/api.d.ts.map +1 -1
  21. package/lib/types/mobile/api.js +29 -0
  22. package/lib/types/mobile/markdown.d.ts +7 -1
  23. package/lib/types/mobile/markdown.d.ts.map +1 -1
  24. package/lib/types/mobile/markdown.js +16 -7
  25. package/lib/types/mobile/markdown.test.js +8 -0
  26. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  27. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  28. package/lib/types/mobile/mobile-styles.js +325 -6
  29. package/lib/types/mobile/views/ChatView.d.ts +10 -0
  30. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  31. package/lib/types/mobile/views/ChatView.js +205 -8
  32. package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
  33. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
  34. package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
  35. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  36. package/lib/types/mobile/views/WorkspaceView.js +46 -5
  37. package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
  38. package/lib/types/mobile-api.d.ts +9 -3
  39. package/lib/types/mobile-api.d.ts.map +1 -1
  40. package/lib/types/mobile-api.js +61 -16
  41. package/lib/types/mobile-pending.d.ts +38 -0
  42. package/lib/types/mobile-pending.d.ts.map +1 -0
  43. package/lib/types/mobile-pending.js +59 -0
  44. package/lib/types/paired-model-catalog.d.ts +17 -0
  45. package/lib/types/paired-model-catalog.d.ts.map +1 -0
  46. package/lib/types/paired-model-catalog.js +252 -0
  47. package/lib/types/pairing.d.ts +13 -4
  48. package/lib/types/pairing.d.ts.map +1 -1
  49. package/lib/types/pairing.js +13 -5
  50. package/lib/types/remote-api.d.ts.map +1 -1
  51. package/lib/types/remote-channel-boot.d.ts +26 -0
  52. package/lib/types/remote-channel-boot.d.ts.map +1 -0
  53. package/lib/types/remote-channel-boot.js +128 -0
  54. package/lib/types/remote-channel-rules.d.ts +40 -0
  55. package/lib/types/remote-channel-rules.d.ts.map +1 -0
  56. package/lib/types/remote-channel-rules.js +32 -0
  57. package/lib/types/routes.d.ts.map +1 -1
  58. package/lib/types/routes.js +12 -23
  59. package/lib/types/uuid-polyfill.d.ts +10 -0
  60. package/lib/types/uuid-polyfill.d.ts.map +1 -0
  61. package/lib/types/uuid-polyfill.js +27 -0
  62. package/package.json +1 -1
  63. package/src/client/index.ts +41 -5
  64. package/src/client/remote-channel.ts +18 -28
  65. package/src/http.ts +87 -12
  66. package/src/index.ts +27 -2
  67. package/src/mobile/api.ts +91 -0
  68. package/src/mobile/markdown.test.ts +9 -0
  69. package/src/mobile/markdown.ts +17 -7
  70. package/src/mobile/mobile-styles.ts +326 -6
  71. package/src/mobile/views/ChatView.stream.test.tsx +231 -0
  72. package/src/mobile/views/ChatView.tsx +328 -10
  73. package/src/mobile/views/WorkspaceView.test.tsx +163 -2
  74. package/src/mobile/views/WorkspaceView.tsx +152 -26
  75. package/src/mobile-api.ts +59 -17
  76. package/src/mobile-pending.ts +88 -0
  77. package/src/paired-model-catalog.ts +224 -0
  78. package/src/pairing.ts +13 -5
  79. package/src/remote-api.ts +0 -1
  80. package/src/remote-channel-boot.ts +131 -0
  81. package/src/remote-channel-rules.ts +62 -0
  82. package/src/routes.ts +13 -24
  83. package/src/uuid-polyfill.ts +28 -0
@@ -16,7 +16,8 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
16
16
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
17
  import { loadHistory, prompt, type SessionView } from './App.tsx'
18
18
  import { errorText, formatTime, staleHostHint } from './App.tsx'
19
- import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
19
+ import { fetchMobilePreferences, models, selectModel, sendCommand, fetchPending, respondApproval, respondQuestion } from '../api.ts'
20
+ import type { PendingApproval, PendingQuestionItem } from '../api.ts'
20
21
  import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
21
22
  import { renderMarkdown } from '../markdown.ts'
22
23
  import { MuxClient } from '../mux.ts'
@@ -164,6 +165,12 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
164
165
  * the legacy Enter-to-send behavior until the preference loads).
165
166
  */
166
167
  const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
168
+ /** Whether the assistant is currently generating (turn/start..turn/end). */
169
+ const [running, setRunning] = useState(false)
170
+ /** Pending tool approvals awaiting user decision (#1025). */
171
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([])
172
+ /** Pending questions awaiting user answer (#1025). */
173
+ const [pendingQuestions, setPendingQuestions] = useState<PendingQuestionItem[]>([])
167
174
 
168
175
  // Read-only mobile display preferences ride the plugin's local
169
176
  // `/m/api` method; a failure keeps the default (Enter sends).
@@ -268,6 +275,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
268
275
  if (frame.type === 'session/event') {
269
276
  if (frame.sessionId !== session.sessionId) return
270
277
  const event = frame.event as WireEvent
278
+ // Track the turn running state for the "outputting" indicator (#1017).
279
+ if (typeof event.type === 'string') {
280
+ if (event.type === 'turn/start') setRunning(true)
281
+ if (event.type === 'turn/end') setRunning(false)
282
+ }
271
283
  if (tailLoadingRef.current) {
272
284
  if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
273
285
  // Bound the tail-load window: drop the oldest buffered event and
@@ -295,10 +307,62 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
295
307
  && frame.sessionId === session.sessionId
296
308
  && frame.key === 'permissions') {
297
309
  setPermissions(parsePermissionSelect(frame.value))
310
+ return
311
+ }
312
+ // Approval/question frames for this session (#1025).
313
+ if (!('sessionId' in frame) || frame.sessionId !== session.sessionId) return
314
+ if (frame.type === 'approval/requested') {
315
+ setPendingApprovals(previous => {
316
+ if (previous.some(a => a.approvalId === frame.approvalId)) return previous
317
+ return [...previous, {
318
+ approvalId: frame.approvalId as string,
319
+ toolName: frame.toolName,
320
+ callId: frame.callId as string | undefined,
321
+ reason: frame.reason,
322
+ }]
323
+ })
324
+ return
325
+ }
326
+ if (frame.type === 'approval/resolved') {
327
+ setPendingApprovals(previous => previous.filter(a => a.approvalId !== frame.approvalId))
328
+ return
329
+ }
330
+ if (frame.type === 'question/requested') {
331
+ const items = (frame.questions as Array<{
332
+ id: string; question: string; detail?: string; header?: string
333
+ options?: Array<{ label: string; description?: string }>; multiSelect?: boolean
334
+ }>)
335
+ setPendingQuestions(items)
336
+ return
337
+ }
338
+ if (frame.type === 'question/resolved') {
339
+ setPendingQuestions([])
340
+ return
298
341
  }
299
342
  })
300
343
  }, [mux, session.sessionId])
301
344
 
345
+ // Weak-network polling fallback: when the assistant is running, poll for
346
+ // pending approvals/questions every 1.5 s so the phone can act even if the
347
+ // SSE channel drops frames (#1025).
348
+ useEffect(() => {
349
+ if (!running) return
350
+ let cancelled = false
351
+ const tick = (): void => {
352
+ void fetchPending(session.sessionId).then(
353
+ (state) => {
354
+ if (cancelled) return
355
+ setPendingApprovals(state.approvals)
356
+ setPendingQuestions(state.questions)
357
+ },
358
+ () => { /* transient; next tick retries */ },
359
+ )
360
+ }
361
+ tick()
362
+ const timer = setInterval(tick, 1500)
363
+ return () => { cancelled = true; clearInterval(timer) }
364
+ }, [running, session.sessionId])
365
+
302
366
  const scrollToBottom = useCallback(() => {
303
367
  const el = scrollRef.current
304
368
  if (el === undefined) return
@@ -424,6 +488,26 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
424
488
  ))}
425
489
  {loading && messages.length === 0 && <p className="chat-typing">加载中…</p>}
426
490
  {!loading && messages.length === 0 && <p className="chat-typing">还没有消息,发一句话开始吧</p>}
491
+ {running && (
492
+ <div className="chat-turn-status" role="status" aria-label="输出中">
493
+ 输出中<span className="chat-turn-dots" aria-hidden><span /><span /><span /></span>
494
+ </div>
495
+ )}
496
+ {pendingApprovals.map(approval => (
497
+ <ApprovalPanel
498
+ key={approval.approvalId}
499
+ approval={approval}
500
+ sessionId={session.sessionId}
501
+ onResolved={(id) => { setPendingApprovals(prev => prev.filter(a => a.approvalId !== id)) }}
502
+ />
503
+ ))}
504
+ {pendingQuestions.length > 0 && (
505
+ <QuestionPanel
506
+ questions={pendingQuestions}
507
+ sessionId={session.sessionId}
508
+ onResolved={() => { setPendingQuestions([]) }}
509
+ />
510
+ )}
427
511
  </div>
428
512
  <div className="chat-tools">
429
513
  <button type="button" className="chat-chip" onClick={() => { setSheet('model') }} aria-haspopup="dialog">
@@ -527,7 +611,7 @@ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystem
527
611
  <ToolDisclosure tools={message.tools} />
528
612
  )}
529
613
  {message.kind === 'assistant'
530
- ? <MarkdownText text={message.text} />
614
+ ? <MarkdownText text={message.text} pending={message.pending === true} />
531
615
  : <CollapsibleText text={message.text} />}
532
616
  {message.failed === true && <span className="chat-msg-failtag">本次回复失败</span>}
533
617
  <span className="chat-msg-time">{formatTime(message.time)}</span>
@@ -556,10 +640,10 @@ function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean
556
640
  )
557
641
  }
558
642
 
559
- /** Collapsed-by-default tool-call disclosure: summary row + expandable details. */
643
+ /** Collapsed-by-default tool-call disclosure: pill tag summary + card details (#529). */
560
644
  function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
561
645
  const [open, setOpen] = useState(false)
562
- const names = [...new Set(tools.map(tool => tool.name))].join(' / ')
646
+ const uniqueNames = [...new Set(tools.map(tool => tool.name))]
563
647
  return (
564
648
  <div className={`chat-disclosure chat-tools${open ? ' chat-disclosure-open' : ''}`}>
565
649
  <button
@@ -570,15 +654,23 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
570
654
  >
571
655
  <span className="chat-disclosure-caret" aria-hidden>›</span>
572
656
  <span className="chat-disclosure-label">工具</span>
573
- {!open && <span className="chat-disclosure-summary">{names}</span>}
657
+ {!open && (
658
+ <span className="chat-disclosure-summary chat-tool-pills">
659
+ {uniqueNames.map(name => (
660
+ <span key={name} className="chat-tool-pill">{name}</span>
661
+ ))}
662
+ </span>
663
+ )}
574
664
  <span className="chat-disclosure-count">{tools.length} 次</span>
575
665
  </button>
576
666
  {open && (
577
667
  <div className="chat-disclosure-body chat-tools-body">
578
668
  {tools.map((tool, index) => (
579
- <div className="chat-tool-item" key={`${tool.callId}-${index}`}>
580
- <span className="chat-tool-name">{tool.name}</span>
581
- {tool.arguments !== undefined && <pre className="chat-tool-args">{tool.arguments}</pre>}
669
+ <div className="chat-tool-card" key={`${tool.callId}-${index}`}>
670
+ <span className="chat-tool-pill">{tool.name}</span>
671
+ {tool.arguments !== undefined && (
672
+ <pre className="chat-tool-args">{tool.arguments}</pre>
673
+ )}
582
674
  </div>
583
675
  ))}
584
676
  </div>
@@ -587,6 +679,17 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
587
679
  )
588
680
  }
589
681
 
682
+ /**
683
+ * Minimum interval between full markdown re-parses of a live (pending)
684
+ * assistant message. Every streamed chunk replaces the message object, and
685
+ * re-parsing the whole accumulated text per chunk turns a long reply into
686
+ * O(n^2) work on mobile. Pending text keeps the last parsed result visible
687
+ * and re-parses at most once per interval; the moment the turn closes the
688
+ * final text parses immediately, so terminal messages render exactly as
689
+ * before.
690
+ */
691
+ export const STREAM_RENDER_INTERVAL_MS = 120
692
+
590
693
  /**
591
694
  * Assistant text rendered as GFM markdown (escape-first, protocol
592
695
  * allow-list — see markdown.ts). Long replies collapse by clamping the
@@ -594,9 +697,59 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
594
697
  * fences or tables never leak malformed markup into the DOM. User
595
698
  * messages stay plain text (CollapsibleText).
596
699
  */
597
- function MarkdownText({ text }: { text: string }) {
700
+ function MarkdownText({ text, pending }: { text: string; pending: boolean }) {
598
701
  const [open, setOpen] = useState(false)
599
- const html = useMemo(() => renderMarkdown(text), [text])
702
+ const [html, setHtml] = useState<string>(() => renderMarkdown(text))
703
+ /** Text of the last render actually applied to `html`. */
704
+ const renderedTextRef = useRef(text)
705
+ /** Newest streamed text, read by the trailing render at fire time. */
706
+ const latestTextRef = useRef(text)
707
+ /** Timestamp of the last applied parse (throttle window start). */
708
+ const lastRenderAtRef = useRef(performance.now())
709
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
710
+
711
+ // Throttled parse for a live stream: skip parses while the newest text is
712
+ // already rendered, parse immediately once the throttle window elapsed,
713
+ // otherwise schedule one trailing render that picks up the newest text.
714
+ useEffect(() => {
715
+ latestTextRef.current = text
716
+ if (!pending) {
717
+ // Turn closed: terminal messages are never throttled, so cancel any
718
+ // scheduled stream render and parse the final text immediately.
719
+ if (timerRef.current !== undefined) {
720
+ clearTimeout(timerRef.current)
721
+ timerRef.current = undefined
722
+ }
723
+ if (text === renderedTextRef.current) return
724
+ lastRenderAtRef.current = performance.now()
725
+ renderedTextRef.current = text
726
+ setHtml(renderMarkdown(text))
727
+ return
728
+ }
729
+ if (text === renderedTextRef.current) return
730
+ const elapsed = performance.now() - lastRenderAtRef.current
731
+ if (elapsed >= STREAM_RENDER_INTERVAL_MS) {
732
+ lastRenderAtRef.current = performance.now()
733
+ renderedTextRef.current = text
734
+ setHtml(renderMarkdown(text))
735
+ return
736
+ }
737
+ if (timerRef.current === undefined) {
738
+ timerRef.current = setTimeout(() => {
739
+ timerRef.current = undefined
740
+ lastRenderAtRef.current = performance.now()
741
+ renderedTextRef.current = latestTextRef.current
742
+ setHtml(renderMarkdown(latestTextRef.current))
743
+ }, STREAM_RENDER_INTERVAL_MS - elapsed)
744
+ }
745
+ }, [text, pending])
746
+
747
+ // Cancel the trailing stream render if the row unmounts mid-stream.
748
+ useEffect(() => {
749
+ return () => {
750
+ if (timerRef.current !== undefined) clearTimeout(timerRef.current)
751
+ }
752
+ }, [])
600
753
  const long = text.length > LONG_TEXT_LIMIT
601
754
  const collapsed = long && !open
602
755
  return (
@@ -937,3 +1090,168 @@ function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystem
937
1090
  </Sheet>
938
1091
  )
939
1092
  }
1093
+
1094
+ /* ── approval / question panels (#1025) ──────────────────────────────── */
1095
+
1096
+ /** One pending tool approval card with allow/reject actions. */
1097
+ function ApprovalPanel({ approval, sessionId, onResolved }: {
1098
+ approval: PendingApproval
1099
+ sessionId: string
1100
+ onResolved(approvalId: string): void
1101
+ }) {
1102
+ const [busy, setBusy] = useState(false)
1103
+ const [panelError, setPanelError] = useState<string | undefined>(undefined)
1104
+
1105
+ const act = (outcome: 'allowed-once' | 'rejected'): void => {
1106
+ if (busy) return
1107
+ setBusy(true)
1108
+ setPanelError(undefined)
1109
+ void respondApproval(sessionId, approval.approvalId, outcome).then(
1110
+ () => { onResolved(approval.approvalId) },
1111
+ (reason: unknown) => {
1112
+ setBusy(false)
1113
+ setPanelError(reason instanceof Error ? reason.message : String(reason))
1114
+ },
1115
+ )
1116
+ }
1117
+
1118
+ return (
1119
+ <div className="chat-approval-panel" role="alert">
1120
+ <div className="chat-approval-header">
1121
+ <span className="chat-tool-pill">{approval.toolName}</span>
1122
+ {approval.reason !== undefined && (
1123
+ <span className="chat-approval-reason">{approval.reason}</span>
1124
+ )}
1125
+ </div>
1126
+ {panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
1127
+ <div className="chat-approval-actions">
1128
+ <button
1129
+ type="button"
1130
+ className="chat-approval-allow"
1131
+ disabled={busy}
1132
+ onClick={() => { act('allowed-once') }}
1133
+ >
1134
+ {busy ? '提交中…' : '允许一次'}
1135
+ </button>
1136
+ <button
1137
+ type="button"
1138
+ className="chat-approval-reject"
1139
+ disabled={busy}
1140
+ onClick={() => { act('rejected') }}
1141
+ >
1142
+ 拒绝
1143
+ </button>
1144
+ </div>
1145
+ </div>
1146
+ )
1147
+ }
1148
+
1149
+ /** Question panel: renders one or more questions with option pickers and a submit button. */
1150
+ function QuestionPanel({ questions, sessionId, onResolved }: {
1151
+ questions: PendingQuestionItem[]
1152
+ sessionId: string
1153
+ onResolved(): void
1154
+ }) {
1155
+ const [selections, setSelections] = useState<Map<string, { selected: string[]; custom: string }>>(
1156
+ () => new Map(questions.map(q => [q.id, { selected: [], custom: '' }])),
1157
+ )
1158
+ const [busy, setBusy] = useState(false)
1159
+ const [panelError, setPanelError] = useState<string | undefined>(undefined)
1160
+
1161
+ const toggle = (questionId: string, label: string, multi: boolean): void => {
1162
+ setSelections(previous => {
1163
+ const next = new Map(previous)
1164
+ const entry = next.get(questionId) ?? { selected: [], custom: '' }
1165
+ if (multi) {
1166
+ const set = new Set(entry.selected)
1167
+ if (set.has(label)) set.delete(label); else set.add(label)
1168
+ next.set(questionId, { ...entry, selected: [...set] })
1169
+ } else {
1170
+ next.set(questionId, { ...entry, selected: [label] })
1171
+ }
1172
+ return next
1173
+ })
1174
+ }
1175
+
1176
+ const setCustom = (questionId: string, value: string): void => {
1177
+ setSelections(previous => {
1178
+ const next = new Map(previous)
1179
+ const entry = next.get(questionId) ?? { selected: [], custom: '' }
1180
+ next.set(questionId, { ...entry, custom: value })
1181
+ return next
1182
+ })
1183
+ }
1184
+
1185
+ const submit = (): void => {
1186
+ if (busy) return
1187
+ setBusy(true)
1188
+ setPanelError(undefined)
1189
+ const answers = questions.map(q => {
1190
+ const entry = selections.get(q.id) ?? { selected: [], custom: '' }
1191
+ return {
1192
+ id: q.id,
1193
+ selected: entry.selected,
1194
+ ...(entry.custom.trim() !== '' ? { custom: entry.custom.trim() } : {}),
1195
+ }
1196
+ })
1197
+ void respondQuestion(sessionId, answers).then(
1198
+ () => { onResolved() },
1199
+ (reason: unknown) => {
1200
+ setBusy(false)
1201
+ setPanelError(reason instanceof Error ? reason.message : String(reason))
1202
+ },
1203
+ )
1204
+ }
1205
+
1206
+ return (
1207
+ <div className="chat-question-panel" role="form" aria-label="问题">
1208
+ {questions.map(q => {
1209
+ const entry = selections.get(q.id) ?? { selected: [], custom: '' }
1210
+ return (
1211
+ <div className="chat-question-group" key={q.id}>
1212
+ {q.header !== undefined && <div className="chat-question-header">{q.header}</div>}
1213
+ <div className="chat-question-text">{q.question}</div>
1214
+ {q.detail !== undefined && <div className="chat-question-detail">{q.detail}</div>}
1215
+ {q.options !== undefined && q.options.length > 0 && (
1216
+ <div className="chat-question-options" role="group" aria-label={q.question}>
1217
+ {q.options.map(option => {
1218
+ const checked = entry.selected.includes(option.label)
1219
+ return (
1220
+ <label key={option.label} className={`chat-question-option${checked ? ' chat-question-option-selected' : ''}`}>
1221
+ <input
1222
+ type={q.multiSelect ? 'checkbox' : 'radio'}
1223
+ name={`q-${q.id}`}
1224
+ checked={checked}
1225
+ onChange={() => { toggle(q.id, option.label, q.multiSelect === true) }}
1226
+ />
1227
+ <span className="chat-question-option-label">{option.label}</span>
1228
+ {option.description !== undefined && (
1229
+ <span className="chat-question-option-desc">{option.description}</span>
1230
+ )}
1231
+ </label>
1232
+ )
1233
+ })}
1234
+ </div>
1235
+ )}
1236
+ <textarea
1237
+ className="chat-question-custom"
1238
+ placeholder="自定义回答(可选)"
1239
+ rows={2}
1240
+ value={entry.custom}
1241
+ onChange={(e) => { setCustom(q.id, e.target.value) }}
1242
+ />
1243
+ </div>
1244
+ )
1245
+ })}
1246
+ {panelError !== undefined && <p className="chat-approval-error">{panelError}</p>}
1247
+ <button
1248
+ type="button"
1249
+ className="chat-question-submit"
1250
+ disabled={busy}
1251
+ onClick={submit}
1252
+ >
1253
+ {busy ? '提交中…' : '提交回答'}
1254
+ </button>
1255
+ </div>
1256
+ )
1257
+ }
@@ -1,17 +1,22 @@
1
1
  // @vitest-environment jsdom
2
2
  /** Mobile workspace landing: roster rendering and QR deep-link selection. */
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, render, screen, waitFor } from '@testing-library/react'
4
+ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react'
5
5
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
6
6
  import { mobileWorkspaceTarget } from './App.tsx'
7
7
  import { WorkspaceView } from './WorkspaceView.tsx'
8
8
 
9
9
  vi.mock('../api.ts', () => ({
10
10
  listWorkspaces: vi.fn(),
11
+ listDirectory: vi.fn(),
12
+ createWorkspace: vi.fn(),
11
13
  }))
12
- import { listWorkspaces } from '../api.ts'
14
+ import { listWorkspaces, listDirectory, createWorkspace } from '../api.ts'
13
15
 
14
16
  const listWorkspacesMock = vi.mocked(listWorkspaces)
17
+ const listDirectoryMock = vi.mocked(listDirectory)
18
+ const createWorkspaceMock = vi.mocked(createWorkspace)
19
+
15
20
  const workspaces: WorkspaceRow[] = [
16
21
  {
17
22
  workspaceId: 'ws-1' as never,
@@ -64,3 +69,159 @@ describe('mobile workspace deep link', () => {
64
69
  expect(onPick).not.toHaveBeenCalled()
65
70
  })
66
71
  })
72
+
73
+ describe('mobile workspace creation', () => {
74
+ it('shows new workspace button and navigates to directory browser', async () => {
75
+ listWorkspacesMock.mockResolvedValue(workspaces)
76
+ listDirectoryMock.mockResolvedValue({
77
+ path: '/home/user',
78
+ home: '/home/user',
79
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
80
+ entries: [
81
+ { name: 'projects', path: '/home/user/projects', hidden: false },
82
+ { name: '.config', path: '/home/user/.config', hidden: true }
83
+ ],
84
+ truncated: false
85
+ })
86
+
87
+ const onPick = vi.fn()
88
+ render(<WorkspaceView onPick={onPick} />)
89
+
90
+ // Wait for workspaces to load
91
+ const createBtn = await screen.findByText('+ 新建工作区')
92
+
93
+ // Click create button
94
+ fireEvent.click(createBtn)
95
+
96
+ // Verify directory browser renders
97
+ expect(await screen.findByText('选择目录')).toBeTruthy()
98
+
99
+ // Verify crumbs
100
+ expect(await screen.findByText('user')).toBeTruthy()
101
+
102
+ // Verify entries
103
+ expect(await screen.findByText('📁 projects')).toBeTruthy()
104
+ expect(await screen.findByText('📁 .config')).toBeTruthy()
105
+ })
106
+
107
+ it('navigates into directory and allows breadcrumb navigation', async () => {
108
+ listWorkspacesMock.mockResolvedValue(workspaces)
109
+
110
+ // Initial dir
111
+ listDirectoryMock.mockResolvedValueOnce({
112
+ path: '/home/user',
113
+ home: '/home/user',
114
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
115
+ entries: [{ name: 'projects', path: '/home/user/projects', hidden: false }],
116
+ truncated: false
117
+ })
118
+
119
+ const onPick = vi.fn()
120
+ render(<WorkspaceView onPick={onPick} />)
121
+
122
+ // Open dir browser
123
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
124
+
125
+ // Wait for first dir listing
126
+ const projBtn = await screen.findByText('📁 projects')
127
+
128
+ // Prepare next listing
129
+ listDirectoryMock.mockResolvedValueOnce({
130
+ path: '/home/user/projects',
131
+ home: '/home/user',
132
+ crumbs: [
133
+ { name: '', path: '/', hidden: false },
134
+ { name: 'home', path: '/home', hidden: false },
135
+ { name: 'user', path: '/home/user', hidden: false },
136
+ { name: 'projects', path: '/home/user/projects', hidden: false }
137
+ ],
138
+ entries: [{ name: 'foo', path: '/home/user/projects/foo', hidden: false }],
139
+ truncated: false
140
+ })
141
+
142
+ // Click into folder
143
+ fireEvent.click(projBtn)
144
+
145
+ // Verify new contents load
146
+ expect(await screen.findByText('📁 foo')).toBeTruthy()
147
+
148
+ // Check breadcrumb
149
+ const homeCrumb = screen.getByText('home')
150
+ expect(homeCrumb).toBeTruthy()
151
+
152
+ // Click breadcrumb
153
+ listDirectoryMock.mockResolvedValueOnce({
154
+ path: '/home',
155
+ home: '/home/user',
156
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }],
157
+ entries: [{ name: 'user', path: '/home/user', hidden: false }],
158
+ truncated: false
159
+ })
160
+
161
+ fireEvent.click(homeCrumb)
162
+ expect(await screen.findByText('📁 user')).toBeTruthy()
163
+ })
164
+
165
+ it('creates workspace successfully', async () => {
166
+ listWorkspacesMock.mockResolvedValue(workspaces)
167
+ listDirectoryMock.mockResolvedValue({
168
+ path: '/home/user/projects/foo',
169
+ home: '/home/user',
170
+ crumbs: [{ name: '', path: '/', hidden: false }, { name: 'foo', path: '/home/user/projects/foo', hidden: false }],
171
+ entries: [],
172
+ truncated: false
173
+ })
174
+
175
+ const newWorkspace = { ...workspaces[0], workspaceId: 'ws-new' as never, title: 'foo' }
176
+ createWorkspaceMock.mockResolvedValue({
177
+ workspace: newWorkspace,
178
+ created: true
179
+ })
180
+
181
+ const onPick = vi.fn()
182
+ render(<WorkspaceView onPick={onPick} />)
183
+
184
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
185
+
186
+ const selectBtn = await screen.findByText('选择此目录')
187
+ fireEvent.click(selectBtn)
188
+
189
+ expect(await screen.findByText('创建中…')).toBeTruthy()
190
+
191
+ await waitFor(() => {
192
+ expect(createWorkspaceMock).toHaveBeenCalledWith('/home/user/projects/foo')
193
+ expect(onPick).toHaveBeenCalledWith(newWorkspace)
194
+ })
195
+ })
196
+
197
+ it('handles errors from directory listing and workspace creation', async () => {
198
+ listWorkspacesMock.mockResolvedValue(workspaces)
199
+ listDirectoryMock.mockRejectedValue(new Error('Permission denied'))
200
+
201
+ const onPick = vi.fn()
202
+ render(<WorkspaceView onPick={onPick} />)
203
+
204
+ fireEvent.click(await screen.findByText('+ 新建工作区'))
205
+
206
+ // Verify error from listing
207
+ expect(await screen.findByText('Permission denied')).toBeTruthy()
208
+
209
+ // Retry with success
210
+ listDirectoryMock.mockResolvedValue({
211
+ path: '/home',
212
+ home: '/home/user',
213
+ crumbs: [],
214
+ entries: [],
215
+ truncated: false
216
+ })
217
+ fireEvent.click(screen.getByText('重试'))
218
+
219
+ const selectBtn = await screen.findByText('选择此目录')
220
+
221
+ // Fail creation
222
+ createWorkspaceMock.mockRejectedValue(new Error('Already exists'))
223
+ fireEvent.click(selectBtn)
224
+
225
+ expect(await screen.findByText('Already exists')).toBeTruthy()
226
+ })
227
+ })