Archived
Fix live session UI updates
This commit is contained in:
@@ -26,6 +26,14 @@ export interface SessionInfo {
|
|||||||
firstMessage: string;
|
firstMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionActivity {
|
||||||
|
sessionId: string;
|
||||||
|
phase: "active" | "idle" | "error";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionStatus {
|
export interface SessionStatus {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
|
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
|
||||||
|
|||||||
@@ -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";
|
import type { ChatLine } from "./components/shared";
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -10,7 +10,9 @@ export interface AppState {
|
|||||||
selectedWorkspace?: Workspace;
|
selectedWorkspace?: Workspace;
|
||||||
selectedSession?: SessionInfo;
|
selectedSession?: SessionInfo;
|
||||||
status?: SessionStatus;
|
status?: SessionStatus;
|
||||||
|
activity?: SessionActivity;
|
||||||
sessionStatuses: Record<string, SessionStatus>;
|
sessionStatuses: Record<string, SessionStatus>;
|
||||||
|
sessionActivities: Record<string, SessionActivity>;
|
||||||
commandDialog?: Extract<CommandResult, { type: "select" }>;
|
commandDialog?: Extract<CommandResult, { type: "select" }>;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,7 @@ export function initialAppState(): AppState {
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionStatuses: {},
|
sessionStatuses: {},
|
||||||
|
sessionActivities: {},
|
||||||
error: "",
|
error: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,12 @@ 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} .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>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
${state.selectedSession ? html`
|
${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>
|
<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>
|
<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}
|
${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}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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, SessionStatus } from "../api";
|
import type { SessionActivity, 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 }) statuses: Record<string, SessionStatus> = {};
|
||||||
|
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||||
@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;
|
||||||
@@ -27,6 +28,8 @@ export class SessionList extends LitElement {
|
|||||||
|
|
||||||
private renderStatus(session: SessionInfo) {
|
private renderStatus(session: SessionInfo) {
|
||||||
const status = this.statuses[session.id];
|
const status = this.statuses[session.id];
|
||||||
|
const activity = this.activities[session.id];
|
||||||
|
if (activity?.phase === "active") return `● ${activity.label} · `;
|
||||||
if (!status) return "";
|
if (!status) return "";
|
||||||
if (status.isStreaming) return "● streaming · ";
|
if (status.isStreaming) return "● streaming · ";
|
||||||
if (status.isBashRunning) return "● bash · ";
|
if (status.isBashRunning) return "● bash · ";
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 { SessionStatus, Workspace } from "../api";
|
import type { SessionActivity, SessionStatus, Workspace } from "../api";
|
||||||
import { formatCost, formatTokenCount } from "../utils/format";
|
import { formatCost, formatTokenCount } from "../utils/format";
|
||||||
import { statusBarStyles } from "./shared";
|
import { statusBarStyles } from "./shared";
|
||||||
|
|
||||||
@customElement("status-bar")
|
@customElement("status-bar")
|
||||||
export class StatusBar extends LitElement {
|
export class StatusBar extends LitElement {
|
||||||
@property({ attribute: false }) status?: SessionStatus;
|
@property({ attribute: false }) status?: SessionStatus;
|
||||||
|
@property({ attribute: false }) activity?: SessionActivity;
|
||||||
@property({ attribute: false }) workspace?: Workspace;
|
@property({ attribute: false }) workspace?: Workspace;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -14,7 +15,8 @@ export class StatusBar extends LitElement {
|
|||||||
if (!status) return html`<div class="bar muted">No session status yet</div>`;
|
if (!status) return html`<div class="bar muted">No session status yet</div>`;
|
||||||
const model = status.model?.id ?? "no model";
|
const model = status.model?.id ?? "no model";
|
||||||
const provider = status.model?.provider ? `${status.model.provider}/` : "";
|
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 context = status.contextUsage;
|
||||||
const contextText = context
|
const contextText = context
|
||||||
? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
||||||
@@ -23,7 +25,7 @@ export class StatusBar extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
|
<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>${provider}${model}</span>
|
||||||
<span>thinking ${status.thinkingLevel ?? "off"}</span>
|
<span>thinking ${status.thinkingLevel ?? "off"}</span>
|
||||||
<span>↑${formatTokenCount(tokens.input)}</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;
|
static styles = statusBarStyles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,13 @@ export const statusBarStyles = css`
|
|||||||
:host { display: block; color: #8b949e; font: 12px system-ui, sans-serif; }
|
: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; }
|
.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 { 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; }
|
.muted { color: #6e7681; }
|
||||||
|
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const autocompleteStyles = css`
|
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 { appendText, normalizeMessages, textMessage } from "../chatMessages";
|
||||||
import { GlobalSessionSocket, 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";
|
||||||
@@ -10,7 +10,10 @@ export class SessionController {
|
|||||||
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() {
|
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() {
|
dispose() {
|
||||||
@@ -20,7 +23,7 @@ export class SessionController {
|
|||||||
|
|
||||||
clearActiveSession() {
|
clearActiveSession() {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.setState({ selectedSession: undefined, messages: [], status: undefined });
|
this.setState({ selectedSession: undefined, messages: [], status: undefined, activity: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async startSession() {
|
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) {
|
private applyStatus(status: SessionStatus) {
|
||||||
this.setState({
|
this.setState({
|
||||||
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
|
sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status },
|
||||||
@@ -132,6 +142,8 @@ export class SessionController {
|
|||||||
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.applyStatus(event.status);
|
this.applyStatus(event.status);
|
||||||
|
} else if (event.type === "activity.update") {
|
||||||
|
this.applyActivity(event.activity);
|
||||||
} 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)] });
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-14
@@ -1,21 +1,27 @@
|
|||||||
import { globalSessionEvents, sessionEvents, type SessionStatus } from "./api";
|
import { globalSessionEvents, sessionEvents, type SessionActivity, type SessionStatus } from "./api";
|
||||||
|
|
||||||
export type SessionUiEvent =
|
export type SessionUiEvent =
|
||||||
| { type: "assistant.delta"; text: string }
|
| { type: "assistant.delta"; text: string }
|
||||||
| { type: "tool.start"; toolName: string }
|
| { type: "tool.start"; toolName: string }
|
||||||
| { type: "tool.end"; toolName: string; isError: boolean }
|
| { type: "tool.end"; toolName: string; isError: boolean }
|
||||||
| { type: "status.update"; status: SessionStatus }
|
| { type: "status.update"; status: SessionStatus }
|
||||||
|
| { type: "activity.update"; activity: SessionActivity }
|
||||||
| { type: "session.error"; message: string };
|
| { type: "session.error"; message: string };
|
||||||
|
|
||||||
export class SessionSocket {
|
export class SessionSocket {
|
||||||
private socket?: WebSocket;
|
private socket?: WebSocket;
|
||||||
|
private sessionId?: string;
|
||||||
private onEvent?: (event: SessionUiEvent) => void;
|
private onEvent?: (event: SessionUiEvent) => void;
|
||||||
|
private reconnectTimer?: number;
|
||||||
|
private reconnectDelay = 500;
|
||||||
|
private shouldReconnect = false;
|
||||||
|
|
||||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void {
|
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.sessionId = sessionId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.socket = sessionEvents(sessionId);
|
this.shouldReconnect = true;
|
||||||
this.socket.onmessage = (message) => this.handleMessage(message.data);
|
this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
setHandler(onEvent: (event: SessionUiEvent) => void): void {
|
setHandler(onEvent: (event: SessionUiEvent) => void): void {
|
||||||
@@ -23,36 +29,121 @@ export class SessionSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.socket?.close();
|
this.shouldReconnect = false;
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
closeSocketQuietly(this.socket);
|
||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
|
this.sessionId = undefined;
|
||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(data: string): void {
|
private open(): void {
|
||||||
const event = JSON.parse(data);
|
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);
|
if (isSessionUiEvent(event)) this.onEvent?.(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GlobalSessionSocket {
|
export class GlobalSessionSocket {
|
||||||
private socket?: WebSocket;
|
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.close();
|
||||||
this.socket = globalSessionEvents();
|
this.onEvent = onEvent;
|
||||||
this.socket.onmessage = (message) => {
|
this.shouldReconnect = true;
|
||||||
const event = JSON.parse(message.data);
|
this.open();
|
||||||
if (event?.type === "status.update") onEvent(event);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.socket?.close();
|
this.shouldReconnect = false;
|
||||||
|
window.clearTimeout(this.reconnectTimer);
|
||||||
|
closeSocketQuietly(this.socket);
|
||||||
this.socket = undefined;
|
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 {
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
|
|||||||
|
|
||||||
export class PiSessionService {
|
export class PiSessionService {
|
||||||
private readonly active = new Map<string, ActiveSession>();
|
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 commandService: SessionCommandService;
|
||||||
private readonly agentDir = getAgentDir();
|
private readonly agentDir = getAgentDir();
|
||||||
private readonly authStorage = AuthStorage.create();
|
private readonly authStorage = AuthStorage.create();
|
||||||
@@ -29,6 +31,7 @@ export class PiSessionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor(private readonly events: SessionEventHub) {
|
constructor(private readonly events: SessionEventHub) {
|
||||||
|
this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000);
|
||||||
this.commandService = new SessionCommandService(
|
this.commandService = new SessionCommandService(
|
||||||
(sessionId) => this.getActive(sessionId),
|
(sessionId) => this.getActive(sessionId),
|
||||||
(sessionId, text) => this.prompt(sessionId, text),
|
(sessionId, text) => this.prompt(sessionId, text),
|
||||||
@@ -90,8 +93,11 @@ export class PiSessionService {
|
|||||||
|
|
||||||
async prompt(sessionId: string, text: string): Promise<void> {
|
async prompt(sessionId: string, text: string): Promise<void> {
|
||||||
const session = await this.getOrOpen(sessionId);
|
const session = await this.getOrOpen(sessionId);
|
||||||
|
this.publishActivity(session, "prompt accepted", "active");
|
||||||
void session.prompt(text).catch((error) => {
|
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();
|
active.unsubscribe();
|
||||||
void active.runtime.session.abort().finally(() => active.runtime.dispose());
|
void active.runtime.session.abort().finally(() => active.runtime.dispose());
|
||||||
this.active.delete(sessionId);
|
this.active.delete(sessionId);
|
||||||
|
this.activities.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
||||||
@@ -147,11 +154,61 @@ 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.publishActivityForEvent(session, event);
|
||||||
this.publishStatus(session);
|
this.publishStatus(session);
|
||||||
});
|
});
|
||||||
this.active.set(session.sessionId, active);
|
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 {
|
private publishStatus(session: AgentSession): void {
|
||||||
const status = this.statusFromSession(session);
|
const status = this.statusFromSession(session);
|
||||||
this.events.publish(session.sessionId, { type: "status.update", status });
|
this.events.publish(session.sessionId, { type: "status.update", status });
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:3000",
|
"/api": { target: "http://localhost:3000", ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user