Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,26 @@ import {
} from "@/browser/utils/ui/workspaceFiltering";
import { cn } from "@/common/lib/utils";

/**
* The metadata the tray reads, so a host without full metadata (the VS Code webview, #5109) can
* feed it.
*/
export type SubAgentTaskWorkspace = Pick<
FrontendWorkspaceMetadata,
| "id"
| "name"
| "title"
| "parentWorkspaceId"
| "archivedAt"
| "unarchivedAt"
| "workflowTask"
| "taskStatus"
| "taskExecutionStatus"
| "reportedAt"
>;

export interface DescendantSubAgent {
workspace: FrontendWorkspaceMetadata;
workspace: SubAgentTaskWorkspace;
depth: number;
}

Expand Down Expand Up @@ -99,7 +117,7 @@ export function decodeDescendantActivity(key: string): Map<string, DescendantAct
* plus the tray's own convention that queued work already counts as active.
*/
export function isSubAgentActive(
workspace: FrontendWorkspaceMetadata,
workspace: SubAgentTaskWorkspace,
hints: DescendantActivityHints = NO_ACTIVITY_HINTS
): boolean {
if (workspace.taskExecutionStatus === "queued" || workspace.taskStatus === "queued") {
Expand Down Expand Up @@ -138,10 +156,10 @@ export interface DescendantAgents {
* User-owned tasks spawned by a worker stay inside that run's group.
*/
export function collectDescendantAgents(
workspaces: Iterable<FrontendWorkspaceMetadata>,
workspaces: Iterable<SubAgentTaskWorkspace>,
parentWorkspaceId: string
): DescendantAgents {
const childrenByParentId = new Map<string, FrontendWorkspaceMetadata[]>();
const childrenByParentId = new Map<string, SubAgentTaskWorkspace[]>();
for (const workspace of workspaces) {
if (
workspace.parentWorkspaceId == null ||
Expand Down Expand Up @@ -307,9 +325,7 @@ const MONITORING_PRESENTATION: SubAgentStatusPresentation = {
iconClassName: "text-foreground",
};

function getTaskStatusPresentation(
workspace: FrontendWorkspaceMetadata
): SubAgentStatusPresentation {
function getTaskStatusPresentation(workspace: SubAgentTaskWorkspace): SubAgentStatusPresentation {
// No execution status (never-reawakened sub-agent) falls through to taskStatus.
if (workspace.taskExecutionStatus !== undefined) {
switch (workspace.taskExecutionStatus) {
Expand Down Expand Up @@ -354,7 +370,7 @@ function getTaskStatusPresentation(
* Running/queued/finishing and interrupted/failed outcomes keep their precedence.
*/
export function getSubAgentStatusPresentation(
workspace: FrontendWorkspaceMetadata,
workspace: SubAgentTaskWorkspace,
hints: DescendantActivityHints = NO_ACTIVITY_HINTS
): SubAgentStatusPresentation {
const presentation = getTaskStatusPresentation(workspace);
Expand All @@ -368,7 +384,7 @@ export function getSubAgentStatusPresentation(
}

function AgentRow(props: {
workspace: FrontendWorkspaceMetadata;
workspace: SubAgentTaskWorkspace;
hints: DescendantActivityHints | undefined;
indentLevel: number;
onNavigate: (workspaceId: string) => void;
Expand Down
5 changes: 4 additions & 1 deletion src/browser/utils/ui/workspaceFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ function isWorkspaceDelegatedActivityQueued(workspace: FrontendWorkspaceMetadata
}

export function isWorkspaceDelegatedActivityActive(
workspace: FrontendWorkspaceMetadata,
workspace: Pick<
FrontendWorkspaceMetadata,
"id" | "taskStatus" | "taskExecutionStatus" | "reportedAt"
>,
options: DelegatedActivityOptions = {}
): boolean {
if (
Expand Down
82 changes: 79 additions & 3 deletions vscode/src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,16 @@ function startServer(initialToken: string, workspaces: unknown[] = [WORKSPACE])
heldCalls: number;
metadata: unknown[];
metadataOpen: number;
activityLists: number;
activityHold: Promise<void> | null;
} = {
token: initialToken,
// Queued workspace.onMetadata updates, delivered after the snapshot (#5109).
metadata: [],
metadataOpen: 0,
activityLists: 0,
// activity.list waits on this while set, with a snapshot the test can recognize.
activityHold: null,
// getOutput waits on this while set, so a test can hold a call in flight.
hold: null,
heldCalls: 0,
Expand Down Expand Up @@ -147,7 +152,24 @@ function startServer(initialToken: string, workspaces: unknown[] = [WORKSPACE])
else await until(() => state.metadata.length > 0, "a metadata update").catch(() => undefined);
}
}),
activity: { list: authed.handler(() => ({})) },
onChat: authed.handler(async function* () {
yield { type: "caught-up" };
await new Promise(() => undefined);
}),
activity: {
list: authed.handler(async () => {
state.activityLists += 1;
notify();
const hold = state.activityHold;
if (!hold) return {};
await hold;
return { [WORKSPACE.id]: { activeBashMonitorCount: 7 } };
}),
subscribe: authed.handler(async function* () {
await new Promise(() => undefined);
yield { type: "heartbeat" };
}),
},
backgroundBashes: {
getOutput: authed.handler(async () => {
const hold = state.hold;
Expand Down Expand Up @@ -472,15 +494,15 @@ describe("chat view bridged oRPC calls reuse the validated API client (#5196)",
});
});

describe("chat view live workspace list (#5109)", () => {
describe("chat view live workspace list and dock activity (#5109)", () => {
const child = (extra: Record<string, unknown> = {}) => ({
...WORKSPACE,
id: "ws-child",
name: "child",
parentWorkspaceId: WORKSPACE.id,
...extra,
});
type Posted = PostedMessage & { workspaces?: Array<{ id: string }> };
type Posted = PostedMessage & { workspaces?: Array<{ id: string }>; activity?: object };
const lists = (posted: Posted[]) => posted.filter((message) => message.type === "workspaces");

test("a burst of metadata events re-posts the list only when the projection changes", async () => {
Expand Down Expand Up @@ -560,4 +582,58 @@ describe("chat view live workspace list (#5109)", () => {
await until(() => executedCommands.length > 0, "the workspace to open");
expect(String(executedCommands[0][1])).toBe("file:///src/xum/renamed");
});

test("the activity pump follows the selection and its descendants, and restarts when they change", async () => {
const harness = await setup();
const { state } = harness.server;
state.metadata.push({ workspaceId: "ws-child", metadata: child() });
notify();
await until(() => lists(harness.posted as Posted[]).length >= 2, "the child to be listed");

harness.send({ type: "selectWorkspace", workspaceId: WORKSPACE.id });
await until(() => state.activityLists === 1, "the activity read for the selection");
const activity = () =>
(harness.posted as Posted[]).filter((message) => message.type === "workspaceActivity");
await until(() => activity().length === 1, "the first activity post");
expect(Object.keys(activity()[0].activity ?? {})).toEqual(["ws-1", "ws-child"]);

// Neither an unrelated update nor a rename (a re-post) restarts the running pump.
state.metadata.push({ workspaceId: "ws-child", metadata: child({ namedWorkspacePath: "/x" }) });
state.metadata.push({ workspaceId: "ws-child", metadata: child({ title: "Renamed" }) });
// Removing the descendant restarts it for the smaller set.
state.metadata.push({ workspaceId: "ws-child", metadata: null });
notify();
await until(() => state.activityLists === 2, "the activity pump to restart");
await until(() => activity().length === 2, "the restarted pump's post");
expect(Object.keys(activity()[1].activity ?? {})).toEqual(["ws-1"]);

harness.send({ type: "selectWorkspace", workspaceId: null });
harness.send({ type: "selectWorkspace", workspaceId: WORKSPACE.id });
await until(() => state.activityLists === 3, "a new pump for the new selection");
});

test("a replaced activity pump's late snapshot posts nothing", async () => {
const harness = await setup();
const { state } = harness.server;
state.metadata.push({ workspaceId: "ws-child", metadata: child() });
notify();
await until(() => lists(harness.posted as Posted[]).length >= 2, "the child to be listed");
let release = () => undefined as void;
state.activityHold = new Promise<void>((resolve) => (release = resolve));
harness.send({ type: "selectWorkspace", workspaceId: WORKSPACE.id });
await until(() => state.activityLists === 1, "the first pump's read to be held");
state.activityHold = null;

// Removing the descendant replaces the pump while the first read is still in flight.
state.metadata.push({ workspaceId: "ws-child", metadata: null });
notify();
const activity = () =>
(harness.posted as Posted[]).filter((message) => message.type === "workspaceActivity");
await until(() => activity().length === 1, "the replacement's post");
release();
await harness.call(); // a round trip after the held read has answered
expect(activity().map((message) => message.activity)).toEqual([
{ "ws-1": { activeBashMonitorCount: 0, streaming: false, activeWorkflowRunIds: [] } },
]);
});
});
Loading
Loading