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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user