Archived
Add realtime terminal tab badges
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
|
||||
|
||||
@@ -10,6 +10,10 @@ export function terminalSocket(projectId: string, workspaceId: string, terminalI
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket`);
|
||||
}
|
||||
|
||||
export function realtimeEvents(): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/events`);
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AppState {
|
||||
selectedDiff: GitDiffResponse | undefined;
|
||||
selectedStagedDiff: GitDiffResponse | undefined;
|
||||
gitStale: boolean;
|
||||
activeTerminalCount: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ export function initialAppState(): AppState {
|
||||
selectedDiff: undefined,
|
||||
selectedStagedDiff: undefined,
|
||||
gitStale: false,
|
||||
activeTerminalCount: 0,
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import type { Project, SessionInfo, ThinkingLevel, Workspace } from "../api";
|
||||
import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
@@ -9,7 +9,8 @@ import { ProjectController } from "../controllers/projectController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController } from "../controllers/workspaceController";
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext } from "../plugins/types";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types";
|
||||
import { corePlugin } from "../plugins/core";
|
||||
import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry } from "../plugins/registry";
|
||||
@@ -62,6 +63,8 @@ export class PiWebApp extends LitElement {
|
||||
() => { this.updateUrl(); },
|
||||
);
|
||||
private readonly keyboard = new KeyboardShortcutDispatcher();
|
||||
private readonly realtime = new RealtimeSocket();
|
||||
private readonly activeTerminalIds = new Set<string>();
|
||||
private readonly plugins = createPluginRegistry();
|
||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -75,7 +78,7 @@ export class PiWebApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
this.sessions.connectStatusUpdates();
|
||||
this.connectRealtime();
|
||||
void this.loadExternalPlugins();
|
||||
void this.loadProjectsAndRestoreRoute();
|
||||
}
|
||||
@@ -85,6 +88,7 @@ export class PiWebApp extends LitElement {
|
||||
window.removeEventListener("keydown", this.onKeyDown);
|
||||
this.keyboard.reset();
|
||||
this.sessions.dispose();
|
||||
this.realtime.close();
|
||||
this.git.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -167,11 +171,54 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private handleWorkspaceChange(previous: AppState, next: AppState) {
|
||||
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return;
|
||||
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return;
|
||||
this.activeTerminalIds.clear();
|
||||
this.setState({ activeTerminalCount: 0 });
|
||||
if (next.selectedWorkspace === undefined) return;
|
||||
void this.refreshActiveTerminals(next.selectedWorkspace);
|
||||
this.refreshSelectedWorkspaceTool(next.workspaceTool);
|
||||
this.git.updatePolling();
|
||||
}
|
||||
|
||||
private connectRealtime(): void {
|
||||
this.realtime.connect(
|
||||
(event) => { this.handleRealtimeEvent(event); },
|
||||
() => {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private handleRealtimeEvent(event: RealtimeEvent): void {
|
||||
if (isTerminalEvent(event)) this.applyTerminalEvent(event);
|
||||
else this.sessions.applyGlobalEvent(event);
|
||||
}
|
||||
|
||||
private applyTerminalEvent(event: TerminalUiEvent): void {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace === undefined) return;
|
||||
const cwd = event.type === "terminal.closed" ? event.cwd : event.terminal.cwd;
|
||||
if (cwd !== workspace.path) return;
|
||||
if (event.type === "terminal.created" && !event.terminal.exited) this.activeTerminalIds.add(event.terminal.id);
|
||||
else this.activeTerminalIds.delete(event.type === "terminal.closed" ? event.terminalId : event.terminal.id);
|
||||
this.setState({ activeTerminalCount: this.activeTerminalIds.size });
|
||||
}
|
||||
|
||||
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
||||
try {
|
||||
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id);
|
||||
if (this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||
this.activeTerminalIds.clear();
|
||||
for (const terminal of terminals) {
|
||||
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
||||
}
|
||||
this.setState({ activeTerminalCount: this.activeTerminalIds.size });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private handleActivityTransition(previous: AppState, next: AppState) {
|
||||
const wasActive = isActive(previous.status);
|
||||
const nowActive = isActive(next.status);
|
||||
@@ -188,7 +235,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private renderWorkspacePanel(hideToolTabs = false) {
|
||||
const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace);
|
||||
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .hideToolTabs=${hideToolTabs} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: QualifiedContributionId) => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
|
||||
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .hideToolTabs=${hideToolTabs} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .onSelectTool=${(tool: QualifiedContributionId) => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
|
||||
}
|
||||
|
||||
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
||||
@@ -213,6 +260,36 @@ export class PiWebApp extends LitElement {
|
||||
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.(workspace) ?? true));
|
||||
}
|
||||
|
||||
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace === undefined) return panel.title;
|
||||
const badge = panel.badge?.(this.createWorkspacePanelContext(workspace));
|
||||
if (badge === undefined || badge === "") return panel.title;
|
||||
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
|
||||
}
|
||||
|
||||
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||
return {
|
||||
workspace,
|
||||
fileTree: this.state.fileTree,
|
||||
expandedDirs: this.state.expandedDirs,
|
||||
selectedFilePath: this.state.selectedFilePath,
|
||||
selectedFileContent: this.state.selectedFileContent,
|
||||
fileTreeStale: this.state.fileTreeStale,
|
||||
gitStatus: this.state.gitStatus,
|
||||
selectedDiffPath: this.state.selectedDiffPath,
|
||||
selectedDiff: this.state.selectedDiff,
|
||||
selectedStagedDiff: this.state.selectedStagedDiff,
|
||||
gitStale: this.state.gitStale,
|
||||
activeTerminalCount: this.state.activeTerminalCount,
|
||||
onRefreshFiles: () => this.files.refreshFiles(),
|
||||
onExpandDir: (path: string) => this.files.expandDir(path),
|
||||
onSelectFile: (path: string) => this.files.selectFile(path),
|
||||
onRefreshGit: () => this.git.refreshGit(),
|
||||
onSelectDiff: (path: string) => this.git.selectDiff(path),
|
||||
};
|
||||
}
|
||||
|
||||
private getActions(): AppAction[] {
|
||||
return this.plugins.getActions(this.createPluginRuntimeContext());
|
||||
}
|
||||
@@ -299,7 +376,7 @@ export class PiWebApp extends LitElement {
|
||||
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
|
||||
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
|
||||
${this.visibleWorkspacePanels().map((panel) => html`
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.selectMainView(panel.id); }}>${panel.title}</button>
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.selectMainView(panel.id); }}>${this.renderMobilePanelTitle(panel)}</button>
|
||||
`)}
|
||||
</div>
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
@@ -334,6 +411,10 @@ function isActive(status: AppState["status"]): boolean {
|
||||
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
|
||||
}
|
||||
|
||||
function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
||||
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { LitElement, html, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
@@ -28,27 +28,39 @@ export class WorkspacePanel extends LitElement {
|
||||
@property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined;
|
||||
@property({ attribute: false }) onRefreshGit: () => void = () => undefined;
|
||||
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
|
||||
@property({ type: Number }) activeTerminalCount = 0;
|
||||
|
||||
override render() {
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
|
||||
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
|
||||
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
|
||||
const context = this.createPanelContext(workspace);
|
||||
return html`
|
||||
<header>
|
||||
${this.hideToolTabs ? null : html`
|
||||
<div class="tabs">
|
||||
${visiblePanels.map((panel) => html`
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${panel.title}</button>
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${this.renderPanelTitle(panel, context)}</button>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
|
||||
</header>
|
||||
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(workspace))}
|
||||
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : html`
|
||||
<div class="panel-content">
|
||||
${selectedPanel.render(context)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPanelTitle(panel: QualifiedWorkspacePanelContribution, context: WorkspacePanelContext): TemplateResult {
|
||||
const badge = panel.badge?.(context);
|
||||
if (badge === undefined || badge === "") return html`${panel.title}`;
|
||||
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
|
||||
}
|
||||
|
||||
private createPanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||
return {
|
||||
workspace,
|
||||
@@ -62,6 +74,7 @@ export class WorkspacePanel extends LitElement {
|
||||
selectedDiff: this.selectedDiff,
|
||||
selectedStagedDiff: this.selectedStagedDiff,
|
||||
gitStale: this.gitStale,
|
||||
activeTerminalCount: this.activeTerminalCount,
|
||||
onRefreshFiles: this.onRefreshFiles,
|
||||
onExpandDir: this.onExpandDir,
|
||||
onSelectFile: this.onSelectFile,
|
||||
|
||||
@@ -39,6 +39,7 @@ export const appStyles = css`
|
||||
.mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid #30363d; overflow-x: auto; }
|
||||
.mobile-navigation-tab, .mobile-navigation-panel, .mobile-panel { display: none; }
|
||||
.mobile-tabs button.selected { border-color: #58a6ff; background: #0d2847; }
|
||||
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid #30363d; overflow: hidden; }
|
||||
@media (max-width: 1180px) {
|
||||
.shell { grid-template-columns: 340px minmax(0, 1fr); }
|
||||
@@ -70,8 +71,10 @@ export const workspacePanelStyles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; color: #e6edf3; background: #0d1117; font: 13px system-ui, sans-serif; }
|
||||
header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #30363d; }
|
||||
.tabs { display: flex; gap: 6px; }
|
||||
button { border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; }
|
||||
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: #58a6ff; background: #0d2847; }
|
||||
.tab-badge { display: inline-block; min-width: 14px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
.panel-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
small, .muted { color: #8b949e; }
|
||||
header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
|
||||
@@ -5,29 +5,25 @@ import { normalizeMessages, textMessage } from "../chatMessages";
|
||||
import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type RawMessagePage } from "../chatHistoryCache";
|
||||
import { applyTranscriptEvent } from "../chatTranscript";
|
||||
import { isShellInput } from "../inputModes";
|
||||
import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket";
|
||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||
import { markSessionArchived, selectionAfterArchivingSession } from "./sessionSelection";
|
||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||
|
||||
export class SessionController {
|
||||
private readonly socket = new SessionSocket();
|
||||
private readonly globalSocket = new GlobalSessionSocket();
|
||||
private selectionSeq = 0;
|
||||
private catchupStreamSessionId: string | undefined;
|
||||
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
|
||||
|
||||
connectStatusUpdates() {
|
||||
this.globalSocket.connect((event) => {
|
||||
applyGlobalEvent(event: GlobalSessionEvent): void {
|
||||
if (event.type === "status.update") this.applyStatus(event.status);
|
||||
else if (event.type === "activity.update") this.applyActivity(event.activity);
|
||||
else this.applySessionName(event.sessionId, event.name);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.socket.close();
|
||||
this.globalSocket.close();
|
||||
}
|
||||
|
||||
clearActiveSession() {
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
id: "workspace.terminal",
|
||||
title: "Terminal",
|
||||
order: 30,
|
||||
badge: (context) => context.activeTerminalCount > 0 ? context.activeTerminalCount : undefined,
|
||||
render: renderTerminal,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface WorkspacePanelContext {
|
||||
selectedDiff: GitDiffResponse | undefined;
|
||||
selectedStagedDiff: GitDiffResponse | undefined;
|
||||
gitStale: boolean;
|
||||
activeTerminalCount: number;
|
||||
onRefreshFiles: () => void;
|
||||
onExpandDir: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
@@ -76,6 +77,7 @@ export interface WorkspacePanelContribution {
|
||||
title: string;
|
||||
order?: number;
|
||||
visible?: (workspace: Workspace) => boolean;
|
||||
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
|
||||
render: (context: WorkspacePanelContext) => TemplateResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { globalSessionEvents, sessionEvents } from "./api";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents } from "./api";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export class SessionSocket {
|
||||
private socket: WebSocket | undefined;
|
||||
@@ -61,6 +61,61 @@ export class SessionSocket {
|
||||
}
|
||||
}
|
||||
|
||||
export class RealtimeSocket {
|
||||
private socket: WebSocket | undefined;
|
||||
private onEvent: ((event: RealtimeEvent) => void) | undefined;
|
||||
private onOpen: (() => void) | undefined;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 500;
|
||||
private shouldReconnect = false;
|
||||
|
||||
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void {
|
||||
this.close();
|
||||
this.onEvent = onEvent;
|
||||
this.onOpen = onOpen;
|
||||
this.shouldReconnect = true;
|
||||
this.open();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.shouldReconnect = false;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
closeSocketQuietly(this.socket);
|
||||
this.socket = undefined;
|
||||
this.onEvent = undefined;
|
||||
this.onOpen = undefined;
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (!this.shouldReconnect) return;
|
||||
const socket = realtimeEvents();
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.reconnectDelay = 500;
|
||||
this.onOpen?.();
|
||||
};
|
||||
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 (isRealtimeEvent(event)) this.onEvent?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
export class GlobalSessionSocket {
|
||||
private socket: WebSocket | undefined;
|
||||
private onEvent: ((event: GlobalSessionEvent) => void) | undefined;
|
||||
@@ -122,6 +177,11 @@ function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
|
||||
return type === "status.update" || type === "activity.update" || type === "session.name";
|
||||
}
|
||||
|
||||
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
|
||||
const type = eventType(event);
|
||||
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed";
|
||||
}
|
||||
|
||||
function eventType(event: unknown): string {
|
||||
if (typeof event !== "object" || event === null || !("type" in event)) return "";
|
||||
const type = event.type;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
export class SessionEventHub {
|
||||
@@ -30,6 +30,10 @@ export class SessionEventHub {
|
||||
}
|
||||
|
||||
publishGlobal(event: GlobalSessionEvent): void {
|
||||
this.publishRealtime(event);
|
||||
}
|
||||
|
||||
publishRealtime(event: RealtimeEvent): void {
|
||||
const payload = JSON.stringify(event);
|
||||
for (const socket of this.globalSockets) {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
|
||||
@@ -15,7 +15,7 @@ await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const sessions = new PiSessionService(eventHub);
|
||||
const terminals = new TerminalService();
|
||||
const terminals = new TerminalService(eventHub);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
|
||||
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
|
||||
});
|
||||
|
||||
app.get("/api/events", { websocket: true }, (socket) => {
|
||||
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
||||
});
|
||||
|
||||
app.all("/api/sessions", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
|
||||
}
|
||||
|
||||
@@ -167,6 +167,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||
eventHub.addGlobal(socket);
|
||||
});
|
||||
|
||||
app.get(`${prefix}/events`, { websocket: true }, (socket) => {
|
||||
eventHub.addGlobal(socket);
|
||||
});
|
||||
}
|
||||
|
||||
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
|
||||
const MAX_REPLAY_BUFFER = 200_000;
|
||||
|
||||
@@ -22,6 +24,8 @@ interface TerminalRecord extends TerminalInfo {
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(private readonly events?: SessionEventHub) {}
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
return [...this.terminals.values()]
|
||||
.filter((terminal) => terminal.cwd === cwd)
|
||||
@@ -59,9 +63,12 @@ export class TerminalService {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
this.publish({ type: "terminal.exited", terminal: toInfo(record) });
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
return toInfo(record);
|
||||
const info = toInfo(record);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
|
||||
get(id: string): TerminalInfo | undefined {
|
||||
@@ -101,6 +108,7 @@ export class TerminalService {
|
||||
this.terminals.delete(id);
|
||||
terminal.events.removeAllListeners();
|
||||
if (!terminal.exited) terminal.pty.kill();
|
||||
this.publish({ type: "terminal.closed", terminalId: id, cwd: terminal.cwd });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -112,6 +120,10 @@ export class TerminalService {
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
return terminal;
|
||||
}
|
||||
|
||||
private publish(event: TerminalUiEvent): void {
|
||||
this.events?.publishRealtime(event);
|
||||
}
|
||||
}
|
||||
|
||||
function toInfo(record: TerminalRecord): TerminalInfo {
|
||||
|
||||
@@ -148,6 +148,11 @@ export interface TerminalInfo {
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export type TerminalUiEvent =
|
||||
| { type: "terminal.created"; terminal: TerminalInfo }
|
||||
| { type: "terminal.exited"; terminal: TerminalInfo }
|
||||
| { type: "terminal.closed"; terminalId: string; cwd: string };
|
||||
|
||||
export interface CommandOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -185,3 +190,4 @@ export type SessionUiEvent =
|
||||
| { type: "pi.event"; eventType: string };
|
||||
|
||||
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
|
||||
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent;
|
||||
|
||||
Reference in New Issue
Block a user