Files
PaperClipAI/ui/src/components/IssueProperties.test.tsx
T
DottaandPaperclip e68188c438 fix: upstream deployed document-comment, routine-annotation, workspace & board-polling fixes (#8536)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - This change touches several already-shipped subsystems —
document-comment annotations, the selected-agent Conference Room chat
surface, routine description annotations, workspace-operation tracking,
and the board polling/inbox UI
> - A batch of incremental fixes and two small backend additions had
accumulated on a local mainline and were deployed to a live instance,
but never landed upstream — so each `origin/master` sync kept
re-diverging
> - Leaving them un-upstreamed means the same delta has to be re-merged
on every sync and risks being lost or silently reverted
> - This pull request rebases that delta cleanly on top of current
`origin/master` (preserving recent upstream work such as reusable
sandbox leases and the relation-list collapse controls) and brings it up
for review
> - The benefit is that mainline and the deployed instance converge, and
these fixes/additions get normal review + CI + Greptile coverage

## Linked Issues or Issue Description

No single GitHub issue tracks this; it is a bundle of bug fixes and two
small feature additions. Following the issue-template fields:

**Bug fixes (what was wrong → what this does):**
- Document comments rendered out of document order, didn't live-update
across clients, swallowed save failures, and lost the markdown text
selection on re-render. Now: doc-order sort, live updates, surfaced
save-failure state, stable selection across re-renders.
- The board polling hot path returned oversized payloads on every poll.
Now: an opt-in `summary` projection trims the heartbeat-run list
payload.
- Assorted UI fixes: sidebar nav peek/streamlining edge cases, markdown
file-viewer re-mount/line-height issues on iOS Safari, inbox badge/skill
deep-link tab selection, and `⌘.` work-mode cycling on iOS.

**Feature additions:**
- `workspace_operations.issue_id` — associate a workspace operation with
the issue that triggered it (new migration `0106`, schema, service,
shared type).
- Routine **description annotations** — comment threads on a routine's
description document, mirroring issue document annotations (new
migration `0107`, `routine_documents` schema, routes/service,
editable-sections UI).
- Selected-agent **Conference Room chat** surface wiring and live
issue-thread updates.

**Related PR:** #8229 (`feat(control-plane): add annotation and
workspace controls`, draft) covers overlapping
annotation/workspace-control territory — flagging it so a reviewer can
reconcile the two rather than double-merging.

## What Changed

- `feat(workspace)`: `workspace_operations.issue_id` migration +
schema/service/type; issue workspace property controls reconciled with
upstream's evolved "Service" row.
- `feat(routines)`: routine description annotations —
`routine_documents` schema, migration `0107`, routes/service,
`editable-sections` UI.
- `fix(document-comments)`: doc-order sort, live updates, save-failure
surfacing, stable markdown selection (+ storybook story, rerender test).
- `feat(chat)`: selected-agent Conference Room chat surface and live
issue-thread updates (`LiveUpdatesProvider`, `issue-chat-messages`,
interactions service).
- `perf(board)`: opt-in `summary` projection for the board
polling/heartbeat-run list payload, plus assorted sidebar / file-viewer
/ inbox / IssueProperties UI fixes.

Organized into 5 logical commits. Migrations are numbered incrementally
after upstream's latest (`0105`) — `0106` then `0107`, no journal
collision.

## Verification

- Built by 3-way merging the deployed delta onto current
`origin/master`; the only merge conflict (`IssueProperties.test.tsx`,
two adjacent test blocks) was resolved in favor of upstream's evolved
"green service link above the workspace row" layout, which matches the
merged component's rendered output.
- Confirmed recent upstream work is preserved post-merge: reusable
sandbox lease teardown (`#8513`), the IssueProperties relation-list
collapse controls, and the sidebar streamlined-nav default.
- Confirmed the net diff vs `origin/master` is exactly the intended
feature delta (65 files) and that overlapping server files (`issues.ts`,
`agents.ts`, `heartbeat.ts`) only add feature code without disturbing
upstream logic.
- This delta is already running on a live deployed instance.
- Full typecheck/test suite + Greptile to run in CI (see checklist).

## Risks

- Two new migrations (`0106`, `0107`). Both are additive (new table /
new nullable column) and ordered after upstream's `0105`; no data
backfill, low risk. If another migration-bearing PR merges first,
renumber before merge.
- Largest blast radius is in the merged overlapping UI/service files;
covered by the existing test suites for those files plus CI.
- Overlaps thematically with draft PR #8229 — reviewers should reconcile
rather than merge both blindly.

## Model Used

Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use
(git, shell). Agentic coding workflow.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-23 09:00:14 -05:00

1961 lines
62 KiB
TypeScript

// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type {
ExecutionWorkspace,
IssueExecutionPolicy,
IssueExecutionState,
IssueLabel,
Project,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueProperties } from "./IssueProperties";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
adapterModels: vi.fn(),
adapterModelProfiles: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
controlRuntimeCommands: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(),
createLabel: vi.fn(),
upsertWatchdog: vi.fn(),
deleteWatchdog: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/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: () => "Me",
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Parent issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
function createLabel(overrides: Partial<IssueLabel> = {}): IssueLabel {
return {
id: "label-1",
companyId: "company-1",
name: "Bug",
color: "#ef4444",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createRuntimeService(overrides: Partial<WorkspaceRuntimeService> = {}): WorkspaceRuntimeService {
return {
id: "service-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
issueId: "issue-1",
scopeType: "execution_workspace",
scopeId: "workspace-1",
serviceName: "web",
status: "running",
lifecycle: "shared",
reuseKey: null,
command: "pnpm dev",
cwd: "/tmp/paperclip",
port: 62475,
url: "http://127.0.0.1:62475",
provider: "local_process",
providerRef: null,
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: new Date("2026-04-06T12:03:00.000Z"),
startedAt: new Date("2026-04-06T12:02:00.000Z"),
stoppedAt: null,
stopPolicy: null,
healthStatus: "healthy",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
...overrides,
};
}
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-main",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 workspace",
status: "active",
cwd: "/tmp/paperclip/PAP-1",
repoUrl: null,
baseRef: "master",
branchName: "pap-1-workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip/PAP-1",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T12:04:00.000Z"),
openedAt: new Date("2026-04-06T12:01:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [createRuntimeService()],
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
...overrides,
};
}
function createProject(overrides: Partial<Project> = {}): Project {
const primaryWorkspace = {
id: "workspace-main",
companyId: "company-1",
projectId: "project-1",
name: "Main",
sourceType: "local_path" as const,
cwd: "/tmp/paperclip",
repoUrl: null,
repoRef: null,
defaultRef: "master",
visibility: "default" as const,
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: true,
runtimeServices: [],
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#6366f1",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: "workspace-main",
repoUrl: null,
repoRef: null,
defaultRef: "master",
repoName: null,
localFolder: "/tmp/paperclip",
managedFolder: "/tmp/paperclip",
effectiveLocalFolder: "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [primaryWorkspace],
primaryWorkspace,
archivedAt: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
return {
mode: "normal",
commentRequired: true,
stages: [],
...overrides,
};
}
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
return {
status: "changes_requested",
currentStageId: "stage-1",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: "changes_requested",
...overrides,
};
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} />
</QueryClientProvider>,
);
});
return root;
}
describe("IssueProperties", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.controlRuntimeCommands.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockIssuesApi.createLabel.mockResolvedValue(createLabel({
id: "label-new",
name: "New label",
color: "#6366f1",
}));
mockIssuesApi.upsertWatchdog.mockResolvedValue({});
mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
});
});
afterEach(() => {
document.body.innerHTML = "";
});
it("groups the assignee picker and gates a live-run reassign behind an interrupt confirm", async () => {
const minimalAgent = (id: string, name: string) =>
({
id,
name,
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number]);
mockAgentsApi.list.mockResolvedValue([minimalAgent("agent-1", "ClaudeCoder"), minimalAgent("agent-2", "QA")]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ assigneeAgentId: "agent-1" }),
childIssues: [],
onUpdate,
inline: true,
hasActiveRun: true,
});
await flush();
// Wait for the agents query to resolve so the current assignee renders.
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
trigger = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("ClaudeCoder"),
);
expect(trigger).toBeTruthy();
});
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
// Live-run banner + grouped section headers are present.
expect(container.querySelector("[data-testid='assignee-running-banner']")?.textContent).toContain(
"ClaudeCoder is running",
);
expect(container.textContent).toContain("Agents");
expect(container.textContent).toContain("Board users");
// Picking a different agent mid-run stages a confirm rather than applying.
const qaOption = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "QA",
);
expect(qaOption).toBeTruthy();
await act(async () => {
qaOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector("[data-testid='interrupt-assign-confirm']")).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
// Confirming applies the reassignment.
const confirmBtn = container.querySelector<HTMLButtonElement>(
"[data-testid='interrupt-assign-confirm-action']",
)!;
await act(async () => {
confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(onUpdate).toHaveBeenCalledWith({ assigneeAgentId: "agent-2", assigneeUserId: null });
act(() => root.unmount());
});
it("filters the no-assignee option with assignee search", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number],
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const searchInput = container.querySelector(
'input[placeholder="Search assignees..."]',
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "no");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("No assignee");
expect(container.textContent).not.toContain("No matches.");
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "zzzz");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(container.textContent).not.toContain("No assignee");
expect(container.textContent).toContain("No matches.");
act(() => root.unmount());
});
it("always exposes the add sub-issue action", async () => {
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onAddSubIssue,
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Sub-tasks");
expect(container.textContent).toContain("Add sub-task");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-task"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAddSubIssue).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("hides watchdog setup controls while the experimental flag is off", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Watchdog");
expect(container.textContent).not.toContain("Set watchdog");
act(() => root.unmount());
});
it("shows watchdog setup controls when the experimental flag is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
expect(container.textContent).toContain("Set watchdog");
});
act(() => root.unmount());
});
it("passes blocker attention to the sidebar status icon", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
act(() => root.unmount());
});
it("renders blocked-by issues as direct chips and edits them from an add action", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-3", identifier: "PAP-3", title: "New blocker", status: "todo" }),
]);
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const blockerLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(blockerLink).not.toBeNull();
expect(blockerLink?.textContent).toContain("PAP-2");
expect(blockerLink?.closest("button")).toBeNull();
expect(blockerLink?.className).toContain("px-2");
expect(blockerLink?.className).toContain("py-0.5");
expect(blockerLink?.className).toContain("text-xs");
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton?.className).toContain("absolute");
expect(container.textContent).toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search tasks..."]')).not.toBeNull();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-3 New blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-2", "issue-3"] });
act(() => root.unmount());
});
it("searches all company issues when adding a blocker", async () => {
const onUpdate = vi.fn();
const loadedIssue = createIssue({ id: "issue-3", identifier: "PAP-3", title: "Loaded issue", status: "todo" });
const remoteIssue = createIssue({ id: "issue-99", identifier: "PAP-99", title: "Remote blocker", status: "in_progress" });
mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => {
if (filters?.q === "remote") return Promise.resolve([remoteIssue]);
return Promise.resolve([loadedIssue]);
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const searchInput = container.querySelector('input[aria-label="Search tasks to add as blockers"]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "remote");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "remote", limit: 50 });
expect(container.textContent).toContain("PAP-99 Remote blocker");
expect(container.textContent).not.toContain("PAP-3 Loaded issue");
});
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-99 Remote blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-99"] });
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton).not.toBeNull();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this task.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
act(() => root.unmount());
});
it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({
id: `child-${index + 1}`,
identifier: `SUB-${index + 1}`,
title: `Sub-task ${index + 1}`,
}));
const root = renderProperties(container, {
issue: createIssue({ blockedBy }),
childIssues,
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.textContent).toContain("BLOCK-5");
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-5");
expect(container.textContent).not.toContain("SUB-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "and 2 more...",
),
).toHaveLength(2);
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "and 2 more...",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("BLOCK-6");
expect(container.textContent).toContain("BLOCK-7");
expect(container.textContent).not.toContain("SUB-6");
expect(container.textContent).toContain("show less");
const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "and 2 more...",
);
expect(expandSubTasks).not.toBeUndefined();
await act(async () => {
expandSubTasks!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("SUB-7");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "and 2 more...",
),
).toHaveLength(0);
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.trim() === "show less",
),
).toHaveLength(2);
const collapseBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "show less",
);
expect(collapseBlockedBy).not.toBeUndefined();
await act(async () => {
collapseBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-6");
expect(container.textContent).toContain("and 2 more...");
act(() => root.unmount());
});
it("resets expanded relation previews when the issue changes", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-a", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
await flush();
const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "and 2 more...",
);
expect(expandBlockedBy).not.toBeUndefined();
await act(async () => {
expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("BLOCK-6");
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-b", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
await flush();
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("and 2 more...");
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const updatedWorkspace = createExecutionWorkspace({
mode: "isolated_workspace",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "stopped", stoppedAt: new Date("2026-04-06T12:06:00.000Z") })],
});
mockExecutionWorkspacesApi.controlRuntimeCommands.mockResolvedValue({
workspace: updatedWorkspace,
operation: {},
});
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
config: {
environmentId: null,
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running", configIndex: 0 })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const serviceLink = container.querySelector(`a[href="${serviceUrl}"]`);
expect(serviceLink).not.toBeNull();
expect(serviceLink?.className).toContain("sm:self-start");
expect(serviceLink?.className).not.toContain("sm:self-end");
expect((container.textContent ?? "").indexOf("Workspace")).toBeLessThan(
(container.textContent ?? "").indexOf("Service"),
);
const stopButton = container.querySelector<HTMLButtonElement>('button[aria-label="Stop"]');
expect(stopButton).not.toBeUndefined();
expect(stopButton?.getAttribute("data-size")).toBe("icon-xs");
expect(stopButton?.getAttribute("data-variant")).toBe("outline");
await act(async () => {
stopButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockExecutionWorkspacesApi.controlRuntimeCommands).toHaveBeenCalledWith(
"workspace-1",
"stop",
expect.objectContaining({ action: "stop", runtimeServiceId: "service-1" }),
);
expect(container.textContent).toContain("Workspace service stopped.");
act(() => root.unmount());
});
it("shows full date and time for issue metadata timestamps", async () => {
const root = renderProperties(container, {
issue: createIssue({
createdAt: new Date(2026, 3, 6, 12, 34),
startedAt: new Date(2026, 3, 6, 12, 35),
completedAt: new Date(2026, 3, 6, 12, 36),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toMatch(/CreatedApr 6, 2026, \d{1,2}:34 (AM|PM)/);
expect(container.textContent).toMatch(/StartedApr 6, 2026, \d{1,2}:35 (AM|PM)/);
expect(container.textContent).toMatch(/CompletedApr 6, 2026, \d{1,2}:36 (AM|PM)/);
act(() => root.unmount());
});
it("shows only the workspace detail link for non-default workspaces", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "isolated_workspace",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
await flush();
const workspaceLink = Array.from(container.querySelectorAll("a")).find(
(link) => link.textContent?.trim() === "View workspace",
);
expect(container.textContent).not.toContain("View workspace tasks");
expect(workspaceLink).not.toBeUndefined();
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
act(() => root.unmount());
});
it("copies branch and folder values with visible feedback", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const root = renderProperties(container, {
issue: createIssue({
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
branchName: "pap-1-workspace",
cwd: "/tmp/paperclip/PAP-1",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
const branchCopyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
expect(branchCopyButton).not.toBeNull();
await act(async () => {
branchCopyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(writeText).toHaveBeenCalledWith("pap-1-workspace");
expect(container.textContent).toContain("Copied");
act(() => root.unmount());
});
it("does not show a service link for the main shared workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
const root = renderProperties(container, {
issue: createIssue({
projectId: "project-1",
projectWorkspaceId: "workspace-main",
executionWorkspaceId: "workspace-1",
currentExecutionWorkspace: createExecutionWorkspace({
mode: "shared_workspace",
projectWorkspaceId: "workspace-main",
runtimeServices: [createRuntimeService({ url: serviceUrl, status: "running" })],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull();
expect(container.textContent).not.toContain("View workspace tasks");
expect(Array.from(container.querySelectorAll("a")).some(
(link) => link.textContent?.trim() === "View workspace",
)).toBe(false);
act(() => root.unmount());
});
it("shows related task references below sub-issues", async () => {
const root = renderProperties(container, {
issue: createIssue({
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Related task",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
],
inbound: [],
},
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Task ids");
expect(container.textContent).toContain("Related Tasks");
expect(container.textContent).toContain("PAP-22");
act(() => root.unmount());
});
it("hides related task references already covered by blockers, blocking, and sub-issues", async () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [
{
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
{
issue: {
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }],
},
{
issue: {
id: "child-44",
identifier: "PAP-44",
title: "Child issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }],
},
],
inbound: [],
},
}),
childIssues: [
createIssue({
id: "child-44",
identifier: "PAP-44",
title: "Child issue",
}),
],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Related Tasks");
act(() => root.unmount());
});
it("shows an add-label button when labels already exist and opens the picker", async () => {
const root = renderProperties(container, {
issue: createIssue({
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull();
expect(container.querySelector('button[title="Delete Bug"]')).toBeNull();
act(() => root.unmount());
});
it("shows selected labels from labelIds even before the issue labels relation refreshes", async () => {
mockIssuesApi.listLabels.mockResolvedValue([createLabel()]);
const root = renderProperties(container, {
issue: createIssue({
labels: [],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await flush();
expect(container.textContent).toContain("Bug");
expect(container.textContent).not.toContain("No labels");
act(() => root.unmount());
});
it("hides model options when the issue uses the assignee default", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: null,
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Model lane");
expect(container.textContent).not.toContain("Codex options");
act(() => root.unmount());
});
it("edits existing custom assignee model options from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.5", label: "GPT-5.5" },
{ id: "gpt-5.4", label: "GPT-5.4" },
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
modelReasoningEffort: "high",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
await flush();
expect(container.textContent).toContain("Custom · gpt-5.4 · high");
expect(container.textContent).toContain("Model lane");
// Wait for the adapter-models query to resolve so the model options render.
let modelButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
modelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("GPT-5.5"));
expect(modelButton).not.toBeUndefined();
});
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.5",
modelReasoningEffort: "high",
},
},
});
act(() => root.unmount());
});
it("clears existing assignee adapter overrides from the properties pane", async () => {
const onUpdate = vi.fn();
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
},
]);
const root = renderProperties(container, {
issue: createIssue({
assigneeAgentId: "agent-1",
assigneeAdapterOverrides: {
adapterConfig: {
model: "gpt-5.4",
},
},
}),
childIssues: [],
onUpdate,
});
await flush();
const clearButton = container.querySelector('button[aria-label="Clear adapter options"]');
expect(clearButton).not.toBeNull();
await act(async () => {
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null });
act(() => root.unmount());
});
it("shows a checkmark on selected labels in the picker", async () => {
mockIssuesApi.listLabels.mockResolvedValue([
createLabel(),
createLabel({ id: "label-2", name: "Feature", color: "#22c55e" }),
]);
const root = renderProperties(container, {
issue: createIssue({
labels: [createLabel()],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const labelButtons = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.includes("Bug") || button.textContent?.includes("Feature"));
const bugButton = labelButtons.find((button) => button.textContent?.includes("Bug") && button.querySelector("svg"));
const featureButton = labelButtons.find((button) => button.textContent?.includes("Feature"));
expect(bugButton).not.toBeUndefined();
expect(featureButton?.querySelector("svg")).toBeNull();
act(() => root.unmount());
});
it("allows setting and clearing a parent issue from the properties pane", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const parentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(parentTrigger).not.toBeUndefined();
await act(async () => {
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
onUpdate.mockClear();
const rerenderedIssue = createIssue({
parentId: "issue-2",
ancestors: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Candidate parent",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
goalId: null,
project: null,
goal: null,
},
],
});
act(() => root.unmount());
const rerenderedRoot = renderProperties(container, {
issue: rerenderedIssue,
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();
expect(selectedParentTrigger!.contains(parentLink)).toBe(false);
await act(async () => {
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const clearParentButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(clearParentButton).not.toBeUndefined();
await act(async () => {
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
act(() => rerenderedRoot.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
}),
childIssues: [],
onUpdate,
});
await flush();
const runReviewButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Run review now"));
expect(runReviewButton).not.toBeUndefined();
await act(async () => {
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
act(() => root.unmount());
});
it("shows a run approval action when approval is the next runnable stage", async () => {
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "approval-stage",
type: "approval",
approvalsNeeded: 1,
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
},
],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run approval now");
expect(container.textContent).not.toContain("Run review now");
act(() => root.unmount());
});
it("keeps the run review action available after changes are requested", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState(),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run review now");
act(() => root.unmount());
});
it("hides the run action while an execution stage is already pending", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_review",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState({
status: "pending",
currentStageType: "review",
lastDecisionOutcome: null,
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Run review now");
expect(container.textContent).not.toContain("Run approval now");
act(() => root.unmount());
});
it("renders monitor controls and clears an existing monitor", async () => {
const 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("Next check");
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull();
const monitorTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Next check"));
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());
});
const watchdogAgent = {
id: "agent-1",
name: "ClaudeCoder",
role: "",
title: null,
icon: null,
status: "active",
orgChainHealth: { status: "ok" },
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number];
function createWatchdogSummary(overrides: Record<string, unknown> = {}) {
return {
id: "watchdog-1",
companyId: "company-1",
issueId: "issue-1",
watchdogAgentId: "agent-1",
instructions: "Keep the tree moving.",
status: "active",
watchdogIssueId: null,
lastObservedFingerprint: null,
lastReviewedFingerprint: null,
lastTriggeredAt: null,
lastCompletedAt: null,
triggerCount: 0,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
} as unknown as NonNullable<Issue["watchdog"]>;
}
it("shows the empty watchdog state and saves a new watchdog via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ watchdog: null }),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
let trigger: HTMLButtonElement | undefined;
await waitForAssertion(() => {
expect(container.textContent).toContain("Watchdog");
trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Set 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("renders an existing watchdog and removes it via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ watchdog: createWatchdogSummary() }),
childIssues: [],
onUpdate,
inline: true,
});
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");
act(() => root.unmount());
});
it("links to the generated watchdog task when one exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const root = renderProperties(container, {
issue: createIssue({ watchdog: createWatchdogSummary({ watchdogIssueId: "issue-wd" }) }),
childIssues: [
createIssue({
id: "issue-wd",
identifier: "PAP-42",
title: "Watchdog: Parent issue",
originKind: "task_watchdog",
}),
],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
const link = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "/issues/issue-wd");
expect(link).toBeTruthy();
expect(link!.textContent).toContain("PAP-42");
});
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: 1,
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: 1,
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"],
},
},
],
});
await flush();
expect(container.textContent).toContain("Github Pull Request");
expect(container.textContent).toContain("Github Issue");
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 Pull Request");
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 Pull Request");
expect(pullRequestLink?.querySelectorAll("svg")).toHaveLength(1);
expect(pullRequestLink?.className).not.toContain("paperclip-mention-chip");
expect(pullRequestLink?.className).not.toContain("rounded-full");
expect(pullRequestLink?.className).not.toContain("border");
act(() => root.unmount());
});
});