feat: show project and workspace activity indicators

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 13:24:02 +02:00
parent fb9e524e5b
commit ebe5639399
29 changed files with 498 additions and 36 deletions
+2 -2
View File
@@ -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";
+6
View File
@@ -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,
+11 -1
View File
@@ -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");
+16 -1
View File
@@ -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 {
+5 -1
View File
@@ -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,
+27 -5
View File
@@ -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 {
+11 -2
View File
@@ -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;
+3 -9
View File
@@ -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;
+10 -2
View File
@@ -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>`;
}
+4
View File
@@ -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();
+2 -5
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+46
View File
@@ -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();
});
});
+32
View File
@@ -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()];
}