Files
pi-gui/ui/src/components/ConversationWorkspace.tsx
T

483 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
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 ?? [])
.map((part) => {
if (part.type === "text") return part.text ?? "";
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function visibleLines(text: string) {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
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,
}: {
message: RuntimeView["messages"][number];
}) {
const role = message.role ?? "system";
if (role === "toolResult") {
const text = messageText(message);
const lines = visibleLines(text);
const label = `Tool result · ${message.toolName ?? "tool"}`;
if (lines.length <= 2)
return (
<article className={`message ${role} tool-result-visible`}>
<strong>{label}</strong>
<pre>{text}</pre>
</article>
);
return (
<article className={`message ${role}`}>
<details className="tool-result">
<summary>
<span className="tool-result-label">{label}</span>
<span className="tool-result-preview">
{lines.slice(0, 2).join("\n")}
</span>
<span className="tool-result-hidden-hint">
{lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "}
hidden · Expand to view
</span>
</summary>
<pre className="tool-result-full">{text}</pre>
</details>
</article>
);
}
return (
<article className={`message ${role}`}>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
);
}
function pendingStatus(pending: RuntimeView["pendingSubmissions"][number]) {
if (pending.phase === "sending")
return pending.delivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt…";
return pending.delivery === "follow_up"
? "Follow-up queued · waiting for Pi"
: "Prompt accepted · waiting for Pi";
}
function todos(messages: RuntimeView["messages"]) {
const order: Record<TodoTask["status"], number> = {
in_progress: 0,
pending: 1,
completed: 2,
deleted: 3,
};
for (let index = messages.length - 1; index >= 0; index--) {
if (
messages[index].role === "toolResult" &&
messages[index].toolName === "todo" &&
Array.isArray(messages[index].details?.tasks)
)
return messages[index]
.details!.tasks!.filter((task) => task.status !== "deleted")
.sort((a, b) => order[a.status] - order[b.status]);
}
return [] as TodoTask[];
}
export function ConversationWorkspace({
runtime,
onDraft,
onSubmit,
onAbort,
onScroll,
onModel,
onThinking,
isSubmitting = false,
isAborting = false,
isModelUpdating = false,
locked = false,
lockReason = "renewing",
status,
onCommand,
}: {
runtime: RuntimeView;
onDraft: (value: string) => void;
onSubmit: () => void;
onAbort: () => void;
onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => Promise<boolean>;
onThinking: (level: string) => Promise<boolean>;
isSubmitting?: boolean;
isAborting?: boolean;
isModelUpdating?: boolean;
locked?: boolean;
lockReason?: "renewing" | "restarting";
status?: SessionStatus;
onCommand: (command: RpcCommand) => void;
}) {
const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking =
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;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
const scrollToLatest = () => {
const transcript = ref.current;
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
onScroll(true, transcript.scrollTop);
transcript.focus();
};
return (
<div
className="workflow"
id="session-workspace"
role="tabpanel"
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
aria-busy={locked}
>
<div className="workflow-main" inert={locked ? true : undefined}>
<div className="transcript-container">
<section
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
aria-label="Conversation"
ref={ref}
tabIndex={-1}
onScroll={(event) => {
const node = event.currentTarget;
onScroll(
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
node.scrollTop,
);
}}
>
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
</div>
</div>
)}
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
{!runtime.loading && !runtime.messages.length && (
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<TranscriptMessage key={index} message={message} />
))}
{runtime.pendingSubmissions.map((pending) => (
<article
className={`message user pending-message ${pending.phase}`}
key={pending.id}
>
<strong>
You <span>{pendingStatus(pending)}</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
</section>
{!runtime.scroll.atBottom && (
<button
type="button"
className="scroll-to-latest"
aria-label="Scroll to latest message"
title="Scroll to latest message"
onClick={scrollToLatest}
>
<span aria-hidden="true">↓</span>
</button>
)}
</div>
<aside className="todos-pane" aria-label="Current todos">
<div className="todos-heading">
<strong>Todos</strong>
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
</div>
{tasks.length ? (
<ol className="todo-list">
{tasks.map((task) => (
<li className={task.status} key={task.id}>
<div>
<strong>{task.subject}</strong>
{task.activeForm && <small>{task.activeForm}</small>}
</div>
<span>{task.status.replace("_", " ")}</span>
</li>
))}
</ol>
) : (
<p className="muted">No active todos yet.</p>
)}
</aside>
</div>
<footer className="workflow-footer" inert={locked ? true : undefined}>
{Object.values(runtime.extensionWidgets)
.flatMap((widget) => widget.lines)
.map((line, index) => (
<div className="extension-widget" key={`${line}-${index}`}>
{line}
</div>
))}
<section
className="pi-controls"
aria-label="Model and thinking controls"
aria-busy={isModelUpdating || locked}
>
<ModelSelector
models={runtime.models}
current={runtime.state.model}
onSelect={onModel}
pending={isModelUpdating || locked}
/>
<label>
Thinking
<select
value={runtime.state.thinkingLevel ?? "medium"}
disabled={isModelUpdating || locked}
onChange={(event) => void onThinking(event.currentTarget.value)}
>
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => (
<option key={level}>{level}</option>
),
)}
</select>
</label>
</section>
<div
className={`session-status-strip ${presentedStatus.phase}`}
role="status"
aria-label="Session status"
aria-live="polite"
>
<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>
</div>
<div className="composer">
<CommandPalette
draft={runtime.draft}
commands={rpcCommandCatalog}
onSelect={onCommand}
/>
<textarea
aria-label="Prompt"
value={runtime.draft}
disabled={isSubmitting || locked}
onChange={(event) => onDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
}
}}
placeholder="Ask Pi…"
/>
<div className="composer-actions">
{runtimeIsWorking && (
<button
type="button"
className="quiet"
disabled={isAborting || locked}
onClick={onAbort}
>
{isAborting ? "Aborting…" : "Abort"}
</button>
)}
<button
type="button"
disabled={
locked ||
isSubmitting ||
!runtime.summary.agentId ||
!runtime.draft.trim()
}
onClick={onSubmit}
>
{isSubmitting ? "Sending…" : "Send"}
</button>
</div>
</div>
</footer>
{locked && (
<div className="session-lock" role="status" aria-live="polite">
<div className="session-lock-card">
<span className="working-bars session-lock-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>
{lockReason === "renewing" ? "Renewing session" : "Restarting Pi"}
</strong>
<p>
{lockReason === "renewing"
? "Clearing active history and starting a fresh Pi session…"
: "Restoring this Pi session and its extensions…"}
</p>
<small>Input and output are temporarily locked.</small>
</div>
</div>
</div>
)}
</div>
);
}