feat(status-ui): add compact session status strip
This commit is contained in:
+101
-53
@@ -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
@@ -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" });
|
||||
|
||||
+24
-41
@@ -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>
|
||||
<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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user