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();
}