codehost 0.9.1 → 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,10 @@
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
+
1
8
  ## [0.9.1](https://github.com/snomiao/codehost/compare/v0.9.0...v0.9.1) (2026-06-08)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.9.1",
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,46 +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
- // 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());
130
179
 
131
180
  // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
132
181
  // auto-connect when a matching server appears, remember the opened folder.
133
182
  const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
134
183
  const resolvedRef = useRef(false);
135
- // 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.
136
186
  const autoConnectRef = useRef(false);
187
+ const hashRoomRef = useRef<string | null>(null);
137
188
  const activeFolderRef = useRef<string | undefined>(undefined);
138
189
  const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
139
190
 
@@ -142,6 +193,15 @@ export function Discovery() {
142
193
  const sharePathRef = useRef<string | null>(null);
143
194
  const [copied, setCopied] = useState(false);
144
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
+
145
205
  // Register the Service Worker + connection broker once. The broker shares one
146
206
  // WebRTC connection per server across tabs; on owner failover it asks us to
147
207
  // reload the iframe so it reconnects through the new owner.
@@ -153,12 +213,13 @@ export function Discovery() {
153
213
  const folder = activeFolderRef.current;
154
214
  setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
155
215
  });
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.
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.
159
219
  const urlToken = tokenFromHash();
160
220
  if (urlToken && validateToken(urlToken).ok) {
161
221
  autoConnectRef.current = true;
222
+ hashRoomRef.current = urlToken;
162
223
  if (window.location.hash) {
163
224
  history.replaceState(null, "", window.location.pathname + window.location.search);
164
225
  }
@@ -171,56 +232,26 @@ export function Discovery() {
171
232
  if (dl && !(urlToken && validateToken(urlToken).ok)) {
172
233
  const histToken = dl.type === "repo" ? historyFor(repoKey(dl.target))?.token : undefined;
173
234
  if (histToken) {
174
- setToken(histToken);
235
+ adoptRoom(histToken);
175
236
  } else {
176
237
  const rooms = getRooms();
177
238
  if (rooms.length) {
178
239
  void findRoomForDeepLink(dl, rooms).then((tok) => {
179
- if (tok) setToken(tok);
240
+ if (tok) adoptRoom(tok);
180
241
  });
181
242
  }
182
243
  }
183
244
  }
184
245
  }, []);
185
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=.
186
249
  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]);
250
+ if (resolvedRef.current) return;
251
+ tryAutoConnect();
252
+ }, [serversByRoom, tokens]);
222
253
 
223
- function applyToken(e: React.FormEvent) {
254
+ function joinFromInput(e: React.FormEvent) {
224
255
  e.preventDefault();
225
256
  const t = draft.trim();
226
257
  const check = validateToken(t);
@@ -229,21 +260,37 @@ export function Discovery() {
229
260
  return;
230
261
  }
231
262
  setTokenError(null);
232
- localStorage.setItem(TOKEN_KEY, t);
233
- setToken(t);
263
+ adoptRoom(t);
234
264
  setDraft("");
235
265
  setEditingToken(false);
236
266
  }
237
267
 
238
- async function connectTo(server: PeerInfo, folder?: string) {
239
- const client = clientRef.current;
240
- 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;
241
287
 
242
288
  rtcRef.current?.close();
243
289
  rtcRef.current = null;
244
290
  setIframeSrc(null);
245
291
  setActivePeerId(server.peerId);
246
292
  activePeerRef.current = server.peerId;
293
+ activeRoomRef.current = room;
247
294
  setConnState("connecting");
248
295
 
249
296
  // The broker decides whether this tab owns the connection. `establish` is
@@ -252,7 +299,7 @@ export function Discovery() {
252
299
  const establish = () =>
253
300
  new Promise<RTCDataChannel>((resolve, reject) => {
254
301
  const rtc = new RtcClient({
255
- sendSignal: (data: RtcSignal) => client.sendSignal(server.peerId, data),
302
+ sendSignal: (data: RtcSignal) => send(server.peerId, data),
256
303
  onState: (state) => {
257
304
  if (state === "failed" || state === "disconnected") setConnState("failed");
258
305
  },
@@ -285,7 +332,7 @@ export function Discovery() {
285
332
  activeFolderRef.current = openFolder;
286
333
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
287
334
  setResolving(null);
288
- recordConnect(server, openFolder);
335
+ recordConnect(server, room, openFolder);
289
336
  updateAddressBar(server, openFolder);
290
337
  } catch {
291
338
  setConnState("failed");
@@ -305,8 +352,10 @@ export function Discovery() {
305
352
  }
306
353
 
307
354
  async function shareLink() {
355
+ const room = activeRoomRef.current;
356
+ if (!room) return;
308
357
  const path = sharePathRef.current ?? window.location.pathname;
309
- const url = `${window.location.origin}${path}#t=${encodeURIComponent(token)}`;
358
+ const url = `${window.location.origin}${path}#t=${encodeURIComponent(room)}`;
310
359
  try {
311
360
  await navigator.clipboard.writeText(url);
312
361
  } catch {
@@ -318,30 +367,36 @@ export function Discovery() {
318
367
  }
319
368
 
320
369
  // 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[]) {
370
+ // daemon, else a root daemon's subfolder) across all rooms and open it once.
371
+ function tryAutoConnect() {
323
372
  if (resolvedRef.current) return;
324
373
  const dl = deepLinkRef.current;
325
374
  if (dl) {
326
- 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);
327
377
  if (!res) return;
328
- const server = list.find((s) => s.peerId === res.peerId);
329
- if (!server) return;
378
+ const match = allServers.find((x) => x.server.peerId === res.peerId);
379
+ if (!match) return;
330
380
  resolvedRef.current = true;
331
- await connectTo(server, res.folder);
381
+ void connectTo(match.server, match.room, res.folder);
332
382
  return;
333
383
  }
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]);
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
+ }
339
394
  }
340
395
  }
341
396
 
342
- function recordConnect(server: PeerInfo, folder?: string) {
397
+ function recordConnect(server: PeerInfo, room: string, folder?: string) {
343
398
  const base = {
344
- token,
399
+ token: room,
345
400
  peerId: server.peerId,
346
401
  kind: server.meta?.kind,
347
402
  name: server.meta?.name,
@@ -360,20 +415,28 @@ export function Discovery() {
360
415
  setIframeSrc(null);
361
416
  setActivePeerId(null);
362
417
  activePeerRef.current = null;
418
+ activeRoomRef.current = null;
363
419
  setConnState("idle");
364
420
  sharePathRef.current = null;
365
421
  if (window.location.pathname !== "/") history.replaceState(null, "", "/");
366
422
  }
367
423
 
368
- 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;
369
432
 
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) => ({
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 }) => ({
374
436
  server: s,
437
+ room,
375
438
  name: s.meta?.name ?? s.peerId.slice(0, 8),
376
- tags: deriveTags(s.meta, { roomLabel }),
439
+ tags: deriveTags(s.meta, { roomLabel: shortRoomLabel(room) }),
377
440
  }));
378
441
  const query = [...activeTags, filter].join(" ");
379
442
  const filtered = tagged.filter((t) => matchQuery({ name: t.name, tags: t.tags }, query));
@@ -389,174 +452,233 @@ export function Discovery() {
389
452
  .filter((t) => ["host", "repo", "wt", "kind", "room"].includes(tagKey(t)))
390
453
  .slice(0, 12);
391
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
+
392
473
  // Connected view: VS Code in an iframe, served over the tunnel.
393
474
  if (iframeSrc && connState === "connected") {
394
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}
395
505
  <div style={styles.page}>
396
506
  <header style={styles.header}>
397
507
  <span style={styles.brand}>codehost</span>
398
508
  <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>}
509
+ <span style={styles.dim}>{getSignalUrl()}</span>
401
510
  <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>
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>
412
516
  </header>
413
- <iframe title="VS Code" src={iframeSrc} style={{ flex: 1, border: "none", width: "100%", background: "#1e1e1e" }} />
414
- </div>
415
- );
416
- }
417
517
 
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 ? (
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>
524
+ )}
525
+
438
526
  <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>
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
+ </>
473
559
  ) : (
474
- <p style={styles.tokenHint}>Token requires {TOKEN_REQUIREMENTS}.</p>
560
+ <span style={styles.dim}>none joined yet</span>
475
561
  )}
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} ✕
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
509
590
  </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}
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} ✕
516
630
  </button>
517
631
  ))}
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}
632
+ {suggestedTags
633
+ .filter((t) => !activeTags.includes(t))
634
+ .map((t) => (
635
+ <button key={t} style={styles.chip} onClick={() => toggleTag(t)}>
636
+ {t}
533
637
  </button>
534
638
  ))}
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
639
  </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>
640
+ )}
641
+ </>
556
642
  )}
557
- </ul>
558
- </main>
559
- </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
+ </>
560
682
  );
561
683
  }
562
684
 
@@ -571,7 +693,10 @@ const styles: Record<string, React.CSSProperties> = {
571
693
  tokenHint: { margin: "0 0 20px", fontSize: 12, color: "#888" },
572
694
  tokenError: { margin: "0 0 20px", fontSize: 12, color: "#f48771" },
573
695
  label: { fontSize: 12, color: "#888" },
574
- tokenSaved: { flex: 1, fontFamily: "monospace", fontSize: 13, color: "#4ec9b0" },
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 },
575
700
  input: { flex: 1, background: "#252525", border: "1px solid #3d3d3d", color: "#eee", padding: "8px 10px", borderRadius: 6, fontSize: 13, outline: "none" },
576
701
  button: { background: "#0e639c", border: "none", color: "#fff", padding: "8px 16px", borderRadius: 6, cursor: "pointer", fontSize: 13 },
577
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
  }