@linxin666/dsh-remote-web-ui 0.3.4 → 0.3.6

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.
@@ -484,23 +484,91 @@
484
484
  margin-top: 8px;
485
485
  }
486
486
 
487
- .fenceRetry {
487
+ .fenceForm {
488
+ margin-top: 20px;
488
489
  width: 100%;
489
- margin-top: 24px;
490
- padding: 11px 16px;
490
+ }
491
+
492
+ .fenceInputRow {
493
+ display: flex;
494
+ gap: 8px;
495
+ width: 100%;
496
+ }
497
+
498
+ .fenceInput {
499
+ flex: 1;
500
+ min-width: 0;
501
+ padding: 10px 14px;
502
+ border: 1px solid var(--dsw-alias-border-l2);
503
+ border-radius: 10px;
504
+ background: var(--dsw-alias-bg-layer-2);
505
+ color: var(--dsw-alias-label-primary);
506
+ font: inherit;
507
+ font-size: 13px;
508
+ transition: border-color 120ms ease, box-shadow 120ms ease;
509
+ }
510
+
511
+ .fenceInput:focus {
512
+ outline: none;
513
+ border-color: var(--dsw-alias-brand-primary);
514
+ box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.15);
515
+ }
516
+
517
+ .fencePairButton {
518
+ flex-shrink: 0;
519
+ padding: 10px 18px;
491
520
  border: 1px solid var(--dsw-alias-brand-primary);
492
521
  border-radius: 10px;
493
522
  background: var(--dsw-alias-brand-primary);
494
523
  color: #fff;
495
524
  font: inherit;
525
+ font-size: 13px;
496
526
  font-weight: 500;
497
527
  cursor: pointer;
528
+ transition: filter 120ms ease, opacity 120ms ease;
498
529
  }
499
530
 
500
- .fenceRetry:hover {
531
+ .fencePairButton:hover:not(:disabled) {
501
532
  filter: brightness(0.96);
502
533
  }
503
534
 
535
+ .fencePairButton:disabled {
536
+ opacity: 0.55;
537
+ cursor: not-allowed;
538
+ }
539
+
540
+ .fencePairButton:focus-visible {
541
+ outline: 2px solid var(--dsw-alias-brand-primary);
542
+ outline-offset: 2px;
543
+ }
544
+
545
+ .fenceError {
546
+ margin: 10px 0 0;
547
+ color: var(--dsw-alias-state-error-primary);
548
+ font-size: 13px;
549
+ line-height: 1.4;
550
+ text-align: left;
551
+ }
552
+
553
+ .fenceRetry {
554
+ width: 100%;
555
+ margin-top: 12px;
556
+ padding: 10px 16px;
557
+ border: 1px solid var(--dsw-alias-border-l2);
558
+ border-radius: 10px;
559
+ background: transparent;
560
+ color: var(--dsw-alias-label-secondary);
561
+ font: inherit;
562
+ font-weight: 500;
563
+ cursor: pointer;
564
+ transition: background-color 120ms ease, color 120ms ease;
565
+ }
566
+
567
+ .fenceRetry:hover {
568
+ background: var(--dsw-alias-interactive-bg-hover);
569
+ color: var(--dsw-alias-label-primary);
570
+ }
571
+
504
572
  .fenceRetry:focus-visible {
505
573
  outline: 2px solid var(--dsw-alias-brand-primary);
506
574
  outline-offset: 3px;
package/src/index.ts CHANGED
@@ -462,6 +462,12 @@ function applyImpl(ctx: Context, config?: Config): void {
462
462
  if (!resolve().enabled) return false
463
463
  return isPairedDeviceRequest(service, request)
464
464
  })
465
+
466
+ if (lanAddresses.length > 0) {
467
+ const urls = lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}/m/`).join(' , ')
468
+ console.log(`remote-web-ui: mobile UI reachable on LAN at ${urls}`)
469
+ }
470
+
465
471
  const sync = (): void => {
466
472
  const value = resolve()
467
473
  service.config = pairingConfigOf(value)
@@ -611,11 +611,19 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
611
611
  * Incremental folder for one message stream. Live chat folds one event at a
612
612
  * time; rebuilding the five index maps by scanning every message per event
613
613
  * made that path O(n) per event (O(n * events) per turn). A folder keeps the
614
- * indexes alive across folds, applies each event in O(1) map operations, and
615
- * returns the previous snapshot identity unchanged when nothing applied, so
616
- * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
617
- * watermark advanced by the first application skips them, which also makes a
618
- * double-invoked React state updater harmless.
614
+ * indexes alive across folds, applies each event in (near-)O(1) map
615
+ * operations, and returns the previous snapshot identity unchanged when
616
+ * nothing applied, so React skips the re-render entirely. Replayed events
617
+ * are no-ops: the maxSeq watermark advanced by the first application skips
618
+ * them, which also makes a double-invoked React state updater harmless.
619
+ *
620
+ * Cost contract: every fold that applies at least one event rebuilds the
621
+ * snapshot as one fresh O(messages) array copy (React needs the new
622
+ * identity), and replacement paths locate rows by an O(messages) identity
623
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
624
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
625
+ * id-to-index map was measured and deliberately rejected: at realistic
626
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
619
627
  */
620
628
  export class EventFolder {
621
629
  private state: FoldState
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
10
  import { act, cleanup, render, screen } from '@testing-library/react'
11
- import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
11
+ import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from './ChatView.tsx'
12
12
  import { type SessionView } from './App.tsx'
13
13
  import { renderMarkdown } from '../markdown.ts'
14
14
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
@@ -150,8 +150,13 @@ describe('ChatView streaming markdown throttle', () => {
150
150
  ]
151
151
  const full = parts.join('')
152
152
 
153
- // The first chunk mounts the pending message; the mount render parses it.
154
- await act(async () => { chunk(mux, parts[0] ?? '', 6) })
153
+ // The first chunk mounts the pending message on the next fold flush
154
+ // (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
155
+ // render parses it.
156
+ await act(async () => {
157
+ chunk(mux, parts[0] ?? '', 6)
158
+ vi.advanceTimersByTime(FOLD_FLUSH_MS + 5)
159
+ })
155
160
  const afterMount = renderMarkdownMock.mock.calls.length
156
161
  expect(afterMount).toBeGreaterThanOrEqual(1)
157
162
 
@@ -38,6 +38,14 @@ export interface ChatViewProps {
38
38
  */
39
39
  export const MAX_TAIL_BUFFER_EVENTS = 500
40
40
 
41
+ /**
42
+ * Cadence for folding buffered live events into the message list. Streaming
43
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
44
+ * and re-render work per second without visible lag, and keeps working in
45
+ * background tabs where requestAnimationFrame is paused.
46
+ */
47
+ export const FOLD_FLUSH_MS = 50
48
+
41
49
  /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
42
50
  const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls'
43
51
  /** localStorage key for the injected-system-message display toggle. */
@@ -147,6 +155,14 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
147
155
  const liveBufferRef = useRef<WireEvent[]>([])
148
156
  /** Incremental folder for this session's stream (indexes stay hot across events). */
149
157
  const folderRef = useRef<EventFolder | undefined>(undefined)
158
+ /**
159
+ * Live events buffered between fold flushes. Folding every single event
160
+ * costs one O(messages) snapshot copy per event; flushing at a fixed
161
+ * cadence folds each burst once while keeping streaming text fluid.
162
+ */
163
+ const foldBufferRef = useRef<WireEvent[]>([])
164
+ /** Timer id of the scheduled fold flush, if any. */
165
+ const foldFlushTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
150
166
  /** True once the live buffer hit its cap (oldest events were dropped). */
151
167
  const liveBufferOverflowRef = useRef(false)
152
168
 
@@ -273,7 +289,21 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
273
289
  // Live frames: fold session events for this session in as they arrive.
274
290
  useEffect(() => {
275
291
  if (mux === undefined) return
276
- return mux.onFrame((frame: MuxFrame) => {
292
+ /** Fold every buffered live event in one batch and publish the snapshot. */
293
+ const flushFoldBuffer = (): void => {
294
+ if (foldFlushTimerRef.current !== undefined) {
295
+ clearTimeout(foldFlushTimerRef.current)
296
+ foldFlushTimerRef.current = undefined
297
+ }
298
+ const events = foldBufferRef.current
299
+ if (events.length === 0) return
300
+ foldBufferRef.current = []
301
+ setMessages(previous => {
302
+ const folder = folderRef.current
303
+ return folder === undefined ? foldEvents(events, previous) : folder.fold(events)
304
+ })
305
+ }
306
+ const unsubscribe = mux.onFrame((frame: MuxFrame) => {
277
307
  if (frame.type === 'session/event') {
278
308
  if (frame.sessionId !== session.sessionId) return
279
309
  const event = frame.event as WireEvent
@@ -298,10 +328,16 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
298
328
  liveBufferRef.current.push(event)
299
329
  return
300
330
  }
301
- setMessages(previous => {
302
- const folder = folderRef.current
303
- return folder === undefined ? foldEvents([event], previous) : folder.fold([event])
304
- })
331
+ foldBufferRef.current.push(event)
332
+ // Only chunk streams batch on the flush cadence; every other event
333
+ // (final message, turn close, user echo, update/delete) flushes the
334
+ // buffer synchronously so settled UI states never wait for a timer.
335
+ const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk'
336
+ if (!isChunk) {
337
+ flushFoldBuffer()
338
+ } else if (foldFlushTimerRef.current === undefined) {
339
+ foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS)
340
+ }
305
341
  return
306
342
  }
307
343
  // Live projection pushes keep the permission picker current.
@@ -342,6 +378,17 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
342
378
  return
343
379
  }
344
380
  })
381
+ return () => {
382
+ unsubscribe()
383
+ // Drop the pending flush: its events belong to the session (or
384
+ // component instance) being torn down, and the next session reseeds
385
+ // its folder from a fresh history tail.
386
+ if (foldFlushTimerRef.current !== undefined) {
387
+ clearTimeout(foldFlushTimerRef.current)
388
+ foldFlushTimerRef.current = undefined
389
+ }
390
+ foldBufferRef.current = []
391
+ }
345
392
  }, [mux, session.sessionId])
346
393
 
347
394
  // Weak-network polling fallback: when the assistant is running, poll for