codehost 0.9.1 → 0.11.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.
@@ -0,0 +1 @@
1
+ {"sessionId":"f8e4e571-944e-43d7-bbfa-267a5251e41c","pid":87968,"procStart":"Mon Jun 8 09:46:02 2026","acquiredAt":1780974562147}
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.11.0](https://github.com/snomiao/codehost/compare/v0.10.0...v0.11.0) (2026-06-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** update URL on Connect + Back returns to the list ([7258a16](https://github.com/snomiao/codehost/commit/7258a168a0bcc1d476aab8a7f3fcd81f35ccfcb5))
7
+
8
+ # [0.10.0](https://github.com/snomiao/codehost/compare/v0.9.1...v0.10.0) (2026-06-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** join multiple rooms at once, merged workspace list ([de5ae61](https://github.com/snomiao/codehost/commit/de5ae610068b6f2a26792ed7d6e67d1fb5c08257))
14
+
1
15
  ## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,13 +17,16 @@ import {
17
17
  resolveRepoTarget,
18
18
  shareableDeepLink,
19
19
  } from "../shared/repo";
20
- import { addRoom, getRooms, historyFor, recordConnection } from "./history";
20
+ import { getRooms, historyFor, recordConnection, setRooms } from "./history";
21
21
  import { deriveTags, matchQuery, shortRoomLabel, tagKey } from "../shared/tags";
22
22
 
23
23
  const TOKEN_KEY = "codehost.token";
24
24
 
25
25
  type ConnState = "idle" | "connecting" | "connected" | "failed";
26
26
 
27
+ /** A server discovered in a specific room (its token routes the signaling). */
28
+ type RoomedServer = { server: PeerInfo; room: string };
29
+
27
30
  /**
28
31
  * Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
29
32
  * prints/opens after `setup`/`serve`). The page is static, so the fragment
@@ -94,46 +97,99 @@ function findRoomForDeepLink(dl: DeepLink, tokens: string[], timeoutMs = 6000):
94
97
  });
95
98
  }
96
99
 
100
+ /**
101
+ * Headless per-room signaling client — one instance per joined room. React's
102
+ * keyed reconciliation (`key={token}`) adds/removes these as the joined set
103
+ * changes, so joining or leaving a room never tears down the other rooms' live
104
+ * discovery (or the active WebRTC session). Renders nothing: it pushes its
105
+ * room's servers/open-state up to the parent and registers a signal sender so
106
+ * the parent can dial peers found in this room.
107
+ */
108
+ function RoomClient(props: {
109
+ token: string;
110
+ onPeers: (peers: PeerInfo[]) => void;
111
+ onStatus: (open: boolean) => void;
112
+ onSignal: (from: string, data: unknown) => void;
113
+ registerSender: (send: ((to: string, data: unknown) => void) | null) => void;
114
+ }) {
115
+ // Keep the latest callbacks in a ref so the socket effect runs once per token,
116
+ // not on every parent re-render (which would needlessly churn the WebSocket).
117
+ const cb = useRef(props);
118
+ cb.current = props;
119
+ const { token } = props;
120
+ useEffect(() => {
121
+ const client = new SignalingClient({
122
+ url: getSignalUrl(),
123
+ token,
124
+ role: "viewer",
125
+ onOpen: () => cb.current.onStatus(true),
126
+ onClose: () => cb.current.onStatus(false),
127
+ onPeers: (peers) => cb.current.onPeers(peers.filter((p) => p.role === "server")),
128
+ onSignal: (from, data) => cb.current.onSignal(from, data),
129
+ });
130
+ cb.current.registerSender((to, data) => client.sendSignal(to, data));
131
+ client.connect();
132
+ return () => {
133
+ client.close();
134
+ cb.current.registerSender(null);
135
+ };
136
+ }, [token]);
137
+ return null;
138
+ }
139
+
97
140
  export function Discovery() {
98
- const [token, setToken] = useState(() => {
141
+ // Joined rooms — each token *is* a room id, and we keep one live signaling
142
+ // client per room (see RoomClient). Seeded from the persisted room list plus
143
+ // any legacy single-token / URL-fragment token, then format-validated.
144
+ const [tokens, setTokens] = useState<string[]>(() => {
145
+ const seed = new Set<string>(getRooms());
146
+ const legacy = localStorage.getItem(TOKEN_KEY);
147
+ if (legacy) seed.add(legacy);
99
148
  const fromHash = tokenFromHash();
100
- if (fromHash && validateToken(fromHash).ok) {
101
- localStorage.setItem(TOKEN_KEY, fromHash);
102
- return fromHash;
103
- }
104
- return localStorage.getItem(TOKEN_KEY) ?? "";
149
+ if (fromHash) seed.add(fromHash);
150
+ return [...seed].filter((t) => validateToken(t).ok);
105
151
  });
106
- // The token is a bearer secret — never pre-fill the input with it (it would be
107
- // left in plaintext in the DOM on every load). Start blank; once a token is
108
- // saved we show a masked label instead, and only reveal the input on "Change".
152
+
153
+ // Per-room discovery state, merged into one workspace list below.
154
+ const [serversByRoom, setServersByRoom] = useState<Record<string, PeerInfo[]>>({});
155
+ const [roomOpen, setRoomOpen] = useState<Record<string, boolean>>({});
156
+
157
+ // Token input = "join another room": validated, then appended to the set.
158
+ // Never pre-filled with a saved token — it's a bearer secret.
109
159
  const [draft, setDraft] = useState("");
110
160
  const [editingToken, setEditingToken] = useState(false);
111
161
  const [tokenError, setTokenError] = useState<string | null>(null);
112
- const [connected, setConnected] = useState(false);
113
- const [servers, setServers] = useState<PeerInfo[]>([]);
114
162
 
115
- // Fake-tag filter over the workspace list: a free-text box plus a set of
116
- // pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
163
+ // Fake-tag filter over the merged workspace list: a free-text box plus a set
164
+ // of pinned tag tokens (chips). Both feed the same `ay ls`-style AND matcher.
117
165
  const [filter, setFilter] = useState("");
118
166
  const [activeTags, setActiveTags] = useState<string[]>([]);
119
167
 
120
- // Active WebRTC connection to one server (Phase 2: echo test).
168
+ // One WebRTC session at a time (you view a single VS Code), discovered across
169
+ // many rooms. `activeRoomRef` is the room the active peer was found in: its
170
+ // client carries the peer's signaling and it's the token Share/history record.
121
171
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
122
172
  const [connState, setConnState] = useState<ConnState>("idle");
123
-
124
- // Once a server's data channel is open we mount its VS Code in an iframe.
125
173
  const [iframeSrc, setIframeSrc] = useState<string | null>(null);
126
174
 
127
- const clientRef = useRef<SignalingClient | null>(null);
128
175
  const rtcRef = useRef<RtcClient | null>(null);
129
176
  const activePeerRef = useRef<string | null>(null);
177
+ const activeRoomRef = useRef<string | null>(null);
178
+ const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
179
+ // Whether the live connection pushed a history entry (so Disconnect/Back can
180
+ // pop it), and whether we're currently *viewing* a workspace (so popstate only
181
+ // tears down from the iframe view, not during a mid-connect URL revert).
182
+ const pushedRef = useRef(false);
183
+ const viewingRef = useRef(false);
130
184
 
131
185
  // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
132
186
  // auto-connect when a matching server appears, remember the opened folder.
133
187
  const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
134
188
  const resolvedRef = useRef(false);
135
- // A valid token in the URL fragment enables single-server auto-connect.
189
+ // A valid token in the URL fragment enables single-server auto-connect, scoped
190
+ // to *that* room so unrelated joined rooms don't block it.
136
191
  const autoConnectRef = useRef(false);
192
+ const hashRoomRef = useRef<string | null>(null);
137
193
  const activeFolderRef = useRef<string | undefined>(undefined);
138
194
  const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
139
195
 
@@ -142,6 +198,15 @@ export function Discovery() {
142
198
  const sharePathRef = useRef<string | null>(null);
143
199
  const [copied, setCopied] = useState(false);
144
200
 
201
+ function adoptRoom(t: string) {
202
+ setTokens((prev) => (prev.includes(t) ? prev : [...prev, t]));
203
+ }
204
+
205
+ // Persist the joined set so rooms survive reloads.
206
+ useEffect(() => {
207
+ setRooms(tokens);
208
+ }, [tokens]);
209
+
145
210
  // Register the Service Worker + connection broker once. The broker shares one
146
211
  // WebRTC connection per server across tabs; on owner failover it asks us to
147
212
  // reload the iframe so it reconnects through the new owner.
@@ -153,12 +218,20 @@ export function Discovery() {
153
218
  const folder = activeFolderRef.current;
154
219
  setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
155
220
  });
156
- // A valid token in the URL fragment (#t=<token>) seeds the room and turns on
157
- // single-server auto-connect; consume it from the address bar afterwards so
158
- // the secret isn't left visible or re-applied on a manual reload.
221
+ // Back / Cmd+Left from a workspace returns to the list: the browser restores
222
+ // the previous URL and we drop the connection. Only when actually viewing —
223
+ // a mid-connect URL revert (failed dial) must leave the list state untouched.
224
+ const onPopState = () => {
225
+ if (viewingRef.current) teardownConn();
226
+ };
227
+ window.addEventListener("popstate", onPopState);
228
+ // A valid token in the URL fragment (#t=<token>) joins the room and turns on
229
+ // single-server auto-connect for it; consume it from the address bar after,
230
+ // so the secret isn't left visible or re-applied on a manual reload.
159
231
  const urlToken = tokenFromHash();
160
232
  if (urlToken && validateToken(urlToken).ok) {
161
233
  autoConnectRef.current = true;
234
+ hashRoomRef.current = urlToken;
162
235
  if (window.location.hash) {
163
236
  history.replaceState(null, "", window.location.pathname + window.location.search);
164
237
  }
@@ -171,56 +244,28 @@ export function Discovery() {
171
244
  if (dl && !(urlToken && validateToken(urlToken).ok)) {
172
245
  const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
173
246
  if (histToken) {
174
- setToken(histToken);
247
+ adoptRoom(histToken);
175
248
  } else {
176
249
  const rooms = getRooms();
177
250
  if (rooms.length) {
178
251
  void findRoomForDeepLink(dl, rooms).then((tok) => {
179
- if (tok) setToken(tok);
252
+ if (tok) adoptRoom(tok);
180
253
  });
181
254
  }
182
255
  }
183
256
  }
257
+
258
+ return () => window.removeEventListener("popstate", onPopState);
184
259
  }, []);
185
260
 
261
+ // Auto-connect once discovery turns up a match: a deep-link target across any
262
+ // room, or the lone server of a room joined via #t=.
186
263
  useEffect(() => {
187
- if (!token) return;
188
- const client = new SignalingClient({
189
- url: getSignalUrl(),
190
- token,
191
- role: "viewer",
192
- onOpen: () => {
193
- setConnected(true);
194
- addRoom(token);
195
- },
196
- onClose: () => setConnected(false),
197
- onPeers: (peers) => {
198
- const list = peers.filter((p) => p.role === "server");
199
- setServers(list);
200
- void tryAutoConnect(list);
201
- },
202
- onSignal: (from, data) => {
203
- if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
204
- },
205
- });
206
- clientRef.current = client;
207
- client.connect();
208
- return () => {
209
- rtcRef.current?.close();
210
- rtcRef.current = null;
211
- if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
212
- client.close();
213
- clientRef.current = null;
214
- setConnected(false);
215
- setServers([]);
216
- setActivePeerId(null);
217
- activePeerRef.current = null;
218
- setConnState("idle");
219
- setIframeSrc(null);
220
- };
221
- }, [token]);
264
+ if (resolvedRef.current) return;
265
+ tryAutoConnect();
266
+ }, [serversByRoom, tokens]);
222
267
 
223
- function applyToken(e: React.FormEvent) {
268
+ function joinFromInput(e: React.FormEvent) {
224
269
  e.preventDefault();
225
270
  const t = draft.trim();
226
271
  const check = validateToken(t);
@@ -229,30 +274,57 @@ export function Discovery() {
229
274
  return;
230
275
  }
231
276
  setTokenError(null);
232
- localStorage.setItem(TOKEN_KEY, t);
233
- setToken(t);
277
+ adoptRoom(t);
234
278
  setDraft("");
235
279
  setEditingToken(false);
236
280
  }
237
281
 
238
- async function connectTo(server: PeerInfo, folder?: string) {
239
- const client = clientRef.current;
240
- if (!client) return;
282
+ function leaveRoom(t: string) {
283
+ if (activeRoomRef.current === t) disconnect();
284
+ setTokens((prev) => prev.filter((x) => x !== t));
285
+ setServersByRoom((m) => {
286
+ const n = { ...m };
287
+ delete n[t];
288
+ return n;
289
+ });
290
+ setRoomOpen((m) => {
291
+ const n = { ...m };
292
+ delete n[t];
293
+ return n;
294
+ });
295
+ sendersRef.current.delete(t);
296
+ }
297
+
298
+ async function connectTo(server: PeerInfo, room: string, folder?: string) {
299
+ const send = sendersRef.current.get(room);
300
+ if (!send) return;
241
301
 
242
302
  rtcRef.current?.close();
243
303
  rtcRef.current = null;
244
304
  setIframeSrc(null);
245
305
  setActivePeerId(server.peerId);
246
306
  activePeerRef.current = server.peerId;
307
+ activeRoomRef.current = room;
308
+ viewingRef.current = false;
247
309
  setConnState("connecting");
248
310
 
311
+ // Update the address bar the instant Connect is clicked (don't wait for the
312
+ // WebRTC handshake) and push a history entry, so Back / Cmd+Left returns to
313
+ // the list. Reverted if the connection fails.
314
+ const openFolder = folder ?? server.meta?.cwd;
315
+ const targetPath = shareablePathFor(server, openFolder);
316
+ const pushed = !!targetPath && targetPath !== window.location.pathname;
317
+ if (pushed) history.pushState(null, "", targetPath);
318
+ pushedRef.current = pushed;
319
+ sharePathRef.current = targetPath ?? window.location.pathname;
320
+
249
321
  // The broker decides whether this tab owns the connection. `establish` is
250
322
  // only invoked when we're the owner (or get promoted on failover); other
251
323
  // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
252
324
  const establish = () =>
253
325
  new Promise<RTCDataChannel>((resolve, reject) => {
254
326
  const rtc = new RtcClient({
255
- sendSignal: (data: RtcSignal) => client.sendSignal(server.peerId, data),
327
+ sendSignal: (data: RtcSignal) => send(server.peerId, data),
256
328
  onState: (state) => {
257
329
  if (state === "failed" || state === "disconnected") setConnState("failed");
258
330
  },
@@ -279,34 +351,38 @@ export function Discovery() {
279
351
  await connBroker.connect(server.peerId, establish);
280
352
  setConnState("connected");
281
353
  // The daemon no longer sets a default folder (current VS Code serve-web
282
- // dropped that flag), so open the served workspace from here: an explicit
354
+ // dropped that flag), so open the served workspace from here: the
283
355
  // deep-link folder if we have one, else the server's reported cwd.
284
- const openFolder = folder ?? server.meta?.cwd;
285
356
  activeFolderRef.current = openFolder;
286
357
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
287
358
  setResolving(null);
288
- recordConnect(server, openFolder);
289
- updateAddressBar(server, openFolder);
359
+ recordConnect(server, room, openFolder);
360
+ viewingRef.current = true;
290
361
  } catch {
291
362
  setConnState("failed");
363
+ // Undo the optimistic history entry / URL change. We never started
364
+ // viewing, so the popstate handler leaves the "failed" card in place.
365
+ if (pushed) {
366
+ pushedRef.current = false;
367
+ history.back();
368
+ }
292
369
  }
293
370
  }
294
371
 
295
- // Reflect the live connection in the address bar as a clean, shareable deep
296
- // link (no token — Share adds that). If we arrived via a deep link, keep its
297
- // pathname; otherwise derive one from the server's repo identity or folder.
298
- function updateAddressBar(server: PeerInfo, folder?: string) {
299
- const path = deepLinkRef.current
372
+ // Shareable deep-link pathname for a server+folder, with no side effects (no
373
+ // token — Share adds that). Keeps an existing deep-link path as-is; otherwise
374
+ // derives /gh|/git|/dev from the server's repo identity or opened folder.
375
+ function shareablePathFor(server: PeerInfo, folder?: string): string | null {
376
+ return deepLinkRef.current
300
377
  ? window.location.pathname
301
378
  : shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
302
- if (!path) return;
303
- sharePathRef.current = path;
304
- if (path !== window.location.pathname) history.replaceState(null, "", path);
305
379
  }
306
380
 
307
381
  async function shareLink() {
382
+ const room = activeRoomRef.current;
383
+ if (!room) return;
308
384
  const path = sharePathRef.current ?? window.location.pathname;
309
- const url = `${window.location.origin}${path}#t=${encodeURIComponent(token)}`;
385
+ const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
310
386
  try {
311
387
  await navigator.clipboard.writeText(url);
312
388
  } catch {
@@ -318,30 +394,36 @@ export function Discovery() {
318
394
  }
319
395
 
320
396
  // Deep-link auto-connect: when servers arrive, pick the best match (exact repo
321
- // daemon, else a root daemon's subfolder) and open it once.
322
- async function tryAutoConnect(list: PeerInfo[]) {
397
+ // daemon, else a root daemon's subfolder) across all rooms and open it once.
398
+ function tryAutoConnect() {
323
399
  if (resolvedRef.current) return;
324
400
  const dl = deepLinkRef.current;
325
401
  if (dl) {
326
- const res = dl.type === "repo" ? resolveRepoTarget(list, dl.target) : resolveDevTarget(list, dl.target);
402
+ const peers = allServers.map((x) => x.server);
403
+ const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
327
404
  if (!res) return;
328
- const server = list.find((s) => s.peerId === res.peerId);
329
- if (!server) return;
405
+ const match = allServers.find((x) => x.server.peerId === res.peerId);
406
+ if (!match) return;
330
407
  resolvedRef.current = true;
331
- await connectTo(server, res.folder);
408
+ void connectTo(match.server, match.room, res.folder);
332
409
  return;
333
410
  }
334
- // No deep link, but a token arrived via the URL: open the room's server
335
- // straight away when there's exactly one; with several, leave the picker.
336
- if (autoConnectRef.current && list.length === 1) {
337
- resolvedRef.current = true;
338
- await connectTo(list[0]);
411
+ // No deep link, but a token arrived via the URL: open that room's server
412
+ // straight away when it has exactly one. Scoped to the hash room so servers
413
+ // in other joined rooms can't push the count past one and block it.
414
+ const hashRoom = hashRoomRef.current;
415
+ if (autoConnectRef.current && hashRoom) {
416
+ const inRoom = allServers.filter((x) => x.room === hashRoom);
417
+ if (inRoom.length === 1) {
418
+ resolvedRef.current = true;
419
+ void connectTo(inRoom[0].server, inRoom[0].room);
420
+ }
339
421
  }
340
422
  }
341
423
 
342
- function recordConnect(server: PeerInfo, folder?: string) {
424
+ function recordConnect(server: PeerInfo, room: string, folder?: string) {
343
425
  const base = {
344
- token,
426
+ token: room,
345
427
  peerId: server.peerId,
346
428
  kind: server.meta?.kind,
347
429
  name: server.meta?.name,
@@ -353,27 +435,50 @@ export function Discovery() {
353
435
  if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder });
354
436
  }
355
437
 
356
- function disconnect() {
438
+ // Tear down the active connection and return to the workspace list. Does NOT
439
+ // touch history — the caller (Disconnect → history.back, or a popstate from
440
+ // Cmd+Left) owns the URL.
441
+ function teardownConn() {
357
442
  rtcRef.current?.close();
358
443
  rtcRef.current = null;
359
444
  if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
360
445
  setIframeSrc(null);
361
446
  setActivePeerId(null);
362
447
  activePeerRef.current = null;
448
+ activeRoomRef.current = null;
363
449
  setConnState("idle");
364
450
  sharePathRef.current = null;
451
+ pushedRef.current = false;
452
+ viewingRef.current = false;
453
+ }
454
+
455
+ function disconnect() {
456
+ // Mirror Cmd+Left: if connecting pushed a history entry, pop it — the
457
+ // browser restores the previous URL and our popstate handler tears down.
458
+ if (pushedRef.current) {
459
+ history.back();
460
+ return;
461
+ }
462
+ teardownConn();
365
463
  if (window.location.pathname !== "/") history.replaceState(null, "", "/");
366
464
  }
367
465
 
368
- const activeServer = servers.find((s) => s.peerId === activePeerId);
466
+ // Merge every room's servers into one list, each tagged with its room so the
467
+ // Connect button knows which client to signal through.
468
+ const allServers: RoomedServer[] = tokens.flatMap((t) =>
469
+ (serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
470
+ );
471
+ const serverCount = allServers.length;
472
+ const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
473
+ const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
369
474
 
370
- // Annotate each server with its mnemonic fake-tags, then filter. The room
371
- // token is hashed to a short label — never rendered raw (it's a bearer secret).
372
- const roomLabel = token ? shortRoomLabel(token) : "";
373
- const tagged = servers.map((s) => ({
475
+ // Annotate each server with its mnemonic fake-tags (incl. its room label), then
476
+ // filter. The room token is hashed to a short label — never rendered raw.
477
+ const tagged = allServers.map(({ server: s, room }) => ({
374
478
  server: s,
479
+ room,
375
480
  name: s.meta?.name ?? s.peerId.slice(0, 8),
376
- tags: deriveTags(s.meta, { roomLabel }),
481
+ tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
377
482
  }));
378
483
  const query = [...activeTags, filter].join(" ");
379
484
  const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
@@ -389,174 +494,233 @@ export function Discovery() {
389
494
  .filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
390
495
  .slice(0, 12);
391
496
 
497
+ // Headless signaling clients, one per joined room. Kept mounted across BOTH
498
+ // views so switching into the iframe never tears down discovery/session.
499
+ const roomClients = tokens.map((t) => (
500
+ <RoomClient
501
+ key={t}
502
+ token={t}
503
+ onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
504
+ onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
505
+ onSignal={(from, data) => {
506
+ if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
507
+ }}
508
+ registerSender={(send) => {
509
+ if (send) sendersRef.current.set(t, send);
510
+ else sendersRef.current.delete(t);
511
+ }}
512
+ />
513
+ ));
514
+
392
515
  // Connected view: VS Code in an iframe, served over the tunnel.
393
516
  if (iframeSrc && connState === "connected") {
394
517
  return (
518
+ <>
519
+ {roomClients}
520
+ <div style={styles.page}>
521
+ <header style={styles.header}>
522
+ <span style={styles.brand}>codehost</span>
523
+ <span style={styles.dim}>·</span>
524
+ <span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
525
+ {activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
526
+ <span style={{ flex: 1 }} />
527
+ <button
528
+ style={styles.shareBtn}
529
+ onClick={shareLink}
530
+ title="Copy a link that opens this workspace (includes the room token)"
531
+ >
532
+ {copied ? "Copied!" : "Share"}
533
+ </button>
534
+ <button style={styles.connectBtn} onClick={disconnect}>
535
+ Disconnect
536
+ </button>
537
+ </header>
538
+ <iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
539
+ </div>
540
+ </>
541
+ );
542
+ }
543
+
544
+ return (
545
+ <>
546
+ {roomClients}
395
547
  <div style={styles.page}>
396
548
  <header style={styles.header}>
397
549
  <span style={styles.brand}>codehost</span>
398
550
  <span style={styles.dim}>·</span>
399
- <span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
400
- {activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
551
+ <span style={styles.dim}>{getSignalUrl()}</span>
401
552
  <span style={{ flex: 1 }} />
402
- <button
403
- style={styles.shareBtn}
404
- onClick={shareLink}
405
- title="Copy a link that opens this workspace (includes the room token)"
406
- >
407
- {copied ? "Copied!" : "Share"}
408
- </button>
409
- <button style={styles.connectBtn} onClick={disconnect}>
410
- Disconnect
411
- </button>
553
+ <span style={{ ...styles.status, color: onlineRooms > 0 ? "#4ec9b0" : "#888" }}>
554
+ {tokens.length === 0
555
+ ? "○ no rooms"
556
+ : `${onlineRooms > 0 ? "●" : "○"} ${onlineRooms}/${tokens.length} rooms`}
557
+ </span>
412
558
  </header>
413
- <iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
414
- </div>
415
- );
416
- }
417
559
 
418
- return (
419
- <div style={styles.page}>
420
- <header style={styles.header}>
421
- <span style={styles.brand}>codehost</span>
422
- <span style={styles.dim}>·</span>
423
- <span style={styles.dim}>{getSignalUrl()}</span>
424
- <span style={{ flex: 1 }} />
425
- <span style={{ ...styles.status, color: connected ? "#4ec9b0" : "#888" }}>
426
- {token ? (connected ? "● connected" : "○ connecting…") : "○ no token"}
427
- </span>
428
- </header>
429
-
430
- <main style={styles.main}>
431
- {resolving && (
432
- <p style={{ color: "#dcb67a", marginBottom: 12 }}>
433
- Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
434
- {token ? "waiting for a live server" : "enter the room's token below"}.
435
- </p>
436
- )}
437
- {token && !editingToken ? (
560
+ <main style={styles.main}>
561
+ {resolving && (
562
+ <p style={{ color: "#dcb67a", marginBottom: 12 }}>
563
+ Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
564
+ {tokens.length > 0 ? "waiting for a live server" : "join the room's token below"}.
565
+ </p>
566
+ )}
567
+
438
568
  <div style={styles.tokenForm}>
439
- <label style={styles.label}>Token</label>
440
- <span style={styles.tokenSaved}>saved · {roomLabel}</span>
441
- <button
442
- type="button"
443
- style={styles.shareBtn}
444
- onClick={() => {
445
- setDraft("");
446
- setTokenError(null);
447
- setEditingToken(true);
448
- }}
449
- >
450
- Change
451
- </button>
452
- </div>
453
- ) : (
454
- <>
455
- <form onSubmit={applyToken} style={styles.tokenForm}>
456
- <label style={styles.label}>Token</label>
457
- <input
458
- value={draft}
459
- onChange={(e) => {
460
- setDraft(e.target.value);
461
- if (tokenError) setTokenError(null);
462
- }}
463
- placeholder="your room token"
464
- style={styles.input}
465
- autoFocus={editingToken}
466
- />
467
- <button type="submit" style={styles.button}>
468
- Connect
469
- </button>
470
- </form>
471
- {tokenError ? (
472
- <p style={styles.tokenError}>{tokenError}</p>
569
+ <label style={styles.label}>Rooms</label>
570
+ {tokens.length > 0 ? (
571
+ <>
572
+ <div style={styles.roomChips}>
573
+ {tokens.map((t) => (
574
+ <span
575
+ key={t}
576
+ style={{ ...styles.chip, ...styles.roomChip, ...(roomOpen[t] ? styles.roomChipOn : {}) }}
577
+ title={roomOpen[t] ? "connected" : "connecting…"}
578
+ >
579
+ {shortRoomLabel(t)}
580
+ <button type="button" style={styles.roomChipX} onClick={() => leaveRoom(t)} title="leave room">
581
+
582
+ </button>
583
+ </span>
584
+ ))}
585
+ </div>
586
+ <span style={{ flex: 1 }} />
587
+ {!editingToken && (
588
+ <button
589
+ type="button"
590
+ style={styles.shareBtn}
591
+ onClick={() => {
592
+ setDraft("");
593
+ setTokenError(null);
594
+ setEditingToken(true);
595
+ }}
596
+ >
597
+ + Add
598
+ </button>
599
+ )}
600
+ </>
473
601
  ) : (
474
- <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
602
+ <span style={styles.dim}>none joined yet</span>
475
603
  )}
476
- </>
477
- )}
478
-
479
- <div style={styles.listHead}>
480
- <h2 style={styles.h2}>Workspaces</h2>
481
- {token && servers.length > 0 && (
482
- <span style={styles.count}>
483
- {filtered.length === tagged.length
484
- ? `${tagged.length}`
485
- : `${filtered.length} / ${tagged.length}`}
486
- </span>
487
- )}
488
- </div>
489
- {!token && <p style={styles.dim}>Enter a token to see your workspaces.</p>}
490
- {token && servers.length === 0 && (
491
- <p style={styles.dim}>
492
- No servers online. Run{" "}
493
- <code style={styles.code}>bunx codehost serve -t {token || "<token>"}</code> on a machine.
494
- </p>
495
- )}
496
- {token && servers.length > 0 && (
497
- <>
498
- <input
499
- value={filter}
500
- onChange={(e) => setFilter(e.target.value)}
501
- placeholder="filter… e.g. repo:codehost host:mbp (space = AND)"
502
- style={styles.search}
503
- />
504
- {(activeTags.length > 0 || suggestedTags.length > 0) && (
505
- <div style={styles.chipRow}>
506
- {activeTags.map((t) => (
507
- <button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
508
- {t} ✕
604
+ </div>
605
+
606
+ {(editingToken || tokens.length === 0) && (
607
+ <>
608
+ <form onSubmit={joinFromInput} style={styles.tokenForm}>
609
+ <input
610
+ value={draft}
611
+ onChange={(e) => {
612
+ setDraft(e.target.value);
613
+ if (tokenError) setTokenError(null);
614
+ }}
615
+ placeholder="paste a room token to join"
616
+ style={styles.input}
617
+ autoFocus={editingToken}
618
+ />
619
+ <button type="submit" style={styles.button}>
620
+ Join
621
+ </button>
622
+ {editingToken && tokens.length > 0 && (
623
+ <button
624
+ type="button"
625
+ style={styles.shareBtn}
626
+ onClick={() => {
627
+ setEditingToken(false);
628
+ setTokenError(null);
629
+ }}
630
+ >
631
+ Cancel
509
632
  </button>
510
- ))}
511
- {suggestedTags
512
- .filter((t) => !activeTags.includes(t))
513
- .map((t) => (
514
- <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
515
- {t}
633
+ )}
634
+ </form>
635
+ {tokenError ? (
636
+ <p style={styles.tokenError}>{tokenError}</p>
637
+ ) : (
638
+ <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
639
+ )}
640
+ </>
641
+ )}
642
+
643
+ <div style={styles.listHead}>
644
+ <h2 style={styles.h2}>Workspaces</h2>
645
+ {serverCount > 0 && (
646
+ <span style={styles.count}>
647
+ {filtered.length === tagged.length
648
+ ? `${tagged.length}`
649
+ : `${filtered.length} / ${tagged.length}`}
650
+ </span>
651
+ )}
652
+ </div>
653
+ {tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
654
+ {tokens.length > 0 && serverCount === 0 && (
655
+ <p style={styles.dim}>
656
+ No servers online. Run <code style={styles.code}>bunx codehost serve -t &lt;token&gt;</code> on a machine.
657
+ </p>
658
+ )}
659
+ {serverCount > 0 && (
660
+ <>
661
+ <input
662
+ value={filter}
663
+ onChange={(e) => setFilter(e.target.value)}
664
+ placeholder="filter… e.g. repo:codehost host:mbp room:ab12 (space = AND)"
665
+ style={styles.search}
666
+ />
667
+ {(activeTags.length > 0 || suggestedTags.length > 0) && (
668
+ <div style={styles.chipRow}>
669
+ {activeTags.map((t) => (
670
+ <button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
671
+ {t} ✕
516
672
  </button>
517
673
  ))}
518
- </div>
519
- )}
520
- </>
521
- )}
522
- <ul style={styles.list}>
523
- {filtered.map(({ server: s, name, tags }) => {
524
- const isActive = s.peerId === activePeerId;
525
- return (
526
- <li key={s.peerId} style={styles.card}>
527
- <div style={styles.cardMain}>
528
- <div style={styles.cardName}>{name}</div>
529
- <div style={styles.tagRow}>
530
- {tags.map((tag) => (
531
- <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
532
- {tag}
674
+ {suggestedTags
675
+ .filter((t) => !activeTags.includes(t))
676
+ .map((t) => (
677
+ <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
678
+ {t}
533
679
  </button>
534
680
  ))}
535
- </div>
536
- <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
537
- {isActive && (
538
- <div style={styles.echo}>
539
- {connState === "connecting" && "negotiating WebRTC…"}
540
- {connState === "failed" && "connection failed"}
541
- </div>
542
- )}
543
681
  </div>
544
- <button
545
- style={styles.connectBtn}
546
- onClick={() => connectTo(s)}
547
- disabled={isActive && connState === "connecting"}
548
- >
549
- {isActive && connState === "connecting" ? "…" : "Connect"}
550
- </button>
551
- </li>
552
- );
553
- })}
554
- {token && servers.length > 0 && filtered.length === 0 && (
555
- <p style={styles.dim}>No workspace matches your filter.</p>
682
+ )}
683
+ </>
556
684
  )}
557
- </ul>
558
- </main>
559
- </div>
685
+ <ul style={styles.list}>
686
+ {filtered.map(({ server: s, room, name, tags }) => {
687
+ const isActive = s.peerId === activePeerId;
688
+ return (
689
+ <li key={s.peerId} style={styles.card}>
690
+ <div style={styles.cardMain}>
691
+ <div style={styles.cardName}>{name}</div>
692
+ <div style={styles.tagRow}>
693
+ {tags.map((tag) => (
694
+ <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
695
+ {tag}
696
+ </button>
697
+ ))}
698
+ </div>
699
+ <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
700
+ {isActive && (
701
+ <div style={styles.echo}>
702
+ {connState === "connecting" && "negotiating WebRTC…"}
703
+ {connState === "failed" && "connection failed"}
704
+ </div>
705
+ )}
706
+ </div>
707
+ <button
708
+ style={styles.connectBtn}
709
+ onClick={() => connectTo(s, room)}
710
+ disabled={isActive && connState === "connecting"}
711
+ >
712
+ {isActive && connState === "connecting" ? "…" : "Connect"}
713
+ </button>
714
+ </li>
715
+ );
716
+ })}
717
+ {serverCount > 0 && filtered.length === 0 && (
718
+ <p style={styles.dim}>No workspace matches your filter.</p>
719
+ )}
720
+ </ul>
721
+ </main>
722
+ </div>
723
+ </>
560
724
  );
561
725
  }
562
726
 
@@ -571,7 +735,10 @@ const styles: Record<string, React.CSSProperties> = {
571
735
  tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
572
736
  tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
573
737
  label: { fontSize: 12, color: "#888" },
574
- tokenSaved: { flex: 1, fontFamily: "monospace", fontSize: 13, color: "#4ec9b0" },
738
+ roomChips: { display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" },
739
+ roomChip: { display: "inline-flex", alignItems: "center", gap: 6, color: "#9aa4af" },
740
+ roomChipOn: { borderColor: "#0e639c", color: "#4ec9b0" },
741
+ roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
575
742
  input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
576
743
  button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
577
744
  listHead: { display: "flex", alignItems: "baseline", gap: 10, margin: "0 0 12px" },
@@ -43,6 +43,11 @@ export function addRoom(token: string): void {
43
43
  if (!rooms.includes(token)) write(ROOMS_KEY, [...rooms, token]);
44
44
  }
45
45
 
46
+ /** Replace the persisted joined-room set (deduped, empties dropped). */
47
+ export function setRooms(tokens: string[]): void {
48
+ write(ROOMS_KEY, [...new Set(tokens.filter(Boolean))]);
49
+ }
50
+
46
51
  export function getHistory(): Record<string, HistoryEntry> {
47
52
  return read<Record<string, HistoryEntry>>(HISTORY_KEY, {});
48
53
  }