// @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, IssueDocument } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getLastProjectId } from "../lib/recent-projects"; import { IssueProperties } from "./IssueProperties"; import { queryKeys } from "../lib/queryKeys"; const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), adapterModels: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn(), })); const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), controlRuntimeCommands: vi.fn(), })); const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn(), getDocument: vi.fn(), listAcceptedPlanDecompositions: vi.fn(), listAttachments: vi.fn(), listDocuments: vi.fn(), listWorkProducts: vi.fn(), listInteractions: 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(), })); const mockSidebarState = vi.hoisted(() => ({ isMobile: false, })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", }), })); vi.mock("../context/SidebarContext", () => ({ useSidebar: () => mockSidebarState, })); 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) => { if (!userId) return null; return userLabelMap?.get(userId) ?? (userId === currentUserId ? "You" : "User"); }, formatUserLabel: (userId: string | null | undefined, userLabelMap?: Map) => { if (!userId) return null; return userLabelMap?.get(userId) ?? "User"; }, })); vi.mock("./StatusIcon", () => ({ StatusIcon: ({ status, blockerAttention, className, glyphContainerClassName, size }: { status: string; blockerAttention?: Issue["blockerAttention"]; className?: string; glyphContainerClassName?: string; size?: string }) => ( {status} ), })); vi.mock("./PriorityIcon", () => ({ PriorityIcon: ({ priority }: { priority: string }) => {priority}, })); vi.mock("./Identity", () => ({ Identity: ({ name, shape }: { name: string; shape?: string }) => {name}, })); vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null, })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, state: _state, disableIssueQuicklook: _disableIssueQuicklook, issuePrefetch: _issuePrefetch, ...props }: { children: ReactNode; to: string; state?: unknown; disableIssueQuicklook?: boolean; issuePrefetch?: unknown } & ComponentProps<"a">) => {children}, useCaseHref: () => (caseId: string) => `/cases/${caseId}`, useLocation: () => ({ hash: "", pathname: "/", search: "", state: null, key: "test" }), })); vi.mock("@/components/ui/separator", () => ({ Separator: () =>
, })); vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("@/components/IssueDocumentAnnotations", () => ({ DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => , IssueDocumentAnnotations: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("@/components/ui/popover", () => ({ Popover: ({ children }: { children: ReactNode }) =>
{children}
, PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.PointerEvent) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).PointerEvent = MouseEvent; } async function act(callback: () => void | Promise) { let result: void | Promise = 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[aria-label="Edit ${label.toLowerCase()}"]`) ?? 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 { return { id: "issue-1", companyId: "company-1", projectId: null, projectWorkspaceId: null, goalId: null, parentId: null, title: "Parent issue", description: null, status: "todo", priority: "medium", reviewPolicy: null, 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 { 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 { 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 { 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", deliveryState: "unknown", 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 { 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 { return { mode: "normal", commentRequired: true, stages: [], ...overrides, }; } function createExecutionState(overrides: Partial = {}): 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, hiddenSettings: string[] = []) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }); queryClient.setQueryData(queryKeys.health, { hiddenSettings }); const root = createRoot(container); act(() => { root.render( , ); }); return { root, queryClient }; } function renderProperties(container: HTMLDivElement, props: ComponentProps) { const { root } = renderPropertiesWithQueryClient(container, props); return root; } describe("IssueProperties", () => { let container: HTMLDivElement; it("surfaces saved PR review links even when external objects are unavailable", async () => { mockIssuesApi.listWorkProducts.mockResolvedValue([{ id: "pr-1", type: "pull_request", provider: "github", title: "Update runtime probe", url: null, metadata: { url: "https://github.com/example/private-repo/pull/42" }, status: "ready_for_review", reviewState: "needs_board_review", updatedAt: new Date(), }]); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, externalObjectsError: true, }); await waitForAssertion(() => { const link = container.querySelector('a[href="https://github.com/example/private-repo/pull/42"]'); expect(link?.textContent).toBe("example/private-repo#42"); expect(container.textContent).toContain("Review requested"); }); act(() => root.unmount()); }); it("refreshes PR state when opening Properties over an already loaded task", async () => { const saved = { id: "pr-1", type: "pull_request", provider: "github", title: "Update runtime probe", url: "https://github.com/example/private-repo/pull/42", metadata: {}, status: "ready_for_review", reviewState: "needs_board_review", updatedAt: new Date(), }; mockIssuesApi.listWorkProducts.mockImplementation(async (_id, options) => [ options?.refreshPullRequests ? { ...saved, metadata: { state: "merged" } } : saved, ]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.issues.workProducts("issue-1"), [{ ...saved, metadata: { state: "merged" } }]); const root = createRoot(container); act(() => root.render( )); await waitForAssertion(() => { expect(mockIssuesApi.listWorkProducts).toHaveBeenCalledWith("issue-1", expect.objectContaining({ refreshPullRequests: true })); expect(container.textContent).toContain("merged"); expect(container.textContent).not.toContain("Review requested"); }); act(() => root.unmount()); }); it("refreshes PRs on first panel open when the thread uses an identifier cache key", async () => { const saved = { id: "pr-1", type: "pull_request", title: "Update runtime probe", url: "https://github.com/example/private-repo/pull/42", metadata: {}, status: "ready_for_review", reviewState: "needs_board_review", updatedAt: new Date(), }; mockIssuesApi.listWorkProducts.mockImplementation(async (_id, options) => [ options?.refreshPullRequests ? { ...saved, metadata: { state: "merged" } } : saved, ]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.issues.workProducts("PAP-1"), [{ ...saved, metadata: { state: "merged" } }]); const root = createRoot(container); act(() => root.render( )); await waitForAssertion(() => { expect(container.textContent).toContain("merged"); expect(container.textContent).not.toContain("Review requested"); }); act(() => root.unmount()); }); it("shows saved PRs while the first provider refresh is stalled", async () => { const saved = { id: "pr-1", type: "pull_request", title: "Update runtime probe", url: "https://github.com/example/private-repo/pull/42", metadata: {}, status: "ready_for_review", reviewState: "needs_board_review", updatedAt: new Date(), }; let finishRefresh!: (products: unknown[]) => void; const refresh = new Promise((resolve) => { finishRefresh = resolve; }); mockIssuesApi.listWorkProducts.mockImplementation(async (_id, options) => options?.refreshPullRequests ? refresh : [saved]); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true }); await waitForAssertion(() => { expect(mockIssuesApi.listWorkProducts).toHaveBeenCalledWith("issue-1", expect.objectContaining({ refreshPullRequests: true })); expect(container.querySelector(`a[href="${saved.url}"]`)).not.toBeNull(); expect(container.textContent).toContain("Review requested"); }); finishRefresh([{ ...saved, metadata: { state: "merged" } }]); await waitForAssertion(() => { expect(container.textContent).toContain("merged"); expect(container.textContent).not.toContain("Review requested"); }); act(() => root.unmount()); }); it.each([ { statusLabel: "Open", statusCategory: "open", liveness: "stale", statusIconKey: "git-pull-request", review: true }, { statusLabel: "Merged", statusCategory: "succeeded", liveness: "fresh", statusIconKey: "git-merge", review: false }, { statusLabel: "Not found", statusCategory: "archived", liveness: "stale", statusIconKey: null, review: true }, ] as const)("combines a saved PR with its $statusLabel provider status and freshness", async (provider) => { const canonical = "https://github.com/example/private-repo/pull/42"; const savedUrl = `${canonical}?diff=split#discussion_r123`; mockIssuesApi.listWorkProducts.mockResolvedValue([{ id: "pr-1", type: "pull_request", title: "Update runtime probe", url: savedUrl, metadata: {}, status: "ready_for_review", reviewState: "needs_board_review", updatedAt: new Date(), }]); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, externalObjects: [{ pill: { providerKey: "github", objectType: "pull_request", url: canonical, ...provider }, mentionCount: 1, sourceLabels: ["Comment"], group: { object: null, mentions: [], mentionCount: 1, sourceLabels: ["Comment"] }, }], }); await waitForAssertion(() => { const links = container.querySelectorAll('a[href*="/pull/42"]'); expect(links).toHaveLength(1); expect(links[0].getAttribute("href")).toBe(savedUrl); const row = links[0].closest("li")!; expect(row.textContent).toContain(provider.statusLabel); expect(row.querySelector("[data-external-liveness]")?.getAttribute("data-external-liveness")).toBe(provider.liveness); expect(row.textContent?.includes("Review requested")).toBe(provider.review); }); act(() => root.unmount()); }); beforeEach(() => { mockSidebarState.isMobile = false; container = document.createElement("div"); document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); mockAgentsApi.adapterModels.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.getDocument.mockResolvedValue(null); mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]); mockIssuesApi.listAttachments.mockResolvedValue([]); mockIssuesApi.listDocuments.mockResolvedValue([]); mockIssuesApi.listWorkProducts.mockResolvedValue([]); mockIssuesApi.listInteractions.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, enableStreamlinedUi: true, }); }); afterEach(() => { document.body.innerHTML = ""; }); it("marks the task-detail property typography and section rhythm", () => { const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, }); const surface = container.querySelector(".task-detail-properties"); expect(surface).not.toBeNull(); expect(surface?.classList).toContain("pl-4"); expect(surface?.querySelectorAll('[data-property-section="true"]').length).toBeGreaterThan(1); expect(surface?.querySelector('[data-property-value="true"]')).not.toBeNull(); const statusVisual = surface?.querySelector( '[data-property-label="Status"] + [data-property-value="true"] [data-testid="status-icon"]', ); expect(statusVisual).not.toBeNull(); expect(statusVisual?.getAttribute("data-size")).toBeNull(); expect(statusVisual?.getAttribute("data-glyph-container-class")).toContain("size-6"); expect(surface?.querySelector('[data-property-section="true"] > div')?.classList) .toContain("text-muted-foreground/70"); const projectLabel = surface?.querySelector('[data-property-label="Project"]'); const labelsLabel = surface?.querySelector('[data-property-label="Labels"]'); if (!projectLabel || !labelsLabel) throw new Error("Expected Project and Labels rows"); expect(projectLabel.compareDocumentPosition(labelsLabel) & Node.DOCUMENT_POSITION_FOLLOWING) .toBeTruthy(); act(() => root.unmount()); }); it("stacks property chips vertically and aligns each label with the first chip", async () => { const blockedBy = Array.from({ length: 3 }, (_, index) => ({ id: `blocker-${index + 1}`, identifier: `BLOCK-${index + 1}`, title: `Blocker ${index + 1}`, status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, })) as NonNullable; const blocks = Array.from({ length: 3 }, (_, index) => ({ id: `blocked-${index + 1}`, identifier: `BLOCKED-${index + 1}`, title: `Blocked task ${index + 1}`, status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, })) as NonNullable; const labels = Array.from({ length: 3 }, (_, index) => createLabel({ id: `label-${index + 1}`, name: `Label ${index + 1}`, })); const childIssues = Array.from({ length: 3 }, (_, index) => createIssue({ id: `child-${index + 1}`, identifier: `SUB-${index + 1}`, title: `Subtask ${index + 1}`, })); const root = renderProperties(container, { issue: createIssue({ blockedBy, blocks, labels, labelIds: labels.map((label) => label.id), }), childIssues, onUpdate: vi.fn(), sidePanelContentOnly: true, }); await flush(); for (const label of ["Labels", "Blocked by", "Blocking", "Subtasks"]) { const labelNode = container.querySelector(`[data-property-label="${label}"]`); const row = labelNode?.closest('[data-property-row="true"]'); const value = row?.querySelector('[data-property-value="true"]'); expect(row?.classList).toContain("items-start"); expect(labelNode?.classList).toContain("mt-0.5"); expect(value?.classList).toContain("flex-col"); expect(value?.classList).toContain("items-start"); } for (const label of ["Labels", "Blocked by", "Subtasks"]) { const trigger = findRowTrigger(container, label); const chipStack = label === "Labels" ? trigger?.querySelector("div") : trigger?.closest('[data-property-value="true"]')?.querySelector(".flex-col"); expect(chipStack?.classList).toContain("flex-col"); expect(chipStack?.classList).toContain("items-start"); } const blockingValue = container .querySelector('[data-property-label="Blocking"]') ?.closest('[data-property-row="true"]') ?.querySelector('[data-property-value="true"] > div'); expect(blockingValue?.classList).toContain("flex-col"); act(() => root.unmount()); }); it("renders the lone Properties header as the active filled tab", async () => { const headerSlot = document.createElement("div"); headerSlot.id = "properties-pane-header-slot"; document.body.appendChild(headerSlot); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), }); await flush(); const tab = headerSlot.querySelector('[role="tab"]'); expect(tab?.textContent).toBe("Properties"); expect(tab?.getAttribute("aria-selected")).toBe("true"); expect(tab?.classList).toContain("bg-muted"); expect(tab?.classList).toContain("px-3"); expect(tab?.classList).toContain("inline-flex"); act(() => root.unmount()); }); it("uses the same filled active-tab treatment in a multi-tab pane header", async () => { const headerSlot = document.createElement("div"); headerSlot.id = "properties-pane-header-slot"; document.body.appendChild(headerSlot); const root = renderProperties(container, { issue: createIssue(), childIssues: [createIssue({ id: "child-1", identifier: "PAP-2", title: "Child task" })], onUpdate: vi.fn(), }); await flush(); const tabs = Array.from(headerSlot.querySelectorAll('[role="tab"]')); const propertiesTab = tabs.find((tab) => tab.textContent === "Properties"); const subtasksTab = tabs.find((tab) => tab.textContent?.includes("Subtasks")); const tabItems = Array.from(headerSlot.querySelectorAll('[data-slot="task-detail-pane-tab"]')); const tabDividers = Array.from(headerSlot.querySelectorAll('[data-slot="task-detail-pane-tab-divider"]')); const subtasksLabel = subtasksTab?.querySelector('[title="Subtasks"]'); const subtasksCount = Array.from(subtasksTab?.querySelectorAll("span") ?? []) .find((span) => span.textContent === "1"); const closeSubtasks = headerSlot.querySelector('[aria-label="Close Subtasks tab"]'); const openClosedTab = headerSlot.querySelector('[aria-label="Open closed sidebar tab"]'); expect(propertiesTab?.getAttribute("data-state")).toBe("active"); expect(subtasksTab?.getAttribute("data-state")).toBe("inactive"); expect(tabItems).toHaveLength(2); expect(tabDividers).toHaveLength(1); expect(tabDividers[0]?.classList).toContain("h-4"); expect(propertiesTab?.classList).toContain("mx-1.5"); expect(propertiesTab?.classList).toContain("px-3"); expect(subtasksTab?.classList).toContain("px-3"); expect(subtasksTab?.classList).toContain("hover:bg-accent/50"); expect(subtasksLabel?.classList).toContain("task-detail-pane-tab-label"); expect(subtasksLabel?.classList).toContain("flex-1"); expect(subtasksLabel?.classList).toContain("overflow-hidden"); expect(subtasksLabel?.classList).toContain("whitespace-nowrap"); expect(subtasksCount?.classList).toContain("group-hover/pane-tab:opacity-0"); expect(closeSubtasks?.classList).toContain("opacity-0"); expect(closeSubtasks?.classList).toContain("right-2.5"); expect(closeSubtasks?.classList).toContain("group-hover/pane-tab:opacity-100"); expect(openClosedTab?.classList).toContain("size-6"); expect(openClosedTab?.classList).not.toContain("ml-1"); expect(openClosedTab?.disabled).toBe(true); for (const tab of tabs) { expect(tab.classList).toContain("task-detail-pane-tab"); expect(tab.classList).toContain("h-7"); expect(tab.classList).toContain("rounded-md"); } await act(async () => { subtasksTab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); }); expect(propertiesTab?.getAttribute("data-state")).toBe("inactive"); expect(subtasksTab?.getAttribute("data-state")).toBe("active"); await act(async () => closeSubtasks?.click()); expect(headerSlot.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Properties"); expect(headerSlot.textContent).not.toContain("Subtasks"); expect(openClosedTab?.disabled).toBe(false); await act(async () => { openClosedTab?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); openClosedTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); let reopenSubtasks: HTMLElement | undefined; await waitForAssertion(() => { reopenSubtasks = Array.from(document.body.querySelectorAll('[role="menuitem"]')) .find((item) => item.textContent === "Subtasks"); expect(reopenSubtasks).not.toBeUndefined(); }); await act(async () => { reopenSubtasks?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); reopenSubtasks?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(headerSlot.querySelector('[role="tab"][data-state="active"]')?.textContent).toContain("Subtasks"); act(() => root.unmount()); }); it("restores master's production property styling when Streamlined UI is off", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableStreamlinedUi: false, enableClassicTaskInterface: false, }); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, }); await waitForAssertion(() => { expect(container.querySelector(".task-detail-properties")).toBeNull(); expect(container.querySelector('[role="tab"]')).toBeNull(); }); expect(container.textContent).toContain("Status"); expect(container.textContent).toContain("Triage"); act(() => root.unmount()); }); it("restores master's plain Properties pane header when Streamlined UI is off", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableStreamlinedUi: false, enableClassicTaskInterface: false, }); const headerSlot = document.createElement("div"); headerSlot.id = "properties-pane-header-slot"; document.body.appendChild(headerSlot); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), }); await waitForAssertion(() => { expect(headerSlot.textContent).toBe("Properties"); expect(headerSlot.querySelector('[role="tab"]')).toBeNull(); expect(container.querySelector(".task-detail-properties")).toBeNull(); }); act(() => root.unmount()); }); it("does not show a Plan tab for a planning-mode issue without a plan document", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: false, }); const root = renderProperties(container, { issue: createIssue({ workMode: "planning" }), childIssues: [], onUpdate: vi.fn(), inline: true, }); await waitForAssertion(() => { expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(false); expect(container.textContent).not.toContain("This task is in plan mode but no plan document has been written yet."); }); act(() => root.unmount()); }); it("overrides a previously selected pane tab for a document deep link", async () => { const planDocument = { id: "document-plan", companyId: "company-1", issueId: "issue-1", key: "plan", title: "Plan", format: "markdown", body: "Plan body", latestRevisionId: "revision-plan", latestRevisionNumber: 1, createdByAgentId: null, createdByUserId: null, updatedByAgentId: null, updatedByUserId: null, lockedAt: null, lockedByAgentId: null, lockedByUserId: null, createdAt: new Date("2026-08-01T00:00:00.000Z"), updatedAt: new Date("2026-08-01T00:00:00.000Z"), } satisfies IssueDocument; const artifactDocument = { ...planDocument, id: "document-evidence", key: "qa-evidence", title: "QA evidence", body: "Evidence body", latestRevisionId: "revision-evidence", } satisfies IssueDocument; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: false, }); mockIssuesApi.getDocument.mockResolvedValue(planDocument); mockIssuesApi.listDocuments.mockResolvedValue([planDocument, artifactDocument]); Element.prototype.scrollIntoView = vi.fn(); const props = { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, } satisfies ComponentProps; const { root, queryClient } = renderPropertiesWithQueryClient(container, props); await waitForAssertion(() => { const planTab = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent === "Plan"); expect(planTab?.getAttribute("data-state")).toBe("active"); }); await act(async () => { root.render( , ); }); await waitForAssertion(() => { const artifactsTab = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent === "Artifacts"); expect(artifactsTab?.getAttribute("data-state")).toBe("active"); expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull(); }); act(() => root.unmount()); }); it("hides the Priority property row while priority UI is off (PAP-411)", async () => { const root = renderProperties(container, { issue: createIssue({ priority: "high" }), childIssues: [], onUpdate: vi.fn(), inline: true, }); await flush(); await waitForAssertion(() => { // The Triage section still renders the Status row... expect(container.querySelector('[data-property-label="Status"]')).not.toBeNull(); // ...but the Priority row is gated behind SHOW_TASK_PRIORITY_UI (off). expect(container.querySelector('[data-property-label="Priority"]')).toBeNull(); }); act(() => root.unmount()); }); 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('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/"); }); 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('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/"); }); 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[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( "[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[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("exposes the add-subtask action from the relationships picker", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: true, }); const onAddSubIssue = vi.fn(); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onAddSubIssue, onUpdate: vi.fn(), }); await waitForAssertion(() => { expect(container.textContent).toContain("Add subtask"); }); expect(container.textContent).toContain("Subtasks"); const addButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Add subtask")); expect(addButton).not.toBeUndefined(); await act(async () => { addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onAddSubIssue).toHaveBeenCalledTimes(1); act(() => root.unmount()); }); it("gives sub-tasks a dedicated chat-shell panel tab", async () => { const onAddSubIssue = vi.fn(); const root = renderProperties(container, { issue: createIssue(), childIssues: [createIssue({ id: "child-2", identifier: "PAP-2", title: "Panel child" })], onAddSubIssue, onUpdate: vi.fn(), inline: true, }); await flush(); const subtasksTab = Array.from(container.querySelectorAll('button[role="tab"]')) .find((button) => button.textContent?.includes("Subtasks")); expect(subtasksTab).not.toBeUndefined(); await act(async () => { subtasksTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); }); expect(container.textContent).toContain("Panel child"); expect(container.textContent).toContain("0 of 1 complete"); expect(container.textContent).toContain("Next action"); expect(container.querySelector('[data-slot="task-row"]')).not.toBeNull(); const addButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Add subtask")); await act(async () => addButton!.click()); expect(onAddSubIssue).toHaveBeenCalledOnce(); act(() => root.unmount()); }); it("always shows watchdog setup controls", async () => { 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.each([false, true])("keeps relationship badges mounted as queries settle (inline=%s)", async (inline) => { let resolveProjects!: (projects: Project[]) => void; mockProjectsApi.list.mockReturnValue(new Promise((resolve) => { resolveProjects = resolve; })); const onUpdate = vi.fn(); const issue = createIssue({ blockedBy: [createIssue({ id: "blocker-1", identifier: "PAP-2", status: "in_progress" })], }); const props = { issue, childIssues: [], onUpdate, inline, sidePanelContentOnly: true }; const { root, queryClient } = renderPropertiesWithQueryClient(container, props); const link = container.querySelector('a[href="/issues/PAP-2"]'); const remove = container.querySelector('[aria-label="Remove PAP-2 as blocker"]'); expect(link).not.toBeNull(); expect(remove).not.toBeNull(); await flush(); await act(async () => resolveProjects([])); await flush(); // A parent page can provide a fresh task object after a query refresh. await act(async () => root.render( , )); expect(container.querySelector('a[href="/issues/PAP-2"]')).toBe(link); expect(container.querySelector('[aria-label="Remove PAP-2 as blocker"]')).toBe(remove); expect(link?.textContent).toContain("in_progress"); expect(onUpdate).not.toHaveBeenCalled(); expect(container.querySelector('[aria-expanded="true"]')).toBeNull(); act(() => root.unmount()); }); it("preserves a locked parent stub without manufacturing a title or navigation link", async () => { const root = renderProperties(container, { issue: createIssue({ parentId: "private-parent", ancestors: [{ id: "private-parent", identifier: "PAP-99", locked: true }] as unknown as Issue["ancestors"] }), childIssues: [], onUpdate: vi.fn(), inline: true, }); await flush(); expect(container.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toContain("PAP-99"); expect(container.querySelector('a[href="/issues/PAP-99"]')).toBeNull(); expect(container.querySelector('[title="private-"]')).toBeNull(); act(() => root.unmount()); }); it("links relationship status and IDs and removes only the selected blocker with its X", async () => { const onUpdate = vi.fn(); const blockers = [ createIssue({ id: "issue-2", identifier: "PAP-2", title: "Existing blocker", status: "in_progress" }), createIssue({ id: "issue-3", identifier: "PAP-3", title: "Keep blocker", status: "todo" }), ]; const root = renderProperties(container, { issue: createIssue({ blockedBy: blockers }), childIssues: [createIssue({ id: "child-1", identifier: "PAP-4", status: "done" })], onUpdate, inline: true, }); await flush(); const row = container.querySelector('[data-property-label="Blocked by"]')!.closest('[data-property-row]')!; const link = row.querySelector('a[href="/issues/PAP-2"]')!; expect(link).not.toBeNull(); expect(link.textContent).toContain("in_progress"); expect(link.closest("button")).toBeNull(); link.addEventListener("click", (event) => event.preventDefault()); await act(async () => link.click()); expect(onUpdate).not.toHaveBeenCalled(); expect(container.querySelector('input[aria-label="Search tasks to add as blockers"]')).toBeNull(); const remove = row.querySelector('button[aria-label="Remove PAP-2 as blocker"]')!; expect(remove.closest("a")).toBeNull(); await act(async () => remove.click()); expect(onUpdate).toHaveBeenCalledExactlyOnceWith({ blockedByIssueIds: ["issue-3"] }); expect(container.querySelector('a[href="/issues/PAP-4"]')?.textContent).toContain("done"); act(() => root.unmount()); }); it("edits blockers from the blocked-by relationship flyout", 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 blockerTrigger = findRowTrigger(container, "Blocked by"); expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2"); expect(container.textContent).not.toContain("Add blocker"); expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull(); await act(async () => { blockerTrigger!.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 blockerTrigger = findRowTrigger(container, "Blocked by"); expect(blockerTrigger).not.toBeUndefined(); await act(async () => { blockerTrigger!.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 by toggling it in the relationship flyout", 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 blockerTrigger = findRowTrigger(container, "Blocked by"); expect(blockerTrigger).not.toBeUndefined(); await act(async () => { blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); const selectedBlocker = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("PAP-2 Existing blocker")); expect(selectedBlocker).not.toBeUndefined(); await act(async () => { selectedBlocker!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] }); act(() => root.unmount()); }); it("uses the same blocked-by relationship picker on mobile", async () => { mockSidebarState.isMobile = true; 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 blockerTrigger = findRowTrigger(container, "Blocked by"); expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2"); await act(async () => { blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); const selectedBlocker = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("PAP-2 Existing blocker")); expect(selectedBlocker).not.toBeUndefined(); await act(async () => { selectedBlocker!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] }); act(() => root.unmount()); }); it("summarizes long blocked-by and subtask relationships in their picker triggers", 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; 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(); const blockedByTrigger = findRowTrigger(container, "Blocked by"); expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1"); expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-2"); expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more"); expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-7"); const subtasksTrigger = findRowTrigger(container, "Subtasks"); expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-1"); expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-2"); expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more"); expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("SUB-7"); await act(async () => { blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(container.textContent).toContain("BLOCK-7"); await act(async () => { blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); await act(async () => { subtasksTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(container.textContent).toContain("SUB-7"); act(() => root.unmount()); }); it("collapses long blocking and related task lists until the more button is clicked", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); 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; 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(); await waitForAssertion(() => { expect(container.textContent).toContain("RELATED-5"); }); expect(container.textContent).toContain("BLOCKING-5"); expect(container.textContent).not.toContain("BLOCKING-6"); 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("updates the blocked-by relationship summary 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; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, }); const root = createRoot(container); act(() => { root.render( , ); }); await flush(); expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1"); expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more"); const nextBlockedBy = [{ id: "next-blocker", identifier: "NEXT-1", title: "Next blocker", status: "todo" as const, priority: "medium" as const, assigneeAgentId: null, assigneeUserId: null, }]; act(() => { root.render( , ); }); await flush(); expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("NEXT-1"); expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-1"); expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("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("uses the configured project color and icon in the project property", async () => { mockProjectsApi.list.mockResolvedValue([ createProject({ name: "Configured Project", color: "#0ea5e9", icon: "rocket", }), ]); const root = renderProperties(container, { issue: createIssue({ projectId: "project-1" }), childIssues: [], onUpdate: vi.fn(), sidePanelContentOnly: true, }); await waitForAssertion(() => { expect(findRowTrigger(container, "Project")?.textContent).toContain("Configured Project"); }); const projectTrigger = findRowTrigger(container, "Project"); const projectTile = projectTrigger?.querySelector('span.inline-flex[aria-hidden="true"]'); expect(projectTile?.style.backgroundColor).toBe("rgb(14, 165, 233)"); expect(projectTile?.querySelector("svg")?.classList).toContain("lucide-rocket"); act(() => root.unmount()); }); it("leaves project memory unchanged until task property edits are persisted", async () => { localStorage.clear(); mockProjectsApi.list.mockResolvedValue([createProject({ name: "Remembered Project" })]); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, }); await flush(); await act(() => findRowTrigger(container, "Project")!.click()); const option = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Remembered Project")!; expect(option).toBeDefined(); act(() => option.click()); expect(getLastProjectId("company-1")).toBeUndefined(); await act(() => findRowTrigger(container, "Project")!.click()); const none = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "No project")!; act(() => none.click()); expect(getLastProjectId("company-1")).toBeUndefined(); expect(getLastProjectId("company-2")).toBeUndefined(); act(() => root.unmount()); localStorage.clear(); }); 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('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)/); for (const label of ["Started", "Completed", "Created"]) { const labelNode = container.querySelector(`[data-property-label="${label}"]`); const value = labelNode?.parentElement?.querySelector('[data-property-value="true"] > span'); expect(value?.classList).toContain("min-w-0"); expect(value?.classList).toContain("truncate"); expect(value?.classList).toContain("whitespace-nowrap"); expect(value?.title).toBe(value?.textContent); } 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(); await flush(); const branchCopyButton = container.querySelector( 'button[aria-label="Copy pap-1-workspace to clipboard"]', ); const folderCopyButton = container.querySelector( 'button[aria-label="Copy /tmp/paperclip/PAP-1 to clipboard"]', ); expect(branchCopyButton).not.toBeNull(); expect(folderCopyButton?.querySelector('[data-middle-truncate="true"]')?.textContent) .toBe("/tmp/paperclip/PAP-1"); expect(folderCopyButton?.title).toBe("/tmp/paperclip/PAP-1"); 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("separates referenced tasks from tasks that mention this task", 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: [ { issue: { id: "issue-23", identifier: "PAP-23", title: "Mentions this task", status: "in_progress", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-1" }], }, ], }, }), childIssues: [], onUpdate: vi.fn(), inline: true, }); await flush(); expect(container.textContent).not.toContain("Task ids"); expect(container.textContent).not.toContain("Related tasks"); const referencesTab = Array.from(container.querySelectorAll('button[role="tab"]')) .find((button) => button.textContent === "References"); await act(async () => { referencesTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); }); expect(container.textContent).toContain("Referenced"); expect(container.textContent).toContain("PAP-22"); expect(container.textContent).toContain("Mentioned in"); expect(container.textContent).toContain("PAP-23"); act(() => root.unmount()); }); it("hides related task references already covered by blockers, blocking, and sub-issues", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); 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(); await waitForAssertion(() => { expect(container.textContent).not.toContain("Referenced"); }); act(() => root.unmount()); }); it("opens the label picker from its relationship value without a redundant add button", 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(); expect(container.querySelector('button[aria-label="Add label"]')).toBeNull(); expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull(); const labelsTrigger = findRowTrigger(container, "Labels"); expect(labelsTrigger?.textContent).toContain("Bug"); const labelChip = labelsTrigger?.querySelector('[title="Bug"]'); expect(labelChip?.classList).toContain("border-0"); expect(labelChip?.style.borderColor).toBe(""); expect(labelChip?.style.color).not.toBe(""); await act(async () => { labelsTrigger!.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("discovers Copilot overrides with the assignee connection and environment", async () => { const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" }; mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Copilot", role: "engineer", status: "active", adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, runtimeConfig: { aiConnection: binding } }]); mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]); const root = renderProperties(container, { issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-5.6-luna" } } }), childIssues: [], onUpdate: vi.fn(), }); await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", { environmentId: "daytona-1", provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", aiConnection: binding, })); 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-6-astra", label: "gpt-6-astra" }, { 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-6-astra")); expect(modelButton).not.toBeUndefined(); }); await act(async () => { modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "high", }, }, }); act(() => root.unmount()); }); it("keeps Astra-only task efforts when the task inherits the agent model", async () => { const onUpdate = vi.fn(); mockAgentsApi.list.mockResolvedValue([ { id: "agent-1", name: "Senior Product Engineer", role: "engineer", title: null, status: "active", adapterType: "codex_local", adapterConfig: { model: "gpt-6-astra" }, icon: null, }, ]); mockAgentsApi.adapterModels.mockResolvedValue([ { id: "gpt-6-astra", label: "gpt-6-astra" }, { id: "gpt-5.4", label: "GPT-5.4" }, ]); const root = renderProperties(container, { issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { modelReasoningEffort: "ultra" }, }, }), childIssues: [], onUpdate, }); await flush(); await flush(); expect(container.textContent).toContain("Ultra"); expect(container.textContent).toContain("Max"); expect(container.textContent).not.toContain("Minimal"); const defaultModelButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Default model")); expect(defaultModelButton).not.toBeUndefined(); await act(async () => { defaultModelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); const defaultModelOption = Array.from(document.body.querySelectorAll("button")) .filter((button) => button.textContent?.trim() === "Default model") .at(-1); expect(defaultModelOption).not.toBeUndefined(); await act(async () => { defaultModelOption!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: { adapterConfig: { modelReasoningEffort: "ultra" }, }, }); 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 labelsTrigger = findRowTrigger(container, "Labels"); expect(labelsTrigger?.textContent).toContain("Bug"); await act(async () => { labelsTrigger!.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 = findRowTrigger(container, "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("searches the server for parent candidates so a lower-priority match past the default page stays selectable", async () => { const onUpdate = vi.fn(); // The default list is priority-first and caps at 500 rows, so a low-priority // match past that cap never enters the client list. Model that gap: the default // page returns only a high-priority candidate, and the server search with `q` // returns the low-priority match that the default page hides. const defaultPageCandidate = createIssue({ id: "issue-2", identifier: "PAP-2", title: "High priority candidate", status: "in_progress", priority: "high", }); const lowPriorityMatch = createIssue({ id: "issue-900", identifier: "PAP-900", title: "Low priority needle", status: "todo", priority: "low", }); mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => { if (filters?.q === "needle") return Promise.resolve([lowPriorityMatch]); return Promise.resolve([defaultPageCandidate]); }); const root = renderProperties(container, { issue: createIssue(), childIssues: [], onUpdate, inline: true, }); await flush(); const parentTrigger = findRowTrigger(container, "Parent"); expect(parentTrigger).not.toBeUndefined(); await act(async () => { parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); // With an empty search the picker shows the default page. The low-priority match is absent. await waitForAssertion(() => { expect(container.textContent).toContain("PAP-2 High priority candidate"); }); expect(container.textContent).not.toContain("PAP-900 Low priority needle"); const searchInput = container.querySelector('input[placeholder="Search tasks..."]') as HTMLInputElement | null; expect(searchInput).not.toBeNull(); await act(async () => { const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; nativeSetter?.call(searchInput, "needle"); searchInput!.dispatchEvent(new Event("input", { bubbles: true })); }); // The typed text re-queries the server with `q`, and the low-priority match now appears. await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "needle", limit: 50 }); expect(container.textContent).toContain("PAP-900 Low priority needle"); expect(container.textContent).not.toContain("PAP-2 High priority candidate"); }); const candidateButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("PAP-900 Low priority needle")); expect(candidateButton).not.toBeUndefined(); await act(async () => { candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-900" }); act(() => root.unmount()); }); it("renders reviewer and approver controls for a monitor-only execution policy", async () => { const policy = { monitor: { nextCheckAt: "2026-10-08T16:00:00.000Z", notes: "Check pull request", scheduledBy: "assignee", kind: "external_service", serviceName: "github-pr", }, } as IssueExecutionPolicy; const root = renderProperties(container, { issue: createIssue({ executionPolicy: policy }), childIssues: [], onUpdate: vi.fn(), }); await flush(); expect(findRowTrigger(container, "Reviewers")?.textContent).toContain("None"); expect(findRowTrigger(container, "Approvers")?.textContent).toContain("None"); expect(container.querySelector('[data-testid="monitor-row-trigger"]')).not.toBeNull(); act(() => root.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: "00000000-0000-4000-8000-000000000010", type: "review", approvalsNeeded: 1, participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", 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: "00000000-0000-4000-8000-000000000011", type: "approval", approvalsNeeded: 1, participants: [{ id: "00000000-0000-4000-8000-000000000014", 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: "00000000-0000-4000-8000-000000000010", type: "review", approvalsNeeded: 1, participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", 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: "00000000-0000-4000-8000-000000000010", type: "review", approvalsNeeded: 1, participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", 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.each([true, false])("clears a monitor with omitted stages while preserving independent limits (authorization: %s)", async (withAuthorization) => { const onUpdate = vi.fn(); const executionPolicy = { ...(withAuthorization ? { authorizationPolicy: { trustPreset: "standard" } } : {}), maxReviewRounds: 4, monitor: { nextCheckAt: "2099-01-01T12:00:00.000Z", notes: "Check service", scheduledBy: "board" }, } as IssueExecutionPolicy; const before = structuredClone(executionPolicy); const root = renderProperties(container, { issue: createIssue({ executionPolicy }), childIssues: [], onUpdate, inline: true, }); await flush(); expect(container.textContent).toContain("Reviewers"); expect(container.textContent).not.toContain("Execution policy unavailable"); const monitorTrigger = container.querySelector('[data-testid="monitor-row-trigger"]')?.closest("button"); expect(monitorTrigger).toBeTruthy(); await act(async () => { monitorTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const clearButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Clear"); expect(clearButton).toBeTruthy(); await act(async () => { clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onUpdate).toHaveBeenCalledWith({ executionPolicy: { mode: "normal", commentRequired: true, stages: [], ...(withAuthorization ? { authorizationPolicy: before.authorizationPolicy } : {}), maxReviewRounds: 4, } }); expect(executionPolicy).toEqual(before); act(() => root.unmount()); }); it.each([ { stages: {} }, { stages: [null] }, { stages: [{ type: "review", participants: {} }] }, { monitor: { nextCheckAt: {}, notes: {} } }, ])("keeps malformed policies unavailable without exposing destructive edits: %j", async (executionPolicy) => { const onUpdate = vi.fn(); const root = renderProperties(container, { issue: createIssue({ executionPolicy: executionPolicy as unknown as IssueExecutionPolicy }), childIssues: [], onUpdate, inline: true, }); await flush(); expect(container.textContent).toContain("Execution policy unavailable. Refresh to try again."); expect(container.textContent).toContain("Assignee"); expect(container.textContent).not.toContain("No reviewers"); expect(container.textContent).not.toContain("No approvers"); expect(container.textContent).not.toContain("Run review now"); expect(container.querySelector('[data-testid="monitor-row-trigger"]')).toBeNull(); expect(container.querySelector('input[type="datetime-local"]')).toBeNull(); expect(onUpdate).not.toHaveBeenCalled(); act(() => root.unmount()); }); it("renders scheduled, retrying, due, overdue, cleared, and empty monitor row states", async () => { // Anchored to a fixed *local* time, not a fixed UTC instant. The row renders // these in the machine's timezone and labels them "Today" only while they // share a local calendar day with `now`. Pinned to UTC, the pair straddles // local midnight from UTC+8 to UTC+9:30 — the label becomes a date and every // assertion below fails for a reason that has nothing to do with row states. // Anchoring locally keeps them on one day everywhere, which also makes the // rendered clock identical in every timezone, so the times below can stay // exact rather than being relaxed to a pattern. const NOW = new Date(2026, 6, 17, 13, 56, 0, 0); const at = (minutesFromNow: number) => new Date(NOW.getTime() + minutesFromNow * 60_000).toISOString(); const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(NOW.getTime()); const baseMonitorState = { status: "scheduled" as const, nextCheckAt: at(132), 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( , ); }); }; renderMonitor(createIssue({ executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }), executionState: createExecutionState({ monitor: baseMonitorState }), monitorAttemptCount: 1, })); await flush(); expect(monitorRowText()).toContain("In 2h 12m"); // The hour is rendered in the machine's timezone, so it is not pinned here: // this instant is 4:08 PM at UTC and 9:08 AM at UTC-7. What the row states // are actually about — the countdown and the attempt suffix — is asserted // exactly, and the countdown above is timezone-independent already. expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1"); renderMonitor(createIssue({ executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(252) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(132) } }), monitorNextCheckAt: new Date(at(192)), })); 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: at(0) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }), })); await flush(); expect(monitorRowText()).toContain("Due now"); expect(monitorRowText()).toContain("checking momentarily…"); renderMonitor(createIssue({ executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), })); 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: at(-120), attemptCount: 2, clearedAt: at(-116), clearReason: "manual", } }), monitorAttemptCount: 2, monitorLastTriggeredAt: new Date(at(-120)), })); 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[0][number]; function createWatchdogSummary(overrides: Record = {}) { 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; } it("shows the empty watchdog state and saves a new watchdog via the API", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ }); 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({ }); 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(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({ }); 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(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({ }); 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({ }); 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"], }, }, { mentionCount: 1, sourceLabels: ["Comment"], pill: { providerKey: "github", objectType: "pull_request", displayKey: "Github PR", iconKey: "github", statusCategory: "unknown", statusIconKey: null, statusLabel: null, liveness: "unknown", displayTitle: "acme/web#242", url: "https://github.com/acme/web/pull/242", }, 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"); const unrefreshedPullRequestLink = Array.from(container.querySelectorAll("a")) .find((anchor) => anchor.getAttribute("href") === "https://github.com/acme/web/pull/242"); expect(unrefreshedPullRequestLink?.textContent).toContain("PR 242 - Not yet refreshed"); expect(unrefreshedPullRequestLink?.textContent).not.toContain("Not yet resolved"); 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 ·