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
26 changes: 23 additions & 3 deletions scripts/screenshot/specs/workspace-agent-session-spinner.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import { captureDocument } from "../capture";

// Verifies the workspace sidebar's right-aligned git status pip: nothing
// when clean, a yellow pip with uncommitted changes, and a red pip when
// conflicted -- the same for every workspace whether or not it has an open
// agent session (an open session only makes the pip spin while it streams).
// conflicted. The pip spins while an agent in the workspace streams output;
// a clean workspace with a working agent shows a gray spinner.
it("shows the right git-state pip for sessioned and session-less workspaces", async () => {
onTestFinished(installFakeAgents());

Expand Down Expand Up @@ -109,9 +109,29 @@ it("shows the right git-state pip for sessioned and session-less workspaces", as
});

await openAgentSession("feat/session-clean");
const cleanSpinner = await screen.findByTestId(
`workspace-status-indicator-${sessionCleanId}`,
);
expect(cleanSpinner).toHaveAttribute("aria-label", "Agent working");
await captureDocument(document, {
name: "workspace-agent-session-spinner-04-clean-working",
expectations: [
"feat/session-clean shows a small gray spinning indicator at its right edge while its new agent starts up.",
"feat/no-session-clean, the other clean row, has no indicator.",
],
});
// The spinner stops once the agent goes idle.
await waitFor(
() =>
expect(
screen.queryByTestId(`workspace-status-indicator-${sessionCleanId}`),
).not.toBeInTheDocument(),
{ timeout: 15000 },
);

await openAgentSession("feat/session-conflict");

// Clean rows never show a pip, session or not.
// Clean rows show no pip once no agent in them is working.
expect(
screen.queryByTestId(`workspace-status-indicator-${noSessionCleanId}`),
).not.toBeInTheDocument();
Expand Down
41 changes: 41 additions & 0 deletions src/lib/workspace-status-indicator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { getWorkspaceStatusIndicator } from "./workspace-status-indicator";

describe("getWorkspaceStatusIndicator", () => {
it("shows nothing for a clean workspace with no working agent", () => {
expect(
getWorkspaceStatusIndicator({
isConflicted: false,
hasChanges: false,
isAgentSessionStreaming: false,
}),
).toBeNull();
});

it("spins a neutral indicator for a clean workspace whose agent is working", () => {
expect(
getWorkspaceStatusIndicator({
isConflicted: false,
hasChanges: false,
isAgentSessionStreaming: true,
}),
).toEqual({ color: "neutral", spin: true, label: "Agent working" });
});

it("keeps the change color and spins it while an agent works", () => {
expect(
getWorkspaceStatusIndicator({
isConflicted: false,
hasChanges: true,
isAgentSessionStreaming: true,
}),
).toEqual({ color: "yellow", spin: true, label: "Uncommitted changes" });
expect(
getWorkspaceStatusIndicator({
isConflicted: true,
hasChanges: true,
isAgentSessionStreaming: false,
}),
).toEqual({ color: "red", spin: false, label: "Conflicted workspace" });
});
});
31 changes: 22 additions & 9 deletions src/lib/workspace-status-indicator.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
export type WorkspaceStatusColor = "red" | "yellow" | "neutral";

export interface WorkspaceStatusIndicator {
color: "red" | "yellow";
color: WorkspaceStatusColor;
spin: boolean;
label: string;
}

export const WORKSPACE_STATUS_DOT_TEXT_CLASS: Record<"red" | "yellow", string> =
{
red: "text-destructive",
yellow: "text-yellow-500",
};
export const WORKSPACE_STATUS_DOT_TEXT_CLASS: Record<
WorkspaceStatusColor,
string
> = {
red: "text-destructive",
yellow: "text-yellow-500",
neutral: "text-muted-foreground",
};

export const WORKSPACE_STATUS_DOT_BG_CLASS: Record<"red" | "yellow", string> = {
export const WORKSPACE_STATUS_DOT_BG_CLASS: Record<
WorkspaceStatusColor,
string
> = {
red: "bg-destructive",
yellow: "bg-yellow-500",
neutral: "bg-muted-foreground",
};

/**
* Sidebar row indicator: a color-coded dot -- red for conflicts, yellow for
* uncommitted changes, nothing when clean -- shown the same way whether or
* not an agent session is open, but spinning while one is actively streaming.
* uncommitted changes, nothing when clean -- that spins while an agent in the
* workspace is streaming output. A clean workspace with a working agent gets
* a neutral spinner, so a running agent always shows in the sidebar.
*/
export function getWorkspaceStatusIndicator(params: {
isConflicted: boolean;
Expand All @@ -40,5 +50,8 @@ export function getWorkspaceStatusIndicator(params: {
label: "Uncommitted changes",
};
}
if (isAgentSessionStreaming) {
return { color: "neutral", spin: true, label: "Agent working" };
}
return null;
}
51 changes: 51 additions & 0 deletions test/integration/sidebar-agent-spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Dashboard } from "../../src/components/Dashboard";
import { createWorkspace } from "../../src/lib/api";
import { installFakeAgents } from "../fake-agent";
import { render, screen, waitFor } from "../test-utils";
import { createTestRepo, findSidebarBranchElement, openRepo } from "../utils";

describe("sidebar agent spinner", () => {
let restoreAgents: () => void;
let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
restoreAgents = installFakeAgents();
user = userEvent.setup();
});

afterEach(() => {
restoreAgents();
});

it("spins on a clean workspace while its agent works, and stops when it goes idle", async () => {
const { repoPath } = createTestRepo(false);
openRepo(repoPath);
const workspaceId = await createWorkspace(repoPath, "feat/clean-agent");
render(<Dashboard />);

await user.click(await findSidebarBranchElement("feat/clean-agent"));
await screen.findByTestId("workspace-terminal-pane");
expect(
screen.queryByTestId(`workspace-status-indicator-${workspaceId}`),
).not.toBeInTheDocument();

await user.keyboard("{Meta>}]{/Meta}");

const spinner = await screen.findByTestId(
`workspace-status-indicator-${workspaceId}`,
{},
{ timeout: 15000 },
);
expect(spinner).toHaveAttribute("aria-label", "Agent working");

await waitFor(
() =>
expect(
screen.queryByTestId(`workspace-status-indicator-${workspaceId}`),
).not.toBeInTheDocument(),
{ timeout: 15000 },
);
}, 60000);
});
Loading