@linxin666/dsh-remote-web-ui 0.2.9 → 0.3.1
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 +46 -8
- package/README.zh.md +7 -2
- package/lib/client.js +103 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +704 -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 +71 -19
- 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 +311 -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 +72 -18
- 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 +272 -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
|
@@ -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
|
|
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
|
|
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 &&
|
|
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-
|
|
580
|
-
<span className="chat-tool-
|
|
581
|
-
{tool.arguments !== undefined &&
|
|
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 =
|
|
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
|
+
})
|