codehost 0.9.0 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.10.0](https://github.com/snomiao/codehost/compare/v0.9.1...v0.10.0) (2026-06-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** join multiple rooms at once, merged workspace list ([de5ae61](https://github.com/snomiao/codehost/commit/de5ae610068b6f2a26792ed7d6e67d1fb5c08257))
7
+
8
+ ## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **web:** stop rendering raw room token in the input ([abf7ea5](https://github.com/snomiao/codehost/commit/abf7ea5abd96f156f178f5f5c8ddb0e31f8fdd81))
14
+
1
15
  # [0.9.0](https://github.com/snomiao/codehost/compare/v0.8.0...v0.9.0) (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.0",
3
+ "version": "0.10.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,42 +97,94 @@ 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
- const [draft, setDraft] = useState(token);
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.
159
+ const [draft, setDraft] = useState("");
160
+ const [editingToken, setEditingToken] = useState(false);
107
161
  const [tokenError, setTokenError] = useState<string | null>(null);
108
- const [connected, setConnected] = useState(false);
109
- const [servers, setServers] = useState<PeerInfo[]>([]);
110
162
 
111
- // Fake-tag filter over the workspace list: a free-text box plus a set of
112
- // 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.
113
165
  const [filter, setFilter] = useState("");
114
166
  const [activeTags, setActiveTags] = useState<string[]>([]);
115
167
 
116
- // 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.
117
171
  const [activePeerId, setActivePeerId] = useState<string | null>(null);
118
172
  const [connState, setConnState] = useState<ConnState>("idle");
119
-
120
- // Once a server's data channel is open we mount its VS Code in an iframe.
121
173
  const [iframeSrc, setIframeSrc] = useState<string | null>(null);
122
174
 
123
- const clientRef = useRef<SignalingClient | null>(null);
124
175
  const rtcRef = useRef<RtcClient | null>(null);
125
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());
126
179
 
127
180
  // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
128
181
  // auto-connect when a matching server appears, remember the opened folder.
129
182
  const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
130
183
  const resolvedRef = useRef(false);
131
- // A valid token in the URL fragment enables single-server auto-connect.
184
+ // A valid token in the URL fragment enables single-server auto-connect, scoped
185
+ // to *that* room so unrelated joined rooms don't block it.
132
186
  const autoConnectRef = useRef(false);
187
+ const hashRoomRef = useRef<string | null>(null);
133
188
  const activeFolderRef = useRef<string | undefined>(undefined);
134
189
  const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
135
190
 
@@ -138,6 +193,15 @@ export function Discovery() {
138
193
  const sharePathRef = useRef<string | null>(null);
139
194
  const [copied, setCopied] = useState(false);
140
195
 
196
+ function adoptRoom(t: string) {
197
+ setTokens((prev) => (prev.includes(t) ? prev : [...prev, t]));
198
+ }
199
+
200
+ // Persist the joined set so rooms survive reloads.
201
+ useEffect(() => {
202
+ setRooms(tokens);
203
+ }, [tokens]);
204
+
141
205
  // Register the Service Worker + connection broker once. The broker shares one
142
206
  // WebRTC connection per server across tabs; on owner failover it asks us to
143
207
  // reload the iframe so it reconnects through the new owner.
@@ -149,12 +213,13 @@ export function Discovery() {
149
213
  const folder = activeFolderRef.current;
150
214
  setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
151
215
  });
152
- // A valid token in the URL fragment (#t=<token>) seeds the room and turns on
153
- // single-server auto-connect; consume it from the address bar afterwards so
154
- // the secret isn't left visible or re-applied on a manual reload.
216
+ // A valid token in the URL fragment (#t=<token>) joins the room and turns on
217
+ // single-server auto-connect for it; consume it from the address bar after,
218
+ // so the secret isn't left visible or re-applied on a manual reload.
155
219
  const urlToken = tokenFromHash();
156
220
  if (urlToken && validateToken(urlToken).ok) {
157
221
  autoConnectRef.current = true;
222
+ hashRoomRef.current = urlToken;
158
223
  if (window.location.hash) {
159
224
  history.replaceState(null, "", window.location.pathname + window.location.search);
160
225
  }
@@ -167,56 +232,26 @@ export function Discovery() {
167
232
  if (dl && !(urlToken && validateToken(urlToken).ok)) {
168
233
  const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
169
234
  if (histToken) {
170
- setToken(histToken);
235
+ adoptRoom(histToken);
171
236
  } else {
172
237
  const rooms = getRooms();
173
238
  if (rooms.length) {
174
239
  void findRoomForDeepLink(dl, rooms).then((tok) => {
175
- if (tok) setToken(tok);
240
+ if (tok) adoptRoom(tok);
176
241
  });
177
242
  }
178
243
  }
179
244
  }
180
245
  }, []);
181
246
 
247
+ // Auto-connect once discovery turns up a match: a deep-link target across any
248
+ // room, or the lone server of a room joined via #t=.
182
249
  useEffect(() => {
183
- if (!token) return;
184
- const client = new SignalingClient({
185
- url: getSignalUrl(),
186
- token,
187
- role: "viewer",
188
- onOpen: () => {
189
- setConnected(true);
190
- addRoom(token);
191
- },
192
- onClose: () => setConnected(false),
193
- onPeers: (peers) => {
194
- const list = peers.filter((p) => p.role === "server");
195
- setServers(list);
196
- void tryAutoConnect(list);
197
- },
198
- onSignal: (from, data) => {
199
- if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
200
- },
201
- });
202
- clientRef.current = client;
203
- client.connect();
204
- return () => {
205
- rtcRef.current?.close();
206
- rtcRef.current = null;
207
- if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
208
- client.close();
209
- clientRef.current = null;
210
- setConnected(false);
211
- setServers([]);
212
- setActivePeerId(null);
213
- activePeerRef.current = null;
214
- setConnState("idle");
215
- setIframeSrc(null);
216
- };
217
- }, [token]);
250
+ if (resolvedRef.current) return;
251
+ tryAutoConnect();
252
+ }, [serversByRoom, tokens]);
218
253
 
219
- function applyToken(e: React.FormEvent) {
254
+ function joinFromInput(e: React.FormEvent) {
220
255
  e.preventDefault();
221
256
  const t = draft.trim();
222
257
  const check = validateToken(t);
@@ -225,19 +260,37 @@ export function Discovery() {
225
260
  return;
226
261
  }
227
262
  setTokenError(null);
228
- localStorage.setItem(TOKEN_KEY, t);
229
- setToken(t);
263
+ adoptRoom(t);
264
+ setDraft("");
265
+ setEditingToken(false);
230
266
  }
231
267
 
232
- async function connectTo(server: PeerInfo, folder?: string) {
233
- const client = clientRef.current;
234
- if (!client) return;
268
+ function leaveRoom(t: string) {
269
+ if (activeRoomRef.current === t) disconnect();
270
+ setTokens((prev) => prev.filter((x) => x !== t));
271
+ setServersByRoom((m) => {
272
+ const n = { ...m };
273
+ delete n[t];
274
+ return n;
275
+ });
276
+ setRoomOpen((m) => {
277
+ const n = { ...m };
278
+ delete n[t];
279
+ return n;
280
+ });
281
+ sendersRef.current.delete(t);
282
+ }
283
+
284
+ async function connectTo(server: PeerInfo, room: string, folder?: string) {
285
+ const send = sendersRef.current.get(room);
286
+ if (!send) return;
235
287
 
236
288
  rtcRef.current?.close();
237
289
  rtcRef.current = null;
238
290
  setIframeSrc(null);
239
291
  setActivePeerId(server.peerId);
240
292
  activePeerRef.current = server.peerId;
293
+ activeRoomRef.current = room;
241
294
  setConnState("connecting");
242
295
 
243
296
  // The broker decides whether this tab owns the connection. `establish` is
@@ -246,7 +299,7 @@ export function Discovery() {
246
299
  const establish = () =>
247
300
  new Promise<RTCDataChannel>((resolve, reject) => {
248
301
  const rtc = new RtcClient({
249
- sendSignal: (data: RtcSignal) => client.sendSignal(server.peerId, data),
302
+ sendSignal: (data: RtcSignal) => send(server.peerId, data),
250
303
  onState: (state) => {
251
304
  if (state === "failed" || state === "disconnected") setConnState("failed");
252
305
  },
@@ -279,7 +332,7 @@ export function Discovery() {
279
332
  activeFolderRef.current = openFolder;
280
333
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
281
334
  setResolving(null);
282
- recordConnect(server, openFolder);
335
+ recordConnect(server, room, openFolder);
283
336
  updateAddressBar(server, openFolder);
284
337
  } catch {
285
338
  setConnState("failed");
@@ -299,8 +352,10 @@ export function Discovery() {
299
352
  }
300
353
 
301
354
  async function shareLink() {
355
+ const room = activeRoomRef.current;
356
+ if (!room) return;
302
357
  const path = sharePathRef.current ?? window.location.pathname;
303
- const url = `${window.location.origin}${path}#t=${encodeURIComponent(token)}`;
358
+ const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
304
359
  try {
305
360
  await navigator.clipboard.writeText(url);
306
361
  } catch {
@@ -312,30 +367,36 @@ export function Discovery() {
312
367
  }
313
368
 
314
369
  // Deep-link auto-connect: when servers arrive, pick the best match (exact repo
315
- // daemon, else a root daemon's subfolder) and open it once.
316
- async function tryAutoConnect(list: PeerInfo[]) {
370
+ // daemon, else a root daemon's subfolder) across all rooms and open it once.
371
+ function tryAutoConnect() {
317
372
  if (resolvedRef.current) return;
318
373
  const dl = deepLinkRef.current;
319
374
  if (dl) {
320
- const res = dl.type === "repo" ? resolveRepoTarget(list, dl.target) : resolveDevTarget(list, dl.target);
375
+ const peers = allServers.map((x) => x.server);
376
+ const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
321
377
  if (!res) return;
322
- const server = list.find((s) => s.peerId === res.peerId);
323
- if (!server) return;
378
+ const match = allServers.find((x) => x.server.peerId === res.peerId);
379
+ if (!match) return;
324
380
  resolvedRef.current = true;
325
- await connectTo(server, res.folder);
381
+ void connectTo(match.server, match.room, res.folder);
326
382
  return;
327
383
  }
328
- // No deep link, but a token arrived via the URL: open the room's server
329
- // straight away when there's exactly one; with several, leave the picker.
330
- if (autoConnectRef.current && list.length === 1) {
331
- resolvedRef.current = true;
332
- await connectTo(list[0]);
384
+ // No deep link, but a token arrived via the URL: open that room's server
385
+ // straight away when it has exactly one. Scoped to the hash room so servers
386
+ // in other joined rooms can't push the count past one and block it.
387
+ const hashRoom = hashRoomRef.current;
388
+ if (autoConnectRef.current && hashRoom) {
389
+ const inRoom = allServers.filter((x) => x.room === hashRoom);
390
+ if (inRoom.length === 1) {
391
+ resolvedRef.current = true;
392
+ void connectTo(inRoom[0].server, inRoom[0].room);
393
+ }
333
394
  }
334
395
  }
335
396
 
336
- function recordConnect(server: PeerInfo, folder?: string) {
397
+ function recordConnect(server: PeerInfo, room: string, folder?: string) {
337
398
  const base = {
338
- token,
399
+ token: room,
339
400
  peerId: server.peerId,
340
401
  kind: server.meta?.kind,
341
402
  name: server.meta?.name,
@@ -354,20 +415,28 @@ export function Discovery() {
354
415
  setIframeSrc(null);
355
416
  setActivePeerId(null);
356
417
  activePeerRef.current = null;
418
+ activeRoomRef.current = null;
357
419
  setConnState("idle");
358
420
  sharePathRef.current = null;
359
421
  if (window.location.pathname !== "/") history.replaceState(null, "", "/");
360
422
  }
361
423
 
362
- const activeServer = servers.find((s) => s.peerId === activePeerId);
424
+ // Merge every room's servers into one list, each tagged with its room so the
425
+ // Connect button knows which client to signal through.
426
+ const allServers: RoomedServer[] = tokens.flatMap((t) =>
427
+ (serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
428
+ );
429
+ const serverCount = allServers.length;
430
+ const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
431
+ const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;
363
432
 
364
- // Annotate each server with its mnemonic fake-tags, then filter. The room
365
- // token is hashed to a short label — never rendered raw (it's a bearer secret).
366
- const roomLabel = token ? shortRoomLabel(token) : "";
367
- const tagged = servers.map((s) => ({
433
+ // Annotate each server with its mnemonic fake-tags (incl. its room label), then
434
+ // filter. The room token is hashed to a short label — never rendered raw.
435
+ const tagged = allServers.map(({ server: s, room }) => ({
368
436
  server: s,
437
+ room,
369
438
  name: s.meta?.name ?? s.peerId.slice(0, 8),
370
- tags: deriveTags(s.meta, { roomLabel }),
439
+ tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
371
440
  }));
372
441
  const query = [...activeTags, filter].join(" ");
373
442
  const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
@@ -383,153 +452,233 @@ export function Discovery() {
383
452
  .filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
384
453
  .slice(0, 12);
385
454
 
455
+ // Headless signaling clients, one per joined room. Kept mounted across BOTH
456
+ // views so switching into the iframe never tears down discovery/session.
457
+ const roomClients = tokens.map((t) => (
458
+ <RoomClient
459
+ key={t}
460
+ token={t}
461
+ onPeers={(peers) => setServersByRoom((m) => ({ ...m, [t]: peers }))}
462
+ onStatus={(open) => setRoomOpen((m) => ({ ...m, [t]: open }))}
463
+ onSignal={(from, data) => {
464
+ if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
465
+ }}
466
+ registerSender={(send) => {
467
+ if (send) sendersRef.current.set(t, send);
468
+ else sendersRef.current.delete(t);
469
+ }}
470
+ />
471
+ ));
472
+
386
473
  // Connected view: VS Code in an iframe, served over the tunnel.
387
474
  if (iframeSrc && connState === "connected") {
388
475
  return (
476
+ <>
477
+ {roomClients}
478
+ <div style={styles.page}>
479
+ <header style={styles.header}>
480
+ <span style={styles.brand}>codehost</span>
481
+ <span style={styles.dim}>·</span>
482
+ <span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
483
+ {activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
484
+ <span style={{ flex: 1 }} />
485
+ <button
486
+ style={styles.shareBtn}
487
+ onClick={shareLink}
488
+ title="Copy a link that opens this workspace (includes the room token)"
489
+ >
490
+ {copied ? "Copied!" : "Share"}
491
+ </button>
492
+ <button style={styles.connectBtn} onClick={disconnect}>
493
+ Disconnect
494
+ </button>
495
+ </header>
496
+ <iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
497
+ </div>
498
+ </>
499
+ );
500
+ }
501
+
502
+ return (
503
+ <>
504
+ {roomClients}
389
505
  <div style={styles.page}>
390
506
  <header style={styles.header}>
391
507
  <span style={styles.brand}>codehost</span>
392
508
  <span style={styles.dim}>·</span>
393
- <span style={styles.dim}>{activeServer?.meta?.name ?? activePeerId?.slice(0, 8)}</span>
394
- {activeServer?.meta?.cwd && <span style={styles.cwd}>{activeServer.meta.cwd}</span>}
509
+ <span style={styles.dim}>{getSignalUrl()}</span>
395
510
  <span style={{ flex: 1 }} />
396
- <button
397
- style={styles.shareBtn}
398
- onClick={shareLink}
399
- title="Copy a link that opens this workspace (includes the room token)"
400
- >
401
- {copied ? "Copied!" : "Share"}
402
- </button>
403
- <button style={styles.connectBtn} onClick={disconnect}>
404
- Disconnect
405
- </button>
511
+ <span style={{ ...styles.status, color: onlineRooms > 0 ? "#4ec9b0" : "#888" }}>
512
+ {tokens.length === 0
513
+ ? "○ no rooms"
514
+ : `${onlineRooms > 0 ? "●" : "○"} ${onlineRooms}/${tokens.length} rooms`}
515
+ </span>
406
516
  </header>
407
- <iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
408
- </div>
409
- );
410
- }
411
517
 
412
- return (
413
- <div style={styles.page}>
414
- <header style={styles.header}>
415
- <span style={styles.brand}>codehost</span>
416
- <span style={styles.dim}>·</span>
417
- <span style={styles.dim}>{getSignalUrl()}</span>
418
- <span style={{ flex: 1 }} />
419
- <span style={{ ...styles.status, color: connected ? "#4ec9b0" : "#888" }}>
420
- {token ? (connected ? "● connected" : "○ connecting…") : "○ no token"}
421
- </span>
422
- </header>
423
-
424
- <main style={styles.main}>
425
- {resolving && (
426
- <p style={{ color: "#dcb67a", marginBottom: 12 }}>
427
- Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
428
- {token ? "waiting for a live server" : "enter the room's token below"}.
429
- </p>
430
- )}
431
- <form onSubmit={applyToken} style={styles.tokenForm}>
432
- <label style={styles.label}>Token</label>
433
- <input
434
- value={draft}
435
- onChange={(e) => {
436
- setDraft(e.target.value);
437
- if (tokenError) setTokenError(null);
438
- }}
439
- placeholder="your room token"
440
- style={styles.input}
441
- />
442
- <button type="submit" style={styles.button}>
443
- {token === draft.trim() ? "Reconnect" : "Connect"}
444
- </button>
445
- </form>
446
- {tokenError ? (
447
- <p style={styles.tokenError}>{tokenError}</p>
448
- ) : (
449
- <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
450
- )}
451
-
452
- <div style={styles.listHead}>
453
- <h2 style={styles.h2}>Workspaces</h2>
454
- {token && servers.length > 0 && (
455
- <span style={styles.count}>
456
- {filtered.length === tagged.length
457
- ? `${tagged.length}`
458
- : `${filtered.length} / ${tagged.length}`}
459
- </span>
518
+ <main style={styles.main}>
519
+ {resolving && (
520
+ <p style={{ color: "#dcb67a", marginBottom: 12 }}>
521
+ Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
522
+ {tokens.length > 0 ? "waiting for a live server" : "join the room's token below"}.
523
+ </p>
460
524
  )}
461
- </div>
462
- {!token && <p style={styles.dim}>Enter a token to see your workspaces.</p>}
463
- {token && servers.length === 0 && (
464
- <p style={styles.dim}>
465
- No servers online. Run{" "}
466
- <code style={styles.code}>bunx codehost serve -t {token || "<token>"}</code> on a machine.
467
- </p>
468
- )}
469
- {token && servers.length > 0 && (
470
- <>
471
- <input
472
- value={filter}
473
- onChange={(e) => setFilter(e.target.value)}
474
- placeholder="filter… e.g. repo:codehost host:mbp (space = AND)"
475
- style={styles.search}
476
- />
477
- {(activeTags.length > 0 || suggestedTags.length > 0) && (
478
- <div style={styles.chipRow}>
479
- {activeTags.map((t) => (
480
- <button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
481
- {t}
525
+
526
+ <div style={styles.tokenForm}>
527
+ <label style={styles.label}>Rooms</label>
528
+ {tokens.length > 0 ? (
529
+ <>
530
+ <div style={styles.roomChips}>
531
+ {tokens.map((t) => (
532
+ <span
533
+ key={t}
534
+ style={{ ...styles.chip, ...styles.roomChip, ...(roomOpen[t] ? styles.roomChipOn : {}) }}
535
+ title={roomOpen[t] ? "connected" : "connecting…"}
536
+ >
537
+ {shortRoomLabel(t)}
538
+ <button type="button" style={styles.roomChipX} onClick={() => leaveRoom(t)} title="leave room">
539
+
540
+ </button>
541
+ </span>
542
+ ))}
543
+ </div>
544
+ <span style={{ flex: 1 }} />
545
+ {!editingToken && (
546
+ <button
547
+ type="button"
548
+ style={styles.shareBtn}
549
+ onClick={() => {
550
+ setDraft("");
551
+ setTokenError(null);
552
+ setEditingToken(true);
553
+ }}
554
+ >
555
+ + Add
556
+ </button>
557
+ )}
558
+ </>
559
+ ) : (
560
+ <span style={styles.dim}>none joined yet</span>
561
+ )}
562
+ </div>
563
+
564
+ {(editingToken || tokens.length === 0) && (
565
+ <>
566
+ <form onSubmit={joinFromInput} style={styles.tokenForm}>
567
+ <input
568
+ value={draft}
569
+ onChange={(e) => {
570
+ setDraft(e.target.value);
571
+ if (tokenError) setTokenError(null);
572
+ }}
573
+ placeholder="paste a room token to join"
574
+ style={styles.input}
575
+ autoFocus={editingToken}
576
+ />
577
+ <button type="submit" style={styles.button}>
578
+ Join
579
+ </button>
580
+ {editingToken && tokens.length > 0 && (
581
+ <button
582
+ type="button"
583
+ style={styles.shareBtn}
584
+ onClick={() => {
585
+ setEditingToken(false);
586
+ setTokenError(null);
587
+ }}
588
+ >
589
+ Cancel
482
590
  </button>
483
- ))}
484
- {suggestedTags
485
- .filter((t) => !activeTags.includes(t))
486
- .map((t) => (
487
- <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
488
- {t}
591
+ )}
592
+ </form>
593
+ {tokenError ? (
594
+ <p style={styles.tokenError}>{tokenError}</p>
595
+ ) : (
596
+ <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
597
+ )}
598
+ </>
599
+ )}
600
+
601
+ <div style={styles.listHead}>
602
+ <h2 style={styles.h2}>Workspaces</h2>
603
+ {serverCount > 0 && (
604
+ <span style={styles.count}>
605
+ {filtered.length === tagged.length
606
+ ? `${tagged.length}`
607
+ : `${filtered.length} / ${tagged.length}`}
608
+ </span>
609
+ )}
610
+ </div>
611
+ {tokens.length === 0 && <p style={styles.dim}>Join a room to see your workspaces.</p>}
612
+ {tokens.length > 0 && serverCount === 0 && (
613
+ <p style={styles.dim}>
614
+ No servers online. Run <code style={styles.code}>bunx codehost serve -t &lt;token&gt;</code> on a machine.
615
+ </p>
616
+ )}
617
+ {serverCount > 0 && (
618
+ <>
619
+ <input
620
+ value={filter}
621
+ onChange={(e) => setFilter(e.target.value)}
622
+ placeholder="filter… e.g. repo:codehost host:mbp room:ab12 (space = AND)"
623
+ style={styles.search}
624
+ />
625
+ {(activeTags.length > 0 || suggestedTags.length > 0) && (
626
+ <div style={styles.chipRow}>
627
+ {activeTags.map((t) => (
628
+ <button key={t} style={{ ...styles.chip, ...styles.chipActive }} onClick={() => toggleTag(t)}>
629
+ {t} ✕
489
630
  </button>
490
631
  ))}
491
- </div>
492
- )}
493
- </>
494
- )}
495
- <ul style={styles.list}>
496
- {filtered.map(({ server: s, name, tags }) => {
497
- const isActive = s.peerId === activePeerId;
498
- return (
499
- <li key={s.peerId} style={styles.card}>
500
- <div style={styles.cardMain}>
501
- <div style={styles.cardName}>{name}</div>
502
- <div style={styles.tagRow}>
503
- {tags.map((tag) => (
504
- <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
505
- {tag}
632
+ {suggestedTags
633
+ .filter((t) => !activeTags.includes(t))
634
+ .map((t) => (
635
+ <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
636
+ {t}
506
637
  </button>
507
638
  ))}
508
- </div>
509
- <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
510
- {isActive && (
511
- <div style={styles.echo}>
512
- {connState === "connecting" && "negotiating WebRTC…"}
513
- {connState === "failed" && "connection failed"}
514
- </div>
515
- )}
516
639
  </div>
517
- <button
518
- style={styles.connectBtn}
519
- onClick={() => connectTo(s)}
520
- disabled={isActive && connState === "connecting"}
521
- >
522
- {isActive && connState === "connecting" ? "…" : "Connect"}
523
- </button>
524
- </li>
525
- );
526
- })}
527
- {token && servers.length > 0 && filtered.length === 0 && (
528
- <p style={styles.dim}>No workspace matches your filter.</p>
640
+ )}
641
+ </>
529
642
  )}
530
- </ul>
531
- </main>
532
- </div>
643
+ <ul style={styles.list}>
644
+ {filtered.map(({ server: s, room, name, tags }) => {
645
+ const isActive = s.peerId === activePeerId;
646
+ return (
647
+ <li key={s.peerId} style={styles.card}>
648
+ <div style={styles.cardMain}>
649
+ <div style={styles.cardName}>{name}</div>
650
+ <div style={styles.tagRow}>
651
+ {tags.map((tag) => (
652
+ <button key={tag} style={styles.tag} onClick={() => addTag(tag)} title={`filter by ${tag}`}>
653
+ {tag}
654
+ </button>
655
+ ))}
656
+ </div>
657
+ <div style={styles.idLine}>peer {s.peerId.slice(0, 8)}</div>
658
+ {isActive && (
659
+ <div style={styles.echo}>
660
+ {connState === "connecting" && "negotiating WebRTC…"}
661
+ {connState === "failed" && "connection failed"}
662
+ </div>
663
+ )}
664
+ </div>
665
+ <button
666
+ style={styles.connectBtn}
667
+ onClick={() => connectTo(s, room)}
668
+ disabled={isActive && connState === "connecting"}
669
+ >
670
+ {isActive && connState === "connecting" ? "…" : "Connect"}
671
+ </button>
672
+ </li>
673
+ );
674
+ })}
675
+ {serverCount > 0 && filtered.length === 0 && (
676
+ <p style={styles.dim}>No workspace matches your filter.</p>
677
+ )}
678
+ </ul>
679
+ </main>
680
+ </div>
681
+ </>
533
682
  );
534
683
  }
535
684
 
@@ -544,6 +693,10 @@ const styles: Record<string, React.CSSProperties> = {
544
693
  tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
545
694
  tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
546
695
  label: { fontSize: 12, color: "#888" },
696
+ roomChips: { display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" },
697
+ roomChip: { display: "inline-flex", alignItems: "center", gap: 6, color: "#9aa4af" },
698
+ roomChipOn: { borderColor: "#0e639c", color: "#4ec9b0" },
699
+ roomChipX: { background: "transparent", border: "none", color: "inherit", cursor: "pointer", fontSize: 11, padding: 0, lineHeight: 1 },
547
700
  input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
548
701
  button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
549
702
  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
  }