feat(status-ui): add compact session status strip

This commit is contained in:
2026-08-25 18:32:33 +02:00
parent 166b065ad4
commit 612b146fd6
5 changed files with 332 additions and 105 deletions
+101 -53
View File
@@ -66,16 +66,17 @@ select:focus-visible {
}
.workflow-header {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: grab;
}
.workspace-heading,
.status-row {
.workspace-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
gap: 7px;
}
.brand {
color: #f0a347;
@@ -84,39 +85,17 @@ select:focus-visible {
letter-spacing: 0.16em;
}
.workflow-header h1 {
overflow: hidden;
margin: 0;
font-size: 16px;
font-size: 14px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row {
margin-top: 1px;
color: #aaa6a0;
.workflow-header > button {
padding: 4px 8px;
font-size: 11px;
}
.state-pill {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #555;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
}
.state-pill.working {
color: #ffd39a;
border-color: #f0a347;
background: #4b3420;
box-shadow:
0 0 0 1px rgba(240, 163, 71, 0.22),
0 0 10px rgba(240, 163, 71, 0.2);
}
.state-pill.recovering {
color: #e0c2a1;
border-color: #8d6745;
}
.state-pill.error {
color: #ffb4ba;
border-color: #a65b67;
}
.working-pulse {
width: 7px;
height: 7px;
@@ -126,17 +105,6 @@ select:focus-visible {
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
animation: work-pulse 1.35s ease-out infinite;
}
.working-banner {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 6px;
border: 1px solid #b76f26;
border-radius: 4px;
background: #41301f;
color: #ffd39a;
font-weight: 700;
}
.workspace-warning {
padding: 6px 8px;
border: 1px solid #8c713c;
@@ -883,31 +851,111 @@ select:focus-visible {
margin: 5px 0;
text-align: center;
}
.composer-progress {
.session-status-strip {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px;
border: 1px solid #454545;
border-left: 3px solid #687168;
border-radius: 5px;
background: #292a29;
color: #b7bcb7;
}
.session-status-primary,
.session-status-meta,
.status-chip {
display: flex;
align-items: center;
}
.session-status-primary {
min-width: 0;
flex: 1;
gap: 6px;
color: #8f988e;
font-size: 11px;
}
.session-status-primary strong {
flex: 0 0 auto;
color: #d8ddd8;
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.session-status-detail {
overflow: hidden;
color: #9da49d;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-status-meta {
max-width: 68%;
min-width: 0;
flex: 0 1 auto;
justify-content: flex-end;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.session-status-meta::-webkit-scrollbar {
display: none;
}
.status-chip {
flex: 0 0 auto;
gap: 4px;
max-width: 180px;
overflow: hidden;
padding: 2px 5px;
border: 1px solid #494b49;
border-radius: 999px;
background: #323332;
color: #bcc1bc;
font-size: 9px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-chip small {
color: #7f877f;
font-size: 8px;
font-weight: 750;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.work-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: #687168;
}
.composer-progress.working {
padding: 4px 6px;
border-left: 3px solid #f0a347;
background: #3b3025;
color: #ffd39a;
font-weight: 700;
.session-status-strip.working {
border-color: #60492f;
border-left-color: #f0a347;
background: #332d26;
}
.composer-progress.working .work-dot {
.session-status-strip.working .session-status-primary strong,
.session-status-strip.working .session-status-detail {
color: #ffd39a;
}
.session-status-strip.working .work-dot {
background: #f0a347;
box-shadow: 0 0 8px #f0a347;
animation: work-pulse 1.35s ease-out infinite;
}
.session-status-strip.recovering {
border-left-color: #b9824b;
}
.session-status-strip.error {
border-color: #6f3f47;
border-left-color: #e47682;
background: #34272a;
}
.session-status-strip.error .session-status-primary strong,
.session-status-strip.error .session-status-detail {
color: #ffb4ba;
}
.composer {
display: flex;
gap: 7px;
+14 -3
View File
@@ -92,6 +92,13 @@ describe("App command progress and errors", () => {
});
});
it("shows explicit Pi readiness in the compact session status", () => {
render(<App />);
const status = screen.getByRole("status", { name: "Session status" });
expect(status).toHaveTextContent("Pi ready");
expect(status).toHaveTextContent("Ready for your next prompt");
});
it("shows restart progress until the replacement snapshot is restored", async () => {
const user = userEvent.setup();
const restart = deferred<unknown>();
@@ -211,9 +218,13 @@ describe("App command progress and errors", () => {
expect(screen.getByLabelText("Prompt")).toBeDisabled();
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
expect(screen.getByText("Working", { selector: ".state-pill" })).toHaveClass(
"working",
);
const sessionStatus = screen.getByRole("status", { name: "Session status" });
expect(sessionStatus).toHaveTextContent("Working");
expect(sessionStatus).toHaveTextContent("Sending prompt to Pi…");
expect(sessionStatus).toHaveTextContent("Bridge");
expect(sessionStatus).toHaveTextContent("ready");
expect(sessionStatus).toHaveClass("working");
expect(rendered.container.querySelector(".status-row")).toBeNull();
await act(async () => {
submissionA.resolve({ delivery: "prompt" });
+27 -44
View File
@@ -58,6 +58,10 @@ function requestedCommand(command: string, args: Record<string, unknown>) {
: command;
}
function sessionStateLabel(state?: string) {
return !state || state === "idle" ? "Pi ready" : state;
}
function commandProgress(command: string) {
if (command === "restart") return "Restarting Pi and restoring the session…";
if (command === "renew_session")
@@ -563,6 +567,19 @@ export default function App() {
isSubmitting ||
selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering";
const sessionStatusLabel =
state.newSessionLaunch?.phase === "starting"
? "Starting"
: isRenewing
? "Renewing"
: isRestarting
? "Restarting"
: isWorking
? "Working"
: sessionStateLabel(selected?.summary.state);
const sessionStatusPhase = isWorking
? ("working" as const)
: (selected?.progress.phase ?? "idle");
const isStartingSession =
state.newSessionLaunch?.phase === "starting" ||
activeOperations.some((entry) => entry.kind === "creating");
@@ -600,50 +617,9 @@ export default function App() {
void getCurrentWindow().startDragging();
}}
>
<div>
<div className="workspace-heading">
<span className="brand">PI</span>
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
{selected && (
<span
className={`state-pill ${isWorking ? "working" : selected.progress.phase}`}
>
{isWorking && <span className="working-pulse" aria-hidden="true" />}
{state.newSessionLaunch?.phase === "starting"
? "Starting"
: isRenewing
? "Renewing"
: isRestarting
? "Restarting"
: isWorking
? "Working"
: selected.summary.state}
</span>
)}
</div>
<div className="status-row">
{isWorking && (
<span className="working-banner" role="status">
<span className="working-pulse" aria-hidden="true" />{" "}
{state.newSessionLaunch?.phase === "starting"
? "Starting new session"
: isRenewing
? "Renewing session"
: isRestarting
? "Restarting Pi"
: "Pi is working"}
</span>
)}
<span role="status" aria-live="polite">
{displayedStatus}
</span>
<span>{state.bridge.status}</span>
{Object.values(selected?.extensionStatuses ?? {}).map((entry) => (
<span className="extension-status" key={entry}>
{entry}
</span>
))}
</div>
<div className="workspace-heading">
<span className="brand">PI</span>
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
</div>
<button
className="quiet"
@@ -876,6 +852,13 @@ export default function App() {
isModelUpdating={modelCommandPending}
locked={isSessionLocked}
lockReason={isRenewing ? "renewing" : "restarting"}
status={{
label: sessionStatusLabel,
detail: displayedStatus,
phase: sessionStatusPhase,
bridge: state.bridge.status,
extensions: Object.values(selected.extensionStatuses),
}}
isSubmitting={isSubmitting}
isAborting={isAborting}
onCommand={(command) => {
+136 -5
View File
@@ -4,6 +4,14 @@ import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette";
import { ModelSelector } from "./ModelSelector";
type SessionStatus = {
label: string;
detail: string;
phase: RuntimeView["progress"]["phase"];
bridge: string;
extensions: string[];
};
export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
@@ -22,6 +30,36 @@ function visibleLines(text: string) {
return lines;
}
function compactCount(value: number) {
const units = [
{ threshold: 1_000_000_000, divisor: 1_000_000_000, suffix: "b" },
{ threshold: 1_000_000, divisor: 1_000_000, suffix: "m" },
{ threshold: 1_000, divisor: 1_000, suffix: "k" },
];
const unit = units.find((candidate) => Math.abs(value) >= candidate.threshold);
if (!unit) return String(value);
const scaled = Math.round((value / unit.divisor) * 10) / 10;
return `${scaled}${unit.suffix}`;
}
function runtimeStatusLabel(isWorking: boolean, state: string) {
if (isWorking) return "Working";
return state === "idle" ? "Pi ready" : state;
}
function plainStatusText(value: string) {
return value
.normalize("NFC")
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "")
.replace(
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uE000-\uF8FF\uFEFF\uFFFD]/gu,
"",
)
.replace(/\s+/gu, " ")
.trim();
}
function TranscriptMessage({
message,
}: {
@@ -107,6 +145,7 @@ export function ConversationWorkspace({
isModelUpdating = false,
locked = false,
lockReason = "renewing",
status,
onCommand,
}: {
runtime: RuntimeView;
@@ -121,6 +160,7 @@ export function ConversationWorkspace({
isModelUpdating?: boolean;
locked?: boolean;
lockReason?: "renewing" | "restarting";
status?: SessionStatus;
onCommand: (command: RpcCommand) => void;
}) {
const ref = useRef<HTMLElement>(null);
@@ -129,6 +169,43 @@ export function ConversationWorkspace({
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
const isWorking =
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
const presentedStatus = status ?? {
label: runtimeStatusLabel(isWorking, runtime.summary.state),
detail: isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail,
phase: isWorking ? ("working" as const) : runtime.progress.phase,
bridge: "unknown",
extensions: Object.values(runtime.extensionStatuses),
};
const visibleStatus = {
label: plainStatusText(presentedStatus.label) || "Pi status",
detail: plainStatusText(presentedStatus.detail) || "No status details",
bridge: plainStatusText(presentedStatus.bridge) || "unknown",
extensions: presentedStatus.extensions.map(plainStatusText).filter(Boolean),
};
const activeTool = runtime.progress.tool
? plainStatusText(runtime.progress.tool)
: undefined;
const inputTokens = runtime.stats.tokens?.input;
const outputTokens = runtime.stats.tokens?.output;
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
const undividedTokens =
inputTokens === undefined && outputTokens === undefined
? runtime.stats.tokens?.total
: undefined;
const context = runtime.stats.contextUsage;
const contextPercent =
context?.percent ??
(context?.tokens != null && context.contextWindow
? (context.tokens / context.contextWindow) * 100
: undefined);
let contextDetail: string | undefined;
if (context?.tokens != null) {
contextDetail = compactCount(context.tokens);
if (context.contextWindow !== undefined)
contextDetail += ` / ${compactCount(context.contextWindow)}`;
if (contextPercent !== undefined)
contextDetail += ` · ${Math.round(contextPercent)}%`;
}
useEffect(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
@@ -269,15 +346,69 @@ export function ConversationWorkspace({
</label>
</section>
<div
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
className={`session-status-strip ${presentedStatus.phase}`}
role="status"
aria-label="Session status"
aria-live="polite"
>
<span className="work-dot" />
<span>
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
<span className="session-status-primary">
<span className="work-dot" aria-hidden="true" />
<strong>{visibleStatus.label}</strong>
<span className="session-status-detail">{visibleStatus.detail}</span>
</span>
<span className="session-status-meta" aria-label="Status details">
{inputTokens !== undefined && (
<span className="status-chip" title="Input tokens">
<small>In</small>
{compactCount(inputTokens)}
</span>
)}
{outputTokens !== undefined && (
<span className="status-chip" title="Output tokens">
<small>Out</small>
{compactCount(outputTokens)}
</span>
)}
{cacheReadTokens !== undefined && (
<span className="status-chip" title="Cache-read tokens">
<small>Cache read</small>
{compactCount(cacheReadTokens)}
</span>
)}
{undividedTokens !== undefined && (
<span className="status-chip" title="Total tokens">
<small>Tokens</small>
{compactCount(undividedTokens)}
</span>
)}
{contextDetail && (
<span className="status-chip" title="Current context window usage">
<small>Context</small>
{contextDetail}
</span>
)}
{runtime.summary.queueCount > 0 && (
<span className="status-chip">
<small>Queue</small>
{runtime.summary.queueCount}
</span>
)}
<span className="status-chip bridge-status">
<small>Bridge</small>
{visibleStatus.bridge}
</span>
{activeTool && (
<span className="status-chip">
<small>Tool</small>
{activeTool}
</span>
)}
{visibleStatus.extensions.map((entry, index) => (
<span className="status-chip extension-status" key={`${entry}-${index}`}>
{entry}
</span>
))}
</span>
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div>
<div className="composer">
<CommandPalette
+54
View File
@@ -178,6 +178,60 @@ describe("multi-session navigation", () => {
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
it("packs session status between model controls and the composer", () => {
const runtime = runtimeView(summary("status", "streaming"));
runtime.summary.queueCount = 2;
runtime.stats = {
tokens: { input: 10_000, output: 2_450, cacheRead: 8_000, total: 12_450 },
contextUsage: { tokens: 32_000, contextWindow: 200_000, percent: 16 },
};
const { container } = render(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn()}
onThinking={vi.fn()}
onCommand={vi.fn()}
status={{
label: "Working",
detail: "Thinking and preparing a response",
phase: "working",
bridge: "ready",
extensions: ["\u001B[32mLSP\u001B[0m \uE0B0\uFFFDactive\u0007"],
}}
/>,
);
const modelControls = screen.getByRole("region", {
name: "Model and thinking controls",
});
const status = screen.getByRole("status", { name: "Session status" });
const composer = container.querySelector(".composer");
for (const text of [
"Working",
"Thinking and preparing a response",
"Bridge",
"ready",
"In",
"10k",
"Out",
"2.5k",
"Cache read",
"8k",
"Context",
"32k / 200k · 16%",
"Queue",
"2",
"LSP active",
])
expect(status).toHaveTextContent(text);
expect(status.textContent).not.toMatch(/[\u001B\u0007\uE0B0\uFFFD]/u);
expect(modelControls.nextElementSibling).toBe(status);
expect(status.nextElementSibling).toBe(composer);
});
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
const user = userEvent.setup();
const onScroll = vi.fn();