No joined rooms tracked on this device yet. Paste an invite link to remember one
(metadata only — the token is never stored).
diff --git a/src/browser/shell.js b/src/browser/shell.js
index 4707ca6..a89bd94 100644
--- a/src/browser/shell.js
+++ b/src/browser/shell.js
@@ -54,7 +54,6 @@ const roster = document.getElementById("shell-roster");
const clearCacheButton = document.getElementById("clear-cache-button");
const historySource = document.getElementById("history-source");
const historySourceLabel = document.getElementById("history-source-label");
-const joinedList = document.getElementById("joined-list");
const joinedEmpty = document.getElementById("joined-empty");
const joinedShowArchived = document.getElementById("joined-show-archived");
const joinedForm = document.getElementById("joined-add");
@@ -72,7 +71,6 @@ const lowerHome = document.getElementById("lower-home");
const lowerRoom = document.getElementById("lower-room");
const channelNav = document.getElementById("channel-nav");
const roomsMore = document.getElementById("rooms-more");
-const joinedMore = document.getElementById("joined-more");
const channelsMore = document.getElementById("channels-more");
// Right info panel (#218b): shown only in the selected-room (three-panel) state.
@@ -82,9 +80,11 @@ const infoRoomStatus = document.getElementById("info-room-status");
const infoRoomRoute = document.getElementById("info-room-route");
// Collapse a list's tail behind a stable show-more/show-less control once it
-// exceeds this many rows, so a long room/channel list never pushes the rest of
-// the rail off-screen and expanding never shifts the layout.
+// exceeds its per-section cap, so a long room/channel list never pushes the rest
+// of the rail off-screen and expanding never shifts the layout. The merged room
+// list (#233) caps at 6; the channel nav caps at 8.
const OVERFLOW_LIMIT = 6;
+const CHANNELS_LIMIT = 8;
// Create-room shell (no central API: the form composes the host CLI command).
const createOverlay = document.getElementById("create-overlay");
@@ -164,9 +164,8 @@ async function init() {
roomsToggle.addEventListener("click", () => shell.classList.toggle("rooms-open"));
brandHome.addEventListener("click", goHome);
guideCreate.addEventListener("click", () => openCreateRoom());
- roomsMore.addEventListener("click", () => toggleOverflow(roomList, roomsMore));
- joinedMore.addEventListener("click", () => toggleOverflow(joinedList, joinedMore));
- channelsMore.addEventListener("click", () => toggleOverflow(channelNav, channelsMore));
+ roomsMore.addEventListener("click", () => toggleOverflow(roomList, roomsMore, OVERFLOW_LIMIT));
+ channelsMore.addEventListener("click", () => toggleOverflow(channelNav, channelsMore, CHANNELS_LIMIT));
exportButton.addEventListener("click", exportTranscript);
clearCacheButton.addEventListener("click", clearActiveCache);
wireCreateRoom();
@@ -215,9 +214,7 @@ async function loadRooms() {
roomsError.hidden = true;
state.rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
ownerLabel.textContent = state.rooms[0]?.owner_user_id || "owner";
- updateShellView();
- railTitle.textContent = `Your rooms · ${state.rooms.length}`;
- renderRoomList();
+ renderRail();
if (state.activeRoomId !== null) {
const active = state.rooms.find((room) => room.room_id === state.activeRoomId);
if (active) renderDetail(active);
@@ -228,8 +225,20 @@ function updateShellView() {
shell.dataset.view = state.rooms.length === 0 && state.joinedRooms.length === 0 ? "empty" : "rooms";
}
-function renderRoomList() {
+// The unified workspace rail (#233): ONE room list holding the rooms you host
+// (control plane) followed by the rooms you've joined (device-local, #178), so a
+// user in many boardrooms switches from a single place. Hosted rooms come first;
+// joined rooms follow, most-recent activity first. Host-owned rows carry a compact
+// `host` tag; joined rows keep their reachability + #210 archive/delete controls.
+// Both row kinds are built inline here — one renderer for the one merged list,
+// sharing a single overflow control (cap 6) and one ~34% scroll region.
+function renderRail() {
+ updateShellView();
+ railTitle.textContent = `Rooms · ${state.rooms.length + state.joinedRooms.length}`;
roomList.replaceChildren();
+
+ // Hosted rooms: the v5 icon · name+status · subtitle · age/action row, with a
+ // `host` tag marking it owner-operated in the merged list.
for (const room of state.rooms) {
const item = document.createElement("li");
const button = document.createElement("button");
@@ -246,39 +255,36 @@ function renderRoomList() {
const main = document.createElement("span");
main.className = "room-main";
-
const title = document.createElement("span");
title.className = "room-row-title";
const name = document.createElement("span");
name.className = "room-name";
- // Primary label is the display title; the slug-like room id stays available as
- // the debug tooltip and the fallback when no title is known (#216).
+ // Primary label is the display title; the slug-like id stays as the tooltip and
+ // the fallback when no title is known (#216).
name.textContent = room.title || room.room_id;
name.title = `room id: ${room.room_id}`;
-
+ const tag = document.createElement("span");
+ tag.className = "room-tag";
+ tag.dataset.tag = "host";
+ tag.textContent = "host";
const badge = document.createElement("span");
badge.className = "status-badge";
badge.dataset.status = room.status;
badge.textContent = room.status;
- title.append(name, badge);
-
+ title.append(name, tag, badge);
const sub = document.createElement("span");
sub.className = "room-sub";
sub.textContent = roomSubtitle(room);
-
main.append(title, sub);
const aside = document.createElement("span");
aside.className = "room-aside";
-
const age = document.createElement("span");
age.className = "room-age";
age.textContent = relativeAge(room.last_seen_at || room.updated_at || room.created_at);
-
const action = document.createElement("span");
action.className = "room-act";
action.textContent = actionVerb(room.status);
-
aside.append(age, action);
button.append(icon, main, aside);
@@ -286,7 +292,77 @@ function renderRoomList() {
item.append(button);
roomList.append(item);
}
- applyOverflow(roomList, roomsMore);
+
+ // Archived joined rows (#210) stay hidden until the toggle is pressed; the toggle
+ // appears only when some exist. The empty note shows only when no joined rooms are
+ // tracked at all (host rooms don't suppress it — it's the joined-room hint).
+ const archivedCount = state.joinedRooms.filter((entry) => entry.archived).length;
+ joinedShowArchived.hidden = archivedCount === 0;
+ joinedShowArchived.setAttribute("aria-pressed", String(state.showArchived));
+ joinedShowArchived.textContent = state.showArchived ? "hide archived" : `show archived (${archivedCount})`;
+ joinedEmpty.hidden = state.joinedRooms.length > 0;
+
+ // Joined rooms (device-local #178), most-recent activity first. Metadata only —
+ // neither the row nor its open href carries a token (it's resolved by the loopback
+ // /joined-rooms/open redirect). Keeps its reachability badge + #210 controls.
+ const activityMs = (entry) => {
+ const parsed = Date.parse(entry.lastSeen || entry.joinedAt || "");
+ return Number.isNaN(parsed) ? 0 : parsed;
+ };
+ const joined = state.joinedRooms
+ .filter((entry) => state.showArchived || !entry.archived)
+ .sort((a, b) => activityMs(b) - activityMs(a));
+ for (const entry of joined) {
+ const item = document.createElement("li");
+ item.className = "joined-row";
+ if (entry.archived) item.dataset.archived = "true";
+ item.dataset.reachability = entry.reachability || "saved";
+ item.dataset.openHref = joinedOpenUrl(entry);
+ item.tabIndex = 0;
+ item.setAttribute("role", "link");
+ item.setAttribute("aria-label", `Open ${entry.title || entry.roomId || entry.baseUrl}`);
+ item.addEventListener("click", () => {
+ if (item.dataset.confirming === "true") return; // don't open while confirming a delete
+ openJoinedRoom(entry);
+ });
+ item.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ if (item.dataset.confirming === "true") return;
+ event.preventDefault();
+ openJoinedRoom(entry);
+ });
+
+ const main = document.createElement("span");
+ main.className = "joined-main";
+ // Primary label is the human-readable display title (#216); the slug-like room
+ // id is only the fallback and otherwise lives as secondary/debug metadata.
+ const roomId = entry.roomId || "";
+ const hasTitle = Boolean(entry.title && entry.title !== roomId);
+ const name = document.createElement("span");
+ name.className = "joined-name";
+ name.textContent = hasTitle ? entry.title : roomId || entry.baseUrl;
+ name.title = roomId ? `room id: ${roomId}` : entry.baseUrl;
+ main.dataset.titled = String(hasTitle);
+ const sub = document.createElement("span");
+ sub.className = "joined-sub";
+ const alias = entry.alias ? `${entry.alias} · ` : "";
+ const idMeta = hasTitle && roomId ? ` · ${roomId}` : "";
+ sub.textContent = `${alias}${hostLabel(entry.baseUrl)}${idMeta}`;
+ main.append(name, sub);
+
+ const aside = document.createElement("span");
+ aside.className = "joined-aside";
+ const badge = document.createElement("span");
+ badge.className = "joined-reach";
+ badge.dataset.reachability = entry.reachability || "saved";
+ badge.textContent = reachabilityLabel(entry.reachability);
+ aside.append(badge, buildJoinedControls(entry, item));
+
+ item.append(main, aside);
+ roomList.append(item);
+ }
+
+ applyOverflow(roomList, roomsMore, OVERFLOW_LIMIT);
}
// Two-character monogram for a room, from its title or id.
@@ -339,7 +415,7 @@ async function selectRoom(roomId) {
state.cacheRendered = false;
timeline.replaceChildren();
shell.classList.remove("rooms-open");
- renderRoomList();
+ renderRail();
const room = state.rooms.find((entry) => entry.room_id === roomId);
// Provisionally show this browser's cached copy until live availability is
// known. These entries are not added to seen/messages, so a live fetch
@@ -364,7 +440,7 @@ function enterRoomState(room) {
lowerHome.hidden = true;
lowerRoom.hidden = false;
infoPanel.hidden = false;
- renderChannelNav();
+ renderChannelNav(room);
setBreadcrumb(room);
}
@@ -389,7 +465,7 @@ function goHome() {
infoPanel.hidden = true;
shell.classList.remove("room-selected");
setBreadcrumb(null);
- renderRoomList();
+ renderRail();
}
// Breadcrumb: "/ / #" in room state, empty at home. Titles only
@@ -402,12 +478,19 @@ function setBreadcrumb(room) {
crumb.textContent = `/ ${room.title || room.room_id} / #general`;
}
-// The selected room's channel navigation. The dashboard reads only the control
-// plane, which carries no channel/forum list, so #218a renders the one channel
-// every room is guaranteed to have — #general chat. Real forum-channel
-// population arrives with #218b, when the room surfaces that own that data mount.
-function renderChannelNav() {
- const channels = [{ id: "general", name: "general", type: "chat" }];
+// The selected room's channel navigation (#233). Renders the room's real channels
+// from `room.channels` ({id, name, type}) when the control-plane payload provides
+// them (populated by the companion #234 ticket), and otherwise falls back to the
+// one channel every room is guaranteed to have — #general chat. Joined rooms carry
+// no channel metadata on the token-free dashboard, so they keep the #general
+// fallback. The list caps at CHANNELS_LIMIT behind its own overflow control.
+function renderChannelNav(room) {
+ // A control-plane channel entry is renderable when it has a stable id and a
+ // display name; type defaults to chat when absent. Guards a malformed payload.
+ const provided = Array.isArray(room?.channels)
+ ? room.channels.filter((channel) => Boolean(channel) && typeof channel.id === "string" && typeof channel.name === "string")
+ : [];
+ const channels = provided.length > 0 ? provided : [{ id: "general", name: "general", type: "chat" }];
channelNav.replaceChildren();
for (const [index, channel] of channels.entries()) {
const item = document.createElement("li");
@@ -428,22 +511,22 @@ function renderChannelNav() {
const type = document.createElement("span");
type.className = "channel-type";
- type.textContent = channel.type;
+ type.textContent = channel.type || "chat";
button.append(hash, name, type);
item.append(button);
channelNav.append(item);
}
- applyOverflow(channelNav, channelsMore);
+ applyOverflow(channelNav, channelsMore, CHANNELS_LIMIT);
}
// Shared list overflow: once a list exceeds OVERFLOW_LIMIT rows, collapse the
// tail behind a single-row "show N more" / "show less" control so a long list
// never crowds out the rest of the rail and expanding causes no layout jump.
// The control lives outside the list, so its expanded state survives re-renders.
-function applyOverflow(listEl, moreBtn) {
+function applyOverflow(listEl, moreBtn, limit = OVERFLOW_LIMIT) {
const items = [...listEl.children];
- const overflow = Math.max(0, items.length - OVERFLOW_LIMIT);
+ const overflow = Math.max(0, items.length - limit);
if (overflow === 0) {
for (const item of items) item.classList.remove("is-collapsed");
moreBtn.hidden = true;
@@ -452,16 +535,16 @@ function applyOverflow(listEl, moreBtn) {
}
const expanded = moreBtn.getAttribute("aria-expanded") === "true";
items.forEach((item, index) => {
- item.classList.toggle("is-collapsed", !expanded && index >= OVERFLOW_LIMIT);
+ item.classList.toggle("is-collapsed", !expanded && index >= limit);
});
moreBtn.hidden = false;
moreBtn.textContent = expanded ? "▾ show less" : `▸ show ${overflow} more…`;
}
-function toggleOverflow(listEl, moreBtn) {
+function toggleOverflow(listEl, moreBtn, limit = OVERFLOW_LIMIT) {
const expanded = moreBtn.getAttribute("aria-expanded") === "true";
moreBtn.setAttribute("aria-expanded", String(!expanded));
- applyOverflow(listEl, moreBtn);
+ applyOverflow(listEl, moreBtn, limit);
}
function renderDetail(room) {
@@ -1057,73 +1140,13 @@ async function loadJoinedRooms() {
renderJoined(merged);
}
+// Update the device-local joined-room set and re-render the merged rail (#233):
+// joined entries now live in the single #room-list, so the joined data flows
+// through the same renderer as hosted rooms. The archived toggle, empty note, and
+// overflow are all owned by renderRail().
function renderJoined(entries) {
state.joinedRooms = entries;
- updateShellView();
- joinedList.replaceChildren();
-
- // Archived rows (#210) are hidden by default; the toggle appears only when some
- // exist and reveals/hides them without touching the host room.
- const archivedCount = entries.filter((entry) => entry.archived).length;
- joinedShowArchived.hidden = archivedCount === 0;
- joinedShowArchived.setAttribute("aria-pressed", String(state.showArchived));
- joinedShowArchived.textContent = state.showArchived ? "hide archived" : `show archived (${archivedCount})`;
-
- const visible = entries.filter((entry) => state.showArchived || !entry.archived);
- joinedEmpty.hidden = visible.length > 0;
- for (const entry of visible) {
- const item = document.createElement("li");
- item.className = "joined-row";
- if (entry.archived) item.dataset.archived = "true";
- item.dataset.reachability = entry.reachability || "saved";
- item.dataset.openHref = joinedOpenUrl(entry);
- item.tabIndex = 0;
- item.setAttribute("role", "link");
- item.setAttribute("aria-label", `Open ${entry.title || entry.roomId || entry.baseUrl}`);
- item.addEventListener("click", () => {
- if (item.dataset.confirming === "true") return; // don't open while confirming a delete
- openJoinedRoom(entry);
- });
- item.addEventListener("keydown", (event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- if (item.dataset.confirming === "true") return;
- event.preventDefault();
- openJoinedRoom(entry);
- });
-
- const main = document.createElement("span");
- main.className = "joined-main";
- // Primary label is the human-readable display title (#216); the slug-like
- // room id is only the fallback and otherwise lives as secondary/debug metadata
- // (the sub line + tooltip). `hasTitle` drives the fallback styling hook.
- const roomId = entry.roomId || "";
- const hasTitle = Boolean(entry.title && entry.title !== roomId);
- const name = document.createElement("span");
- name.className = "joined-name";
- name.textContent = hasTitle ? entry.title : roomId || entry.baseUrl;
- name.title = roomId ? `room id: ${roomId}` : entry.baseUrl;
- main.dataset.titled = String(hasTitle);
- const sub = document.createElement("span");
- sub.className = "joined-sub";
- const alias = entry.alias ? `${entry.alias} · ` : "";
- // Show the room id as secondary/debug metadata only when the primary label is
- // a real title (otherwise the primary already shows the id — no duplication).
- const idMeta = hasTitle && roomId ? ` · ${roomId}` : "";
- sub.textContent = `${alias}${hostLabel(entry.baseUrl)}${idMeta}`;
- main.append(name, sub);
-
- const aside = document.createElement("span");
- aside.className = "joined-aside";
- const badge = document.createElement("span");
- badge.className = "joined-reach";
- badge.dataset.reachability = entry.reachability || "saved";
- badge.textContent = reachabilityLabel(entry.reachability);
- aside.append(badge, buildJoinedControls(entry, item));
-
- item.append(main, aside);
- joinedList.append(item);
- }
- applyOverflow(joinedList, joinedMore);
+ renderRail();
}
// Device-local lifecycle controls for a joined-room row (#210). Archive hides the
diff --git a/test/browser-platform.test.ts b/test/browser-platform.test.ts
index ebd2ff2..6caf5ca 100644
--- a/test/browser-platform.test.ts
+++ b/test/browser-platform.test.ts
@@ -729,7 +729,7 @@ test("a browser room join bridges into the owner dashboard's 'Rooms I'm in' toke
const dashPage = await browser.newPage({ viewport: { width: 1280, height: 820 } });
await dashPage.goto(platform.baseUrl);
await dashPage.waitForFunction(
- () => [...document.querySelectorAll("#joined-list .joined-name")].some((n) => (n.textContent || "").includes("bridge-room")),
+ () => [...document.querySelectorAll("#room-list .joined-name")].some((n) => (n.textContent || "").includes("bridge-room")),
{ timeout: 15000 }
);
@@ -1330,3 +1330,221 @@ test("deleting a joined room clears its dashboard-origin cached history (#211/#2
await platform.close();
}
});
+
+// #233 — the top rail is ONE merged room list: hosted rooms first (each carrying a
+// compact `host` tag), then joined rooms by most-recent activity. The old
+// "Your rooms" / "Rooms I'm in" section split is gone; joined affordances stay.
+test("the unified rail merges hosted + joined into one host-tagged list, hosted first (#233)", async () => {
+ const root = await makeRoot();
+ await createControlPlaneRoom(root, roomInput({ room_id: "alpha", title: "Alpha Room", status: "active" }));
+ await createControlPlaneRoom(root, roomInput({ room_id: "beta", title: "Beta Room", status: "paused" }));
+ const older = "2026-07-10T00:00:00.000Z";
+ const newer = "2026-07-13T00:00:00.000Z";
+ await recordJoinedRoom(root, { roomId: "older-join", title: "Older Join", alias: "me", baseUrl: "http://127.0.0.1:8", joinedAt: older, lastSeen: older });
+ await recordJoinedRoom(root, { roomId: "recent-join", title: "Recent Join", alias: "me", baseUrl: "http://127.0.0.1:9", joinedAt: newer, lastSeen: newer });
+
+ const platform = await listen(createPlatformHttpServer({ root, ownerUserId: "owner-1" }));
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 820 } });
+ await page.goto(platform.baseUrl);
+ await page.waitForSelector('.platform-shell[data-view="rooms"]');
+ await page.waitForSelector(".joined-row");
+
+ // The list split is gone: the separate joined room list is removed and both
+ // hosted and joined rows live in the one merged #room-list.
+ assert.equal(await page.locator("#joined-list").count(), 0);
+ assert.equal(await page.locator("#joined-more").count(), 0);
+ assert.equal(await page.locator("#room-list").count(), 1);
+ assert.equal(await page.locator("#room-list .room-row").count(), 2);
+ assert.equal(await page.locator("#room-list .joined-row").count(), 2);
+
+ // Host-owned rows carry a visible `host` tag; joined rows keep reachability.
+ assert.equal(await page.locator('.room-row .room-tag[data-tag="host"]').count(), 2);
+ assert.equal((await page.locator('.room-row[data-room-id="alpha"] .room-tag').textContent())?.trim(), "host");
+ assert.equal(await page.locator(".joined-row .joined-reach").count(), 2);
+ // Joined rows must NOT carry the host tag.
+ assert.equal(await page.locator(".joined-row .room-tag").count(), 0);
+
+ // Order: both hosted rows come before any joined row; joined rows are ordered
+ // by most-recent activity (Recent Join before Older Join).
+ const kinds = await page.locator("#room-list > li").evaluateAll((lis) =>
+ lis.map((li) => (li.classList.contains("joined-row") ? "joined" : li.querySelector(".room-row") ? "host" : "?"))
+ );
+ assert.deepEqual(kinds, ["host", "host", "joined", "joined"]);
+ const joinedNames = await page.locator(".joined-row .joined-name").evaluateAll((els) => els.map((e) => e.textContent?.trim()));
+ assert.deepEqual(joinedNames, ["Recent Join", "Older Join"]);
+
+ // Selecting a hosted row from the merged list still enters the room state.
+ await page.click('.room-row[data-room-id="alpha"]');
+ await page.waitForSelector("#lower-room:not([hidden])");
+ assert.equal((await page.locator("#crumb").textContent())?.trim(), "/ Alpha Room / #general");
+
+ // No raw token anywhere in the merged rail.
+ assert.equal(/tgl_|token=|Bearer/i.test((await page.locator(".room-rail").innerHTML()) ?? ""), false);
+ } finally {
+ await browser.close();
+ await platform.close();
+ }
+});
+
+// #233 — the ~34% / ~66% split holds: with a long room list the rooms region caps
+// at ~1/3 with its OWN scroll, the lower region keeps ~2/3, and expanding the
+// merged list's view-more reveals rows inside the rooms scroll without moving the
+// lower region (no layout jump, other section never hidden).
+test("the rail keeps a ~1/3-2/3 split and view-more expands within the rooms scroll (#233)", async () => {
+ const root = await makeRoot();
+ for (let i = 0; i < 12; i++) {
+ await createControlPlaneRoom(root, roomInput({ room_id: `room-${i}`, title: `room-${i}`, status: "active" }));
+ }
+
+ const platform = await listen(createPlatformHttpServer({ root, ownerUserId: "owner-1" }));
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 820 } });
+ await page.goto(platform.baseUrl);
+ await page.waitForSelector('.platform-shell[data-view="rooms"]');
+ await page.waitForSelector("#rooms-more:not([hidden])");
+
+ // 12 rooms, cap 6 → 6 collapsed behind the merged list's single control.
+ assert.equal(await page.locator("#room-list > li").count(), 12);
+ assert.equal(await page.locator("#room-list > li:not(.is-collapsed)").count(), 6);
+ assert.match((await page.locator("#rooms-more").textContent()) ?? "", /show 6 more/);
+
+ // Both regions own their scroll; the rooms region caps near 1/3, the lower near
+ // 2/3, and the rooms content overflows (so its cap is doing real work).
+ const layout = await page.evaluate(() => {
+ const rail = document.querySelector(".room-rail") as HTMLElement;
+ const rooms = document.querySelector(".rail-rooms") as HTMLElement;
+ const lower = document.querySelector(".rail-lower") as HTMLElement;
+ return {
+ roomsScroll: getComputedStyle(rooms).overflowY,
+ lowerScroll: getComputedStyle(lower).overflowY,
+ roomsRatio: rooms.getBoundingClientRect().height / rail.getBoundingClientRect().height,
+ lowerRatio: lower.getBoundingClientRect().height / rail.getBoundingClientRect().height,
+ roomsOverflows: rooms.scrollHeight > rooms.clientHeight,
+ lowerTop: Math.round(lower.getBoundingClientRect().top)
+ };
+ });
+ assert.equal(layout.roomsScroll, "auto");
+ assert.equal(layout.lowerScroll, "auto");
+ assert.ok(layout.roomsRatio >= 0.28 && layout.roomsRatio <= 0.4, `rooms region was ${layout.roomsRatio} of the rail, expected ~1/3`);
+ assert.ok(layout.lowerRatio >= 0.55, `lower region was ${layout.lowerRatio} of the rail, expected ~2/3`);
+ assert.equal(layout.roomsOverflows, true, "rooms region did not overflow its capped height");
+
+ // Expanding view-more reveals all rows inside the rooms scroll; the lower region
+ // does not move (no layout jump, never pushed off-screen).
+ await page.click("#rooms-more");
+ assert.equal(await page.locator("#room-list > li:not(.is-collapsed)").count(), 12);
+ assert.match((await page.locator("#rooms-more").textContent()) ?? "", /show less/);
+ const lowerTopAfter = await page.evaluate(() => Math.round((document.querySelector(".rail-lower") as HTMLElement).getBoundingClientRect().top));
+ assert.equal(lowerTopAfter, layout.lowerTop, "expanding the room list shifted the lower region");
+ } finally {
+ await browser.close();
+ await platform.close();
+ }
+});
+
+// #233 — the channel nav renders `room.channels` ({id,name,type}) when the
+// control-plane payload provides them, and falls back to #general when absent.
+test("channel nav renders room.channels from the payload and falls back to #general (#233)", async () => {
+ const root = await makeRoot();
+ const platform = await listen(createPlatformHttpServer({ root, ownerUserId: "owner-1" }));
+ const browser = await chromium.launch();
+ const roster = [{ alias: "host", kind: "human", role: "host", status: "attending" }];
+ const rooms = [
+ {
+ room_id: "multi",
+ title: "Multi",
+ status: "active",
+ owner_user_id: "owner-1",
+ roster,
+ route_health: { reachable: true, host_connected: true },
+ channels: [
+ { id: "general", name: "general", type: "chat" },
+ { id: "design", name: "design", type: "forum" },
+ { id: "ops", name: "ops", type: "chat" }
+ ]
+ },
+ { room_id: "plain", title: "Plain", status: "active", owner_user_id: "owner-1", roster, route_health: { reachable: true, host_connected: true } }
+ ];
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 820 } });
+ await page.route("**/rooms**", async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (/\/rooms\/[^/]+\/messages$/.test(pathname)) {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, messages: [], next_since_id: 0, host_log_available: true }) });
+ return;
+ }
+ if (pathname.endsWith("/rooms")) {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, rooms }) });
+ return;
+ }
+ await route.continue();
+ });
+ await page.goto(platform.baseUrl);
+ await page.waitForSelector('.room-row[data-room-id="multi"]');
+
+ // Payload channels render with name + type; the first is active.
+ await page.click('.room-row[data-room-id="multi"]');
+ await page.waitForSelector("#lower-room:not([hidden])");
+ assert.equal(await page.locator("#channel-nav .channel-row").count(), 3);
+ const names = await page.locator("#channel-nav .channel-name").evaluateAll((els) => els.map((e) => e.textContent?.trim()));
+ assert.deepEqual(names, ["general", "design", "ops"]);
+ const types = await page.locator("#channel-nav .channel-type").evaluateAll((els) => els.map((e) => e.textContent?.trim()));
+ assert.deepEqual(types, ["chat", "forum", "chat"]);
+ await page.waitForSelector("#channel-nav .channel-row.on:has-text('general')");
+
+ // A room with no channels falls back to the single #general chat channel.
+ await page.click("#brand-home");
+ await page.click('.room-row[data-room-id="plain"]');
+ await page.waitForSelector("#lower-room:not([hidden])");
+ assert.equal(await page.locator("#channel-nav .channel-row").count(), 1);
+ assert.equal((await page.locator("#channel-nav .channel-name").textContent())?.trim(), "general");
+ } finally {
+ await browser.close();
+ await platform.close();
+ }
+});
+
+// #233 — the channel nav caps at 8 behind its own view-more control (independent of
+// the rooms cap of 6).
+test("channel nav caps at 8 with a view-more control (#233)", async () => {
+ const root = await makeRoot();
+ const platform = await listen(createPlatformHttpServer({ root, ownerUserId: "owner-1" }));
+ const browser = await chromium.launch();
+ const roster = [{ alias: "host", kind: "human", role: "host", status: "attending" }];
+ const channels = Array.from({ length: 10 }, (_unused, i) => ({ id: i === 0 ? "general" : `chan-${i}`, name: i === 0 ? "general" : `chan-${i}`, type: "chat" }));
+ const rooms = [{ room_id: "big", title: "Big", status: "active", owner_user_id: "owner-1", roster, route_health: { reachable: true, host_connected: true }, channels }];
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 820 } });
+ await page.route("**/rooms**", async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (/\/rooms\/[^/]+\/messages$/.test(pathname)) {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, messages: [], next_since_id: 0, host_log_available: true }) });
+ return;
+ }
+ if (pathname.endsWith("/rooms")) {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, rooms }) });
+ return;
+ }
+ await route.continue();
+ });
+ await page.goto(platform.baseUrl);
+ await page.waitForSelector('.room-row[data-room-id="big"]');
+ await page.click('.room-row[data-room-id="big"]');
+ await page.waitForSelector("#channels-more:not([hidden])");
+
+ // 10 channels, cap 8 → 2 collapsed behind the channel view-more control.
+ assert.equal(await page.locator("#channel-nav > li").count(), 10);
+ assert.equal(await page.locator("#channel-nav > li:not(.is-collapsed)").count(), 8);
+ assert.match((await page.locator("#channels-more").textContent()) ?? "", /show 2 more/);
+
+ await page.click("#channels-more");
+ assert.equal(await page.locator("#channel-nav > li:not(.is-collapsed)").count(), 10);
+ assert.match((await page.locator("#channels-more").textContent()) ?? "", /show less/);
+ } finally {
+ await browser.close();
+ await platform.close();
+ }
+});