Improve session reconnect behavior

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 13:00:10 +02:00
parent fd28af3c93
commit ad05497319
10 changed files with 114 additions and 15 deletions
+9 -1
View File
@@ -90,6 +90,14 @@ export const api = {
}; };
export function sessionEvents(sessionId: string): WebSocket { export function sessionEvents(sessionId: string): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
}
export function globalSessionEvents(): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
}
function webSocketBaseUrl(): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(`${protocol}//${location.host}/api/sessions/${sessionId}/events`); return `${protocol}//${location.host}`;
} }
+2
View File
@@ -10,6 +10,7 @@ export interface AppState {
selectedWorkspace?: Workspace; selectedWorkspace?: Workspace;
selectedSession?: SessionInfo; selectedSession?: SessionInfo;
status?: SessionStatus; status?: SessionStatus;
sessionStatuses: Record<string, SessionStatus>;
commandDialog?: Extract<CommandResult, { type: "select" }>; commandDialog?: Extract<CommandResult, { type: "select" }>;
error: string; error: string;
} }
@@ -20,6 +21,7 @@ export function initialAppState(): AppState {
workspaces: [], workspaces: [],
sessions: [], sessions: [],
messages: [], messages: [],
sessionStatuses: {},
error: "", error: "",
}; };
} }
+5 -1
View File
@@ -12,6 +12,7 @@ import "./SessionList";
import "./ChatView"; import "./ChatView";
import type { ChatView } from "./ChatView"; import type { ChatView } from "./ChatView";
import "./PromptEditor"; import "./PromptEditor";
import type { PromptEditor } from "./PromptEditor";
import "./StatusBar"; import "./StatusBar";
import "./CommandPicker"; import "./CommandPicker";
import { appStyles } from "./shared"; import { appStyles } from "./shared";
@@ -20,6 +21,7 @@ import { appStyles } from "./shared";
export class PiWebApp extends LitElement { export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView; @query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor;
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
@@ -42,6 +44,7 @@ export class PiWebApp extends LitElement {
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
window.addEventListener("popstate", this.onPopState); window.addEventListener("popstate", this.onPopState);
this.sessions.connectStatusUpdates();
void this.loadProjectsAndRestoreRoute(); void this.loadProjectsAndRestoreRoute();
} }
@@ -75,6 +78,7 @@ export class PiWebApp extends LitElement {
await this.chatView?.updateComplete; await this.chatView?.updateComplete;
await nextFrame(); await nextFrame();
this.chatView?.restoreScrollPosition(); this.chatView?.restoreScrollPosition();
this.promptEditor?.focusInput();
} }
private updateUrl() { private updateUrl() {
@@ -96,7 +100,7 @@ export class PiWebApp extends LitElement {
</header> </header>
<project-list .projects=${state.projects} .selected=${state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))}></project-list> <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> <workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .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} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))}></session-list>
</aside> </aside>
<main> <main>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
+7 -2
View File
@@ -1,5 +1,5 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type SlashCommand } from "../api"; import { api, type FileSuggestion, type SlashCommand } from "../api";
import { promptEditorStyles, type CompletionItem } from "./shared"; import { promptEditorStyles, type CompletionItem } from "./shared";
import "./AutocompleteMenu"; import "./AutocompleteMenu";
@@ -11,6 +11,7 @@ export class PromptEditor extends LitElement {
@property() cwd?: string; @property() cwd?: string;
@property({ attribute: false }) onSend?: (text: string) => void; @property({ attribute: false }) onSend?: (text: string) => void;
@property({ attribute: false }) onCloseSession?: () => void; @property({ attribute: false }) onCloseSession?: () => void;
@query("textarea") private textarea?: HTMLTextAreaElement;
@state() private draft = ""; @state() private draft = "";
@state() private completions: CompletionItem[] = []; @state() private completions: CompletionItem[] = [];
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
@@ -30,11 +31,15 @@ export class PromptEditor extends LitElement {
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu>
</div> </div>
<button ?disabled=${this.disabled} @click=${this.send}>Send</button> <button ?disabled=${this.disabled} @click=${this.send}>Send</button>
<button ?disabled=${this.disabled} @click=${() => this.onCloseSession?.()}>Close</button> <button ?disabled=${this.disabled} title="Stop this session runtime on the server" @click=${() => this.onCloseSession?.()}>Stop</button>
</footer> </footer>
`; `;
} }
focusInput() {
this.textarea?.focus();
}
private updateDraft(value: string) { private updateDraft(value: string) {
this.draft = value; this.draft = value;
void this.refreshCompletions(); void this.refreshCompletions();
+13 -2
View File
@@ -1,11 +1,12 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import type { SessionInfo } from "../api"; import type { SessionInfo, SessionStatus } from "../api";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@customElement("session-list") @customElement("session-list")
export class SessionList extends LitElement { export class SessionList extends LitElement {
@property({ attribute: false }) sessions: SessionInfo[] = []; @property({ attribute: false }) sessions: SessionInfo[] = [];
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) selected?: SessionInfo; @property({ attribute: false }) selected?: SessionInfo;
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@@ -17,12 +18,22 @@ export class SessionList extends LitElement {
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2> <h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
${this.sessions.map((session) => html` ${this.sessions.map((session) => html`
<button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}> <button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}>
<span>${session.name || session.firstMessage || session.id.slice(0, 8)}</span><small>${session.messageCount} messages</small> <span>${session.name || session.firstMessage || session.id.slice(0, 8)}</span><small>${this.renderStatus(session)}${session.messageCount} messages</small>
</button> </button>
`)} `)}
</section> </section>
`; `;
} }
private renderStatus(session: SessionInfo) {
const status = this.statuses[session.id];
if (!status) return "";
if (status.isStreaming) return "● streaming · ";
if (status.isBashRunning) return "● bash · ";
if (status.isCompacting) return "● compacting · ";
if (status.pendingMessageCount) return `${status.pendingMessageCount} pending · `;
return "";
}
static styles = listStyles; static styles = listStyles;
} }
@@ -1,15 +1,21 @@
import { api, type CommandResult, type SessionInfo } from "../api"; import { api, type CommandResult, type SessionInfo, type SessionStatus } from "../api";
import { appendText, normalizeMessages, textMessage } from "../chatMessages"; import { appendText, normalizeMessages, textMessage } from "../chatMessages";
import { SessionSocket, type SessionUiEvent } from "../sessionSocket"; import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket";
import type { GetState, SetState, UpdateUrl } from "./types"; import type { GetState, SetState, UpdateUrl } from "./types";
export class SessionController { export class SessionController {
private readonly socket = new SessionSocket(); private readonly socket = new SessionSocket();
private readonly globalSocket = new GlobalSessionSocket();
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
connectStatusUpdates() {
this.globalSocket.connect((event) => this.applyStatus(event.status));
}
dispose() { dispose() {
this.socket.close(); this.socket.close();
this.globalSocket.close();
} }
clearActiveSession() { clearActiveSession() {
@@ -32,8 +38,13 @@ export class SessionController {
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) {
this.socket.close(); this.socket.close();
try { try {
this.setState({ selectedSession: session, messages: normalizeMessages(await api.messages(session.id)), status: await api.status(session.id) }); const buffered: SessionUiEvent[] = [];
this.socket.connect(session.id, (event) => this.applyEvent(event)); this.socket.connect(session.id, (event) => buffered.push(event));
const [messages, status] = await Promise.all([api.messages(session.id), api.status(session.id)]);
this.setState({ selectedSession: session, messages: normalizeMessages(messages), status });
this.applyStatus(status);
for (const event of buffered) this.applyEvent(event);
this.socket.setHandler((event) => this.applyEvent(event));
if (options?.updateUrl !== false) this.updateUrl(); if (options?.updateUrl !== false) this.updateUrl();
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
@@ -104,6 +115,13 @@ export class SessionController {
} }
} }
private applyStatus(status: SessionStatus) {
this.setState({
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
status: this.getState().selectedSession?.id === status.sessionId ? status : this.getState().status,
});
}
private applyEvent(event: SessionUiEvent) { private applyEvent(event: SessionUiEvent) {
const messages = this.getState().messages; const messages = this.getState().messages;
if (event.type === "assistant.delta") { if (event.type === "assistant.delta") {
@@ -113,7 +131,7 @@ export class SessionController {
} else if (event.type === "tool.end") { } else if (event.type === "tool.end") {
this.setState({ messages: [...messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)] }); this.setState({ messages: [...messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)] });
} else if (event.type === "status.update") { } else if (event.type === "status.update") {
this.setState({ status: event.status }); this.applyStatus(event.status);
} else if (event.type === "session.error") { } else if (event.type === "session.error") {
this.setState({ messages: [...messages, textMessage("system", event.message)] }); this.setState({ messages: [...messages, textMessage("system", event.message)] });
} }
+30 -2
View File
@@ -1,4 +1,4 @@
import { sessionEvents, type SessionStatus } from "./api"; import { globalSessionEvents, sessionEvents, type SessionStatus } from "./api";
export type SessionUiEvent = export type SessionUiEvent =
| { type: "assistant.delta"; text: string } | { type: "assistant.delta"; text: string }
@@ -9,13 +9,40 @@ export type SessionUiEvent =
export class SessionSocket { export class SessionSocket {
private socket?: WebSocket; private socket?: WebSocket;
private onEvent?: (event: SessionUiEvent) => void;
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void { connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void {
this.close(); this.close();
this.onEvent = onEvent;
this.socket = sessionEvents(sessionId); this.socket = sessionEvents(sessionId);
this.socket.onmessage = (message) => this.handleMessage(message.data);
}
setHandler(onEvent: (event: SessionUiEvent) => void): void {
this.onEvent = onEvent;
}
close(): void {
this.socket?.close();
this.socket = undefined;
this.onEvent = undefined;
}
private handleMessage(data: string): void {
const event = JSON.parse(data);
if (isSessionUiEvent(event)) this.onEvent?.(event);
}
}
export class GlobalSessionSocket {
private socket?: WebSocket;
connect(onEvent: (event: Extract<SessionUiEvent, { type: "status.update" }>) => void): void {
this.close();
this.socket = globalSessionEvents();
this.socket.onmessage = (message) => { this.socket.onmessage = (message) => {
const event = JSON.parse(message.data); const event = JSON.parse(message.data);
if (isSessionUiEvent(event)) onEvent(event); if (event?.type === "status.update") onEvent(event);
}; };
} }
@@ -28,3 +55,4 @@ export class SessionSocket {
function isSessionUiEvent(event: any): event is SessionUiEvent { 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", "session.error"].includes(event?.type);
} }
+4
View File
@@ -113,6 +113,10 @@ app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", {
eventHub.add(request.params.sessionId, socket); eventHub.add(request.params.sessionId, socket);
}); });
app.get("/api/sessions/events", { websocket: true }, (socket) => {
eventHub.addGlobal(socket);
});
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => { app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => {
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" });
try { try {
+13
View File
@@ -2,6 +2,7 @@ import type { WebSocket } from "ws";
export class SessionEventHub { export class SessionEventHub {
private readonly socketsBySession = new Map<string, Set<WebSocket>>(); private readonly socketsBySession = new Map<string, Set<WebSocket>>();
private readonly globalSockets = new Set<WebSocket>();
add(sessionId: string, socket: WebSocket): void { add(sessionId: string, socket: WebSocket): void {
let sockets = this.socketsBySession.get(sessionId); let sockets = this.socketsBySession.get(sessionId);
@@ -13,10 +14,22 @@ export class SessionEventHub {
socket.on("close", () => sockets?.delete(socket)); socket.on("close", () => sockets?.delete(socket));
} }
addGlobal(socket: WebSocket): void {
this.globalSockets.add(socket);
socket.on("close", () => this.globalSockets.delete(socket));
}
publish(sessionId: string, event: unknown): void { publish(sessionId: string, event: unknown): void {
const payload = JSON.stringify(event); const payload = JSON.stringify(event);
for (const socket of this.socketsBySession.get(sessionId) ?? []) { for (const socket of this.socketsBySession.get(sessionId) ?? []) {
if (socket.readyState === socket.OPEN) socket.send(payload); if (socket.readyState === socket.OPEN) socket.send(payload);
} }
} }
publishGlobal(event: unknown): void {
const payload = JSON.stringify(event);
for (const socket of this.globalSockets) {
if (socket.readyState === socket.OPEN) socket.send(payload);
}
}
} }
+8 -2
View File
@@ -135,7 +135,7 @@ export class PiSessionService {
this.bindRuntime(active); this.bindRuntime(active);
runtime.setRebindSession(async () => this.bindRuntime(active)); runtime.setRebindSession(async () => this.bindRuntime(active));
this.active.set(runtime.session.sessionId, active); this.active.set(runtime.session.sessionId, active);
this.events.publish(runtime.session.sessionId, { type: "status.update", status: this.statusFromSession(runtime.session) }); this.publishStatus(runtime.session);
return active; return active;
} }
@@ -147,11 +147,17 @@ export class PiSessionService {
const { session } = active.runtime; const { session } = active.runtime;
active.unsubscribe = session.subscribe((event) => { active.unsubscribe = session.subscribe((event) => {
this.events.publish(session.sessionId, toClientEvent(event)); this.events.publish(session.sessionId, toClientEvent(event));
this.events.publish(session.sessionId, { type: "status.update", status: this.statusFromSession(session) }); this.publishStatus(session);
}); });
this.active.set(session.sessionId, active); this.active.set(session.sessionId, active);
} }
private publishStatus(session: AgentSession): void {
const status = this.statusFromSession(session);
this.events.publish(session.sessionId, { type: "status.update", status });
this.events.publishGlobal({ type: "status.update", status });
}
private statusFromSession(session: AgentSession): ClientSessionStatus { private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats(); const stats = session.getSessionStats();
return { return {