Add realtime terminal tab badges

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 10:45:56 +02:00
parent 045f4e247a
commit 80cf879e0f
16 changed files with 219 additions and 27 deletions
+2 -2
View File
@@ -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";
+4
View File
@@ -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}`;
+2
View File
@@ -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: "",
};
}
+87 -6
View File
@@ -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(); }));
}
+16 -3
View File
@@ -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,
+4 -1
View File
@@ -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) => {
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);
});
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() {
+1
View File
@@ -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,
},
];
+2
View File
@@ -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;
}
+63 -3
View File
@@ -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;