483 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|