diff --git a/apps/ui/e2e/__screens__/bridge-colheads.png b/apps/ui/e2e/__screens__/bridge-colheads.png
index aef6656c..dba46ca0 100644
Binary files a/apps/ui/e2e/__screens__/bridge-colheads.png and b/apps/ui/e2e/__screens__/bridge-colheads.png differ
diff --git a/apps/ui/src/app.css b/apps/ui/src/app.css
index a7e77105..d2001e93 100644
--- a/apps/ui/src/app.css
+++ b/apps/ui/src/app.css
@@ -2261,7 +2261,32 @@
opacity: 0.55;
}
-.r-tab .r-tab-icon {
+/* The glyph arm is an 11px 1-bit SVG box; the avatar arm is a 15px mono
+ * letter. They split so the glyph box carries NO font metrics (the SVG is the
+ * content) and the letter keeps its type. */
+.r-tab .r-tab-icon[data-kind="glyph"] {
+ display: block;
+ width: 11px;
+ height: 11px;
+ /* D2: an odd 11px box centered in the tab's 32px content box (34px −
+ * 2×1px border, box-sizing: border-box) lands at (32 − 11) / 2 = 10.5px —
+ * a half pixel that smears every 1px crispEdges cell across two device
+ * pixels. Whole-integer margins that fill the content box exactly
+ * (10 + 11 + 11 = 32 per axis) leave zero free space for centering to
+ * split, so the box's offset is its whole-pixel margin (10px). */
+ margin: 10px 11px 11px 10px;
+}
+
+/* Seat the SVG at the box's top edge. An inline-level replaced box rides the
+ * parent's text baseline, so the inherited line-height pushes the glyph 2px
+ * down and out of its box — the whole-pixel margin above would then describe
+ * the span, not the pixels. state-dot.css carries the same rule for the same
+ * reason. */
+.r-tab .r-tab-icon[data-kind="glyph"] > svg {
+ display: block;
+}
+
+.r-tab .r-tab-icon[data-kind="avatar"] {
font-size: 15px;
line-height: 1;
}
diff --git a/apps/ui/src/components/RightSidebar.activitybar.test.tsx b/apps/ui/src/components/RightSidebar.activitybar.test.tsx
new file mode 100644
index 00000000..38f1d6f4
--- /dev/null
+++ b/apps/ui/src/components/RightSidebar.activitybar.test.tsx
@@ -0,0 +1,146 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { render } from "@solidjs/testing-library";
+import { flush } from "solid-js";
+import { STUB_COMMS_STATE } from "../comms-stub";
+import { StoreContext } from "../context";
+import { type AppStore, createAppStore } from "../store";
+import { testQueryClient } from "../test-support";
+import { RightSidebar } from "./RightSidebar";
+
+// Render spec for the activity-bar tab icon (RightSidebar.tsx, the `.r-tab-icon`
+// span). The item type split at RIG-3603 into a glyph arm (a fixed 1-bit
+// ``) and an avatar arm (a person's initial as text + StateDot); this
+// file defends that BOTH arms render as their contract says — no coverage
+// existed before. FleetPane/tab loop are reached through the exported
+// RightSidebar, the same seam a real activity-bar click uses.
+function mountRightSidebar(): { store: AppStore; container: HTMLElement } {
+ let store!: AppStore;
+ const { container } = render(() => {
+ store = createAppStore({
+ initialComms: STUB_COMMS_STATE,
+ queryClient: testQueryClient(),
+ });
+ return (
+
+
+
+ );
+ });
+ return { store, container };
+}
+
+// The tab button for a given aria-label, so a case targets one arm rather than
+// reading the first `.r-tab` and hoping it is the intended one.
+function tabByLabel(container: HTMLElement, label: string): HTMLButtonElement {
+ const button = [
+ ...container.querySelectorAll("nav.r-activity .r-tab"),
+ ].find((b) => b.getAttribute("aria-label") === label);
+ if (!button) throw new Error(`no activity-bar tab labelled "${label}"`);
+ return button;
+}
+
+describe("RightSidebar activity bar tab icons", () => {
+ // pinAgent write-throughs to the process-wide happy-dom localStorage, so clear
+ // it around every case (the fleet-pane suite's discipline).
+ beforeEach(() => globalThis.localStorage.clear());
+ afterEach(() => globalThis.localStorage.clear());
+
+ // The glyph arm: a static tab draws a 1-bit `` — an SVG with
+ // crispEdges and lit `` cells, carrying NO text. A regression to the
+ // old `{tab.icon}` string would render text and no SVG, reddening both legs.
+ test("a static tab renders a crispEdges SVG glyph with no text", () => {
+ const { container } = mountRightSidebar();
+ const icon = tabByLabel(container, "Fleet status").querySelector(
+ ".r-tab-icon",
+ );
+ expect(icon).not.toBeNull();
+ expect(icon?.getAttribute("data-kind")).toBe("glyph");
+ const svg = icon?.querySelector("svg");
+ expect(svg).not.toBeNull();
+ expect(svg?.getAttribute("shape-rendering")).toBe("crispEdges");
+ // Lit cells prove it is the real bitmap, not an empty box.
+ expect(svg?.querySelectorAll("rect").length).toBeGreaterThan(0);
+ // No initial leaked through — the glyph arm is textless.
+ expect(icon?.textContent?.trim()).toBe("");
+ });
+
+ // The avatar arm: a resolvable fleet tab renders the handle's initial as TEXT
+ // (no SVG glyph) plus the agent's StateDot badge. "compass-ui" → "C".
+ test("a resolvable fleet tab renders its initial as text plus a StateDot", () => {
+ const { store, container } = mountRightSidebar();
+ store.pinAgent("acc-compass-ui");
+ flush();
+ const tab = tabByLabel(container, "compass-ui");
+ const icon = tab.querySelector(".r-tab-icon");
+ expect(icon?.getAttribute("data-kind")).toBe("avatar");
+ expect(icon?.textContent?.trim()).toBe("C");
+ // The avatar arm draws text, not a Glyph SVG.
+ expect(icon?.querySelector("svg")).toBeNull();
+ // The live agent badges the tab with a StateDot.
+ expect(tab.querySelector(".cx-state-dot")).not.toBeNull();
+ });
+
+ // An unreachable pin (an id resolving to no fixture agent) still renders its
+ // initial, but carries NO StateDot — the absent badge is the visual mark of a
+ // dead pin (RIG-1645), so this reddens if the tab badges an unresolved agent.
+ test("an unreachable fleet tab renders its initial but no StateDot", () => {
+ globalThis.localStorage.setItem(
+ "compass.pinnedAgents.acc-matt",
+ JSON.stringify([{ id: "acc-ghost", handle: "ghosthandle" }]),
+ );
+ const { container } = mountRightSidebar();
+ flush();
+ const tab = tabByLabel(container, "ghosthandle (unreachable)");
+ const icon = tab.querySelector(".r-tab-icon");
+ expect(icon?.getAttribute("data-kind")).toBe("avatar");
+ expect(icon?.textContent?.trim()).toBe("G");
+ expect(tab.querySelector(".cx-state-dot")).toBeNull();
+ });
+
+ // D2 — the whole-pixel offset. happy-dom applies no stylesheet and computes
+ // no layout, so real geometry is NOT observable here. This is a PROXY: it
+ // parses app.css and asserts the mechanism that guarantees the offset — the
+ // glyph box is an integer 11px square whose integer margins fill .r-tab's
+ // 32px content box (34px − 2×1px border, box-sizing: border-box) EXACTLY on
+ // each axis. With zero free space, flex centering has no slack to halve, so
+ // the box's offset is its whole-pixel margin, not the 10.5px a centered 11px
+ // box would take. It proves the declared geometry is whole-pixel; it does NOT
+ // prove the browser rasterizes it there (that is the T6 visual baseline).
+ test("the glyph box CSS pins a whole-pixel offset in the 34px tab (D2 proxy)", () => {
+ const css = readFileSync(join(import.meta.dir, "../app.css"), "utf8");
+ const rule = css.match(
+ /\.r-tab \.r-tab-icon\[data-kind="glyph"\]\s*\{([^}]*)\}/,
+ )?.[1];
+ expect(rule).toBeDefined();
+ const decl = (prop: string): string | undefined =>
+ rule
+ ?.match(new RegExp(`(?:^|[;{\\s])${prop}\\s*:\\s*([^;]+);`))?.[1]
+ .trim();
+ const px = (v: string | undefined): number => {
+ const n = Number(v?.replace("px", ""));
+ expect(Number.isInteger(n)).toBe(true);
+ return n;
+ };
+ const width = px(decl("width"));
+ const height = px(decl("height"));
+ expect(width).toBe(11);
+ expect(height).toBe(11);
+ // margin shorthand: top right bottom left.
+ const margins = (decl("margin") ?? "").split(/\s+/);
+ expect(margins.length).toBe(4);
+ const [mt, mr, mb, ml] = margins.map((m) => px(m));
+ // The 32px content box is filled exactly on each axis — no centering slack.
+ expect(ml + width + mr).toBe(32);
+ expect(mt + height + mb).toBe(32);
+ // Seating: without `display: block` on the SVG itself the glyph rides the
+ // text baseline and leaves the box the margins above just placed, so this
+ // geometry would describe the span and not the pixels a user sees.
+ const seat = css.match(
+ /\.r-tab \.r-tab-icon\[data-kind="glyph"\]\s*>\s*svg\s*\{([^}]*)\}/,
+ )?.[1];
+ expect(seat).toBeDefined();
+ expect(seat).toMatch(/display\s*:\s*block/);
+ });
+});
diff --git a/apps/ui/src/components/RightSidebar.tsx b/apps/ui/src/components/RightSidebar.tsx
index d5d5ced5..198c1075 100644
--- a/apps/ui/src/components/RightSidebar.tsx
+++ b/apps/ui/src/components/RightSidebar.tsx
@@ -15,7 +15,7 @@ import {
primaryPr,
} from "../board-render";
import type { Channel } from "../comms-stub";
-import type { ActivityBarItem } from "../constants";
+import type { AvatarTabItem } from "../constants";
import { useStore } from "../context";
import {
type Agent,
@@ -27,6 +27,7 @@ import {
STUB_FILES,
} from "../stub-data";
import { ChannelView } from "./ChannelView";
+import { Glyph } from "./Glyph";
import { RuntimeMarker } from "./RuntimeMarker";
import { StateDot } from "./StateDot";
@@ -426,10 +427,9 @@ const RepoBranchDropdown: Component = () => {
* agent's full workspace via store.openAgent. Only rendered for a RESOLVABLE
* pin (RIG-1645 P2): the pane arm resolves reachability before choosing this
* vs the unreachable block, so there is no unresolved-agentId fallback here. */
-const FleetPane: Component<{ item: ActivityBarItem }> = (props) => {
+const FleetPane: Component<{ item: AvatarTabItem }> = (props) => {
const store = useStore();
- const agent = (): Agent | undefined =>
- props.item.agentId ? store.agentById(props.item.agentId) : undefined;
+ const agent = (): Agent | undefined => store.agentById(props.item.agentId);
return (
{(a) => {
@@ -461,7 +461,7 @@ const FleetPane: Component<{ item: ActivityBarItem }> = (props) => {
* affordance for an unreachable pin, whose left-tree row is gone (the tree
* renders the VISIBLE set). Unpinning routes through `store.unpinAgent`, which
* drops the pin and falls the active tab back to `status`. */
-const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => {
+const AgentUnreachable: Component<{ item: AvatarTabItem }> = (props) => {
const store = useStore();
return (
@@ -471,13 +471,7 @@ const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => {
@@ -574,13 +568,13 @@ export const RightSidebar: Component = () => {
// item-construction site. Never undefined for a pinned `agent:` tab (that was
// the blank-pane gap); undefined only for a non-`agent:` tab or an `agent:`
// tab with no matching pin (which falls through to `status`).
- const activeFleetItem = (): ActivityBarItem | undefined => {
+ const activeFleetItem = (): AvatarTabItem | undefined => {
const active = store.activeRightTab();
if (!active.startsWith("agent:")) return undefined;
return store
.rightTabGroups()
.flatMap((g) => g.items)
- .find((i) => i.id === active);
+ .find((i): i is AvatarTabItem => i.kind === "avatar" && i.id === active);
};
return (
@@ -659,8 +653,14 @@ export const RightSidebar: Component = () => {
{(tab) => {
+ // Only the avatar arm carries an agentId / unreachable
+ // mark and a StateDot; the glyph arm draws a fixed symbol.
const agent = (): Agent | undefined =>
- tab.agentId ? store.agentById(tab.agentId) : undefined;
+ tab.kind === "avatar"
+ ? store.agentById(tab.agentId)
+ : undefined;
+ const unreachable = (): boolean =>
+ tab.kind === "avatar" && tab.unreachable === true;
return (