Group and clear workspace status in the sidebar (#1317)

* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
This commit is contained in:
Mohamed Boudra
2026-06-04 01:20:20 +08:00
committed by GitHub
parent 71ce96b913
commit 31ae545289
65 changed files with 3758 additions and 290 deletions
@@ -7,7 +7,7 @@ import { z } from "zod";
import { Session } from "./session.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import type {
AgentClient,
AgentCreateSessionOptions,
@@ -59,6 +59,7 @@ interface SessionTestAccess {
agentStorage: {
list(...args: unknown[]): Promise<unknown[]>;
get(agentId: string): Promise<unknown>;
upsert(record: unknown): Promise<void>;
};
agentManager: {
listAgents(): unknown[];
@@ -155,6 +156,7 @@ function makeAgent(input: {
pendingPermissions?: number;
requiresAttention?: boolean;
attentionReason?: AgentSnapshotPayload["attentionReason"];
attentionTimestamp?: string | null;
}): AgentSnapshotPayload {
const pendingPermissionCount = input.pendingPermissions ?? 0;
return {
@@ -193,7 +195,39 @@ function makeAgent(input: {
labels: {},
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: null,
attentionTimestamp: input.attentionTimestamp ?? null,
archivedAt: null,
};
}
function makeStoredAgent(input: {
id: string;
cwd: string;
updatedAt: string;
requiresAttention?: boolean;
attentionReason?: StoredAgentRecord["attentionReason"];
}): StoredAgentRecord {
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
createdAt: input.updatedAt,
updatedAt: input.updatedAt,
lastActivityAt: input.updatedAt,
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "closed",
lastModeId: null,
config: { provider: "codex", cwd: input.cwd },
runtimeInfo: { provider: "codex", sessionId: null },
features: [],
persistence: null,
lastError: null,
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: input.requiresAttention ? input.updatedAt : null,
internal: false,
archivedAt: null,
};
}
@@ -459,6 +493,7 @@ function createSessionForWorkspaceTests(
agentStorage: asAgentStorage({
list: async () => [],
get: async () => null,
upsert: async () => {},
}),
projectRegistry: {
initialize: async () => {},
@@ -1024,6 +1059,200 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
});
});
test("workspace clear attention clears stored-only agents and responds", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspace = createPersistedWorkspaceRecord({
workspaceId: REPO_CWD,
projectId: REPO_CWD,
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: REPO_CWD,
rootPath: REPO_CWD,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
});
let storedRecord = makeStoredAgent({
id: "stored-agent-1",
cwd: REPO_CWD,
updatedAt: "2026-03-30T15:00:00.000Z",
requiresAttention: true,
attentionReason: "finished",
});
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.get = async (id: string) =>
id === workspace.workspaceId ? workspace : null;
session.projectRegistry.list = async () => [project];
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
session.agentStorage.get = async (agentId: string) =>
agentId === storedRecord.id ? storedRecord : null;
session.agentStorage.upsert = async (record: unknown) => {
storedRecord = record as StoredAgentRecord;
};
session.listAgentPayloads = async () => [
makeAgent({
id: storedRecord.id,
cwd: storedRecord.cwd,
status: "closed",
updatedAt: storedRecord.updatedAt,
requiresAttention: true,
attentionReason: "finished",
}),
];
await session.handleMessage({
type: "workspace.clear_attention.request",
workspaceId: workspace.workspaceId,
requestId: "req-1",
});
expect(storedRecord.requiresAttention).toBe(false);
expect(storedRecord.attentionReason).toBeNull();
expect(storedRecord.attentionTimestamp).toBeNull();
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
requestId: "req-1",
workspaceId: workspace.workspaceId,
clearedAgentIds: [storedRecord.id],
success: true,
error: null,
});
const agentUpdate = findByType(emitted, "agent_update");
expect(agentUpdate.payload.kind).toBe("upsert");
if (agentUpdate.payload.kind === "upsert") {
expect(agentUpdate.payload.agent.requiresAttention).toBe(false);
}
});
test("workspace clear attention responds with an error instead of timing out", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.get = async () => null;
await session.handleMessage({
type: "workspace.clear_attention.request",
workspaceId: "missing-workspace",
requestId: "req-1",
});
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
requestId: "req-1",
workspaceId: "missing-workspace",
clearedAgentIds: [],
success: false,
error: "Workspace not found: missing-workspace",
});
});
test("workspace clear attention can clear multiple workspaces in one request", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaces = [
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-a",
projectId: "/tmp/repo-a",
cwd: "/tmp/repo-a",
kind: "directory",
displayName: "repo-a",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-b",
projectId: "/tmp/repo-b",
cwd: "/tmp/repo-b",
kind: "directory",
displayName: "repo-b",
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
];
const projects = workspaces.map((workspace) =>
createPersistedProjectRecord({
projectId: workspace.projectId,
rootPath: workspace.cwd,
kind: "non_git",
displayName: workspace.displayName,
createdAt: "2026-03-30T15:00:00.000Z",
updatedAt: "2026-03-30T15:00:00.000Z",
}),
);
const storedRecords = new Map(
workspaces.map((workspace, index) => [
`stored-agent-${index + 1}`,
makeStoredAgent({
id: `stored-agent-${index + 1}`,
cwd: workspace.cwd,
updatedAt: "2026-03-30T15:00:00.000Z",
requiresAttention: true,
attentionReason: "finished",
}),
]),
);
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.list = async () => workspaces;
session.workspaceRegistry.get = async (id: string) =>
workspaces.find((workspace) => workspace.workspaceId === id) ?? null;
session.projectRegistry.list = async () => projects;
session.projectRegistry.get = async (id: string) =>
projects.find((project) => project.projectId === id) ?? null;
session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null;
session.agentStorage.upsert = async (record: unknown) => {
const storedRecord = record as StoredAgentRecord;
storedRecords.set(storedRecord.id, storedRecord);
};
session.listAgentPayloads = async () =>
Array.from(storedRecords.values()).map((record) =>
makeAgent({
id: record.id,
cwd: record.cwd,
status: "closed",
updatedAt: record.updatedAt,
requiresAttention: record.requiresAttention,
attentionReason: record.attentionReason,
}),
);
await session.handleMessage({
type: "workspace.clear_attention.request",
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
requestId: "req-1",
});
expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([
false,
false,
]);
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
requestId: "req-1",
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
clearedAgentIds: ["stored-agent-1", "stored-agent-2"],
results: [
{
workspaceId: workspaces[0].workspaceId,
clearedAgentIds: ["stored-agent-1"],
success: true,
error: null,
},
{
workspaceId: workspaces[1].workspaceId,
clearedAgentIds: ["stored-agent-2"],
success: true,
error: null,
},
],
success: true,
error: null,
});
});
test("close_items_request archives agents and kills terminals in one batch", async () => {
const emitted: SessionOutboundMessage[] = [];
const archivedAt = "2026-04-01T00:00:00.000Z";
@@ -3885,6 +4114,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
} as const;
@@ -3911,6 +4141,203 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
expect(descriptors).toEqual([baselineDescriptor]);
});
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
const setupSession = () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-status-entered",
rootPath: REPO_CWD,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-status-entered",
projectId: project.projectId,
cwd: "/tmp/repo",
kind: "worktree",
displayName: "feature",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
return { session, workspace };
};
const buildDescriptor = (session: TestSession, workspaceId: string) =>
session.buildWorkspaceDescriptorMap({ includeGitData: false }).then((map) => {
const descriptor = map.get(workspaceId);
expect(descriptor).toBeDefined();
return descriptor!;
});
// 1. Empty workspace — no agents contribute. statusEnteredAt must be null
// and the workspace status is "done".
{
const { session, workspace } = setupSession();
session.listAgentPayloads = async () => [];
const descriptor = await buildDescriptor(session, workspace.workspaceId);
expect(descriptor.status).toBe("done");
expect(descriptor.statusEnteredAt).toBeNull();
}
// 2. Single idle agent (derives to "done") — statusEnteredAt uses the
// agent's updatedAt as a best-effort timestamp.
{
const { session, workspace } = setupSession();
const updatedAt = "2026-05-12T09:30:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt,
}),
];
const descriptor = await buildDescriptor(session, workspace.workspaceId);
expect(descriptor.status).toBe("done");
expect(descriptor.statusEnteredAt).toBe(updatedAt);
}
// 3. Highest-priority across all buckets: a "needs_input" agent beats
// a "running" agent beats a "done" agent. statusEnteredAt is the winning
// bucket's newest agent timestamp.
{
const { session, workspace } = setupSession();
const doneUpdatedAt = "2026-05-12T09:30:00.000Z";
const runningUpdatedAt = "2026-05-12T10:00:00.000Z";
const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt: doneUpdatedAt,
}),
makeAgent({
id: "agent-running",
cwd: workspace.cwd,
status: "running",
updatedAt: runningUpdatedAt,
}),
makeAgent({
id: "agent-needs-input",
cwd: workspace.cwd,
status: "idle",
updatedAt: needsInputUpdatedAt,
pendingPermissions: 1,
}),
];
const descriptor = await buildDescriptor(session, workspace.workspaceId);
expect(descriptor.status).toBe("needs_input");
expect(descriptor.statusEnteredAt).toBe(needsInputUpdatedAt);
}
// 4. Same-bucket: keep the previous bucket entry time even when newer
// agents contribute to the same winning bucket.
{
const { session, workspace } = setupSession();
const earlyUpdatedAt = "2026-05-12T08:00:00.000Z";
const lateUpdatedAt = "2026-05-12T08:30:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done-early",
cwd: workspace.cwd,
status: "idle",
updatedAt: earlyUpdatedAt,
}),
];
const first = await buildDescriptor(session, workspace.workspaceId);
expect(first.status).toBe("done");
expect(first.statusEnteredAt).toBe(earlyUpdatedAt);
// Second call: same winning bucket, newer agent updatedAt must not move
// the workspace bucket entry time forward.
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done-early",
cwd: workspace.cwd,
status: "idle",
updatedAt: earlyUpdatedAt,
}),
makeAgent({
id: "agent-done-late",
cwd: workspace.cwd,
status: "idle",
updatedAt: lateUpdatedAt,
}),
];
const second = await buildDescriptor(session, workspace.workspaceId);
expect(second.status).toBe("done");
expect(second.statusEnteredAt).toBe(earlyUpdatedAt);
}
// 5. Priority unmasking: a higher-priority bucket clears, revealing a
// lower-priority one. The unmask time must be "now".
{
const { session, workspace } = setupSession();
const unmaskTime = "2026-05-12T12:00:00.000Z";
vi.setSystemTime(new Date(unmaskTime));
const doneUpdatedAt = "2026-05-12T08:00:00.000Z";
const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt: doneUpdatedAt,
}),
makeAgent({
id: "agent-needs-input",
cwd: workspace.cwd,
status: "idle",
updatedAt: needsInputUpdatedAt,
pendingPermissions: 1,
}),
];
const first = await buildDescriptor(session, workspace.workspaceId);
expect(first.status).toBe("needs_input");
// Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt.
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt: doneUpdatedAt,
}),
];
const second = await buildDescriptor(session, workspace.workspaceId);
expect(second.status).toBe("done");
expect(second.statusEnteredAt).toBe(unmaskTime);
vi.useRealTimers();
}
// 6. Attention agent uses attentionTimestamp as the entered-at signal.
{
const { session, workspace } = setupSession();
const attentionTs = "2026-05-12T11:00:00.000Z";
const updatedAt = "2026-05-12T10:00:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
id: "agent-attention",
cwd: workspace.cwd,
status: "idle",
updatedAt,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: attentionTs,
}),
];
const descriptor = await buildDescriptor(session, workspace.workspaceId);
expect(descriptor.status).toBe("attention");
// attentionTimestamp takes priority over updatedAt
expect(descriptor.statusEnteredAt).toBe(attentionTs);
}
});
test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => {
const session = createSessionForWorkspaceTests();
const archivingAt = "2026-04-30T20:45:00.000Z";
@@ -4513,7 +4940,6 @@ test("project.rename.request returns accepted=false when project is not found",
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
);
session.projectRegistry.get = async () => null;
await session.handleMessage({
type: "project.rename.request",
projectId: "does-not-exist",