@linxin666/dsh-remote-web-ui 0.1.6 → 0.1.7

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.md CHANGED
@@ -230,10 +230,19 @@ Notes:
230
230
  - Quick tunnels are free and need no login, but the hostname is random per
231
231
  run: every `cloudflared` restart changes it, so update `--trusted-host`
232
232
  and `publicBaseUrl` together. Cloudflare documents no uptime guarantees;
233
- in-flight-request concurrency is capped (HTTP 429 past it), and Quick
234
- Tunnels do not support Server-Sent Events — harmless here, because the
235
- only SSE surface (the desktop panel status stream) is loopback-only and
236
- the phone side uses plain requests plus heartbeats.
233
+ in-flight-request concurrency is capped (HTTP 429 past it), and **Quick
234
+ Tunnels do not forward Server-Sent Events**. `Tailscale Serve` (and
235
+ `tailscale serve` on a single port) behaves the same way. SSE is how the
236
+ phone receives **live messages** in real time, so over a quick tunnel or
237
+ Tailscale Serve the mobile chat falls back to polling: the phone still
238
+ sends and receives messages (everything else rides plain HTTP, which does
239
+ forward), only a new message may arrive a few seconds late instead of
240
+ instantly. The plugin polls `session.history` on a short interval once the
241
+ SSE channel goes silent, and resumes streaming as soon as SSE works again.
242
+ For true real-time push, point the QR at a tunnel that forwards SSE — a
243
+ Cloudflare **named tunnel** (domain hosted on Cloudflare, see below), or a
244
+ plain TCP port forward (LAN address, the `tailscale up` virtual-interface
245
+ address, or a manual `ssh -L` / cloudflared TCP tunnel to the port).
237
246
  - A quick tunnel is public: anyone with the URL can load the static page.
238
247
  The pairing gate is the real fence — unpaired devices get 403 on every
239
248
  `/api` call — so keep `requirePairingForLan` on.
@@ -346,4 +355,4 @@ QR as a dependency-free SVG component — no canvas, no server-side image
346
355
  generation. It is inlined into the client bundle at build time (like the
347
356
  official skin/turtle-ui plugins inline their non-shared deps), so profile
348
357
  installations need no extra runtime dependency beyond the dsh peer closure.
349
- `schemastery` is the DSH-standard config schema validator.
358
+ `schemastery` is the DSH-standard config schema validator.
package/lib/mobile.js CHANGED
@@ -12764,17 +12764,41 @@ discriminatedUnion("type", [
12764
12764
  function browserSource(url) {
12765
12765
  return new EventSource(url);
12766
12766
  }
12767
+ const DEFAULT_POLL_INTERVAL_MS = 3e3;
12768
+ const DEFAULT_STALL_THRESHOLD_MS = 12e3;
12769
+ /** Poll window: enough recent events to cover a few seconds of agent output. */
12770
+ const DEFAULT_POLL_PAGE_SIZE = 50;
12767
12771
  /**
12768
12772
  * Keep one SSE subscription open, fanning validated frames out to
12769
12773
  * subscribers. EventSource owns reconnection (with its own backoff); this
12770
- * class only manages the subscription lifecycle.
12774
+ * class only manages the subscription lifecycle, plus a polling fallback
12775
+ * that keeps the open session live when the SSE channel cannot deliver.
12771
12776
  */
12772
12777
  var MuxClient = class {
12773
12778
  sourceFactory;
12779
+ pollLatest;
12780
+ pollIntervalMs;
12781
+ stallThresholdMs;
12782
+ now;
12774
12783
  listeners = /* @__PURE__ */ new Set();
12775
12784
  source;
12776
12785
  stopped = false;
12777
12786
  url;
12787
+ /** The session to keep live via fallback polling (undefined = none). */
12788
+ observeSessionId;
12789
+ /** Last epoch ms the SSE channel produced a frame (or the stream opened). */
12790
+ lastDataAt = 0;
12791
+ /**
12792
+ * Whether the SSE channel has ever delivered a frame in this stream (a
12793
+ * delivered frame proves the tunnel forwards SSE; silence alone then means
12794
+ * the agent idle, not a dead channel — only an onerror re-arms fallback).
12795
+ */
12796
+ sseAlive = false;
12797
+ /** Per-session highest event seq already emitted, for poll dedup. */
12798
+ pollWatermark = /* @__PURE__ */ new Map();
12799
+ stallTimer;
12800
+ pollTimer;
12801
+ polling = false;
12778
12802
  /**
12779
12803
  * @param url - the mobile events endpoint (browser-relative).
12780
12804
  * @param options - seams.
@@ -12782,17 +12806,25 @@ var MuxClient = class {
12782
12806
  constructor(url = "/m/api/events.mux", options = {}) {
12783
12807
  this.url = url;
12784
12808
  this.sourceFactory = options.sourceFactory ?? browserSource;
12809
+ this.pollLatest = options.pollLatest ?? ((sessionId) => history(sessionId, void 0, DEFAULT_POLL_PAGE_SIZE));
12810
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
12811
+ this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
12812
+ this.now = options.now ?? (() => Date.now());
12785
12813
  }
12786
12814
  /** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
12787
12815
  start() {
12788
12816
  this.stopped = false;
12789
- if (this.source !== void 0) return;
12790
- this.connect();
12817
+ this.lastDataAt = this.now();
12818
+ if (this.source === void 0) this.connect();
12819
+ this.startStallChecker();
12791
12820
  }
12792
12821
  /** Close for good. */
12793
12822
  stop() {
12794
12823
  this.stopped = true;
12824
+ this.stopStallChecker();
12825
+ this.stopPolling();
12795
12826
  this.closeSource();
12827
+ this.observeSessionId = void 0;
12796
12828
  }
12797
12829
  /** Subscribe to validated frames; returns an unsubscribe function. */
12798
12830
  onFrame(listener) {
@@ -12801,16 +12833,94 @@ var MuxClient = class {
12801
12833
  this.listeners.delete(listener);
12802
12834
  };
12803
12835
  }
12836
+ /**
12837
+ * Point the fallback at one open session (or `undefined` to stop it).
12838
+ * While the SSE channel is stalled this client polls that session's
12839
+ * history and re-emits new events as `session/event` frames.
12840
+ */
12841
+ observe(sessionId) {
12842
+ this.observeSessionId = sessionId;
12843
+ if (sessionId === void 0) {
12844
+ this.stopPolling();
12845
+ return;
12846
+ }
12847
+ if (!this.polling && !this.stopped && !this.sseAlive && this.now() - this.lastDataAt > this.stallThresholdMs) this.startPolling();
12848
+ }
12804
12849
  connect() {
12850
+ this.sseAlive = false;
12805
12851
  const source = this.sourceFactory(this.url);
12806
12852
  this.source = source;
12807
12853
  source.onmessage = (event) => {
12808
12854
  this.handleMessage(event.data);
12809
12855
  };
12810
12856
  source.onerror = () => {
12811
- if (this.stopped && this.source === source) this.closeSource();
12857
+ if (this.stopped && this.source === source) {
12858
+ this.closeSource();
12859
+ return;
12860
+ }
12861
+ this.sseAlive = false;
12862
+ if (this.observeSessionId !== void 0) this.startPolling();
12812
12863
  };
12813
12864
  }
12865
+ startStallChecker() {
12866
+ this.stopStallChecker();
12867
+ this.stallTimer = setInterval(() => {
12868
+ if (this.stopped) return;
12869
+ if (this.observeSessionId === void 0) return;
12870
+ if (this.polling) return;
12871
+ if (this.sseAlive) return;
12872
+ if (this.now() - this.lastDataAt > this.stallThresholdMs) this.startPolling();
12873
+ }, 1e3);
12874
+ }
12875
+ stopStallChecker() {
12876
+ if (this.stallTimer !== void 0) {
12877
+ clearInterval(this.stallTimer);
12878
+ this.stallTimer = void 0;
12879
+ }
12880
+ }
12881
+ startPolling() {
12882
+ if (this.polling || this.stopped) return;
12883
+ this.polling = true;
12884
+ this.pollTick();
12885
+ this.pollTimer = setInterval(() => {
12886
+ this.pollTick();
12887
+ }, this.pollIntervalMs);
12888
+ }
12889
+ stopPolling() {
12890
+ this.polling = false;
12891
+ if (this.pollTimer !== void 0) {
12892
+ clearInterval(this.pollTimer);
12893
+ this.pollTimer = void 0;
12894
+ }
12895
+ }
12896
+ /**
12897
+ * Fetch the latest history page for the observed session and re-emit any
12898
+ * event above the per-session watermark as a `session/event` frame.
12899
+ * Idempotent by seq: listeners (and the fold) never see a duplicate.
12900
+ */
12901
+ async pollTick() {
12902
+ const sessionId = this.observeSessionId;
12903
+ if (sessionId === void 0) {
12904
+ this.stopPolling();
12905
+ return;
12906
+ }
12907
+ try {
12908
+ const page = await this.pollLatest(sessionId);
12909
+ let maxSeq = this.pollWatermark.get(sessionId) ?? -1;
12910
+ for (const entry of page.events) {
12911
+ const event = entry.event;
12912
+ const seq = typeof event?.seq === "number" ? event.seq : -1;
12913
+ if (seq <= maxSeq) continue;
12914
+ maxSeq = seq;
12915
+ this.emit({
12916
+ type: "session/event",
12917
+ sessionId,
12918
+ event
12919
+ });
12920
+ }
12921
+ this.pollWatermark.set(sessionId, maxSeq);
12922
+ } catch {}
12923
+ }
12814
12924
  handleMessage(data) {
12815
12925
  if (typeof data !== "string" || data === "") return;
12816
12926
  let parsed;
@@ -12823,8 +12933,14 @@ var MuxClient = class {
12823
12933
  if (!envelope.success) return;
12824
12934
  const frame = muxFrameSchema.safeParse(envelope.data.payload);
12825
12935
  if (!frame.success) return;
12936
+ this.sseAlive = true;
12937
+ this.lastDataAt = this.now();
12938
+ if (this.polling) this.stopPolling();
12939
+ this.emit(frame.data);
12940
+ }
12941
+ emit(frame) {
12826
12942
  for (const listener of this.listeners) try {
12827
- listener(frame.data);
12943
+ listener(frame);
12828
12944
  } catch {}
12829
12945
  }
12830
12946
  closeSource() {
@@ -13499,7 +13615,6 @@ function ChatView({ session, mux, onBack }) {
13499
13615
  setLoading(false);
13500
13616
  const projections = page.projections?.values;
13501
13617
  setPermissions(parsePermissionSelect(projections?.["permissions"]));
13502
- scrollToBottom();
13503
13618
  }, (reason) => {
13504
13619
  if (cancelled) return;
13505
13620
  setError(errorText(reason));
@@ -13523,14 +13638,20 @@ function ChatView({ session, mux, onBack }) {
13523
13638
  if (frame.type === "session/projection" && frame.sessionId === session.sessionId && frame.key === "permissions") setPermissions(parsePermissionSelect(frame.value));
13524
13639
  });
13525
13640
  }, [mux, session.sessionId]);
13526
- (0, import_react.useEffect)(() => {
13527
- if (messages.some((message) => message.pending === true)) scrollToBottom();
13528
- }, [messages]);
13529
13641
  const scrollToBottom = (0, import_react.useCallback)(() => {
13530
13642
  const el = scrollRef.current;
13531
13643
  if (el === void 0) return;
13532
13644
  el.scrollTop = el.scrollHeight;
13533
13645
  }, []);
13646
+ const lastMessageKeyRef = (0, import_react.useRef)(void 0);
13647
+ (0, import_react.useEffect)(() => {
13648
+ const last = messages[messages.length - 1];
13649
+ if (last === void 0) return;
13650
+ const key = last.seq + ":" + (last.pending === true ? "p" : "f");
13651
+ if (key === lastMessageKeyRef.current) return;
13652
+ lastMessageKeyRef.current = key;
13653
+ scrollToBottom();
13654
+ }, [messages, scrollToBottom]);
13534
13655
  /** Load one older page and prepend it. The fold is directional (incremental
13535
13656
  * tails only), so the older page folds standalone and concatenates ahead —
13536
13657
  * host page boundaries never cut a message, so the seam is exact. */
@@ -14535,6 +14656,9 @@ function App() {
14535
14656
  mux.stop();
14536
14657
  };
14537
14658
  }, []);
14659
+ (0, import_react.useEffect)(() => {
14660
+ muxRef.current?.observe(route.kind === "chat" ? route.session.sessionId : void 0);
14661
+ }, [route]);
14538
14662
  const back = (0, import_react.useCallback)(() => {
14539
14663
  setRoute((previous) => {
14540
14664
  if (previous.kind === "chat") return {