Files
PaperClipAI/ui/src/components/IssueProperties.test.tsx
T

2622 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type {
ExecutionWorkspace,
IssueExecutionPolicy,
IssueExecutionState,
IssueLabel,
Project,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueProperties } from "./IssueProperties";
import { queryKeys } from "../lib/queryKeys";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
adapterModels: vi.fn(),
adapterModelProfiles: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
controlRuntimeCommands: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
createLabel: vi.fn(),
upsertWatchdog: vi.fn(),
deleteWatchdog: vi.fn(),
unarchiveFromInbox: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
listUserDirectory: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({ pushToast: vi.fn() }),
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("../lib/recent-assignees", () => ({
getRecentAssigneeIds: () => [],
getRecentAssigneeSelectionIds: () => [],
sortAgentsByRecency: (agents: unknown[]) => agents,
trackRecentAssignee: vi.fn(),
trackRecentAssigneeUser: vi.fn(),
}));
vi.mock("../lib/assignees", () => ({
formatAssigneeUserLabel: (userId: string | null | undefined, currentUserId?: string | null, userLabelMap?: Map<string, string>) => {
if (!userId) return null;
return userLabelMap?.get(userId) ?? (userId === currentUserId ? "You" : "User");
},
formatUserLabel: (userId: string | null | undefined, userLabelMap?: Map<string, string>) => {
if (!userId) return null;
return userLabelMap?.get(userId) ?? "User";
},
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
Identity: ({ name, shape }: { name: string; shape?: string }) => <span data-shape={shape ?? "circle"}>{name}</span>,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
useCaseHref: () => (caseId: string) => `/cases/${caseId}`,
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
/**
* Finds the trigger button for a property row by its (sentence-case) label.
* `PropertyRow` exposes a stable label hook so layout utility changes do not
* affect tests that need to find the corresponding value slot.
*/
function findRowTrigger(container: HTMLElement, label: string): HTMLButtonElement | undefined {
const labelSpan = container.querySelector(`[data-property-label="${label}"]`);
const row = labelSpan?.closest('[data-property-row="true"]');
return (row?.querySelector("button") as HTMLButtonElement | null) ?? undefined;
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Parent issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
function createLabel(overrides: Partial<IssueLabel> = {}): IssueLabel {
return {
id: "label-1",
companyId: "company-1",
name: "Bug",
color: "#ef4444",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: "service-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
issueId: "issue-1",
scopeType: "execution_workspace",
scopeId: "workspace-1",
serviceName: "web",
status: "running",
lifecycle: "shared",
reuseKey: null,
command: "pnpm dev",
cwd: "/tmp/paperclip",
port: 62475,
url: "http://127.0.0.1:62475",
provider: "local_process",
providerRef: null,
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: new Date("2026-04-06T12:03:00.000Z"),
startedAt: new Date("2026-04-06T12:02:00.000Z"),
stoppedAt: null,
stopPolicy: null,
healthStatus: "healthy",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
...overrides,
};
}
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 workspace",
status: "active",
cwd: "/tmp/paperclip/PAP-1",
repoUrl: null,
baseRef: "master",
branchName: "pap-1-workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip/PAP-1",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T12:04:00.000Z"),
openedAt: new Date("2026-04-06T12:01:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [createRuntimeService()],
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
...overrides,
};
}
function createProject(overrides: Partial<Project> = {}): Project {
const primaryWorkspace = {
id: "workspace-main",
companyId: "company-1",
projectId: "project-1",
name: "Main",
sourceType: "local_path" as const,
cwd: "/tmp/paperclip",
repoUrl: null,
repoRef: null,
defaultRef: "master",
visibility: "default" as const,
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: true,
runtimeServices: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#6366f1",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: "workspace-main",
repoUrl: null,
repoRef: null,
defaultRef: "master",
repoName: null,
localFolder: "/tmp/paperclip",
managedFolder: "/tmp/paperclip",
effectiveLocalFolder: "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [primaryWorkspace],
primaryWorkspace,
archivedAt: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
return {
mode: "normal",
commentRequired: true,
stages: [],
...overrides,
};
}
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
return {
status: "changes_requested",
currentStageId: "stage-1",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: "changes_requested",
...overrides,
};
}
function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const { root } = renderPropertiesWithQueryClient(container, props);
return root;
}
describe("IssueProperties", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
id: "label-new",
name: "New label",
color: "#6366f1",
}));
mockIssuesApi.upsertWatchdog.mockResolvedValue({});
mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true });
mockIssuesApi.unarchiveFromInbox.mockResolvedValue({ ok: true });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockAccessApi.listUserDirectory.mockResolvedValue({
users: [
{
principalId: "user-1",
status: "active",
user: { id: "user-1", name: "Riley Board", email: "riley@example.com", image: null },
},
{
principalId: "user-2",
status: "active",
user: { id: "user-2", name: "Morgan Product", email: "morgan@example.com", image: null },
},
],
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
});
});
afterEach(() => {
document.body.innerHTML = "";
});
it("shows assignee and originating without responsible wording", async () => {
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
createdByUserId: "user-1",
responsibleUserId: "user-2",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Assignee");
expect(container.textContent).toContain("CodexCoder");
expect(container.textContent).toContain("Originating");
expect(container.textContent).toContain("Riley Board");
expect(container.textContent).not.toContain("Morgan Product");
expect(container.textContent).not.toContain("Responsible");
expect(container.textContent).not.toContain("Kicked off by");
expect(container.textContent).not.toContain("Created by");
expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
});
act(() => root.unmount());
});
it("shows originating without merged responsible wording when responsible is derived from the creator", async () => {
const root = renderProperties(container, {
issue: createIssue({
createdByUserId: "user-1",
responsibleUserId: null,
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Originating");
expect(container.textContent).toContain("Riley Board");
expect(container.textContent).not.toContain("Kicked off by");
expect(container.textContent).not.toContain("Kicked off by · responsible");
expect(container.textContent).not.toContain("(auto)");
expect(container.textContent).not.toContain("Created by");
});
act(() => root.unmount());
});
it("shows originating agent without responsible wording", async () => {
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
const root = renderProperties(container, {
issue: createIssue({
createdByAgentId: "agent-1",
createdByUserId: null,
responsibleUserId: null,
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Originating");
expect(container.textContent).toContain("CodexCoder");
expect(container.textContent).toContain("Assignee");
expect(container.textContent).toContain("Unassigned");
expect(container.textContent).not.toContain("Responsible");
expect(container.textContent).not.toContain("Kicked off by");
expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
});
act(() => root.unmount());
});
it("attributes an agent-created issue to the transitive responsible user with a via affordance", async () => {
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
const root = renderProperties(container, {
issue: createIssue({
createdByAgentId: "agent-1",
createdByUserId: null,
responsibleUserId: "user-2",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Originating");
expect(container.textContent).toContain("Morgan Product");
expect(container.textContent).toContain("via CodexCoder");
expect(container.textContent).not.toContain("Responsible");
expect(container.textContent).not.toContain("Kicked off by");
});
act(() => root.unmount());
});
it("shows originating responsible user for a routine execution with no creator", async () => {
const root = renderProperties(container, {
issue: createIssue({
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: "user-2",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Originating");
expect(container.textContent).toContain("Morgan Product");
expect(container.textContent).not.toContain("via ");
});
act(() => root.unmount());
});
it("groups the assignee picker and gates a live-run reassign behind an interrupt confirm", async () => {
const minimalAgent = (id: string, name: string) =>
({
id,
name,
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number]);
mockAgentsApi.list.mockResolvedValue([minimalAgent("agent-1", "ClaudeCoder"), minimalAgent("agent-2", "QA")]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ assigneeAgentId: "agent-1" }),
childIssues: [],
onUpdate,
inline: true,
hasActiveRun: true,
});
await flush();
// Wait for the agents query to resolve so the current assignee renders.
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
trigger = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("ClaudeCoder"),
);
expect(trigger).toBeTruthy();
});
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
// Live-run banner + grouped section headers are present.
expect(container.querySelector("[data-testid='assignee-running-banner']")?.textContent).toContain(
"ClaudeCoder is running",
);
expect(container.textContent).toContain("Agents");
expect(container.textContent).toContain("Board users");
// Picking a different agent mid-run stages a confirm rather than applying.
const qaOption = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "QA",
);
expect(qaOption).toBeTruthy();
await act(async () => {
qaOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector("[data-testid='interrupt-assign-confirm']")).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
// Confirming applies the reassignment.
const confirmBtn = container.querySelector<HTMLButtonElement>(
"[data-testid='interrupt-assign-confirm-action']",
)!;
await act(async () => {
confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(onUpdate).toHaveBeenCalledWith({ assigneeAgentId: "agent-2", assigneeUserId: null });
act(() => root.unmount());
});
it("filters the no-assignee option with assignee search", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number],
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const searchInput = container.querySelector(
'input[placeholder="Search assignees..."]',
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "no");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("No assignee");
expect(container.textContent).not.toContain("No matches.");
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "zzzz");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).not.toContain("No assignee");
expect(container.textContent).toContain("No matches.");
act(() => root.unmount());
});
it("always exposes the add sub-issue action", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onAddSubIssue,
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Sub-tasks");
expect(container.textContent).toContain("Add sub-task");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-task"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAddSubIssue).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("hides watchdog setup controls while the experimental flag is off", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Watchdog");
expect(container.textContent).not.toContain("Set watchdog");
act(() => root.unmount());
});
it("shows watchdog setup controls when the experimental flag is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
// Empty watchdog uses the uniform muted "None" empty state (ux-spec §6).
expect(findRowTrigger(container, "Watchdog")?.textContent).toContain("None");
});
act(() => root.unmount());
});
it("passes blocker attention to the sidebar status icon", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
act(() => root.unmount());
});
it("renders blocked-by issues as direct chips and edits them from an add action", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-3", identifier: "PAP-3", title: "New blocker", status: "todo" }),
]);
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const blockerLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(blockerLink).not.toBeNull();
expect(blockerLink?.textContent).toContain("PAP-2");
expect(blockerLink?.closest("button")).toBeNull();
expect(blockerLink?.className).toContain("px-2");
expect(blockerLink?.className).toContain("py-0.5");
expect(blockerLink?.className).toContain("text-xs");
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton?.className).toContain("absolute");
expect(container.textContent).toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search tasks..."]')).not.toBeNull();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-3 New blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-2", "issue-3"] });
act(() => root.unmount());
});
it("searches all company issues when adding a blocker", async () => {
const onUpdate = vi.fn();
const loadedIssue = createIssue({ id: "issue-3", identifier: "PAP-3", title: "Loaded issue", status: "todo" });
const remoteIssue = createIssue({ id: "issue-99", identifier: "PAP-99", title: "Remote blocker", status: "in_progress" });
mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => {
if (filters?.q === "remote") return Promise.resolve([remoteIssue]);
return Promise.resolve([loadedIssue]);
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const searchInput = container.querySelector('input[aria-label="Search tasks to add as blockers"]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "remote");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "remote", limit: 50 });
expect(container.textContent).toContain("PAP-99 Remote blocker");
expect(container.textContent).not.toContain("PAP-3 Loaded issue");
});
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-99 Remote blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-99"] });
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton).not.toBeNull();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
act(() => root.unmount());
});
it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({
id: `child-${index + 1}`,
identifier: `SUB-${index + 1}`,
title: `Sub-task ${index + 1}`,
}));
const root = renderProperties(container, {
issue: createIssue({ blockedBy }),
childIssues,
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.textContent).toContain("BLOCK-5");
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-5");
expect(container.textContent).not.toContain("SUB-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show 2 more",
),
).toHaveLength(2);
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("BLOCK-6");
expect(container.textContent).toContain("BLOCK-7");
expect(container.textContent).not.toContain("SUB-6");
expect(container.textContent).toContain("Show less");
const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandSubTasks).not.toBeUndefined();
await act(async () => {
expandSubTasks!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("SUB-7");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show 2 more",
),
).toHaveLength(0);
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show less",
),
).toHaveLength(2);
const collapseBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show less",
);
expect(collapseBlockedBy).not.toBeUndefined();
await act(async () => {
collapseBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("Show 2 more");
act(() => root.unmount());
});
it("collapses long blocking and related task lists until the more button is clicked", async () => {
const blocking = Array.from({ length: 7 }, (_, index) => ({
id: `blocking-${index + 1}`,
identifier: `BLOCKING-${index + 1}`,
title: `Blocking issue ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blocks"]>;
const relatedIssues = Array.from({ length: 7 }, (_, index) => ({
id: `related-${index + 1}`,
identifier: `RELATED-${index + 1}`,
title: `Related issue ${index + 1}`,
status: "todo" as const,
priority: "medium" as const,
assigneeAgentId: null,
assigneeUserId: null,
}));
const root = renderProperties(container, {
issue: createIssue({
blocks: blocking,
relatedWork: {
outbound: relatedIssues.map((issue) => ({
issue,
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: issue.identifier }],
})),
inbound: [],
},
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.textContent).toContain("BLOCKING-5");
expect(container.textContent).not.toContain("BLOCKING-6");
expect(container.textContent).toContain("RELATED-5");
expect(container.textContent).not.toContain("RELATED-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "Show 2 more",
),
).toHaveLength(2);
const expandBlocking = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandBlocking).not.toBeUndefined();
await act(async () => {
expandBlocking!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("BLOCKING-6");
expect(container.textContent).toContain("BLOCKING-7");
expect(container.textContent).not.toContain("RELATED-6");
const expandRelated = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandRelated).not.toBeUndefined();
await act(async () => {
expandRelated!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("RELATED-6");
expect(container.textContent).toContain("RELATED-7");
act(() => root.unmount());
});
it("collapses long external URL rows until the more button is clicked", async () => {
const externalObjects = Array.from({ length: 7 }, (_, index) => ({
mentionCount: 1,
sourceLabels: ["Description"],
pill: {
providerKey: "url" as const,
objectType: "link" as const,
displayKey: null,
iconKey: null,
statusCategory: "unknown" as const,
statusIconKey: null,
statusLabel: null,
liveness: "unknown" as const,
displayTitle: `https://example.com/reference-${index + 1}`,
url: `https://example.com/reference-${index + 1}`,
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Description"],
},
}));
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
externalObjects,
});
await flush();
expect(container.textContent).toContain("reference-5");
expect(container.textContent).not.toContain("reference-6");
expect(container.textContent).toContain("References");
const expandUrls = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandUrls).not.toBeUndefined();
await act(async () => {
expandUrls!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("reference-6");
expect(container.textContent).toContain("reference-7");
expect(container.textContent).toContain("Show less");
act(() => root.unmount());
});
it("resets expanded relation previews when the issue changes", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-a", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
await flush();
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Show 2 more",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("BLOCK-6");
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-b", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
await flush();
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("Show 2 more");
act(() => root.unmount());
});
it("keeps the current archived project visible in the project property", async () => {
mockProjectsApi.list.mockResolvedValue([
createProject({
id: "archived-project",
name: "Archived Project",
archivedAt: new Date("2026-04-08T00:00:00.000Z"),
}),
]);
const root = renderProperties(container, {
issue: createIssue({ projectId: "archived-project" }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(mockProjectsApi.list).toHaveBeenCalledWith("company-1", { includeArchived: true });
await waitForAssertion(() => {
expect(findRowTrigger(container, "Project")?.textContent).toContain("Archived Project");
});
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const updatedWorkspace = createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "stopped", stoppedAt: new Date("2026-04-06T12:06:00.000Z") })],
});
mockExecutionWorkspacesApi.controlRuntimeCommands.mockResolvedValue({
workspace: updatedWorkspace,
operation: {},
});
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
config: {
environmentId: null,
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running", configIndex: 0 })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
expect(serviceLink).not.toBeNull();
expect(serviceLink?.className).toContain("sm:self-start");
expect(serviceLink?.className).not.toContain("sm:self-end");
expect((container.textContent ?? "").indexOf("Workspace")).toBeLessThan(
(container.textContent ?? "").indexOf("Service"),
);
const stopButton = container.querySelector<HTMLButtonElement>('button[aria-label="Stop"]');
expect(stopButton).not.toBeUndefined();
expect(stopButton?.getAttribute("data-size")).toBe("icon-xs");
expect(stopButton?.getAttribute("data-variant")).toBe("outline");
await act(async () => {
stopButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockExecutionWorkspacesApi.controlRuntimeCommands).toHaveBeenCalledWith(
"workspace-1",
"stop",
expect.objectContaining({ action: "stop", runtimeServiceId: "service-1" }),
);
expect(container.textContent).toContain("Workspace service stopped.");
act(() => root.unmount());
});
it("shows full date and time for issue metadata timestamps", async () => {
const root = renderProperties(container, {
issue: createIssue({
createdAt: new Date(2026, 3, 6, 12, 34),
startedAt: new Date(2026, 3, 6, 12, 35),
completedAt: new Date(2026, 3, 6, 12, 36),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toMatch(/CreatedApr 6, 2026, \d{1,2}:34 (AM|PM)/);
expect(container.textContent).toMatch(/StartedApr 6, 2026, \d{1,2}:35 (AM|PM)/);
expect(container.textContent).toMatch(/CompletedApr 6, 2026, \d{1,2}:36 (AM|PM)/);
act(() => root.unmount());
});
it("shows only the workspace detail link for non-default workspaces", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
await flush();
const workspaceLink = Array.from(container.querySelectorAll("a")).find(
(link) => link.textContent?.trim() === "View workspace",
);
expect(container.textContent).not.toContain("View workspace tasks");
expect(workspaceLink).not.toBeUndefined();
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
act(() => root.unmount());
});
it("copies branch and folder values with visible feedback", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const root = renderProperties(container, {
issue: createIssue({
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
branchName: "pap-1-workspace",
cwd: "/tmp/paperclip/PAP-1",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const branchCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
expect(branchCopyButton).not.toBeNull();
await act(async () => {
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(writeText).toHaveBeenCalledWith("pap-1-workspace");
expect(container.textContent).toContain("Copied");
act(() => root.unmount());
});
it("does not show a service link for the main shared workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "shared_workspace",
projectWorkspaceId: "workspace-main",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull();
expect(container.textContent).not.toContain("View workspace tasks");
expect(Array.from(container.querySelectorAll("a")).some(
(link) => link.textContent?.trim() === "View workspace",
)).toBe(false);
act(() => root.unmount());
});
it("shows related task references below sub-issues", async () => {
const root = renderProperties(container, {
issue: createIssue({
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Related task",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
],
inbound: [],
},
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Task ids");
expect(container.textContent).toContain("Related tasks");
expect(container.textContent).toContain("PAP-22");
act(() => root.unmount());
});
it("hides related task references already covered by blockers, blocking, and sub-issues", async () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [
{
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
{
issue: {
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }],
},
{
issue: {
id: "child-44",
identifier: "PAP-44",
title: "Child issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }],
},
],
inbound: [],
},
}),
childIssues: [
createIssue({
id: "child-44",
identifier: "PAP-44",
title: "Child issue",
}),
],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Related tasks");
act(() => root.unmount());
});
it("shows an add-label button when labels already exist and opens the picker", async () => {
const root = renderProperties(container, {
issue: createIssue({
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull();
expect(container.querySelector('button[title="Delete Bug"]')).toBeNull();
act(() => root.unmount());
});
it("shows selected labels from labelIds even before the issue labels relation refreshes", async () => {
mockIssuesApi.listLabels.mockResolvedValue([createLabel()]);
const root = renderProperties(container, {
issue: createIssue({
labels: [],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await flush();
expect(container.textContent).toContain("Bug");
expect(container.textContent).not.toContain("No labels");
act(() => root.unmount());
});
it("hides model options when the issue uses the responsible default", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: null,
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Model lane");
expect(container.textContent).not.toContain("Codex options");
act(() => root.unmount());
});
it("edits existing custom assignee model options from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.5", label: "GPT-5.5" },
{ id: "gpt-5.4", label: "GPT-5.4" },
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
modelReasoningEffort: "high",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
await flush();
expect(container.textContent).toContain("Override · gpt-5.4 · high");
expect(container.textContent).toContain("Model lane");
// Wait for the adapter-models query to resolve so the model options render.
let modelButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
modelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("GPT-5.5"));
expect(modelButton).not.toBeUndefined();
});
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.5",
modelReasoningEffort: "high",
},
},
});
act(() => root.unmount());
});
it("clears existing assignee adapter overrides from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
// The trailing "clear" X was removed (ux-spec: one trailing-action style).
// Clearing now happens by selecting the "Primary" model lane inside the picker.
const optionsTrigger = findRowTrigger(container, "Model");
expect(optionsTrigger).toBeTruthy();
await act(async () => {
optionsTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const primaryLane = Array.from(container.querySelectorAll('button[role="radio"]'))
.find((button) => button.textContent?.trim() === "Primary");
expect(primaryLane).not.toBeUndefined();
await act(async () => {
primaryLane!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null });
act(() => root.unmount());
});
it("shows a checkmark on selected labels in the picker", async () => {
mockIssuesApi.listLabels.mockResolvedValue([
createLabel(),
createLabel({ id: "label-2", name: "Feature", color: "#22c55e" }),
]);
const root = renderProperties(container, {
issue: createIssue({
labels: [createLabel()],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const labelButtons = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.includes("Bug") || button.textContent?.includes("Feature"));
const bugButton = labelButtons.find((button) => button.textContent?.includes("Bug") && button.querySelector("svg"));
const featureButton = labelButtons.find((button) => button.textContent?.includes("Feature"));
expect(bugButton).not.toBeUndefined();
expect(featureButton?.querySelector("svg")).toBeNull();
act(() => root.unmount());
});
it("allows setting and clearing a parent issue from the properties pane", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
// Empty parent now uses the uniform muted "None" empty state (ux-spec §6).
const parentTrigger = findRowTrigger(container, "Parent");
expect(parentTrigger?.textContent).toContain("None");
await act(async () => {
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
onUpdate.mockClear();
const rerenderedIssue = createIssue({
parentId: "issue-2",
ancestors: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Candidate parent",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
goalId: null,
project: null,
goal: null,
},
],
});
act(() => root.unmount());
const rerenderedRoot = renderProperties(container, {
issue: rerenderedIssue,
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();
expect(selectedParentTrigger!.contains(parentLink)).toBe(false);
await act(async () => {
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const clearParentButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(clearParentButton).not.toBeUndefined();
await act(async () => {
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
act(() => rerenderedRoot.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
}),
childIssues: [],
onUpdate,
});
await flush();
const runReviewButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Run review now"));
expect(runReviewButton).not.toBeUndefined();
await act(async () => {
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
act(() => root.unmount());
});
it("shows a run approval action when approval is the next runnable stage", async () => {
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "approval-stage",
type: "approval",
approvalsNeeded: 1,
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
},
],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run approval now");
expect(container.textContent).not.toContain("Run review now");
act(() => root.unmount());
});
it("keeps the run review action available after changes are requested", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState(),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run review now");
act(() => root.unmount());
});
it("hides the run action while an execution stage is already pending", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_review",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState({
status: "pending",
currentStageType: "review",
lastDecisionOutcome: null,
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Run review now");
expect(container.textContent).not.toContain("Run approval now");
act(() => root.unmount());
});
it("renders monitor controls and clears an existing monitor", async () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-04-11T10:00:00.000Z").getTime());
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionPolicy: createExecutionPolicy({
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment",
scheduledBy: "board",
},
}),
executionState: createExecutionState({
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
lastDecisionOutcome: null,
monitor: {
status: "scheduled",
nextCheckAt: "2026-04-11T12:30:00.000Z",
lastTriggeredAt: null,
attemptCount: 0,
notes: "Check deployment",
scheduledBy: "board",
clearedAt: null,
clearReason: null,
},
}),
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
expect(container.textContent).toContain("Monitor");
expect(container.textContent).toContain("In 2h 30m");
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull();
const monitorTrigger = container.querySelector('[data-testid="monitor-row-trigger"]')?.closest("button");
expect(monitorTrigger).not.toBeUndefined();
await act(async () => {
monitorTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const inputs = Array.from(container.querySelectorAll("input"));
const datetimeInput = inputs.find((input) => input.getAttribute("type") === "datetime-local");
const textInput = inputs.find((input) => input.getAttribute("placeholder") === "What should the agent re-check?");
const clearButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Clear"));
expect(datetimeInput).toBeTruthy();
expect(textInput).toBeTruthy();
expect(clearButton).toBeTruthy();
expect(datetimeInput!.value).toBeTruthy();
expect(textInput!.value).toBe("Check deployment");
act(() => {
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
});
act(() => root.unmount());
dateNowSpy.mockRestore();
});
it("renders scheduled, retrying, due, overdue, cleared, and empty monitor row states", async () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-07-17T13:56:00.000Z").getTime());
const baseMonitorState = {
status: "scheduled" as const,
nextCheckAt: "2026-07-17T16:08:00.000Z",
lastTriggeredAt: null,
attemptCount: 1,
notes: "Verify deployment",
scheduledBy: "board" as const,
clearedAt: null,
clearReason: null,
};
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const root = createRoot(container);
const monitorRowText = () => container.querySelector('[data-testid="monitor-row-trigger"]')?.textContent;
const renderMonitor = (issue: Issue) => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties issue={issue} childIssues={[]} onUpdate={vi.fn()} inline />
</QueryClientProvider>,
);
});
};
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }),
executionState: createExecutionState({ monitor: baseMonitorState }),
monitorAttemptCount: 1,
}));
await flush();
expect(monitorRowText()).toContain("In 2h 12m");
expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1");
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T18:08:00.000Z" } }),
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T16:08:00.000Z" } }),
monitorNextCheckAt: new Date("2026-07-17T17:08:00.000Z"),
}));
await flush();
expect(monitorRowText()).toContain("In 2h 12m");
expect(monitorRowText()).toContain("Today, 4:08 PM");
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }),
executionState: createExecutionState({ monitor: { ...baseMonitorState, attemptCount: 3 } }),
monitorAttemptCount: 3,
}));
await flush();
expect(monitorRowText()).toContain("Attempt 3");
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }),
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }),
}));
await flush();
expect(monitorRowText()).toContain("Due now");
expect(monitorRowText()).toContain("checking momentarily…");
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }),
executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }),
}));
await flush();
expect(monitorRowText()).toContain("Overdue by 18m");
expect(monitorRowText()).toContain("Today, 1:38 PM · fires on next tick");
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy(),
executionState: createExecutionState({ monitor: {
...baseMonitorState,
status: "cleared",
nextCheckAt: null,
lastTriggeredAt: "2026-07-17T11:56:00.000Z",
attemptCount: 2,
clearedAt: "2026-07-17T12:00:00.000Z",
clearReason: "manual",
} }),
monitorAttemptCount: 2,
monitorLastTriggeredAt: new Date("2026-07-17T11:56:00.000Z"),
}));
await flush();
expect(monitorRowText()).toContain("Cleared");
expect(monitorRowText()).toContain("last checked 2h ago · after attempt 2");
renderMonitor(createIssue());
await flush();
expect(monitorRowText()).toContain("None");
act(() => root.unmount());
dateNowSpy.mockRestore();
});
const watchdogAgent = {
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number];
function createWatchdogSummary(overrides: Record<string, unknown> = {}) {
return {
id: "watchdog-1",
companyId: "company-1",
issueId: "issue-1",
watchdogAgentId: "agent-1",
instructions: "Keep the tree moving.",
status: "active",
watchdogIssueId: null,
lastObservedFingerprint: null,
lastReviewedFingerprint: null,
lastTriggeredAt: null,
lastCompletedAt: null,
triggerCount: 0,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
} as unknown as NonNullable<Issue["watchdog"]>;
}
it("shows the empty watchdog state and saves a new watchdog via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ watchdog: null }),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
trigger = findRowTrigger(container, "Watchdog");
expect(trigger).toBeTruthy();
});
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
// Choose the agent through the inline selector, then save.
let agentOption: HTMLElement | undefined;
await waitForAssertion(() => {
agentOption = Array.from(container.querySelectorAll("button, [role='option']"))
.find((node) => node.textContent?.includes("ClaudeCoder")) as HTMLElement | undefined;
expect(agentOption).toBeTruthy();
});
// Open the selector if the option is not yet visible, then click it.
await act(async () => {
agentOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const instructions = Array.from(container.querySelectorAll("textarea"))
.find((node) => node.getAttribute("placeholder")?.includes("watchdog"));
expect(instructions).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
setter.call(instructions!, "Watch the deploy");
instructions!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
const saveButton = Array.from(container.querySelectorAll("button"))
.find((button) => /Set watchdog|Update/.test(button.textContent ?? "") && button.closest("[class*='space-y']"));
const finalSave = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent === "Set watchdog" && button !== trigger) ?? saveButton;
expect(finalSave).toBeTruthy();
await act(async () => {
finalSave!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.upsertWatchdog).toHaveBeenCalledWith(
"issue-1",
expect.objectContaining({ agentId: "agent-1" }),
);
act(() => root.unmount());
});
it("updates cached issue detail when saving a watchdog", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const savedWatchdog = createWatchdogSummary({
instructions: "Watch the deploy",
});
mockIssuesApi.upsertWatchdog.mockResolvedValueOnce(savedWatchdog);
const issue = createIssue({ watchdog: null });
const { root, queryClient } = renderPropertiesWithQueryClient(container, {
issue,
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
queryClient.setQueryData(queryKeys.issues.detail(issue.id), issue);
await flush();
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
trigger = findRowTrigger(container, "Watchdog");
expect(trigger).toBeTruthy();
});
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
let agentOption: HTMLElement | undefined;
await waitForAssertion(() => {
agentOption = Array.from(container.querySelectorAll("button, [role='option']"))
.find((node) => node.textContent?.includes("ClaudeCoder")) as HTMLElement | undefined;
expect(agentOption).toBeTruthy();
});
await act(async () => {
agentOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const finalSave = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent === "Set watchdog" && button !== trigger);
expect(finalSave).toBeTruthy();
await act(async () => {
finalSave!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(queryClient.getQueryData<Issue>(queryKeys.issues.detail(issue.id))?.watchdog)
.toEqual(savedWatchdog);
act(() => root.unmount());
});
it("renders an existing watchdog and removes it via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const issue = createIssue({ watchdog: createWatchdogSummary() });
const { root, queryClient } = renderPropertiesWithQueryClient(container, {
issue,
childIssues: [],
onUpdate,
inline: true,
});
queryClient.setQueryData(queryKeys.issues.detail(issue.id), issue);
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("ClaudeCoder");
});
const trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("ClaudeCoder"));
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const removeButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove"));
expect(removeButton).toBeTruthy();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.deleteWatchdog).toHaveBeenCalledWith("issue-1");
expect(queryClient.getQueryData<Issue>(queryKeys.issues.detail(issue.id))?.watchdog)
.toBeNull();
act(() => root.unmount());
});
it("truncates the watchdog instructions one-line summary in the properties value column", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const instructions = "get greptile to stop re-reviewing the same task unless a fresh code change lands";
const root = renderProperties(container, {
issue: createIssue({
watchdog: createWatchdogSummary({ instructions }),
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
// ux-spec: watchdog row shows agent + a truncated one-line summary; the
// full instructions live in the popover (surfaced here via the row title).
let instructionNode: HTMLSpanElement | undefined;
await waitForAssertion(() => {
instructionNode = Array.from(container.querySelectorAll("span"))
.find((node) =>
node.textContent?.includes("get greptile")
&& node.className.includes("text-muted-foreground")
&& !node.className.includes("inline-flex")
) as HTMLSpanElement | undefined;
expect(instructionNode).toBeTruthy();
});
expect(instructionNode!.className).toContain("truncate");
expect(instructionNode!.className).not.toContain("whitespace-normal");
expect(instructionNode!.className).not.toContain("break-words");
const watchdogTrigger = findRowTrigger(container, "Watchdog");
expect(watchdogTrigger?.querySelector("[title]")?.getAttribute("title")).toBe(instructions);
act(() => root.unmount());
});
it("links to the generated watchdog task when one exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const root = renderProperties(container, {
issue: createIssue({ watchdog: createWatchdogSummary({ watchdogIssueId: "issue-wd" }) }),
childIssues: [
createIssue({
id: "issue-wd",
identifier: "PAP-42",
title: "Watchdog: Parent issue",
originKind: "task_watchdog",
}),
],
onUpdate: vi.fn(),
inline: true,
});
await flush();
// The link is now an icon-only "open task" affordance (ux-spec: one
// trailing-action style), so we assert on href + accessible label.
await waitForAssertion(() => {
const link = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "/issues/issue-wd");
expect(link).toBeTruthy();
expect(link!.getAttribute("aria-label")).toBe("Open watchdog task");
});
act(() => root.unmount());
});
it("renders each external object as its own properties row using display metadata", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
externalObjects: [
{
mentionCount: 2,
sourceLabels: ["Description"],
pill: {
providerKey: "github",
objectType: "pull_request",
displayKey: null,
iconKey: "github",
statusCategory: "succeeded",
statusIconKey: null,
statusLabel: "Merged",
liveness: "fresh",
displayTitle: "acme/web#241: Add rich object presentation metadata",
url: "https://github.com/acme/web/pull/241",
},
group: {
object: null,
mentions: [],
mentionCount: 2,
sourceLabels: ["Description"],
},
},
{
mentionCount: 1,
sourceLabels: ["Comment"],
pill: {
providerKey: "github",
objectType: "issue",
displayKey: "Github Issue",
iconKey: "github",
statusCategory: "open",
statusIconKey: "circle-dot",
statusLabel: "Open",
liveness: "fresh",
displayTitle: "acme/web#12: Follow-up",
url: "https://github.com/acme/web/issues/12",
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Comment"],
},
},
{
mentionCount: 1,
sourceLabels: ["Comment"],
pill: {
providerKey: "url",
objectType: "link",
displayKey: null,
iconKey: null,
statusCategory: "unknown",
statusIconKey: null,
statusLabel: null,
liveness: "unknown",
displayTitle: "https://example.com/release-notes",
url: "https://example.com/release-notes",
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Comment"],
},
},
],
});
await flush();
expect(container.textContent).toContain("Github PR");
expect(container.textContent).not.toContain("Github Pull Request");
expect(container.textContent).not.toContain("×2");
expect(container.textContent).toContain("Github Issue");
expect(container.textContent).toContain("URL");
expect(container.textContent).not.toContain("URL link");
expect(container.textContent).toContain("PR 241 - Merged");
expect(container.textContent).toContain("Merged");
expect(container.textContent).toContain("Open");
expect(container.textContent).not.toContain("External objects");
const label = Array.from(container.querySelectorAll("span"))
.find((span) => span.textContent === "Github PR");
expect(label?.querySelector("svg")).toBeTruthy();
const pullRequestLink = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "https://github.com/acme/web/pull/241");
expect(pullRequestLink?.textContent).toContain("PR 241 - Merged");
expect(pullRequestLink?.textContent).not.toContain("acme/web#241");
expect(pullRequestLink?.textContent).not.toContain("Github PR");
expect(pullRequestLink?.querySelectorAll("svg")).toHaveLength(1);
expect(pullRequestLink?.className).not.toContain("paperclip-mention-chip");
expect(pullRequestLink?.className).not.toContain("rounded-full");
expect(pullRequestLink?.className).not.toContain("border");
act(() => root.unmount());
});
it("shows agent-archive attribution and unarchive only in the properties pane", async () => {
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-9", name: "Gardener", status: "active", adapterType: "codex_local", icon: null },
]);
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "agent",
archivedByAgentId: "agent-9",
archivedByRunId: "run-1",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Archived");
// The value shows just the agent name (the row label already says
// "Archived"), giving the name the full column width at 320px.
expect(container.textContent).toContain("Gardener");
const unarchive = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"));
expect(unarchive).toBeTruthy();
});
// The tooltip must carry the full "Archived by <name> · <time>" phrasing so
// the attribution is recoverable if a long name truncates at the 320px pane
// width (PAP-14182 review fix).
const attribution = Array.from(container.querySelectorAll("span"))
.find((span) => span.getAttribute("title")?.startsWith("Archived by Gardener"));
expect(attribution).toBeTruthy();
const unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"))!;
await act(async () => {
unarchiveButton.click();
});
await flush();
expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1");
act(() => root.unmount());
});
it("surfaces unarchive failures inline", async () => {
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-9", name: "Gardener", status: "active", adapterType: "codex_local", icon: null },
]);
mockIssuesApi.unarchiveFromInbox.mockRejectedValue(new Error("Archive policy denied"));
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "agent",
archivedByAgentId: "agent-9",
archivedByRunId: "run-1",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
let unarchiveButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"));
expect(unarchiveButton).toBeTruthy();
});
await act(async () => {
unarchiveButton!.click();
});
await flush();
await waitForAssertion(() => {
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Archive policy denied");
});
act(() => root.unmount());
});
it("does not render archive attribution for user (manual) archives", async () => {
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "user",
archivedByAgentId: null,
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Updated");
});
expect(container.textContent).not.toContain("Archived by");
expect(
Array.from(container.querySelectorAll("button")).some((button) => button.textContent?.includes("Unarchive")),
).toBe(false);
act(() => root.unmount());
});
});