Fix live session UI updates

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 13:42:51 +02:00
parent 2ffff85313
commit bc21d1a6f8
10 changed files with 215 additions and 27 deletions
+8
View File
@@ -26,6 +26,14 @@ export interface SessionInfo {
firstMessage: string;
}
export interface SessionActivity {
sessionId: string;
phase: "active" | "idle" | "error";
label: string;
detail?: string;
at: string;
}
export interface SessionStatus {
sessionId: string;
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
+4 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult, Project, SessionInfo, SessionStatus, Workspace } from "./api";
import type { CommandResult, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
export interface AppState {
@@ -10,7 +10,9 @@ export interface AppState {
selectedWorkspace?: Workspace;
selectedSession?: SessionInfo;
status?: SessionStatus;
activity?: SessionActivity;
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
commandDialog?: Extract<CommandResult, { type: "select" }>;
error: string;
}
@@ -22,6 +24,7 @@ export function initialAppState(): AppState {
sessions: [],
messages: [],
sessionStatuses: {},
sessionActivities: {},
error: "",
};
}
+2 -2
View File
@@ -100,12 +100,12 @@ export class PiWebApp extends LitElement {
</header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list>
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list>
</aside>
<main>
${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html`
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar>
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor>
${state.commandDialog ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}></command-picker>` : null}
+4 -1
View File
@@ -1,12 +1,13 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { SessionInfo, SessionStatus } from "../api";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { listStyles } from "./shared";
@customElement("session-list")
export class SessionList extends LitElement {
@property({ attribute: false }) sessions: SessionInfo[] = [];
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) selected?: SessionInfo;
@property({ type: Boolean }) canStart = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@@ -27,6 +28,8 @@ export class SessionList extends LitElement {
private renderStatus(session: SessionInfo) {
const status = this.statuses[session.id];
const activity = this.activities[session.id];
if (activity?.phase === "active") return `${activity.label} · `;
if (!status) return "";
if (status.isStreaming) return "● streaming · ";
if (status.isBashRunning) return "● bash · ";
+12 -3
View File
@@ -1,12 +1,13 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { SessionStatus, Workspace } from "../api";
import type { SessionActivity, SessionStatus, Workspace } from "../api";
import { formatCost, formatTokenCount } from "../utils/format";
import { statusBarStyles } from "./shared";
@customElement("status-bar")
export class StatusBar extends LitElement {
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) workspace?: Workspace;
render() {
@@ -14,7 +15,8 @@ export class StatusBar extends LitElement {
if (!status) return html`<div class="bar muted">No session status yet</div>`;
const model = status.model?.id ?? "no model";
const provider = status.model?.provider ? `${status.model.provider}/` : "";
const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : "idle";
const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount ? "queued" : "idle";
const active = state !== "idle" || this.activity?.phase === "active";
const context = status.contextUsage;
const contextText = context
? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
@@ -23,7 +25,7 @@ export class StatusBar extends LitElement {
return html`
<div class="bar">
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
<span>${state}</span>
<span class=${active ? "activity active" : "activity"}><span class="dot"></span>${this.activityText(state)}</span>
<span>${provider}${model}</span>
<span>thinking ${status.thinkingLevel ?? "off"}</span>
<span>↑${formatTokenCount(tokens.input)}</span>
@@ -35,5 +37,12 @@ export class StatusBar extends LitElement {
`;
}
private activityText(state: string): string {
const activity = this.activity;
if (!activity) return state;
if (state !== "idle" && activity.phase === "idle") return state;
return activity.detail ? `${activity.label}: ${activity.detail}` : activity.label;
}
static styles = statusBarStyles;
}
+6 -1
View File
@@ -92,8 +92,13 @@ export const statusBarStyles = css`
:host { display: block; color: #8b949e; font: 12px system-ui, sans-serif; }
.bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-bottom: 1px solid #30363d; background: #0d1117; white-space: nowrap; overflow: hidden; }
span { overflow: hidden; text-overflow: ellipsis; }
span:first-child { flex: 1 1 auto; min-width: 80px; }
.bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
.activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; }
.activity.active { color: #3fb950; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
.activity.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; }
.muted { color: #6e7681; }
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
`;
export const autocompleteStyles = css`
@@ -1,4 +1,4 @@
import { api, type CommandResult, type SessionInfo, type SessionStatus } from "../api";
import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api";
import { appendText, normalizeMessages, textMessage } from "../chatMessages";
import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket";
import type { GetState, SetState, UpdateUrl } from "./types";
@@ -10,7 +10,10 @@ export class SessionController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
connectStatusUpdates() {
this.globalSocket.connect((event) => this.applyStatus(event.status));
this.globalSocket.connect((event) => {
if (event.type === "status.update") this.applyStatus(event.status);
else this.applyActivity(event.activity);
});
}
dispose() {
@@ -20,7 +23,7 @@ export class SessionController {
clearActiveSession() {
this.socket.close();
this.setState({ selectedSession: undefined, messages: [], status: undefined });
this.setState({ selectedSession: undefined, messages: [], status: undefined, activity: undefined });
}
async startSession() {
@@ -115,6 +118,13 @@ export class SessionController {
}
}
private applyActivity(activity: SessionActivity) {
this.setState({
sessionActivities: { ...this.getState().sessionActivities, [activity.sessionId]: activity },
activity: this.getState().selectedSession?.id === activity.sessionId ? activity : this.getState().activity,
});
}
private applyStatus(status: SessionStatus) {
this.setState({
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
@@ -132,6 +142,8 @@ export class SessionController {
this.setState({ messages: [...messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)] });
} else if (event.type === "status.update") {
this.applyStatus(event.status);
} else if (event.type === "activity.update") {
this.applyActivity(event.activity);
} else if (event.type === "session.error") {
this.setState({ messages: [...messages, textMessage("system", event.message)] });
}
+105 -14
View File
@@ -1,21 +1,27 @@
import { globalSessionEvents, sessionEvents, type SessionStatus } from "./api";
import { globalSessionEvents, sessionEvents, type SessionActivity, type SessionStatus } from "./api";
export type SessionUiEvent =
| { type: "assistant.delta"; text: string }
| { type: "tool.start"; toolName: string }
| { type: "tool.end"; toolName: string; isError: boolean }
| { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity }
| { type: "session.error"; message: string };
export class SessionSocket {
private socket?: WebSocket;
private sessionId?: string;
private onEvent?: (event: SessionUiEvent) => void;
private reconnectTimer?: number;
private reconnectDelay = 500;
private shouldReconnect = false;
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void {
this.close();
this.sessionId = sessionId;
this.onEvent = onEvent;
this.socket = sessionEvents(sessionId);
this.socket.onmessage = (message) => this.handleMessage(message.data);
this.shouldReconnect = true;
this.open();
}
setHandler(onEvent: (event: SessionUiEvent) => void): void {
@@ -23,36 +29,121 @@ export class SessionSocket {
}
close(): void {
this.socket?.close();
this.shouldReconnect = false;
window.clearTimeout(this.reconnectTimer);
closeSocketQuietly(this.socket);
this.socket = undefined;
this.sessionId = undefined;
this.onEvent = undefined;
}
private handleMessage(data: string): void {
const event = JSON.parse(data);
private open(): void {
if (!this.sessionId || !this.shouldReconnect) return;
const socket = sessionEvents(this.sessionId);
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;
};
socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onerror = () => socket.close();
socket.onclose = () => {
if (this.socket === socket) this.socket = undefined;
this.scheduleReconnect();
};
}
private scheduleReconnect(): void {
if (!this.shouldReconnect) return;
window.clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000);
this.reconnectTimer = window.setTimeout(() => this.open(), delay);
}
private async handleMessage(data: MessageEvent["data"]): Promise<void> {
const event = await parseSocketEvent(data);
if (isSessionUiEvent(event)) this.onEvent?.(event);
}
}
export class GlobalSessionSocket {
private socket?: WebSocket;
private onEvent?: (event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void;
private reconnectTimer?: number;
private reconnectDelay = 500;
private shouldReconnect = false;
connect(onEvent: (event: Extract<SessionUiEvent, { type: "status.update" }>) => void): void {
connect(onEvent: (event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void): void {
this.close();
this.socket = globalSessionEvents();
this.socket.onmessage = (message) => {
const event = JSON.parse(message.data);
if (event?.type === "status.update") onEvent(event);
};
this.onEvent = onEvent;
this.shouldReconnect = true;
this.open();
}
close(): void {
this.socket?.close();
this.shouldReconnect = false;
window.clearTimeout(this.reconnectTimer);
closeSocketQuietly(this.socket);
this.socket = undefined;
this.onEvent = undefined;
}
private open(): void {
if (!this.shouldReconnect) return;
const socket = globalSessionEvents();
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;
};
socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onerror = () => socket.close();
socket.onclose = () => {
if (this.socket === socket) this.socket = undefined;
this.scheduleReconnect();
};
}
private scheduleReconnect(): void {
if (!this.shouldReconnect) return;
window.clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000);
this.reconnectTimer = window.setTimeout(() => this.open(), delay);
}
private async handleMessage(data: MessageEvent["data"]): Promise<void> {
const event = await parseSocketEvent(data);
if (isGlobalSessionEvent(event)) this.onEvent?.(event);
}
}
function isSessionUiEvent(event: any): event is SessionUiEvent {
return ["assistant.delta", "tool.start", "tool.end", "status.update", "session.error"].includes(event?.type);
return ["assistant.delta", "tool.start", "tool.end", "status.update", "activity.update", "session.error"].includes(event?.type);
}
function isGlobalSessionEvent(event: unknown): event is Extract<SessionUiEvent, { type: "status.update" | "activity.update" }> {
return typeof event === "object" && event !== null && ("type" in event) && ((event as any).type === "status.update" || (event as any).type === "activity.update");
}
async function parseSocketEvent(data: MessageEvent["data"]): Promise<unknown> {
try {
if (typeof data === "string") return JSON.parse(data);
if (data instanceof Blob) return JSON.parse(await data.text());
if (data instanceof ArrayBuffer) return JSON.parse(new TextDecoder().decode(data));
return undefined;
} catch {
return undefined;
}
}
function closeSocketQuietly(socket: WebSocket | undefined): void {
if (!socket) return;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
if (socket.readyState === WebSocket.CONNECTING) {
socket.onopen = () => socket.close();
return;
}
socket.close();
}
+58 -1
View File
@@ -18,6 +18,8 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
export class PiSessionService {
private readonly active = new Map<string, ActiveSession>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService;
private readonly agentDir = getAgentDir();
private readonly authStorage = AuthStorage.create();
@@ -29,6 +31,7 @@ export class PiSessionService {
};
constructor(private readonly events: SessionEventHub) {
this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
(sessionId, text) => this.prompt(sessionId, text),
@@ -90,8 +93,11 @@ export class PiSessionService {
async prompt(sessionId: string, text: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
this.publishActivity(session, "prompt accepted", "active");
void session.prompt(text).catch((error) => {
this.events.publish(sessionId, { type: "session.error", message: error instanceof Error ? error.message : String(error) });
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message);
this.events.publish(sessionId, { type: "session.error", message });
});
}
@@ -114,6 +120,7 @@ export class PiSessionService {
active.unsubscribe();
void active.runtime.session.abort().finally(() => active.runtime.dispose());
this.active.delete(sessionId);
this.activities.delete(sessionId);
}
private async getOrOpen(sessionId: string): Promise<AgentSession> {
@@ -147,11 +154,61 @@ export class PiSessionService {
const { session } = active.runtime;
active.unsubscribe = session.subscribe((event) => {
this.events.publish(session.sessionId, toClientEvent(event));
this.publishActivityForEvent(session, event);
this.publishStatus(session);
});
this.active.set(session.sessionId, active);
}
private publishHeartbeats(): void {
for (const active of this.active.values()) {
const { session } = active.runtime;
const activity = this.activities.get(session.sessionId);
const isActive = session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0 || activity?.phase === "active";
if (!isActive) continue;
this.publishStatus(session);
if (activity) this.publishActivity(session, activity.label, "active", activity.detail);
else this.publishActivity(session, this.activityLabelFromStatus(session), "active");
}
}
private activityLabelFromStatus(session: AgentSession): string {
if (session.isCompacting) return "compacting";
if (session.isBashRunning) return "running bash";
if (session.isStreaming) return "agent running";
if (session.pendingMessageCount) return "queued";
return "active";
}
private publishActivityForEvent(session: AgentSession, event: any): void {
if (event.type === "agent_start") return this.publishActivity(session, "agent running", "active");
if (event.type === "agent_end") {
this.publishActivity(session, "idle", "idle");
setTimeout(() => {
this.publishActivity(session, "idle", "idle");
this.publishStatus(session);
}, 250);
return;
}
if (event.type === "turn_end") return this.publishActivity(session, "turn complete", "active");
if (event.type === "message_start") return this.publishActivity(session, "message started", "active");
if (event.type === "message_end") return this.publishActivity(session, "message complete", "idle");
if (event.type === "message_update") return this.publishActivity(session, "receiving response", "active");
if (event.type === "tool_execution_start") return this.publishActivity(session, "running tool", "active", event.toolName);
if (event.type === "tool_execution_end") return this.publishActivity(session, event.isError ? "tool failed" : "tool complete", event.isError ? "error" : "active", event.toolName);
if (event.type === "bash_execution_start") return this.publishActivity(session, "running bash", "active");
if (event.type === "bash_execution_end") return this.publishActivity(session, "bash complete", "active");
this.publishActivity(session, event.type.replaceAll("_", " "), "active");
}
private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
const at = new Date().toISOString();
this.activities.set(session.sessionId, { phase, label, detail, at });
const activity = { sessionId: session.sessionId, phase, label, detail, at };
this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity });
}
private publishStatus(session: AgentSession): void {
const status = this.statusFromSession(session);
this.events.publish(session.sessionId, { type: "status.update", status });