feat(v0.4): multi-device discovery + share link (frontend)
- tabs.ts: on open, syncLiveSessions() fetches /live-sessions and adds a tab for each host session not already open → any device sees & mirrors everything running on the host. Empty storage + no live sessions → one fresh tab. activeSessionId() + openSession(id) helpers. - share.ts: 🔗 toolbar button → QR + <origin>/?join=<id> for the active session. - main.ts: ?join=<id> on load opens/focuses that shared session, then strips the param. Mount share button (toolbar: 🔍 ⚙ ▦ 🕘 ⌨ 🔗 📱). - style.css: #sharemodal reuses the QR card/backdrop. Verified in-browser: fresh device auto-discovered the host session and replayed its scrollback; two concurrent clients (browser + ws) both received live output (clientCount=2); share modal shows the join URL/QR.
This commit is contained in:
@@ -133,7 +133,9 @@ export class TabApp {
|
||||
} catch {
|
||||
legacy = null
|
||||
}
|
||||
stored = [{ id: legacy, title: null }]
|
||||
// v0.2.x single-session migration only; otherwise stay empty and let
|
||||
// syncLiveSessions() decide (host sessions, or a fresh tab as fallback).
|
||||
if (legacy) stored = [{ id: legacy, title: null }]
|
||||
}
|
||||
|
||||
for (const s of stored) this.addEntry(s.id, s.title)
|
||||
@@ -146,7 +148,71 @@ export class TabApp {
|
||||
}
|
||||
if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0
|
||||
this.rebuild()
|
||||
this.activate(active)
|
||||
if (this.tabs.length > 0) this.activate(active)
|
||||
|
||||
// v0.4: discover the host's running sessions and show them as tabs too.
|
||||
void this.syncLiveSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4 multi-device: fetch the host's currently-running sessions and add a tab
|
||||
* for each one not already open, so opening the app on any device shows (and
|
||||
* joins/mirrors) everything running on the host. Falls back to one fresh tab
|
||||
* when nothing is stored and nothing is live.
|
||||
*/
|
||||
private async syncLiveSessions(): Promise<void> {
|
||||
let live: Array<{ id: string }> = []
|
||||
try {
|
||||
const res = await fetch('/live-sessions')
|
||||
const data: unknown = await res.json()
|
||||
if (Array.isArray(data)) live = data as Array<{ id: string }>
|
||||
} catch {
|
||||
live = []
|
||||
}
|
||||
|
||||
const open = new Set(
|
||||
this.tabs.map((t) => t.session.id).filter((x): x is string => typeof x === 'string'),
|
||||
)
|
||||
let added = false
|
||||
for (const info of live) {
|
||||
if (typeof info.id === 'string' && !open.has(info.id)) {
|
||||
this.addEntry(info.id, null)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
// Nothing restored and nothing live → start one fresh session.
|
||||
if (this.tabs.length === 0) {
|
||||
this.addEntry(null, null)
|
||||
added = true
|
||||
}
|
||||
if (!added) return
|
||||
|
||||
this.rebuild()
|
||||
const idx =
|
||||
this.activeIndex >= 0 && this.activeIndex < this.tabs.length ? this.activeIndex : 0
|
||||
this.activeIndex = idx
|
||||
this.tabs.forEach((t, i) => (i === idx ? t.session.show() : t.session.hide()))
|
||||
this.tabs.forEach((t) => this.refreshTab(t))
|
||||
this.updateApprovalBar()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** The active tab's server session id (null until it has attached). */
|
||||
activeSessionId(): string | null {
|
||||
return this.tabs[this.activeIndex]?.session.id ?? null
|
||||
}
|
||||
|
||||
/** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
|
||||
openSession(sessionId: string): void {
|
||||
const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
|
||||
if (existing >= 0) {
|
||||
this.activate(existing)
|
||||
return
|
||||
}
|
||||
this.addEntry(sessionId, null)
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
this.activate(this.tabs.length - 1)
|
||||
}
|
||||
|
||||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user