diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index 24bf394a1..933ae23be 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -17,12 +17,15 @@ TerminalSession │ └── onChange(snapshot, previous) fires only on resolved-state transitions │ └── onActivityChange({ activity, previous }) subscribed in TerminalManager - ├── emits terminalsChanged workspace bucket + tab indicators + ├── emits terminalsChanged terminal list/tab indicators only └── subscribeTerminalActivity per-transition stream for notification policy + └── subscribeTerminalWorkspaceContributionChanged workspace status rollup only ``` `TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes. +Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle → working, working → idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate only the affected workspace. + ### Transitions carry their own history Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`). diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 0c743eeab..2ab2896fd 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -212,6 +212,7 @@ function createTerminalManagerStub(overrides: Partial = {}): Te listDirectories: vi.fn().mockReturnValue([]), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn().mockReturnValue(() => {}), + subscribeTerminalWorkspaceContributionChanged: vi.fn().mockReturnValue(() => {}), ...overrides, } as unknown as TerminalManager; } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index ac816bb68..243eea817 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1261,7 +1261,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const structuredSnapshot = buildStoredAgentPayload( record, - providerSnapshotManager.listRegisteredProviderIds(), + new Set(providerSnapshotManager.listRegisteredProviderIds()), ); return { content: [], @@ -1308,7 +1308,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ); const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); const storedRecords = await agentStorage.list(); - const registeredProviderIds = providerSnapshotManager.listRegisteredProviderIds(); + const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); const storedAgents = storedRecords .filter((record) => !record.internal && !liveIds.has(record.id)) .filter((record) => includeArchived || !record.archivedAt) diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index c7dc20f67..5fc511372 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -24,6 +24,19 @@ interface BuildSessionConfigOptions { validProviders?: Iterable; } +function isProviderRegistered( + validProviders: Iterable | undefined, + provider: AgentProvider, +): boolean { + if (!validProviders) { + return true; + } + if (validProviders instanceof Set) { + return validProviders.has(provider); + } + return new Set(validProviders).has(provider); +} + /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -67,9 +80,7 @@ export function buildSessionConfig( record: StoredAgentRecord, options?: BuildSessionConfigOptions, ): AgentSessionConfig | null { - const validProviders = options?.validProviders; - const isValidProvider = validProviders ? new Set(validProviders).has(record.provider) : true; - if (!isValidProvider) { + if (!isProviderRegistered(options?.validProviders, record.provider)) { return null; } const overrides = buildConfigOverrides(record); @@ -90,7 +101,7 @@ export function isStoredAgentProviderAvailable( record: StoredAgentRecord, validProviders?: Iterable, ): boolean { - return buildSessionConfig(record, { validProviders }) !== null; + return isProviderRegistered(validProviders, record.provider); } export function extractTimestamps(record: StoredAgentRecord): { @@ -117,7 +128,7 @@ export function toAgentPersistenceHandle( return null; } const provider = handle.provider; - if (!new Set(registeredProviders).has(provider)) { + if (!isProviderRegistered(registeredProviders, provider)) { return null; } if (!handle.sessionId) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 8667680a6..2ae3b6af9 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -888,10 +888,12 @@ function createWorkspaceGitSnapshot( function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType }): { setTerminalTitle: ReturnType; subscribeTerminalsChanged: ReturnType; + subscribeTerminalWorkspaceContributionChanged: ReturnType; } { return { setTerminalTitle: options?.setTerminalTitle ?? vi.fn(), subscribeTerminalsChanged: vi.fn(() => () => {}), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}), }; } @@ -3548,7 +3550,10 @@ describe("session workspace script handling", () => { const session = createSessionForTest({ workspaceGitService, workspaceRegistry, - terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) }, + terminalManager: { + subscribeTerminalsChanged: vi.fn(() => () => {}), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}), + }, serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) }, scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) }, getDaemonTcpPort: () => 6767, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 37bba12f0..7b1703f5a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -29,7 +29,10 @@ import { type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, } from "./messages.js"; -import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { + TerminalManager, + TerminalWorkspaceContributionChangedEvent, +} from "../terminal/terminal-manager.js"; import { TerminalSessionController } from "../terminal/terminal-session-controller.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { @@ -159,6 +162,7 @@ import { deriveProjectGroupingName, deriveWorkspaceDisplayName, generateWorkspaceId, + resolveWorkspaceIdForRecord, } from "./workspace-registry-model.js"; import { createPersistedProjectRecord, @@ -820,7 +824,7 @@ export class Session { private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; - private unsubscribeTerminalActivityEvents: (() => void) | null = null; + private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; private clientActivity: { @@ -1295,16 +1299,15 @@ export class Session { private subscribeToOptionalManagers(): void { this.terminalController.start(); if (this.terminalManager) { - this.unsubscribeTerminalActivityEvents = this.terminalManager.subscribeTerminalsChanged( - (event) => { - void this.emitWorkspaceUpdateForCwd(event.cwd).catch((error) => { + this.unsubscribeTerminalWorkspaceContributionEvents = + this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => { + void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => { this.sessionLogger.warn( - { err: error, cwd: event.cwd }, - "Failed to emit workspace update after terminals changed", + { err: error, terminalId: event.terminalId }, + "Failed to emit workspace update after terminal contribution changed", ); }); - }, - ); + }); } const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { // COMPAT(providersSnapshot): keep provider visibility gating for older clients. @@ -1484,7 +1487,7 @@ export class Session { private buildStoredAgentPayload( record: StoredAgentRecord, - registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(), + registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()), ): AgentSnapshotPayload { return buildStoredAgentPayload(record, registeredProviderIds); } @@ -6202,8 +6205,12 @@ export class Session { */ private async listAgentPayloads(filter?: { labels?: Record; + includeArchived?: boolean; includeUnavailablePersisted?: boolean; }): Promise { + const includeArchived = filter?.includeArchived === true; + const labelEntries = filter?.labels ? Object.entries(filter.labels) : []; + // Get live agents with session modes const agentSnapshots = this.agentManager.listAgents(); const liveAgents = await Promise.all( @@ -6214,9 +6221,12 @@ export class Session { // (excluding internal agents which are for ephemeral system tasks) const registryRecords = await this.agentStorage.list(); const liveIds = new Set(agentSnapshots.map((a) => a.id)); - const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(); + const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()); const persistedAgents = registryRecords .filter((record) => !liveIds.has(record.id) && !record.internal) + // Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents. + .filter((record) => includeArchived || !record.archivedAt) + .filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value)) .filter( (record) => filter?.includeUnavailablePersisted === true || @@ -6227,14 +6237,14 @@ export class Session { let agents = [...liveAgents, ...persistedAgents]; agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider)); + if (!includeArchived) { + agents = agents.filter((agent) => !agent.archivedAt); + } // Filter by labels if filter provided - if (filter?.labels) { - const filterLabels = filter.labels; + if (labelEntries.length > 0) { agents = agents.filter((agent) => - Object.entries(filterLabels).every( - ([key, _value]) => agent.labels[key] === filterLabels[key], - ), + labelEntries.every(([key, value]) => agent.labels[key] === value), ); } @@ -6398,6 +6408,7 @@ export class Session { let agents = await this.listAgentPayloads({ labels: filter?.labels, + includeArchived: filter?.includeArchived, includeUnavailablePersisted: request.type === "fetch_agent_history_request", }); const activePlacementsByCwd = @@ -7163,6 +7174,25 @@ export class Session { return emptyProject ? { emptyProject } : null; } + private async emitWorkspaceUpdateForTerminalContribution( + event: TerminalWorkspaceContributionChangedEvent, + ): Promise { + if (event.workspaceId) { + const workspaces = await this.workspaceRegistry.list(); + const workspaceId = resolveWorkspaceIdForRecord( + { workspaceId: event.workspaceId, cwd: event.cwd }, + workspaces, + ); + if (workspaceId) { + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { + skipReconcile: true, + }); + return; + } + } + await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true }); + } + private async emitWorkspaceUpdateForCwd( cwd: string, options?: { @@ -9171,9 +9201,9 @@ export class Session { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } - if (this.unsubscribeTerminalActivityEvents) { - this.unsubscribeTerminalActivityEvents(); - this.unsubscribeTerminalActivityEvents = null; + if (this.unsubscribeTerminalWorkspaceContributionEvents) { + this.unsubscribeTerminalWorkspaceContributionEvents(); + this.unsubscribeTerminalWorkspaceContributionEvents = null; } if (this.unsubscribeProviderSnapshotEvents) { this.unsubscribeProviderSnapshotEvents(); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f4a4586eb..9bd998034 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { expect, test, vi } from "vitest"; import { z } from "zod"; import { Session } from "./session.js"; +import type { SessionOptions } from "./session.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { TerminalManager } from "../terminal/terminal-manager.js"; +import { createTerminalManager } from "../terminal/terminal-manager.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import type { @@ -48,6 +51,18 @@ import { const REPO_CWD = path.resolve("/tmp/repo"); const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); +const terminalManagers: TerminalManager[] = []; + +afterEach(async () => { + while (terminalManagers.length > 0) { + const manager = terminalManagers.pop(); + if (manager) { + manager.killAll(); + } + } + await flushTerminalContributionWork(); +}); + interface SessionTestAccess { projectRegistry: { list(...args: unknown[]): Promise; @@ -461,7 +476,9 @@ function createSessionForWorkspaceTests( appVersion?: string | null; onMessage?: (message: SessionOutboundMessage) => void; workspaceGitService?: ReturnType; - terminalManager?: ReturnType | null; + terminalManager?: TerminalManager | null; + projectRegistry?: SessionOptions["projectRegistry"]; + workspaceRegistry?: SessionOptions["workspaceRegistry"]; } = {}, ): TestSession { const logger = { @@ -517,7 +534,7 @@ function createSessionForWorkspaceTests( : null, upsert: async () => {}, }), - projectRegistry: { + projectRegistry: options.projectRegistry ?? { initialize: async () => {}, existsOnDisk: async () => true, list: async () => [], @@ -526,7 +543,7 @@ function createSessionForWorkspaceTests( archive: async () => {}, remove: async () => {}, }, - workspaceRegistry: { + workspaceRegistry: options.workspaceRegistry ?? { initialize: async () => {}, existsOnDisk: async () => true, list: async () => [ @@ -788,7 +805,13 @@ test("unsupported persisted agents are excluded from active lists but preserved await expect(session.listAgentPayloads()).resolves.toEqual([]); - await expect(session.listAgentPayloads({ includeUnavailablePersisted: true })).resolves.toEqual([ + await expect(session.listAgentPayloads({ includeUnavailablePersisted: true })).resolves.toEqual( + [], + ); + + await expect( + session.listAgentPayloads({ includeArchived: true, includeUnavailablePersisted: true }), + ).resolves.toEqual([ expect.objectContaining({ id: "agent-unsupported", provider: "gemini", @@ -5372,3 +5395,391 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull(); expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home"); }); + +function createSessionWithTerminalManager(options: { + workspaces: PersistedWorkspaceRecord[]; + projects: PersistedProjectRecord[]; + onMessage?: (message: SessionOutboundMessage) => void; +}): { session: TestSession; terminalManager: TerminalManager } { + const terminalManager = createTerminalManager(); + terminalManagers.push(terminalManager); + + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => options.projects, + get: async (projectId: string) => + options.projects.find((project) => project.projectId === projectId) ?? null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => options.workspaces, + get: async (workspaceId: string) => + options.workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + + const session = createSessionForWorkspaceTests({ + onMessage: options.onMessage, + terminalManager, + projectRegistry, + workspaceRegistry, + }); + + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-workspaces", + filter: undefined, + isBootstrapping: false, + lastEmittedByWorkspaceId: new Map(), + pendingUpdatesByWorkspaceId: new Map(), + }; + + return { session, terminalManager }; +} + +async function flushTerminalContributionWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitForWorkspaceUpdate( + emitted: SessionOutboundMessage[], + predicate: (message: Extract) => boolean, + description: string, +): Promise> { + const startedAt = Date.now(); + while (Date.now() - startedAt < 1000) { + const match = filterByType(emitted, "workspace_update").find(predicate); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for workspace_update: ${description}`); +} + +test("title-only terminal change does not build workspace descriptors or emit workspace_update", async () => { + const emitted: SessionOutboundMessage[] = []; + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-title-")); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-title", + projectId: "proj-title", + cwd, + kind: "directory", + displayName: "title-workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-title", + rootPath: cwd, + kind: "non_git", + displayName: "title-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { session, terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspace], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + const buildDescriptorMapSpy = vi.fn( + async () => new Map([[workspace.workspaceId, expect.any(Object)]]), + ); + session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy; + const listAgentPayloadsSpy = vi.fn(async () => []); + session.listAgentPayloads = listAgentPayloadsSpy; + + const terminal = await terminalManager.createTerminal({ + cwd, + workspaceId: workspace.workspaceId, + }); + terminalManager.setTerminalTitle(terminal.id, "New title"); + await flushTerminalContributionWork(); + + expect(buildDescriptorMapSpy).not.toHaveBeenCalled(); + expect(listAgentPayloadsSpy).not.toHaveBeenCalled(); + expect(filterByType(emitted, "workspace_update")).toHaveLength(0); +}); + +test("terminal activity contribution change updates the correct workspace", async () => { + const emitted: SessionOutboundMessage[] = []; + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-activity-")); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-activity", + projectId: "proj-activity", + cwd, + kind: "directory", + displayName: "activity-workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-activity", + rootPath: cwd, + kind: "non_git", + displayName: "activity-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspace], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + + const terminal = await terminalManager.createTerminal({ + cwd, + workspaceId: workspace.workspaceId, + }); + await terminalManager.setTerminalActivity(terminal.id, "working"); + + const update = await waitForWorkspaceUpdate( + emitted, + (message) => + message.payload.kind === "upsert" && + message.payload.workspace.id === workspace.workspaceId && + message.payload.workspace.status === "running", + "terminal activity marks the owning workspace running", + ); + expect(update.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: workspace.workspaceId, + status: "running", + }, + }); +}); + +test("same-cwd workspace attribution targets the terminal's workspace", async () => { + const emitted: SessionOutboundMessage[] = []; + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-")); + const workspaceA = createPersistedWorkspaceRecord({ + workspaceId: "ws-same-a", + projectId: "proj-same", + cwd, + kind: "directory", + displayName: "workspace-a", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaceB = createPersistedWorkspaceRecord({ + workspaceId: "ws-same-b", + projectId: "proj-same", + cwd, + kind: "directory", + displayName: "workspace-b", + createdAt: "2026-03-01T12:00:01.000Z", + updatedAt: "2026-03-01T12:00:01.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-same", + rootPath: cwd, + kind: "non_git", + displayName: "same-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspaceA, workspaceB], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + + const terminal = await terminalManager.createTerminal({ + cwd, + workspaceId: workspaceB.workspaceId, + }); + await terminalManager.setTerminalActivity(terminal.id, "working"); + await waitForWorkspaceUpdate( + emitted, + (message) => + message.payload.kind === "upsert" && + message.payload.workspace.id === workspaceB.workspaceId && + message.payload.workspace.status === "running", + "same-cwd terminal activity targets the stamped workspace", + ); + + const updates = filterByType(emitted, "workspace_update"); + const upserts = updates.filter((update) => update.payload.kind === "upsert"); + const targetIds = new Set(upserts.map((update) => update.payload.workspace.id)); + expect(targetIds.has(workspaceB.workspaceId)).toBe(true); + expect(targetIds.has(workspaceA.workspaceId)).toBe(false); + expect(upserts[upserts.length - 1]?.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: workspaceB.workspaceId, + status: "running", + }, + }); +}); + +test("nested worktree attribution targets the deepest active workspace", async () => { + const emitted: SessionOutboundMessage[] = []; + const rootCwd = mkdtempSync(path.join(tmpdir(), "paseo-session-nested-")); + const worktreeCwd = path.join(rootCwd, "worktree"); + const terminalCwd = path.join(worktreeCwd, "subdir"); + mkdirSync(terminalCwd, { recursive: true }); + const workspaceRoot = createPersistedWorkspaceRecord({ + workspaceId: "ws-root", + projectId: "proj-nested", + cwd: rootCwd, + kind: "local_checkout", + displayName: "root", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaceWorktree = createPersistedWorkspaceRecord({ + workspaceId: "ws-worktree", + projectId: "proj-nested", + cwd: worktreeCwd, + kind: "worktree", + displayName: "worktree", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-nested", + rootPath: rootCwd, + kind: "git", + displayName: "nested-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspaceRoot, workspaceWorktree], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + + const terminal = await terminalManager.createTerminal({ cwd: terminalCwd }); + await terminalManager.setTerminalActivity(terminal.id, "working"); + await waitForWorkspaceUpdate( + emitted, + (message) => + message.payload.kind === "upsert" && + message.payload.workspace.id === workspaceWorktree.workspaceId && + message.payload.workspace.status === "running", + "nested terminal activity targets the deepest workspace", + ); + + const updates = filterByType(emitted, "workspace_update"); + const upserts = updates.filter((update) => update.payload.kind === "upsert"); + const targetIds = new Set(upserts.map((update) => update.payload.workspace.id)); + // The terminal has no workspaceId, so it falls back to cwd attribution. + // The deepest active workspace covering `terminalCwd` should win. + expect(targetIds.has(workspaceWorktree.workspaceId)).toBe(true); + expect(targetIds.has(workspaceRoot.workspaceId)).toBe(false); + expect(upserts[upserts.length - 1]?.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: workspaceWorktree.workspaceId, + status: "running", + }, + }); +}); + +test("removing an idle terminal does not update workspace status", async () => { + const emitted: SessionOutboundMessage[] = []; + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-idle-")); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-remove-idle", + projectId: "proj-remove-idle", + cwd, + kind: "directory", + displayName: "remove-idle-workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-remove-idle", + rootPath: cwd, + kind: "non_git", + displayName: "remove-idle-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { session, terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspace], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + const buildDescriptorMapSpy = vi.fn(async () => new Map()); + session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy; + + const terminal = await terminalManager.createTerminal({ + cwd, + workspaceId: workspace.workspaceId, + }); + terminalManager.killTerminal(terminal.id); + await flushTerminalContributionWork(); + + expect(buildDescriptorMapSpy).not.toHaveBeenCalled(); + expect(filterByType(emitted, "workspace_update")).toHaveLength(0); +}); + +test("removing a contributing terminal clears workspace status", async () => { + const emitted: SessionOutboundMessage[] = []; + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-contrib-")); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-remove-contrib", + projectId: "proj-remove-contrib", + cwd, + kind: "directory", + displayName: "remove-contrib-workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: "proj-remove-contrib", + rootPath: cwd, + kind: "non_git", + displayName: "remove-contrib-project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const { terminalManager } = createSessionWithTerminalManager({ + workspaces: [workspace], + projects: [project], + onMessage: (message) => emitted.push(message), + }); + + const terminal = await terminalManager.createTerminal({ + cwd, + workspaceId: workspace.workspaceId, + }); + await terminalManager.setTerminalActivity(terminal.id, "working"); + await waitForWorkspaceUpdate( + emitted, + (message) => + message.payload.kind === "upsert" && + message.payload.workspace.id === workspace.workspaceId && + message.payload.workspace.status === "running", + "contributing terminal enters running state", + ); + emitted.length = 0; + + terminalManager.killTerminal(terminal.id); + + const update = await waitForWorkspaceUpdate( + emitted, + (message) => + message.payload.kind === "upsert" && + message.payload.workspace.id === workspace.workspaceId && + message.payload.workspace.status === "done", + "removing a contributing terminal clears workspace status", + ); + expect(update.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: workspace.workspaceId, + status: "done", + }, + }); +}); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index f7ab2925c..f7ddbdd95 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -77,7 +77,10 @@ export function asDaemonConfigStore(stub: { export function asTerminalManager(stub: { [K in keyof NonNullable]?: unknown; }): NonNullable { - return createStub>(stub); + return createStub>({ + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + ...stub, + }); } export function asGitHubService(stub: { diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 1fdbfa8e5..cf6749b45 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -235,6 +235,7 @@ function createTerminalManagerStub(options?: { killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => () => {}), subscribeTerminalActivity: vi.fn(() => () => {}), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => () => {}), } satisfies TerminalManager, }; } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 964a07c5f..193821c70 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,6 +1,7 @@ import { it, expect, afterEach } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; +import type { TerminalWorkspaceContributionChangedEvent } from "./terminal-manager.js"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -528,3 +529,81 @@ it("clearTerminalAttention leaves non-attention terminals unchanged", async () = expect(session.getActivity()?.state).toBe("working"); }); + +it("emits workspace contribution event when activity bucket changes", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + const events: TerminalWorkspaceContributionChangedEvent[] = []; + const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => { + events.push(event); + }); + + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); + await manager.setTerminalActivity(session.id, "working"); + + expect(events).toEqual([ + { + terminalId: session.id, + cwd, + workspaceId: "ws-test", + }, + ]); + + unsubscribe(); +}); + +it("does not emit workspace contribution event for title-only changes", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + const events: TerminalWorkspaceContributionChangedEvent[] = []; + const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => { + events.push(event); + }); + + const session = await manager.createTerminal({ cwd }); + manager.setTerminalTitle(session.id, "New title"); + + expect(events).toEqual([]); + + unsubscribe(); +}); + +it("emits workspace contribution event when a contributing terminal is removed", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + const events: TerminalWorkspaceContributionChangedEvent[] = []; + const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => { + events.push(event); + }); + + const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" }); + await manager.setTerminalActivity(session.id, "working"); + events.length = 0; + manager.killTerminal(session.id); + + expect(events).toEqual([ + { + terminalId: session.id, + cwd, + workspaceId: "ws-test", + }, + ]); + + unsubscribe(); +}); + +it("does not emit workspace contribution event when an idle terminal is removed", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + const events: TerminalWorkspaceContributionChangedEvent[] = []; + const unsubscribe = manager.subscribeTerminalWorkspaceContributionChanged((event) => { + events.push(event); + }); + + const session = await manager.createTerminal({ cwd }); + manager.killTerminal(session.id); + + expect(events).toEqual([]); + + unsubscribe(); +}); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index f9c7c500a..c5966b363 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -10,6 +10,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { resolve, sep } from "node:path"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; +import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; export interface TerminalListItem { id: string; @@ -37,6 +38,16 @@ export interface TerminalActivityTransitionEvent { export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void; +export interface TerminalWorkspaceContributionChangedEvent { + terminalId: string; + cwd: string; + workspaceId?: string; +} + +export type TerminalWorkspaceContributionChangedListener = ( + event: TerminalWorkspaceContributionChangedEvent, +) => void; + export interface TerminalManager { getTerminals(cwd: string, options?: { workspaceId?: string }): Promise; createTerminal(options: { @@ -74,6 +85,9 @@ export interface TerminalManager { killAll(): void; subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; subscribeTerminalActivity(listener: TerminalActivityListener): () => void; + subscribeTerminalWorkspaceContributionChanged( + listener: TerminalWorkspaceContributionChangedListener, + ): () => void; } export interface TerminalManagerOptions { @@ -95,6 +109,8 @@ export function createTerminalManager( const terminalActivityTokenById = new Map(); const terminalsChangedListeners = new Set(); const terminalActivityListeners = new Set(); + const terminalWorkspaceContributionChangedListeners = + new Set(); const defaultEnvByRootCwd = new Map>(); function removeSessionById(id: string, options: { kill: boolean }): void { @@ -137,6 +153,16 @@ export function createTerminalManager( session.kill(); } + const previousActivity = session.getActivity(); + const previousBucket = deriveTerminalActivityStatusBucket(previousActivity); + if (previousBucket !== null) { + emitTerminalWorkspaceContributionChanged({ + terminalId: session.id, + cwd: session.cwd, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + }); + } + emitTerminalsChanged({ cwd: session.cwd }); } @@ -168,6 +194,15 @@ export function createTerminalManager( const unsubscribeActivity = session.onActivityChange((transition) => { emitTerminalActivityTransition({ session, transition }); emitTerminalsChanged({ cwd: session.cwd }); + const previousBucket = deriveTerminalActivityStatusBucket(transition.previous); + const nextBucket = deriveTerminalActivityStatusBucket(transition.activity); + if (previousBucket !== nextBucket) { + emitTerminalWorkspaceContributionChanged({ + terminalId: session.id, + cwd: session.cwd, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + }); + } }); terminalExitUnsubscribeById.set(session.id, unsubscribeExit); terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle); @@ -231,6 +266,18 @@ export function createTerminalManager( } } + function emitTerminalWorkspaceContributionChanged( + event: TerminalWorkspaceContributionChangedEvent, + ): void { + for (const listener of terminalWorkspaceContributionChangedListeners) { + try { + listener(event); + } catch { + // no-op + } + } + } + return { async getTerminals( cwd: string, @@ -431,5 +478,14 @@ export function createTerminalManager( terminalActivityListeners.delete(listener); }; }, + + subscribeTerminalWorkspaceContributionChanged( + listener: TerminalWorkspaceContributionChangedListener, + ): () => void { + terminalWorkspaceContributionChangedListeners.add(listener); + return () => { + terminalWorkspaceContributionChangedListeners.delete(listener); + }; + }, }; } diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 40e72ca1e..c870e4f99 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -98,6 +98,7 @@ describe("terminal-session-controller restore", () => { killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const controller = new TerminalSessionController({ terminalManager, @@ -232,6 +233,7 @@ describe("terminal-session-controller wrap-flag gating", () => { killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), } as unknown as TerminalManager; const controller = new TerminalSessionController({ terminalManager, @@ -307,6 +309,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { return vi.fn(); }), subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; @@ -371,6 +374,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; const controller = new TerminalSessionController({ @@ -449,6 +453,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { return vi.fn(); }), subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 2afb31ea2..3e095b178 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -188,14 +188,6 @@ function watchTerminal(session: TerminalSession): void { ]); } -manager.subscribeTerminalsChanged((event) => { - sendToParent({ - type: "terminalsChanged", - cwd: event.cwd, - terminals: event.terminals, - }); -}); - function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message)); createTerminalQueue = nextRequest.catch(() => {}); @@ -265,33 +257,17 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { } case "killTerminal": { - const session = manager.getTerminal(message.terminalId); - const cwd = session?.cwd; manager.killTerminal(message.terminalId); + // Removal is owned by session.onExit -> terminalExit; the parent mirror + // clears contribution and emits terminalsChanged from that single path. clearTerminalSubscriptions(message.terminalId); - if (cwd) { - sendToParent({ - type: "terminalRemoved", - terminalId: message.terminalId, - cwd, - }); - } sendToParent({ type: "response", requestId: message.requestId, ok: true }); return; } case "killTerminalAndWait": { - const session = manager.getTerminal(message.terminalId); - const cwd = session?.cwd; await manager.killTerminalAndWait(message.terminalId, message.options); clearTerminalSubscriptions(message.terminalId); - if (cwd) { - sendToParent({ - type: "terminalRemoved", - terminalId: message.terminalId, - cwd, - }); - } sendToParent({ type: "response", requestId: message.requestId, ok: true }); return; } diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index cb82a23c5..2da2f5f12 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -115,11 +115,6 @@ export type TerminalWorkerEvent = terminal: WorkerTerminalInfo; state: TerminalState; } - | { - type: "terminalRemoved"; - terminalId: string; - cwd: string; - } | { type: "terminalMessage"; terminalId: string; @@ -142,11 +137,6 @@ export type TerminalWorkerEvent = exitCode: number | null; }; } - | { - type: "terminalsChanged"; - cwd: string; - terminals: WorkerTerminalInfo[]; - } | { type: "terminalActivityChange"; terminalId: string; diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 3fb2c936c..f26b306a4 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -5,7 +5,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; -import type { TerminalActivityTransitionEvent, TerminalManager } from "./terminal-manager.js"; +import type { + TerminalActivityTransitionEvent, + TerminalManager, + TerminalWorkspaceContributionChangedEvent, +} from "./terminal-manager.js"; import { resolvePaseoCliBinDir, resolvePaseoCliExecutablePath, @@ -753,3 +757,148 @@ it("removes worker terminals after killAndWait", async () => { expect(manager.getTerminal(session.id)).toBeUndefined(); expect(manager.listDirectories()).not.toContain(cwd); }); + +it("produces one terminals-changed snapshot per title change", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + activity: null, + }, + state: createTerminalState(), + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((t) => t.id) }); + }); + + worker.emitWorkerMessage({ + type: "terminalTitleChange", + terminalId: "terminal-a", + title: "Updated", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toEqual({ + cwd: "/workspace", + terminalIds: ["terminal-a"], + }); +}); + +it("produces one terminals-changed snapshot and one contribution event per activity change", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }, + state: createTerminalState(), + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((t) => t.id) }); + }); + const contributions: TerminalWorkspaceContributionChangedEvent[] = []; + manager.subscribeTerminalWorkspaceContributionChanged((event) => { + contributions.push(event); + }); + + const workingActivity = { state: "working" as const, changedAt: 1000 }; + worker.emitWorkerMessage({ + type: "terminalActivityChange", + terminalId: "terminal-a", + activity: workingActivity, + previous: null, + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toEqual({ + cwd: "/workspace", + terminalIds: ["terminal-a"], + }); + expect(contributions).toEqual([ + { + terminalId: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }, + ]); +}); + +it("removes a killed worker terminal from terminalExit without duplicate snapshots", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + const workingActivity = { state: "working" as const, changedAt: 1000 }; + + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: workingActivity, + }, + state: createTerminalState(), + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + const contributions: TerminalWorkspaceContributionChangedEvent[] = []; + manager.subscribeTerminalWorkspaceContributionChanged((event) => { + contributions.push(event); + }); + + manager.killTerminal("terminal-a"); + const request = worker.sentMessages.find( + (message) => message.type === "killTerminal" && message.terminalId === "terminal-a", + ); + if (!request) { + throw new Error("killTerminal request not sent"); + } + worker.emitWorkerMessage({ type: "response", requestId: request.requestId, ok: true }); + + worker.emitWorkerMessage({ + type: "terminalExit", + terminalId: "terminal-a", + info: { + exitCode: null, + signal: null, + lastOutputLines: [], + }, + }); + + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(snapshots).toEqual([{ cwd: "/workspace", terminalIds: [] }]); + expect(contributions).toEqual([ + { + terminalId: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }, + ]); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index ed23868dd..4272e0985 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; +import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; import type { ClientMessage, ServerMessage, @@ -19,6 +20,8 @@ import type { TerminalActivityTransitionEvent, TerminalListItem, TerminalManager, + TerminalWorkspaceContributionChangedEvent, + TerminalWorkspaceContributionChangedListener, TerminalsChangedEvent, TerminalsChangedListener, } from "./terminal-manager.js"; @@ -141,6 +144,8 @@ export function createWorkerTerminalManager( const terminalActivityTokenById = new Map(); const terminalsChangedListeners = new Set(); const terminalActivityListeners = new Set(); + const terminalWorkspaceContributionChangedListeners = + new Set(); let workerExited = false; let workerShutdownTimer: ReturnType | null = null; @@ -154,6 +159,18 @@ export function createWorkerTerminalManager( } } + function emitTerminalWorkspaceContributionChanged( + event: TerminalWorkspaceContributionChangedEvent, + ): void { + for (const listener of Array.from(terminalWorkspaceContributionChangedListeners)) { + try { + listener(event); + } catch { + // no-op + } + } + } + function emitTerminalActivityTransition(event: TerminalActivityTransitionEvent): void { for (const listener of Array.from(terminalActivityListeners)) { try { @@ -415,7 +432,15 @@ export function createWorkerTerminalManager( listener(message.info); } record.exitListeners.clear(); - removeRecord(message.terminalId); + const previousBucket = deriveTerminalActivityStatusBucket(record.activity); + const removedRecord = removeRecord(message.terminalId); + if (previousBucket !== null && removedRecord) { + emitTerminalWorkspaceContributionChanged({ + terminalId: removedRecord.info.id, + cwd: removedRecord.info.cwd, + ...(removedRecord.info.workspaceId ? { workspaceId: removedRecord.info.workspaceId } : {}), + }); + } emitTerminalsChanged({ cwd: record.info.cwd, terminals: listTerminalItemsForCwd(record.info.cwd), @@ -468,6 +493,7 @@ export function createWorkerTerminalManager( if (!record) { return; } + const previousActivity = record.activity; record.activity = message.activity; const transition: TerminalActivityTransition = { activity: message.activity, @@ -483,46 +509,28 @@ export function createWorkerTerminalManager( activity: message.activity, previous: message.previous, }); + const previousBucket = deriveTerminalActivityStatusBucket(previousActivity); + const nextBucket = deriveTerminalActivityStatusBucket(message.activity); + if (previousBucket !== nextBucket) { + emitTerminalWorkspaceContributionChanged({ + terminalId: record.info.id, + cwd: record.info.cwd, + ...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}), + }); + } emitTerminalsChanged({ cwd: record.info.cwd, terminals: listTerminalItemsForCwd(record.info.cwd), }); } - function handleTerminalsChangedEvent( - message: Extract, - ): void { - for (const terminal of message.terminals) { - const record = recordsById.get(terminal.id); - if (record) { - record.activity = terminal.activity; - } - } - emitTerminalsChanged({ - cwd: message.cwd, - terminals: message.terminals.map((terminal) => ({ - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd, - ...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}), - ...(terminal.title ? { title: terminal.title } : {}), - activity: terminal.activity, - })), - }); - } - function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { registerRecord({ info: message.terminal, state: message.state }); - return; - } - - case "terminalRemoved": { - removeRecord(message.terminalId); emitTerminalsChanged({ - cwd: message.cwd, - terminals: listTerminalItemsForCwd(message.cwd), + cwd: message.terminal.cwd, + terminals: listTerminalItemsForCwd(message.terminal.cwd), }); return; } @@ -547,11 +555,6 @@ export function createWorkerTerminalManager( return; } - case "terminalsChanged": { - handleTerminalsChangedEvent(message); - return; - } - case "terminalActivityChange": { handleTerminalActivityChangeEvent(message); return; @@ -827,6 +830,15 @@ export function createWorkerTerminalManager( terminalActivityListeners.delete(listener); }; }, + + subscribeTerminalWorkspaceContributionChanged( + listener: TerminalWorkspaceContributionChangedListener, + ): () => void { + terminalWorkspaceContributionChangedListeners.add(listener); + return () => { + terminalWorkspaceContributionChangedListeners.delete(listener); + }; + }, }; }