Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified apps/ui/e2e/__screens__/bridge-colheads.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 26 additions & 1 deletion apps/ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
146 changes: 146 additions & 0 deletions apps/ui/src/components/RightSidebar.activitybar.test.tsx
Original file line number Diff line number Diff line change
@@ -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
// `<Glyph/>`) 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 (
<StoreContext value={store}>
<RightSidebar />
</StoreContext>
);
});
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<HTMLButtonElement>("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 `<Glyph/>` — an SVG with
// crispEdges and lit `<rect>` 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/);
});
});
48 changes: 28 additions & 20 deletions apps/ui/src/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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 (
<Show when={agent()}>
{(a) => {
Expand Down Expand Up @@ -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 (
<div class="fleet-pane fleet-unreachable">
Expand All @@ -471,13 +471,7 @@ const AgentUnreachable: Component<{ item: ActivityBarItem }> = (props) => {
<button
type="button"
class="r-unpin-agent"
// Both item builders (fleetItemForAgent, unreachableFleetItem) always set
// agentId, and AgentUnreachable only renders for a pinned item read out of
// rightTabGroups(), so it is never undefined here. Asserting (rather than
// `?? ""`) surfaces a genuinely-empty agentId as a bug instead of silently
// no-op-ing through unpinAgent("").
// biome-ignore lint/style/noNonNullAssertion: guaranteed by both builders (see above)
onClick={() => store.unpinAgent(props.item.agentId!)}
onClick={() => store.unpinAgent(props.item.agentId)}
>
Unpin {props.item.title}
</button>
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -659,25 +653,31 @@ export const RightSidebar: Component = () => {
</Show>
<For each={group.items}>
{(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 (
<button
type="button"
class={[
"r-tab",
{
active: store.activeRightTab() === tab.id,
unreachable: tab.unreachable === true,
unreachable: unreachable(),
},
]}
title={
tab.unreachable === true
unreachable()
? `${tab.title} (unreachable)`
: tab.title
}
aria-label={
tab.unreachable === true
unreachable()
? `${tab.title} (unreachable)`
: tab.title
}
Expand All @@ -686,8 +686,16 @@ export const RightSidebar: Component = () => {
}
onClick={() => store.setActiveRightTab(tab.id)}
>
<span class="r-tab-icon" aria-hidden="true">
{tab.icon}
<span
class="r-tab-icon"
data-kind={tab.kind}
aria-hidden="true"
>
{tab.kind === "glyph" ? (
<Glyph name={tab.name} />
) : (
tab.letter
)}
</span>
<Show when={agent()}>
{(a) => (
Expand Down
60 changes: 59 additions & 1 deletion apps/ui/src/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { describe, expect, test } from "bun:test";
import { avatarInitial } from "./constants";
import {
avatarInitial,
fleetItemForAgent,
unreachableFleetItem,
} from "./constants";
import type { Agent } from "./stub-data";

// A minimal resolvable agent: the constructor reads only account.id and
// account.handle, but the type wants a whole Agent.
function agentWith(id: string, handle: string): Agent {
return {
account: { id, handle, displayName: handle, kind: "agent" },
terminals: [],
};
}

describe("avatarInitial", () => {
test("uppercases a plain handle's first letter", () => {
Expand Down Expand Up @@ -55,3 +69,47 @@ describe("avatarInitial", () => {
expect(avatarInitial("_hidden")).toBe("_");
});
});

describe("fleetItemForAgent", () => {
// A resolvable agent builds the avatar arm, keyed on the live account, with
// no unreachable mark so its agentId badges a real StateDot.
test("builds an unmarked avatar item from the live account", () => {
const item = fleetItemForAgent(agentWith("acc-cook", "cook"));
expect(item.kind).toBe("avatar");
expect(item.id).toBe("agent:acc-cook");
expect(item.agentId).toBe("acc-cook");
expect(item.title).toBe("cook");
expect(item.unreachable).toBeUndefined();
});

// The initial routes through avatarInitial: a non-ASCII handle clamps to "?"
// rather than leaking a glyph the brand face can't render.
test("derives the letter through avatarInitial (non-ASCII clamps to ?)", () => {
expect(fleetItemForAgent(agentWith("acc-1", "Живко")).letter).toBe("?");
expect(fleetItemForAgent(agentWith("acc-2", "mintaka")).letter).toBe("M");
});
});

describe("unreachableFleetItem", () => {
// A pin builds the avatar arm marked unreachable, titled by the cached
// handle, with its agentId carrying the pinned id (which resolves no agent).
test("builds a marked avatar item from the cached pin", () => {
const item = unreachableFleetItem({
id: "acc-ghost",
handle: "ghosthandle",
});
expect(item.kind).toBe("avatar");
expect(item.id).toBe("agent:acc-ghost");
expect(item.agentId).toBe("acc-ghost");
expect(item.title).toBe("ghosthandle");
expect(item.unreachable).toBe(true);
});

// The initial routes through avatarInitial here too — same derivation as the
// live constructor, so a cached non-ASCII handle clamps.
test("derives the letter through avatarInitial", () => {
expect(unreachableFleetItem({ id: "acc-3", handle: "Émile" }).letter).toBe(
"E",
);
});
});
Loading
Loading