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
4 changes: 4 additions & 0 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { LeftSidebar } from "./components/LeftSidebar";
import { Palette } from "./components/Palette";
import { RightSidebar } from "./components/RightSidebar";
import { RuntimeMarker } from "./components/RuntimeMarker";
import { ShortcutsOverlay } from "./components/ShortcutsOverlay";
import { StateDot } from "./components/StateDot";
import { UsageBar } from "./components/UsageBar";
Expand Down Expand Up @@ -111,6 +112,9 @@ const App: Component<RouteSectionProps> = (props) => {
onClick={() => store.openAgent(agent().account.id)}
>
<StateDot state={agent().lifecycle ?? "idle"} />
<Show when={agent().runtime}>
{(m) => <RuntimeMarker marker={m()} />}
</Show>
{agent().account.displayName}
</button>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/components/AgentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { Agent, Terminal } from "../stub-data";
import { ChannelView } from "./ChannelView";
import { LogPanel } from "./LogPanel";
import { RuntimeMarker } from "./RuntimeMarker";
import { StateDot } from "./StateDot";

/** A terminal pane: the fixture scrollback for the terminal a tab references. A
Expand Down Expand Up @@ -215,6 +216,9 @@ export const AgentView: Component = () => {
<div class="agent-view">
<div class="av-header">
<StateDot state={agent().lifecycle ?? "idle"} />
<Show when={agent().runtime}>
{(m) => <RuntimeMarker marker={m()} />}
</Show>
<span class="av-name">{agent().account.handle}</span>
<Show when={agent().model}>
<span class="av-model">{agent().model}</span>
Expand Down
7 changes: 7 additions & 0 deletions apps/ui/src/components/Bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { createRovingGroup, type Stop } from "../keyboard/roving";
import type { IssueState } from "../stub-data";
import { BadgeGlyph } from "./BadgeGlyph";
import { IssueCard } from "./IssueCard";
import { RuntimeMarker } from "./RuntimeMarker";
import { StateDot } from "./StateDot";

/** How the board groups rows. Swimlane = one row per agent (default); status =
Expand Down Expand Up @@ -665,6 +666,9 @@ export const Bridge: Component = () => {
onClick={() => store.openAgent(agent.account.id)}
>
<StateDot state={agent.lifecycle ?? "idle"} />
<Show when={agent.runtime}>
{(m) => <RuntimeMarker marker={m()} />}
</Show>
<span>
<span class="g-name">{agent.account.handle}</span>
<br />
Expand Down Expand Up @@ -758,6 +762,9 @@ export const Bridge: Component = () => {
onClick={() => store.openAgent(agent().account.id)}
>
<StateDot state={agent().lifecycle ?? "idle"} />
<Show when={agent().runtime}>
{(m) => <RuntimeMarker marker={m()} />}
</Show>
<span class="g-name">{agent().account.handle}</span>
<span class="g-open" aria-hidden="true">
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { detectPlatform } from "../keyboard/dispatch";
import { shortcutForAria } from "../keyboard/keymap";
import { type Agent, type AgentTreeNode, agentTree } from "../stub-data";
import { CoachTip, CoachTipContent, CoachTipTrigger } from "./CoachTip";
import { RuntimeMarker } from "./RuntimeMarker";
import { StateDot } from "./StateDot";

/** An agent leaf row in the tree — the per-agent select button, plus a hover
Expand Down Expand Up @@ -46,6 +47,7 @@ const AgentLeaf: Component<{ agent: Agent; badge?: number }> = (props) => {
onClick={() => store.openAgent(a().account.id)}
>
<StateDot state={a().lifecycle ?? "idle"} />
<Show when={a().runtime}>{(m) => <RuntimeMarker marker={m()} />}</Show>
<span class="name">{a().account.handle}</span>
<Show when={a().role !== undefined && a().role !== "worker"}>
<span class="role-pip" data-role={a().role} title={a().role}>
Expand Down
10 changes: 9 additions & 1 deletion apps/ui/src/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
STUB_FILES,
} from "../stub-data";
import { ChannelView } from "./ChannelView";
import { RuntimeMarker } from "./RuntimeMarker";
import { StateDot } from "./StateDot";

const FILE_ICON: Record<string, string> = { dir: "▸", file: "·" };
Expand Down Expand Up @@ -689,7 +690,14 @@ export const RightSidebar: Component = () => {
{tab.icon}
</span>
<Show when={agent()}>
{(a) => <StateDot state={a().lifecycle ?? "idle"} />}
{(a) => (
<>
<StateDot state={a().lifecycle ?? "idle"} />
<Show when={a().runtime}>
{(m) => <RuntimeMarker marker={m()} />}
</Show>
</>
)}
</Show>
</button>
);
Expand Down
39 changes: 39 additions & 0 deletions apps/ui/src/components/RuntimeMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type Component, Show } from "solid-js";
import type { RuntimeMarker as RuntimeMarkerModel } from "../stub-data";
import "../design/components/runtime-marker.css";

/** The session runtime marker: a small mono text token beside the state dot
* naming which runner backend the session runs on (`podman` / `microvm` /
* `apple-container` / `host` / `unknown`). Where egress is `unenforced` the
* marker additionally reads as uncontained — a warn-colored `!` suffix plus a
* `data-uncontained` hook — so a user glancing at the roster can tell a
* host-network session from a contained one. An `unknown` tier or posture
* renders as such and never as contained. Color/sizing live in
* `runtime-marker.css`. */
export const RuntimeMarker: Component<{ marker: RuntimeMarkerModel }> = (
props,
) => {
const uncontained = () => props.marker.posture === "unenforced";
const label = () =>
`runtime ${props.marker.tier}, egress ${props.marker.posture}`;
return (
<span
class="cx-runtime-marker"
data-tier={props.marker.tier}
data-posture={props.marker.posture}
data-uncontained={uncontained() ? "1" : undefined}
title={label()}
// role="img" so the label replaces the glyphs for assistive tech: the
// bare tier text and the "!" do not say "egress unenforced" on their own.
role="img"
aria-label={label()}
>
{props.marker.tier}
<Show when={uncontained()}>
<span class="cx-runtime-uncontained" aria-hidden="true">
!
</span>
</Show>
</span>
);
};
29 changes: 29 additions & 0 deletions apps/ui/src/design/components/runtime-marker.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* Runtime marker — .cx-runtime-marker[data-tier][data-posture]. A small mono
text token beside the state dot naming the session's runner backend. Rides
the low-emphasis meta color so it reads as metadata, not a live signal. An
uncontained (unenforced-egress) session flips to the warn color and shows a
`!` suffix so a host-network session is distinguishable at a glance; an
unknown tier/posture stays muted and never reads as contained. Consumes only
--cx-* tiers. */

.cx-runtime-marker {
display: inline-flex;
align-items: center;
gap: 1px;
font-family: var(--cx-font-ui);
font-size: var(--cx-text-xs);
line-height: 1;
letter-spacing: 0.3px;
color: var(--cx-text-faint);
}

/* Uncontained egress (unenforced posture) — the one state a user must not read
as contained. Warn color + the `!` suffix carry it on both the color and the
shape axis, so it survives a monochrome or reduced-color view. */
.cx-runtime-marker[data-uncontained="1"] {
color: var(--cx-warn);
}

.cx-runtime-uncontained {
font-weight: 700;
}
59 changes: 59 additions & 0 deletions apps/ui/src/live/adapt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AccountSchema,
AgentAccountSchema,
AgentPresence,
AgentSessionStatusSchema,
AskOptionSchema,
AskQuestionSchema,
AskSchema,
Expand All @@ -13,13 +14,15 @@ import {
ChannelPostPolicy,
ChannelSchema,
create,
EgressPosture,
ForgeProvider,
IssueSchema,
IssueState,
MessageBlockSchema,
MessageSchema,
PullRequestSchema,
RosterEntrySchema,
RuntimeTier,
SystemAccountSchema,
TopicSchema,
UserAccountSchema,
Expand All @@ -35,6 +38,7 @@ import {
adaptMessage,
adaptPullRequest,
adaptRosterEntry,
adaptRuntimeMarker,
adaptTopic,
agentHomeChannelIds,
deriveMembership,
Expand Down Expand Up @@ -1008,3 +1012,58 @@ describe("adaptRosterEntry", () => {
expect(info.lifecycle).toBeUndefined();
});
});

describe("adaptRuntimeMarker", () => {
test("maps an unknown wire value to unknown, not undefined", () => {
// A server newer than this UI can send an enum value it has no name for;
// an undefined posture reads as contained to every downstream check.
// The generated enum type is closed, but protobuf-es keeps an unnamed
// numeric value on decode, so the runtime shape is reproduced directly.
const skewed = create(AgentSessionStatusSchema, {
sessionId: "s-future",
agentAccountId: "acc-future",
});
Object.assign(skewed, { runtimeTier: 99, egressPosture: 99 });

const marker = adaptRuntimeMarker(skewed);

expect(marker.tier).toBe("unknown");
expect(marker.posture).toBe("unknown");
});

// The runtime marker maps AgentSessionStatus.{runtime_tier,egress_posture}
// to the user-facing roster tokens. The semantics a user reads: a host tier
// with unenforced egress is the host backend AND visibly uncontained; a
// zero/unset value is unknown and never armed/contained.
test("host tier + unenforced egress maps to host and uncontained", () => {
const marker = adaptRuntimeMarker(
create(AgentSessionStatusSchema, {
runtimeTier: RuntimeTier.HOST,
egressPosture: EgressPosture.UNENFORCED,
}),
);
expect(marker.tier).toBe("host");
expect(marker.posture).toBe("unenforced");
});

test("a contained podman session reads as armed, not uncontained", () => {
const marker = adaptRuntimeMarker(
create(AgentSessionStatusSchema, {
runtimeTier: RuntimeTier.PODMAN,
egressPosture: EgressPosture.ARMED,
}),
);
expect(marker.tier).toBe("podman");
expect(marker.posture).toBe("armed");
});

test("a zero/unspecified value maps to unknown, never armed or contained", () => {
// The generated default for an absent field is the 0 enum value; it must
// render as unknown so a user never reads an unresolved session as armed.
const marker = adaptRuntimeMarker(create(AgentSessionStatusSchema, {}));
expect(marker.tier).toBe("unknown");
expect(marker.posture).toBe("unknown");
expect(marker.posture).not.toBe("armed");
expect(marker.posture).not.toBe("unenforced");
});
});
42 changes: 42 additions & 0 deletions apps/ui/src/live/adapt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ import {
ChannelGroupVisibility,
ChannelKind,
ChannelPostPolicy,
EgressPosture,
ForgeProvider,
IssueState,
RuntimeTier,
type Account as WireAccount,
type AgentAttribution as WireAgentAttribution,
type AgentSessionStatus as WireAgentSessionStatus,
type Ask as WireAsk,
type AskQuestion as WireAskQuestion,
type ChangedStats as WireChangedStats,
Expand Down Expand Up @@ -73,6 +76,9 @@ import type {
Review as DomainReview,
ReviewThread as DomainReviewThread,
TrackerRef as DomainTrackerRef,
EgressPostureMark,
RuntimeMarker,
RuntimeTierMark,
} from "../stub-data";
import type { MapMessage } from "./comms-state";

Expand Down Expand Up @@ -556,3 +562,39 @@ export function adaptRosterEntry(
},
];
}

/** The wire `RuntimeTier` enum → the domain's runner-backend mark. Total over
* the enum (`satisfies Record<RuntimeTier, …>` makes a new wire tier a compile
* error here). `UNSPECIFIED → "unknown"`: an unresolved or version-skewed tier
* is rendered honestly, never assumed to be a contained backend. */
const RUNTIME_TIER: Record<RuntimeTier, RuntimeTierMark> = {
[RuntimeTier.UNSPECIFIED]: "unknown",
[RuntimeTier.PODMAN]: "podman",
[RuntimeTier.MICROVM]: "microvm",
[RuntimeTier.APPLE_CONTAINER]: "apple-container",
[RuntimeTier.HOST]: "host",
} satisfies Record<RuntimeTier, RuntimeTierMark>;

/** The wire `EgressPosture` enum → the domain's posture mark. Total, same
* rationale as RUNTIME_TIER. `UNSPECIFIED → "unknown"`: an unresolved posture
* is never rendered as contained. `UNENFORCED` is a DECLARED posture the user
* must read as uncontained, not a failed arm (compass.proto EgressPosture). */
const EGRESS_POSTURE: Record<EgressPosture, EgressPostureMark> = {
[EgressPosture.UNSPECIFIED]: "unknown",
[EgressPosture.ARMED]: "armed",
[EgressPosture.UNENFORCED]: "unenforced",
} satisfies Record<EgressPosture, EgressPostureMark>;

/** Map a wire `AgentSessionStatus` to the session runtime marker the roster
* renders beside the state dot: the Runner-reported runtime tier and egress
* posture, each mapped total over its wire enum. Pure — the identity/lifecycle
* fields the status also carries are the presence path's job, not this marker's. */
export function adaptRuntimeMarker(w: WireAgentSessionStatus): RuntimeMarker {
// protobuf-es keeps an unknown numeric enum value on decode, so a server
// newer than this UI yields a miss here. Fall back rather than index bare:
// an undefined posture would silently drop the uncontained warning.
return {
tier: RUNTIME_TIER[w.runtimeTier] ?? "unknown",
posture: EGRESS_POSTURE[w.egressPosture] ?? "unknown",
};
}
Loading
Loading