Archived
feat: show project and workspace activity indicators
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show active session and terminal activity on project and workspace rows so background work is visible from navigation.
|
||||
@@ -1,3 +1,3 @@
|
||||
export { api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
|
||||
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
parseTerminalInfo,
|
||||
parseThinkingLevelsResponse,
|
||||
parseWorkspace,
|
||||
parseWorkspaceActivityResponse,
|
||||
} from "./parsers";
|
||||
import { gitDiffUrl, messageUrl } from "./urls";
|
||||
|
||||
@@ -34,6 +35,10 @@ export const piWebApi = {
|
||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
|
||||
};
|
||||
|
||||
export const projectsApi = {
|
||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
||||
@@ -100,6 +105,7 @@ export const gitApi = {
|
||||
|
||||
export const api = {
|
||||
...piWebApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
...sessionsApi,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("accepts legacy array message pages and paged message responses", () => {
|
||||
@@ -35,6 +35,16 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace activity snapshots", () => {
|
||||
expect(parseWorkspaceActivityResponse({
|
||||
generatedAt: "now",
|
||||
workspaces: [{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "later" }],
|
||||
})).toEqual({
|
||||
generatedAt: "now",
|
||||
workspaces: [{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "later" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid enum-like fields", () => {
|
||||
expect(() => parseSlashCommand({ name: "bad", source: "remote" })).toThrow("Invalid command source");
|
||||
expect(() => parseFileSuggestion({ path: "a", kind: "deleted" })).toThrow("Invalid file kind");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
|
||||
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -298,6 +298,21 @@ export function parseTerminalInfo(value: unknown): TerminalInfo {
|
||||
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
|
||||
}
|
||||
|
||||
export function parseWorkspaceActivity(value: unknown): WorkspaceActivity {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
cwd: requireString(record, "cwd"),
|
||||
hasSessionActivity: requireBoolean(record, "hasSessionActivity"),
|
||||
hasTerminalActivity: requireBoolean(record, "hasTerminalActivity"),
|
||||
updatedAt: requireString(record, "updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivityResponse {
|
||||
const record = requireRecord(value);
|
||||
return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") };
|
||||
}
|
||||
|
||||
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/types";
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AppState {
|
||||
activity: SessionActivity | undefined;
|
||||
sessionStatuses: Record<string, SessionStatus>;
|
||||
sessionActivities: Record<string, SessionActivity>;
|
||||
workspaceActivities: Record<string, WorkspaceActivity>;
|
||||
workspacesByProjectId: Record<string, Workspace[]>;
|
||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
|
||||
@@ -66,6 +68,8 @@ export function initialAppState(): AppState {
|
||||
activity: undefined,
|
||||
sessionStatuses: {},
|
||||
sessionActivities: {},
|
||||
workspaceActivities: {},
|
||||
workspacesByProjectId: {},
|
||||
commandDialog: undefined,
|
||||
modelDialog: undefined,
|
||||
thinkingDialog: undefined,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import { piWebApi, 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 { isSessionActive } from "../../../shared/activity";
|
||||
import { ActivityController } from "../controllers/activityController";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
@@ -50,6 +52,10 @@ export class PiWebApp extends LitElement {
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
);
|
||||
private readonly activity = new ActivityController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
);
|
||||
private readonly auth = new AuthController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -95,11 +101,13 @@ export class PiWebApp extends LitElement {
|
||||
private readonly onFocus = () => {
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
}
|
||||
};
|
||||
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
|
||||
@@ -127,6 +135,7 @@ export class PiWebApp extends LitElement {
|
||||
this.connectRealtime();
|
||||
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.loadExternalPlugins();
|
||||
void this.loadProjectsAndRestoreRoute();
|
||||
}
|
||||
@@ -181,6 +190,14 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshWorkspaceActivity(): Promise<void> {
|
||||
try {
|
||||
await this.activity.refresh();
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh workspace activity", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreRoute(updateUrl: boolean) {
|
||||
const route = readRoute();
|
||||
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
|
||||
@@ -262,12 +279,14 @@ export class PiWebApp extends LitElement {
|
||||
() => {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
||||
void this.refreshWorkspaceActivity();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private handleRealtimeEvent(event: RealtimeEvent): void {
|
||||
if (isTerminalEvent(event)) this.applyTerminalEvent(event);
|
||||
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
|
||||
else if (isTerminalEvent(event)) this.applyTerminalEvent(event);
|
||||
else this.sessions.applyGlobalEvent(event);
|
||||
}
|
||||
|
||||
@@ -296,8 +315,8 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private handleActivityTransition(previous: AppState, next: AppState) {
|
||||
const wasActive = isActive(previous.status);
|
||||
const nowActive = isActive(next.status);
|
||||
const wasActive = isActive(previous);
|
||||
const nowActive = isActive(next);
|
||||
if (wasActive && !nowActive) {
|
||||
this.setState({ fileTreeStale: true, gitStale: true });
|
||||
this.refreshSelectedWorkspaceTool(this.state.workspaceTool);
|
||||
@@ -328,6 +347,8 @@ export class PiWebApp extends LitElement {
|
||||
<project-list
|
||||
.projects=${this.state.projects}
|
||||
.selected=${this.state.selectedProject}
|
||||
.activities=${this.state.workspaceActivities}
|
||||
.workspacesByProjectId=${this.state.workspacesByProjectId}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("projects")}
|
||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("projects"); }}
|
||||
@@ -340,6 +361,7 @@ export class PiWebApp extends LitElement {
|
||||
<workspace-list
|
||||
.workspaces=${this.state.workspaces}
|
||||
.selected=${this.state.selectedWorkspace}
|
||||
.activities=${this.state.workspaceActivities}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
||||
@@ -624,8 +646,8 @@ function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||
}
|
||||
|
||||
function isActive(status: AppState["status"]): boolean {
|
||||
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
|
||||
function isActive(state: Pick<AppState, "status" | "activity">): boolean {
|
||||
return isSessionActive(state.status, state.activity);
|
||||
}
|
||||
|
||||
function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Project } from "../api";
|
||||
import type { Project, Workspace, WorkspaceActivity } from "../api";
|
||||
import { projectActivityIndicator } from "../workspaceActivity";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@@ -8,6 +10,8 @@ import { listStyles } from "./shared";
|
||||
export class ProjectList extends LitElement {
|
||||
@property({ attribute: false }) projects: Project[] = [];
|
||||
@property({ attribute: false }) selected?: Project;
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||
@@ -48,7 +52,7 @@ export class ProjectList extends LitElement {
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span>${project.name}</span><small>${project.path}</small>
|
||||
<span>${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
|
||||
@@ -69,6 +73,11 @@ export class ProjectList extends LitElement {
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Projects</span><small>${this.projects.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(project: Project) {
|
||||
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active") ?? "";
|
||||
}
|
||||
|
||||
private toggleMenu(projectId: string, target: EventTarget | null) {
|
||||
if (this.openMenuProjectId === projectId) {
|
||||
this.openMenuProjectId = undefined;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@@ -144,15 +146,7 @@ export class SessionList extends LitElement {
|
||||
private renderStatus(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||
if (session.archived === true) return "read-only · ";
|
||||
const status = this.statuses[session.id];
|
||||
const activity = this.activities[session.id];
|
||||
if (activity?.phase === "active") return `● ${activity.label} · `;
|
||||
if (status === undefined) return "";
|
||||
if (status.isStreaming) return "● streaming · ";
|
||||
if (status.isBashRunning) return "● bash · ";
|
||||
if (status.isCompacting) return "● compacting · ";
|
||||
if (status.pendingMessageCount > 0) return `● ${String(status.pendingMessageCount)} pending · `;
|
||||
return "";
|
||||
return renderActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active") ?? "";
|
||||
}
|
||||
|
||||
static override styles = listStyles;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Workspace } from "../api";
|
||||
import type { Workspace, WorkspaceActivity } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
import { renderWorkspaceLabelItems } from "./workspaceLabel";
|
||||
@@ -13,6 +15,7 @@ export class WorkspaceList extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
|
||||
@@ -39,7 +42,7 @@ export class WorkspaceList extends LitElement {
|
||||
<span class="workspace-label-base">${label}</span>
|
||||
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
|
||||
</span>
|
||||
<small>${workspace.path}</small>
|
||||
<small>${this.renderActivity(workspace)}${workspace.path}</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -53,6 +56,11 @@ export class WorkspaceList extends LitElement {
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Workspaces</span><small>${this.workspaces.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(workspace: Workspace) {
|
||||
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities));
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active") ?? "";
|
||||
}
|
||||
|
||||
private scrollSelectedIntoView(): void {
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
export type ActivityIndicatorKind = "session" | "terminal";
|
||||
|
||||
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||
if (kind === undefined) return undefined;
|
||||
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
|
||||
}
|
||||
@@ -169,6 +169,9 @@ export const listStyles = css`
|
||||
.workspace-row .action-main { border-radius: 8px; }
|
||||
.tree-marker { color: var(--pi-dim); margin-right: 5px; }
|
||||
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
|
||||
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.action-menu { position: relative; align-self: stretch; }
|
||||
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||
@@ -183,6 +186,7 @@ export const listStyles = css`
|
||||
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
|
||||
.workspace-label-link { color: var(--pi-accent); text-decoration: none; }
|
||||
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
`;
|
||||
|
||||
export const chatStyles = css`
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
||||
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
||||
import type { GetState, SetState } from "./types";
|
||||
|
||||
export interface ActivityControllerDependencies {
|
||||
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
}
|
||||
|
||||
export class ActivityController {
|
||||
private readonly api: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const snapshot = await this.api.workspaceActivity();
|
||||
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
|
||||
}
|
||||
|
||||
applyWorkspaceActivity(activity: WorkspaceActivity): void {
|
||||
this.setState({ workspaceActivities: applyWorkspaceActivityToMap(this.getState().workspaceActivities, activity) });
|
||||
}
|
||||
}
|
||||
|
||||
export function indexWorkspaceActivities(snapshot: WorkspaceActivityResponse): Record<string, WorkspaceActivity> {
|
||||
const activities: Record<string, WorkspaceActivity> = {};
|
||||
for (const activity of snapshot.workspaces) {
|
||||
if (isWorkspaceActivityActive(activity)) activities[activity.cwd] = activity;
|
||||
}
|
||||
return activities;
|
||||
}
|
||||
|
||||
export function applyWorkspaceActivityToMap(current: Record<string, WorkspaceActivity>, activity: WorkspaceActivity): Record<string, WorkspaceActivity> {
|
||||
const next = { ...current };
|
||||
if (isWorkspaceActivityActive(activity)) {
|
||||
next[activity.cwd] = activity;
|
||||
return next;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(next).filter(([cwd]) => cwd !== activity.cwd));
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export class ProjectController {
|
||||
async loadProjects() {
|
||||
this.setState({ error: "" });
|
||||
try {
|
||||
this.setState({ projects: await api.projects() });
|
||||
const projects = await api.projects();
|
||||
const projectIds = new Set(projects.map((project) => project.id));
|
||||
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
|
||||
this.setState({ projects, workspacesByProjectId });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export class WorkspaceController {
|
||||
|
||||
forgetProject(projectId: string): void {
|
||||
this.workspaceSelection.forgetProject(projectId);
|
||||
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([candidate]) => candidate !== projectId));
|
||||
this.setState({ workspacesByProjectId });
|
||||
}
|
||||
|
||||
async selectProject(project: Project, target?: RouteTarget) {
|
||||
@@ -28,7 +30,7 @@ export class WorkspaceController {
|
||||
this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" });
|
||||
try {
|
||||
const workspaces = await api.workspaces(project.id);
|
||||
this.setState({ workspaces });
|
||||
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces } });
|
||||
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
|
||||
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
||||
else if (target?.updateUrl !== false) this.updateUrl();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSessionActive } from "../../../../shared/activity";
|
||||
import type { AppState } from "../../appState";
|
||||
import type { PluginAction } from "../types";
|
||||
|
||||
@@ -114,7 +115,7 @@ export function createCoreActions(): PluginAction[] {
|
||||
title: "Stop Active Work",
|
||||
shortcut: "mod+.",
|
||||
group: "Session",
|
||||
enabled: (context) => context.state.selectedSession !== undefined && isActive(context.state.status),
|
||||
enabled: (context) => context.state.selectedSession !== undefined && isSessionActive(context.state.status, context.state.activity),
|
||||
run: (context) => context.stopActiveWork(),
|
||||
},
|
||||
];
|
||||
@@ -127,7 +128,3 @@ function hasWorkspace(context: { state: AppState }): boolean {
|
||||
function hasGitWorkspace(context: { state: AppState }): boolean {
|
||||
return context.state.selectedWorkspace?.isGitRepo === true;
|
||||
}
|
||||
|
||||
function isActive(status: AppState["status"]): boolean {
|
||||
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
|
||||
|
||||
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
|
||||
const type = eventType(event);
|
||||
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed";
|
||||
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
|
||||
}
|
||||
|
||||
function eventType(event: unknown): string {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project, Workspace, WorkspaceActivity } from "./api";
|
||||
import { projectActivityIndicator, workspaceActivityFor, workspaceActivityIndicator } from "./workspaceActivity";
|
||||
|
||||
function project(id = "p1", path = "/repo"): Project {
|
||||
return { id, name: id, path, createdAt: "now" };
|
||||
}
|
||||
|
||||
function workspace(projectId: string, path: string): Workspace {
|
||||
return { id: path, projectId, path, label: path, isMain: path === "/repo", isGitRepo: true, isGitWorktree: true };
|
||||
}
|
||||
|
||||
function activity(cwd: string, patch: Partial<WorkspaceActivity> = {}): WorkspaceActivity {
|
||||
return { cwd, hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "now", ...patch };
|
||||
}
|
||||
|
||||
describe("workspace activity aggregation", () => {
|
||||
it("matches activity to workspace paths", () => {
|
||||
const ws = workspace("p1", "/repo");
|
||||
expect(workspaceActivityFor(ws, { "/repo": activity("/repo") })?.hasSessionActivity).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a terminal indicator only when there is no session activity", () => {
|
||||
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }))).toBe("terminal");
|
||||
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: true, hasTerminalActivity: true }))).toBe("session");
|
||||
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: false, hasTerminalActivity: false }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("aggregates project activity from known workspaces, including external worktrees", () => {
|
||||
expect(projectActivityIndicator(
|
||||
project("p1", "/repo"),
|
||||
[workspace("p1", "/repo"), workspace("p1", "/tmp/worktree")],
|
||||
{ "/tmp/worktree": activity("/tmp/worktree") },
|
||||
)).toBe("session");
|
||||
});
|
||||
|
||||
it("returns a project terminal indicator only when matched workspaces have no session activity", () => {
|
||||
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo": activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }) })).toBe("terminal");
|
||||
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo": activity("/repo", { hasSessionActivity: true, hasTerminalActivity: true }) })).toBe("session");
|
||||
});
|
||||
|
||||
it("falls back to project path matching before workspaces have been loaded", () => {
|
||||
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo/packages/app": activity("/repo/packages/app") })).toBe("session");
|
||||
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/other": activity("/other") })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ActivityIndicatorKind } from "./components/activityBadge";
|
||||
import type { Project, Workspace, WorkspaceActivity } from "./api";
|
||||
|
||||
export function workspaceActivityFor(workspace: Workspace, activities: Record<string, WorkspaceActivity>): WorkspaceActivity | undefined {
|
||||
return activities[workspace.path];
|
||||
}
|
||||
|
||||
export function workspaceActivityIndicator(activity: WorkspaceActivity | undefined): ActivityIndicatorKind | undefined {
|
||||
if (activity?.hasSessionActivity === true) return "session";
|
||||
if (activity?.hasTerminalActivity === true) return "terminal";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function projectActivityIndicator(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): ActivityIndicatorKind | undefined {
|
||||
const matched = matchedProjectActivities(project, knownWorkspaces, activities);
|
||||
if (matched.some((activity) => activity.hasSessionActivity)) return "session";
|
||||
if (matched.some((activity) => activity.hasTerminalActivity)) return "terminal";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchedProjectActivities(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): WorkspaceActivity[] {
|
||||
const knownWorkspacePaths = new Set(knownWorkspaces.filter((workspace) => workspace.projectId === project.id).map((workspace) => workspace.path));
|
||||
const matched = new Map<string, WorkspaceActivity>();
|
||||
for (const path of knownWorkspacePaths) {
|
||||
const activity = activities[path];
|
||||
if (activity !== undefined) matched.set(activity.cwd, activity);
|
||||
}
|
||||
for (const activity of Object.values(activities)) {
|
||||
if (activity.cwd === project.path || activity.cwd.startsWith(`${project.path}/`)) matched.set(activity.cwd, activity);
|
||||
}
|
||||
return [...matched.values()];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { WorkspaceActivityResponse } from "../../shared/apiTypes.js";
|
||||
|
||||
export interface WorkspaceActivityRouteService {
|
||||
snapshot(): WorkspaceActivityResponse;
|
||||
}
|
||||
|
||||
export function registerWorkspaceActivityRoutes(app: FastifyInstance, activity: WorkspaceActivityRouteService, prefix = ""): void {
|
||||
app.get(`${prefix}/activity`, () => activity.snapshot());
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RealtimeEvent, SessionStatus } from "../../shared/apiTypes";
|
||||
import { WorkspaceActivityService } from "./workspaceActivityService";
|
||||
|
||||
function status(patch: Partial<SessionStatus> = {}): SessionStatus {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceActivityService", () => {
|
||||
it("publishes and snapshots session activity by cwd", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
service.applySessionStatus("/repo", status({ isStreaming: true }));
|
||||
|
||||
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false }]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false } });
|
||||
|
||||
service.applySessionStatus("/repo", status({ isStreaming: false }));
|
||||
|
||||
expect(service.snapshot().workspaces).toEqual([]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||
});
|
||||
|
||||
it("combines sessions and terminals and clears closed terminals", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" });
|
||||
service.updateTerminal({ id: "t1", cwd: "/repo", exited: false });
|
||||
|
||||
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: true }]);
|
||||
|
||||
service.removeSession("s1");
|
||||
service.updateTerminal({ id: "t1", cwd: "/repo", exited: true });
|
||||
|
||||
expect(service.snapshot().workspaces).toEqual([]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isSessionActive, isWorkspaceActivityActive } from "../../shared/activity.js";
|
||||
import type { RealtimeEvent, SessionActivity, SessionStatus, TerminalInfo, WorkspaceActivity, WorkspaceActivityResponse } from "../../shared/apiTypes.js";
|
||||
|
||||
export interface WorkspaceActivityPublisher {
|
||||
publishRealtime(event: RealtimeEvent): void;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
cwd: string;
|
||||
status?: SessionStatus;
|
||||
activity?: SessionActivity;
|
||||
}
|
||||
|
||||
interface TerminalRecord {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export class WorkspaceActivityService {
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(private readonly publisher?: WorkspaceActivityPublisher) {}
|
||||
|
||||
applySessionStatus(cwd: string, status: SessionStatus): void {
|
||||
const previousCwd = this.sessions.get(status.sessionId)?.cwd;
|
||||
const record = this.sessions.get(status.sessionId) ?? { cwd };
|
||||
record.cwd = cwd;
|
||||
record.status = status;
|
||||
this.sessions.set(status.sessionId, record);
|
||||
this.pruneIdleSession(status.sessionId);
|
||||
this.publishChangedCwds(previousCwd, cwd);
|
||||
}
|
||||
|
||||
applySessionActivity(cwd: string, activity: SessionActivity): void {
|
||||
const previousCwd = this.sessions.get(activity.sessionId)?.cwd;
|
||||
const record = this.sessions.get(activity.sessionId) ?? { cwd };
|
||||
record.cwd = cwd;
|
||||
record.activity = activity;
|
||||
this.sessions.set(activity.sessionId, record);
|
||||
this.pruneIdleSession(activity.sessionId);
|
||||
this.publishChangedCwds(previousCwd, cwd);
|
||||
}
|
||||
|
||||
removeSession(sessionId: string): void {
|
||||
const cwd = this.sessions.get(sessionId)?.cwd;
|
||||
this.sessions.delete(sessionId);
|
||||
this.publishCwd(cwd);
|
||||
}
|
||||
|
||||
updateTerminal(terminal: Pick<TerminalInfo, "id" | "cwd" | "exited">): void {
|
||||
const previousCwd = this.terminals.get(terminal.id)?.cwd;
|
||||
if (terminal.exited) this.terminals.delete(terminal.id);
|
||||
else this.terminals.set(terminal.id, { cwd: terminal.cwd });
|
||||
this.publishChangedCwds(previousCwd, terminal.cwd);
|
||||
}
|
||||
|
||||
removeTerminal(terminalId: string, cwd?: string): void {
|
||||
const previousCwd = this.terminals.get(terminalId)?.cwd ?? cwd;
|
||||
this.terminals.delete(terminalId);
|
||||
this.publishCwd(previousCwd);
|
||||
}
|
||||
|
||||
snapshot(): WorkspaceActivityResponse {
|
||||
return {
|
||||
workspaces: this.activeCwds().map((cwd) => this.summaryForCwd(cwd)).filter(isWorkspaceActivityActive),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private pruneIdleSession(sessionId: string): void {
|
||||
const record = this.sessions.get(sessionId);
|
||||
if (record !== undefined && !isSessionActive(record.status, record.activity)) this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
private publishChangedCwds(previousCwd: string | undefined, cwd: string): void {
|
||||
this.publishCwd(previousCwd);
|
||||
if (previousCwd !== cwd) this.publishCwd(cwd);
|
||||
}
|
||||
|
||||
private publishCwd(cwd: string | undefined): void {
|
||||
if (cwd === undefined || cwd === "") return;
|
||||
this.publisher?.publishRealtime({ type: "workspace.activity", activity: this.summaryForCwd(cwd) });
|
||||
}
|
||||
|
||||
private activeCwds(): string[] {
|
||||
const cwds = new Set<string>();
|
||||
for (const record of this.sessions.values()) {
|
||||
if (isSessionActive(record.status, record.activity)) cwds.add(record.cwd);
|
||||
}
|
||||
for (const record of this.terminals.values()) cwds.add(record.cwd);
|
||||
return [...cwds].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private summaryForCwd(cwd: string): WorkspaceActivity {
|
||||
return {
|
||||
cwd,
|
||||
hasSessionActivity: [...this.sessions.values()].some((record) => record.cwd === cwd && isSessionActive(record.status, record.activity)),
|
||||
hasTerminalActivity: [...this.terminals.values()].some((terminal) => terminal.cwd === cwd),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { WorkspaceActivityService } from "./activity/workspaceActivityService.js";
|
||||
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
|
||||
import { SessionEventHub } from "./realtime/sessionEventHub.js";
|
||||
import { AuthService } from "./sessions/authService.js";
|
||||
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||
@@ -17,10 +19,12 @@ const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||
const auth = new AuthService();
|
||||
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry });
|
||||
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
|
||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||
const terminals = new TerminalService(eventHub);
|
||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||
registerWorkspaceActivityRoutes(app, workspaceActivity);
|
||||
registerAuthRoutes(app, auth);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
@@ -30,6 +30,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
|
||||
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
||||
});
|
||||
|
||||
app.all("/api/activity", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/auth", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
|
||||
app.all("/api/sessions", (request, reply) => proxy(request, reply));
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
@@ -163,6 +164,7 @@ export interface PiSessionServiceDependencies {
|
||||
createAgentRuntime?: CreateAgentRuntime;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
heartbeatIntervalMs?: number;
|
||||
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession">;
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
@@ -177,6 +179,7 @@ export class PiSessionService {
|
||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession"> | undefined;
|
||||
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
@@ -185,6 +188,7 @@ export class PiSessionService {
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
this.workspaceActivity = deps.workspaceActivity;
|
||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
||||
this.commandService = new SessionCommandService(
|
||||
(sessionId) => this.getActive(sessionId),
|
||||
@@ -215,6 +219,7 @@ export class PiSessionService {
|
||||
this.authLossWarnings.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
this.workspaceActivity?.removeSession(active.runtime.session.sessionId);
|
||||
await active.runtime.session.abort();
|
||||
await active.runtime.dispose();
|
||||
}));
|
||||
@@ -466,6 +471,7 @@ export class PiSessionService {
|
||||
if (!active) return;
|
||||
this.active.delete(sessionId);
|
||||
this.activities.delete(sessionId);
|
||||
this.workspaceActivity?.removeSession(sessionId);
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
@@ -638,12 +644,14 @@ export class PiSessionService {
|
||||
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
||||
this.activities.set(session.sessionId, stored);
|
||||
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
|
||||
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
|
||||
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
||||
this.events.publishGlobal({ type: "activity.update", activity });
|
||||
}
|
||||
|
||||
private publishStatus(session: PiAgentSession): void {
|
||||
const status = this.statusFromSession(session);
|
||||
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
|
||||
this.events.publish(session.sessionId, { type: "status.update", status });
|
||||
this.events.publishGlobal({ type: "status.update", status });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
const MAX_REPLAY_BUFFER = 200_000;
|
||||
|
||||
@@ -24,7 +25,7 @@ interface TerminalRecord extends TerminalInfo {
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(private readonly events?: SessionEventHub) {}
|
||||
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
return [...this.terminals.values()]
|
||||
@@ -63,10 +64,13 @@ export class TerminalService {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
this.publish({ type: "terminal.exited", terminal: toInfo(record) });
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.exited", terminal: info });
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
const info = toInfo(record);
|
||||
this.workspaceActivity?.updateTerminal(info);
|
||||
this.publish({ type: "terminal.created", terminal: info });
|
||||
return info;
|
||||
}
|
||||
@@ -107,6 +111,7 @@ export class TerminalService {
|
||||
if (terminal === undefined) return;
|
||||
this.terminals.delete(id);
|
||||
terminal.events.removeAllListeners();
|
||||
this.workspaceActivity?.removeTerminal(id, terminal.cwd);
|
||||
if (!terminal.exited) terminal.pty.kill();
|
||||
this.publish({ type: "terminal.closed", terminalId: id, cwd: terminal.cwd });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionActive, sessionActivityLabel, isWorkspaceActivityActive } from "./activity";
|
||||
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
|
||||
|
||||
const idleStatus: SessionStatus = {
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
describe("activity helpers", () => {
|
||||
it("detects and labels active session states consistently", () => {
|
||||
expect(isSessionActive(idleStatus)).toBe(false);
|
||||
expect(sessionActivityLabel(idleStatus)).toBeUndefined();
|
||||
|
||||
expect(isSessionActive({ ...idleStatus, isStreaming: true })).toBe(true);
|
||||
expect(sessionActivityLabel({ ...idleStatus, isStreaming: true })).toBe("streaming");
|
||||
|
||||
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
|
||||
expect(sessionActivityLabel({ ...idleStatus, pendingMessageCount: 2 })).toBe("2 pending");
|
||||
|
||||
expect(sessionActivityLabel(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" })).toBe("running tool: read");
|
||||
});
|
||||
|
||||
it("detects workspace activity presence without exposing details", () => {
|
||||
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
|
||||
expect(isWorkspaceActivityActive(idle)).toBe(false);
|
||||
expect(isWorkspaceActivityActive({ ...idle, hasSessionActivity: true })).toBe(true);
|
||||
expect(isWorkspaceActivityActive({ ...idle, hasTerminalActivity: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
|
||||
|
||||
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
|
||||
return activity?.phase === "active"
|
||||
|| status?.isStreaming === true
|
||||
|| status?.isBashRunning === true
|
||||
|| status?.isCompacting === true
|
||||
|| (status?.pendingMessageCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function sessionActivityLabel(status?: SessionStatus, activity?: SessionActivity): string | undefined {
|
||||
if (activity?.phase === "active") return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
|
||||
if (status === undefined) return undefined;
|
||||
if (status.isCompacting) return "compacting";
|
||||
if (status.isBashRunning) return "bash";
|
||||
if (status.isStreaming) return "streaming";
|
||||
if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isWorkspaceActivityActive(activity: WorkspaceActivity | undefined): boolean {
|
||||
return activity !== undefined && (activity.hasSessionActivity || activity.hasTerminalActivity);
|
||||
}
|
||||
+18
-1
@@ -107,6 +107,18 @@ export interface SessionStatus {
|
||||
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
|
||||
}
|
||||
|
||||
export interface WorkspaceActivity {
|
||||
cwd: string;
|
||||
hasSessionActivity: boolean;
|
||||
hasTerminalActivity: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceActivityResponse {
|
||||
workspaces: WorkspaceActivity[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -242,6 +254,11 @@ export type TerminalUiEvent =
|
||||
| { type: "terminal.exited"; terminal: TerminalInfo }
|
||||
| { type: "terminal.closed"; terminalId: string; cwd: string };
|
||||
|
||||
export interface WorkspaceActivityUiEvent {
|
||||
type: "workspace.activity";
|
||||
activity: WorkspaceActivity;
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -280,4 +297,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;
|
||||
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
|
||||
|
||||
Reference in New Issue
Block a user