feat(status-ui): improve concurrent session workflows
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
This commit is contained in:
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
export function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
@@ -15,21 +16,47 @@ export function messageText(message: RuntimeView["messages"][number]) {
|
||||
.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 TranscriptMessage({
|
||||
message,
|
||||
}: {
|
||||
message: RuntimeView["messages"][number];
|
||||
}) {
|
||||
const role = message.role ?? "system";
|
||||
if (role === "toolResult")
|
||||
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>Tool result · {message.toolName ?? "tool"}</summary>
|
||||
<pre>{messageText(message)}</pre>
|
||||
<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>
|
||||
@@ -77,6 +104,9 @@ export function ConversationWorkspace({
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
isAborting = false,
|
||||
isModelUpdating = false,
|
||||
locked = false,
|
||||
lockReason = "renewing",
|
||||
onCommand,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
@@ -84,90 +114,108 @@ export function ConversationWorkspace({
|
||||
onSubmit: () => void;
|
||||
onAbort: () => void;
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => void;
|
||||
onThinking: (level: string) => void;
|
||||
onModel: (model: Model) => Promise<boolean>;
|
||||
onThinking: (level: string) => Promise<boolean>;
|
||||
isSubmitting?: boolean;
|
||||
isAborting?: boolean;
|
||||
isModelUpdating?: boolean;
|
||||
locked?: boolean;
|
||||
lockReason?: "renewing" | "restarting";
|
||||
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";
|
||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||
const isWorking =
|
||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
||||
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">
|
||||
<section
|
||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||
aria-label="Conversation"
|
||||
ref={ref}
|
||||
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}
|
||||
<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>
|
||||
{runtime.progress.tool && (
|
||||
<small>Using {runtime.progress.tool}</small>
|
||||
)}
|
||||
<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>
|
||||
</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}
|
||||
)}
|
||||
{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}
|
||||
>
|
||||
<strong>
|
||||
You <span>{pendingStatus(pending)}</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<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>
|
||||
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
|
||||
</div>
|
||||
{tasks.length ? (
|
||||
<ol className="todo-list">
|
||||
@@ -186,7 +234,7 @@ export function ConversationWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<footer className="workflow-footer">
|
||||
<footer className="workflow-footer" inert={locked ? true : undefined}>
|
||||
{Object.values(runtime.extensionWidgets)
|
||||
.flatMap((widget) => widget.lines)
|
||||
.map((line, index) => (
|
||||
@@ -197,32 +245,20 @@ export function ConversationWorkspace({
|
||||
<section
|
||||
className="pi-controls"
|
||||
aria-label="Model and thinking controls"
|
||||
aria-busy={isModelUpdating || locked}
|
||||
>
|
||||
<label>
|
||||
Model
|
||||
<select
|
||||
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
|
||||
onChange={(event) => {
|
||||
const [provider, id] = event.currentTarget.value.split("/");
|
||||
onModel({ provider, id });
|
||||
}}
|
||||
>
|
||||
<option value="">Current model</option>
|
||||
{runtime.models.map((model) => (
|
||||
<option
|
||||
key={`${model.provider}/${model.id}`}
|
||||
value={`${model.provider}/${model.id}`}
|
||||
>
|
||||
{model.provider}/{model.id ?? model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ModelSelector
|
||||
models={runtime.models}
|
||||
current={runtime.state.model}
|
||||
onSelect={onModel}
|
||||
pending={isModelUpdating || locked}
|
||||
/>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
onChange={(event) => onThinking(event.currentTarget.value)}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
@@ -252,7 +288,7 @@ export function ConversationWorkspace({
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || locked}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
@@ -267,7 +303,7 @@ export function ConversationWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
disabled={isAborting}
|
||||
disabled={isAborting || locked}
|
||||
onClick={onAbort}
|
||||
>
|
||||
{isAborting ? "Aborting…" : "Abort"}
|
||||
@@ -276,6 +312,7 @@ export function ConversationWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
locked ||
|
||||
isSubmitting ||
|
||||
!runtime.summary.agentId ||
|
||||
!runtime.draft.trim()
|
||||
@@ -287,6 +324,28 @@ export function ConversationWorkspace({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user