From ebe5639399afbb8fd7fa06418b4c69f7d8ebe00d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 19 May 2026 13:24:02 +0200 Subject: [PATCH] feat: show project and workspace activity indicators --- .changeset/workspace-activity-indicators.md | 5 + src/client/src/api.ts | 4 +- src/client/src/api/clients.ts | 6 ++ src/client/src/api/parsers.test.ts | 12 ++- src/client/src/api/parsers.ts | 17 ++- src/client/src/appState.ts | 6 +- src/client/src/components/PiWebApp.ts | 32 +++++- src/client/src/components/ProjectList.ts | 13 ++- src/client/src/components/SessionList.ts | 12 +-- src/client/src/components/WorkspaceList.ts | 12 ++- src/client/src/components/activityBadge.ts | 8 ++ src/client/src/components/shared.ts | 4 + .../src/controllers/activityController.ts | 41 +++++++ .../src/controllers/projectController.ts | 5 +- .../src/controllers/workspaceController.ts | 4 +- src/client/src/plugins/core/actions.ts | 7 +- src/client/src/sessionSocket.ts | 2 +- src/client/src/workspaceActivity.test.ts | 46 ++++++++ src/client/src/workspaceActivity.ts | 32 ++++++ .../activity/workspaceActivityRoutes.ts | 10 ++ .../activity/workspaceActivityService.test.ts | 50 +++++++++ .../activity/workspaceActivityService.ts | 102 ++++++++++++++++++ src/server/sessiond.ts | 8 +- src/server/sessiond/sessionProxyRoutes.ts | 1 + src/server/sessions/piSessionService.ts | 8 ++ src/server/terminals/terminalService.ts | 9 +- src/shared/activity.test.ts | 36 +++++++ src/shared/activity.ts | 23 ++++ src/shared/apiTypes.ts | 19 +++- 29 files changed, 498 insertions(+), 36 deletions(-) create mode 100644 .changeset/workspace-activity-indicators.md create mode 100644 src/client/src/components/activityBadge.ts create mode 100644 src/client/src/controllers/activityController.ts create mode 100644 src/client/src/workspaceActivity.test.ts create mode 100644 src/client/src/workspaceActivity.ts create mode 100644 src/server/activity/workspaceActivityRoutes.ts create mode 100644 src/server/activity/workspaceActivityService.test.ts create mode 100644 src/server/activity/workspaceActivityService.ts create mode 100644 src/shared/activity.test.ts create mode 100644 src/shared/activity.ts diff --git a/.changeset/workspace-activity-indicators.md b/.changeset/workspace-activity-indicators.md new file mode 100644 index 0000000..59a1651 --- /dev/null +++ b/.changeset/workspace-activity-indicators.md @@ -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. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 16ef81c..36961a3 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index ecd9d99..cb8a7c9 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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, diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 0a9880a..c9be29c 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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"); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 2494ec1..66d950f 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { 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 { diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 6d3515c..dd3a37f 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; sessionActivities: Record; + workspaceActivities: Record; + workspacesByProjectId: Record; commandDialog: Extract | 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, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 1cb57fd..e692dbe 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 { + 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 { { this.toggleNavigationSection("projects"); }} @@ -340,6 +361,7 @@ export class PiWebApp extends LitElement { this.plugins.getWorkspaceLabelItems(this.state, workspace)} @@ -624,8 +646,8 @@ function patchChangesState(state: AppState, patch: Partial): 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): boolean { + return isSessionActive(state.status, state.activity); } function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent { diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index c3ead23..e65b813 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -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 = {}; + @property({ attribute: false }) workspacesByProjectId: Record = {}; @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)); }} >
- ${project.name}${project.path} + ${project.name}${this.renderActivity(project)}${project.path}
@@ -69,6 +73,11 @@ export class ProjectList extends LitElement { return html``; } + 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; diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index b203f4e..fe1e9bc 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -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; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 2758c23..288d386 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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 = {}; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; @property({ attribute: false }) onToggleCollapsed?: () => void; @@ -39,7 +42,7 @@ export class WorkspaceList extends LitElement { ${label} ${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))} - ${workspace.path} + ${this.renderActivity(workspace)}${workspace.path}
`; @@ -53,6 +56,11 @@ export class WorkspaceList extends LitElement { return html``; } + 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(".action-row.selected")?.scrollIntoView({ block: "nearest" }); } diff --git a/src/client/src/components/activityBadge.ts b/src/client/src/components/activityBadge.ts new file mode 100644 index 0000000..8750895 --- /dev/null +++ b/src/client/src/components/activityBadge.ts @@ -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``; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 6f69368..dfde745 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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` diff --git a/src/client/src/controllers/activityController.ts b/src/client/src/controllers/activityController.ts new file mode 100644 index 0000000..b20f569 --- /dev/null +++ b/src/client/src/controllers/activityController.ts @@ -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; +} + +export class ActivityController { + private readonly api: Pick; + + constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) { + this.api = deps.api ?? defaultApi; + } + + async refresh(): Promise { + 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 { + const activities: Record = {}; + for (const activity of snapshot.workspaces) { + if (isWorkspaceActivityActive(activity)) activities[activity.cwd] = activity; + } + return activities; +} + +export function applyWorkspaceActivityToMap(current: Record, activity: WorkspaceActivity): Record { + const next = { ...current }; + if (isWorkspaceActivityActive(activity)) { + next[activity.cwd] = activity; + return next; + } + return Object.fromEntries(Object.entries(next).filter(([cwd]) => cwd !== activity.cwd)); +} diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index a8a3961..9ed98d5 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -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) }); } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 8e8405b..f1a6ec4 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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(); diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index c7fb7e6..c0f6b4d 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -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; -} diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 56f0baa..a221aa7 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -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 { diff --git a/src/client/src/workspaceActivity.test.ts b/src/client/src/workspaceActivity.test.ts new file mode 100644 index 0000000..ef7c237 --- /dev/null +++ b/src/client/src/workspaceActivity.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/client/src/workspaceActivity.ts b/src/client/src/workspaceActivity.ts new file mode 100644 index 0000000..6fd25bd --- /dev/null +++ b/src/client/src/workspaceActivity.ts @@ -0,0 +1,32 @@ +import type { ActivityIndicatorKind } from "./components/activityBadge"; +import type { Project, Workspace, WorkspaceActivity } from "./api"; + +export function workspaceActivityFor(workspace: Workspace, activities: Record): 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): 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): WorkspaceActivity[] { + const knownWorkspacePaths = new Set(knownWorkspaces.filter((workspace) => workspace.projectId === project.id).map((workspace) => workspace.path)); + const matched = new Map(); + 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()]; +} diff --git a/src/server/activity/workspaceActivityRoutes.ts b/src/server/activity/workspaceActivityRoutes.ts new file mode 100644 index 0000000..d9354e6 --- /dev/null +++ b/src/server/activity/workspaceActivityRoutes.ts @@ -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()); +} diff --git a/src/server/activity/workspaceActivityService.test.ts b/src/server/activity/workspaceActivityService.test.ts new file mode 100644 index 0000000..46eabf7 --- /dev/null +++ b/src/server/activity/workspaceActivityService.test.ts @@ -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 { + 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 } }); + }); +}); diff --git a/src/server/activity/workspaceActivityService.ts b/src/server/activity/workspaceActivityService.ts new file mode 100644 index 0000000..d70282a --- /dev/null +++ b/src/server/activity/workspaceActivityService.ts @@ -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(); + private readonly terminals = new Map(); + + 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): 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(); + 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(), + }; + } +} diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 63fd231..4f49a54 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -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); diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index b6f0e99..37789d7 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -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)); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 4047dfb..97fb1d9 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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; } 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 | 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 }); } diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index 098f0f7..141322c 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -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(); - constructor(private readonly events?: SessionEventHub) {} + constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick) {} 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 }); } diff --git a/src/shared/activity.test.ts b/src/shared/activity.test.ts new file mode 100644 index 0000000..7135a22 --- /dev/null +++ b/src/shared/activity.test.ts @@ -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); + }); +}); diff --git a/src/shared/activity.ts b/src/shared/activity.ts new file mode 100644 index 0000000..dd97ae8 --- /dev/null +++ b/src/shared/activity.ts @@ -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); +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index a9b77f8..659c642 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; -export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent; +export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;