feat(status-ui): add compact session status strip
This commit is contained in:
+101
-53
@@ -66,16 +66,17 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
.workflow-header {
|
.workflow-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.workspace-heading,
|
.workspace-heading {
|
||||||
.status-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
color: #f0a347;
|
color: #f0a347;
|
||||||
@@ -84,39 +85,17 @@ select:focus-visible {
|
|||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
.workflow-header h1 {
|
.workflow-header h1 {
|
||||||
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status-row {
|
.workflow-header > button {
|
||||||
margin-top: 1px;
|
padding: 4px 8px;
|
||||||
color: #aaa6a0;
|
|
||||||
font-size: 11px;
|
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 {
|
.working-pulse {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -126,17 +105,6 @@ select:focus-visible {
|
|||||||
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
|
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
|
||||||
animation: work-pulse 1.35s ease-out infinite;
|
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 {
|
.workspace-warning {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid #8c713c;
|
border: 1px solid #8c713c;
|
||||||
@@ -883,31 +851,111 @@ select:focus-visible {
|
|||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
text-align: center;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
.session-status-primary {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
gap: 6px;
|
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 {
|
.work-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #687168;
|
background: #687168;
|
||||||
}
|
}
|
||||||
.composer-progress.working {
|
.session-status-strip.working {
|
||||||
padding: 4px 6px;
|
border-color: #60492f;
|
||||||
border-left: 3px solid #f0a347;
|
border-left-color: #f0a347;
|
||||||
background: #3b3025;
|
background: #332d26;
|
||||||
color: #ffd39a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
.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;
|
background: #f0a347;
|
||||||
box-shadow: 0 0 8px #f0a347;
|
box-shadow: 0 0 8px #f0a347;
|
||||||
animation: work-pulse 1.35s ease-out infinite;
|
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 {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 7px;
|
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 () => {
|
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const restart = deferred<unknown>();
|
const restart = deferred<unknown>();
|
||||||
@@ -211,9 +218,13 @@ describe("App command progress and errors", () => {
|
|||||||
expect(screen.getByLabelText("Prompt")).toBeDisabled();
|
expect(screen.getByLabelText("Prompt")).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
|
||||||
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
|
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Working", { selector: ".state-pill" })).toHaveClass(
|
const sessionStatus = screen.getByRole("status", { name: "Session status" });
|
||||||
"working",
|
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 () => {
|
await act(async () => {
|
||||||
submissionA.resolve({ delivery: "prompt" });
|
submissionA.resolve({ delivery: "prompt" });
|
||||||
|
|||||||
+27
-44
@@ -58,6 +58,10 @@ function requestedCommand(command: string, args: Record<string, unknown>) {
|
|||||||
: command;
|
: command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionStateLabel(state?: string) {
|
||||||
|
return !state || state === "idle" ? "Pi ready" : state;
|
||||||
|
}
|
||||||
|
|
||||||
function commandProgress(command: string) {
|
function commandProgress(command: string) {
|
||||||
if (command === "restart") return "Restarting Pi and restoring the session…";
|
if (command === "restart") return "Restarting Pi and restoring the session…";
|
||||||
if (command === "renew_session")
|
if (command === "renew_session")
|
||||||
@@ -563,6 +567,19 @@ export default function App() {
|
|||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
selected?.progress.phase === "working" ||
|
selected?.progress.phase === "working" ||
|
||||||
selected?.progress.phase === "recovering";
|
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 =
|
const isStartingSession =
|
||||||
state.newSessionLaunch?.phase === "starting" ||
|
state.newSessionLaunch?.phase === "starting" ||
|
||||||
activeOperations.some((entry) => entry.kind === "creating");
|
activeOperations.some((entry) => entry.kind === "creating");
|
||||||
@@ -600,50 +617,9 @@ export default function App() {
|
|||||||
void getCurrentWindow().startDragging();
|
void getCurrentWindow().startDragging();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="workspace-heading">
|
||||||
<div className="workspace-heading">
|
<span className="brand">PI</span>
|
||||||
<span className="brand">PI</span>
|
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
|
||||||
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="quiet"
|
className="quiet"
|
||||||
@@ -876,6 +852,13 @@ export default function App() {
|
|||||||
isModelUpdating={modelCommandPending}
|
isModelUpdating={modelCommandPending}
|
||||||
locked={isSessionLocked}
|
locked={isSessionLocked}
|
||||||
lockReason={isRenewing ? "renewing" : "restarting"}
|
lockReason={isRenewing ? "renewing" : "restarting"}
|
||||||
|
status={{
|
||||||
|
label: sessionStatusLabel,
|
||||||
|
detail: displayedStatus,
|
||||||
|
phase: sessionStatusPhase,
|
||||||
|
bridge: state.bridge.status,
|
||||||
|
extensions: Object.values(selected.extensionStatuses),
|
||||||
|
}}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
isAborting={isAborting}
|
isAborting={isAborting}
|
||||||
onCommand={(command) => {
|
onCommand={(command) => {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
|||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { ModelSelector } from "./ModelSelector";
|
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]) {
|
export function messageText(message: RuntimeView["messages"][number]) {
|
||||||
if (typeof message.content === "string") return message.content;
|
if (typeof message.content === "string") return message.content;
|
||||||
return (message.content ?? [])
|
return (message.content ?? [])
|
||||||
@@ -22,6 +30,36 @@ function visibleLines(text: string) {
|
|||||||
return lines;
|
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({
|
function TranscriptMessage({
|
||||||
message,
|
message,
|
||||||
}: {
|
}: {
|
||||||
@@ -107,6 +145,7 @@ export function ConversationWorkspace({
|
|||||||
isModelUpdating = false,
|
isModelUpdating = false,
|
||||||
locked = false,
|
locked = false,
|
||||||
lockReason = "renewing",
|
lockReason = "renewing",
|
||||||
|
status,
|
||||||
onCommand,
|
onCommand,
|
||||||
}: {
|
}: {
|
||||||
runtime: RuntimeView;
|
runtime: RuntimeView;
|
||||||
@@ -121,6 +160,7 @@ export function ConversationWorkspace({
|
|||||||
isModelUpdating?: boolean;
|
isModelUpdating?: boolean;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
lockReason?: "renewing" | "restarting";
|
lockReason?: "renewing" | "restarting";
|
||||||
|
status?: SessionStatus;
|
||||||
onCommand: (command: RpcCommand) => void;
|
onCommand: (command: RpcCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
@@ -129,6 +169,43 @@ export function ConversationWorkspace({
|
|||||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||||
const isWorking =
|
const isWorking =
|
||||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
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(() => {
|
useEffect(() => {
|
||||||
if (runtime.scroll.atBottom && ref.current)
|
if (runtime.scroll.atBottom && ref.current)
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
@@ -269,15 +346,69 @@ export function ConversationWorkspace({
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
<div
|
<div
|
||||||
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
|
className={`session-status-strip ${presentedStatus.phase}`}
|
||||||
role="status"
|
role="status"
|
||||||
|
aria-label="Session status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span className="work-dot" />
|
<span className="session-status-primary">
|
||||||
<span>
|
<span className="work-dot" aria-hidden="true" />
|
||||||
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
|
<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>
|
</span>
|
||||||
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|||||||
@@ -178,6 +178,60 @@ describe("multi-session navigation", () => {
|
|||||||
await user.click(screen.getByText("Tool result · read"));
|
await user.click(screen.getByText("Tool result · read"));
|
||||||
expect(details).toHaveAttribute("open");
|
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 () => {
|
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onScroll = vi.fn();
|
const onScroll = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user