Files
PaperClipAI/ui/src/components/IssueProperties.test.tsx
T
DottaandPaperclip 3dffd2197d test: consolidate owner fixtures and retain real regression proof (#16047)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tests protect sandbox execution, run recovery, credentials and the
board UI.
> - Several owner suites repeat the same setup and prove the same result
more than once.
> - These copies add maintenance work and can hide weak assertions.
> - Each cut needs a retained test that proves every distinct contract.
> - This pull request consolidates repeated setup and removes twelve
redundant executions.
> - It also replaces two weak assertions with checks of real outputs.
> - The benefit is 2,831 fewer test lines with clearer regression proof.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The maintenance and regression strength of the existing Vitest owner
suites.

**Subsystem affected**

Sandbox adapter utilities, server heartbeat recovery and environment
capabilities, and the board UI.

**Current behavior**

Tests copy database rows, query builders, render calls and runtime
inputs. Some cases repeat an existing contract. One token test omits a
real bridge credential. A PostHog host comparison can compare absent
controls.

**Proposed behavior**

Use small local helpers with fresh values and the same action order.
Keep each distinct contract in an owner test. Check real bridge
credentials and fixed PostHog method states. Carry the exact persisted
first-run success assertion into the stronger cancellation keeper.

**Reason and benefit**

The ten test files shrink by 2,831 net lines. Twelve redundant
executions retire. All other case tables, inputs, security boundaries,
timeouts and cleanup rules remain.

**Breaking changes**

None. This round changes tests and audit evidence only.

**Additional context**

Refs: #15978, the merged first cleanup. Refs: #16022, the merged prior
cleanup. Its squash merge is `dabb8c5daf430a1987af611e2e7ca4c7b23ef7d4`.

Related open work: #14634 repairs feedback fixture completion; #14477
supplies explicit sandbox fixture tools; #14198 changes cleanup retry
behavior in another heartbeat file. This audit preserves those separate
action and cleanup contracts. It does not apply their changes.

This PR was first stacked on `codex/test-audit-followup`. After #16022
was squash merged, only the two Round 6 commits were replayed onto
merged master `dabb8c5daf430a1987af611e2e7ca4c7b23ef7d4`. The original
stack head is preserved in a local snapshot branch. The current diff
contains only ten test files and two evidence files. No merge is
requested.

## What Changed

- Consolidate repeated setup in three sandbox adapter suites. Remove
seven redundant executions and retain the real protocol, cleanup and
scratch-file checks. Save 875 lines.
- Consolidate heartbeat database fixtures and read queries. Retain exact
wake cancellation, total run count and the actual first-run success
receipt. Remove one duplicate execution. Save 1,372 lines.
- Remove two repeated rejected-capability cases. Carry the absent
snapshot assertion into the retained runner case. Save 22 lines.
- Consolidate UI fixtures and render calls. Remove two repeated
connection cases. Replace the weak PostHog comparison with fixed method
and required-key checks in both real hosts. Save 562 lines.
- Record audit scope, decision and expansion counts, all twelve keeper
mappings and deliberate mutation results in the [audit
report](https://github.com/paperclipai/paperclip/blob/codex/test-audit-round-six-delivery/doc/plans/2026-10-12-test-audit-round6.md)
and [controls
evidence](https://github.com/paperclipai/paperclip/blob/codex/test-audit-round-six-delivery/doc/plans/2026-10-12-test-audit-round6-controls.json).

## Verification

Source baseline: `a090035cafb29c06f6fca2dbe43561ad866b7c1b`. All ten
original test programs are byte-identical at the stacked base
`58c5c4aa241c85f4665a3159c192888bbb2a5cfa`.

- Baseline: 1,841/1,841 cases passed in the main thirty-file
owner/sibling run. Another 219/219 cases passed in nine extra files.
- Preservation: all four proposal packages passed independent review.
Whole-program helper expansion retains ordered inputs, callbacks,
awaits, queries, fresh allocations and hooks outside the declared
assertion migrations and repairs.
- Candidate: all 2,048 cases passed across 39 complete owner/sibling
files. The main run passed 1,831 cases. The extra run passed 217 cases.
Both runs had zero failures and zero skips.
- PostHog control: the repaired positive case passed. The original weak
case passed with authentication choices removed. The repaired case
failed at its fixed OAuth aria-checked assertion under the same
mutation.
- Eight accepted mutations failed at their intended assertions. They
cover bridge-token exclusion, fallback cleanup, READY bytes, real
scratch-file preservation, exact wake cancellation, the persisted
first-run success receipt, rejected capability snapshots and reachable
PostHog method controls. Two original weak cases passed with the same
defects. All production source bytes were restored.
- Rejected control: changing run status before legacy release timed out
before wake cancellation. This result is not counted as assertion proof.
Its replacement changed only the persisted receipt after real drain in
the existing transaction. It failed at the direct fixed succeeded-row
assertion.
- No new code comments or 1,000-line threshold crossings were
introduced. No production, schema, lockfile or workflow changes are
included.

On the exact final test sources, later committed as
`d8de9422f0703d4508a9dc880b67a07f13e0f110`, `pnpm -r typecheck`, `pnpm
check:token-gates` and `pnpm build` passed. The required full `pnpm
test:run` completed with **exit 1** after **52.14 minutes** on clean,
immutable `d8de9422f0703d4508a9dc880b67a07f13e0f110`. HEAD and tracked
sources remained unchanged.

The first general-server group ran 889 files: **882 passed, one failed
and six skipped**. Its 18,032 cases were **17,925 passed, two failed and
105 skipped**. The only failing file was
`server/src/__tests__/remove-test-home.test.ts`. On macOS, the read-only
runtime bundle and symlink cleanup cases returned `ENOTEMPTY`. The
permission-error case expects `EACCES` or `EPERM`. Four reported failure
entries cover these two cases, including their cleanup errors. The test
and helper are byte-identical to previous master
`4b766b872993045a2e787404f0475503683e5ded` and audit master
`e364b92c82`.

The default wrapper stopped after that group failed. **Later local
groups did not run.** The earlier public-MCP, HTTP log redaction and
workspace snapshot failures did not recur. The 39 focused owner/sibling
files and all 48 remote CI jobs completed independently. This result is
an unresolved macOS cleanup limitation, not full local success. No test,
skip, retry, timeout or cleanup rule was changed to hide it.

The three adapter files received a whitespace tidy after focused
execution. Full runtime AST and string/template/regex byte equality link
those tested sources to the final sources. All 134 adapter helper calls
were checked again. Replayed code commit
`3d3defd2a60595a623045d9e2c6c586f4acb3452` has the exact final test
bytes. Evidence commit `e868e6645ce0d598d756d3458f0b3d71afd6db4a` adds
the report and controls JSON, with one historical ancestry
clarification. All ten accepted test hashes and all seven
production-owner source guards match. No package, lockfile, workspace,
TypeScript, Rust or Vitest dependency manifest changed since the audit
snapshot.

The [original stack CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/38312876910)
passed all 48 jobs on head `c171d6c0d603d22bb997c1f9a9bdcd5bcc5d22aa`.
Those results are historical stack proof.

The [fresh master-context CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/38318223856)
completed successfully with **all 48 jobs passed** on current head
`e868e6645ce0d598d756d3458f0b3d71afd6db4a`. Its tested merge commit was
`41292af60c6eb60d477637d23822bb17b08dc07e`, combining workflow base
`88bf4c6c1d222f818faa56257efe79207c892a6c` with that head. The delivery
commits remain based on pinned master
`dabb8c5daf430a1987af611e2e7ca4c7b23ef7d4`. Both required master checks,
`ci / verify` and `ci / e2e`, passed. Two separate optional Storybook
checks were skipped by their scope gate.

[Fresh Greptile
review](https://github.com/paperclipai/paperclip/pull/16047#issuecomment-6133323165)
gives the current head **5/5**, with no findings, inline comments or
unresolved review threads. The [current-head commitperclip
review](https://github.com/paperclipai/paperclip/actions/runs/38318223666)
also completed successfully. This PR remains open and draft; no merge is
requested. The local macOS failure above remains unresolved.

## Risks

- A helper could share a value or capture the wrong binding. Independent
expansion and lexical binding checks guard the actual helper calls.
- A retired case could contain a distinct assertion. Each cut has an
exact keeper map and carried observations.
- A mutation could fail before the intended assertion. Controls reject
timeouts and earlier failures. The rejected receipt mutation remains in
the evidence.
- The delivery ancestry changed after the parent squash merge. Exact
source hashes preserve the audited tests and producer receipts. Fresh CI
and review passed in the current master context; the broad local run
remains evidence from its immutable audit snapshot.
- The complete local default failed in two unchanged macOS cleanup
cases. Later local groups were not run. This limitation remains visible
even though focused checks and remote CI passed.

## Model Used

OpenAI Codex, based on GPT-6, assisted with this work. The exact served
model ID and context window are not exposed in this session. It used
repository tools, code execution, AST analysis and parallel audit
agents.

## 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)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 21:43:12 -05:00

3706 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type {
ExecutionWorkspace,
IssueExecutionPolicy,
IssueExecutionState,
IssueLabel,
Project,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getLastProjectId } from "../lib/recent-projects";
import { IssueProperties } from "./IssueProperties";
import { applyOptimisticIssueFieldUpdate } from "../lib/optimistic-issue-comments";
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<string, string>) => {
if (!userId) return null;
return userLabelMap?.get(userId) ?? (userId === currentUserId ? "You" : "User");
},
formatUserLabel: (userId: string | null | undefined, userLabelMap?: Map<string, string>) => {
if (!userId) return null;
return userLabelMap?.get(userId) ?? "User";
},
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status, blockerAttention, className, glyphContainerClassName, size }: { status: string; blockerAttention?: Issue["blockerAttention"]; className?: string; glyphContainerClassName?: string; size?: string }) => (
<span className={className} data-glyph-container-class={glyphContainerClassName} data-testid="status-icon" data-size={size} data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("./PriorityIcon", () => ({
PriorityIcon: ({ priority }: { priority: string }) => <span>{priority}</span>,
}));
vi.mock("./Identity", () => ({
Identity: ({ name, shape }: { name: string; shape?: string }) => <span data-shape={shape ?? "circle"}>{name}</span>,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
state: _state,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch: _issuePrefetch,
...props
}: { children: ReactNode; to: string; state?: unknown; disableIssueQuicklook?: boolean; issuePrefetch?: unknown } & ComponentProps<"a">) => <a href={to} {...props}>{children}</a>,
useCaseHref: () => (caseId: string) => `/cases/${caseId}`,
useLocation: () => ({ hash: "", pathname: "/", search: "", state: null, key: "test" }),
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/IssueDocumentAnnotations", () => ({
DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => <span data-doc-key={docKey} />,
IssueDocumentAnnotations: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
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;
if (!globalThis.PointerEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
/**
* Finds the trigger button for a property row by its (sentence-case) label.
* `PropertyRow` exposes a stable label hook so layout utility changes do not
* affect tests that need to find the corresponding value slot.
*/
function findRowTrigger(container: HTMLElement, label: string): HTMLButtonElement | undefined {
const labelSpan = container.querySelector(`[data-property-label="${label}"]`);
const row = labelSpan?.closest('[data-property-row="true"]');
return ((row?.querySelector(`button[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> = {}): 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> = {}): 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",
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> = {}): 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 createRelation(id: string, identifier: string, title: string, status: Issue["status"] = "todo") {
return {
id,
identifier,
title,
status,
priority: "medium" as const,
assigneeAgentId: null,
assigneeUserId: null,
};
}
function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>, hiddenSettings: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const { root } = renderPropertiesWithQueryClient(container, props);
return root;
}
function seniorProductEngineer() {
return {
id: "agent-1",
name: "Senior Product Engineer",
role: "engineer",
title: null,
status: "active",
adapterType: "codex_local",
icon: null,
};
}
function reviewPolicyStages(): Pick<IssueExecutionPolicy, "stages"> {
return {
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 }],
},
],
};
}
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(<QueryClientProvider client={queryClient}>
<IssueProperties issue={createIssue()} childIssues={[]} onUpdate={vi.fn()} inline />
</QueryClientProvider>));
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(<QueryClientProvider client={queryClient}>
<IssueProperties issue={createIssue()} childIssues={[]} onUpdate={vi.fn()} inline />
</QueryClientProvider>));
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<unknown[]>((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) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable<Issue["blockedBy"]>;
const blocks = Array.from({ length: 3 }, (_, index) => (createRelation(`blocked-${index + 1}`, `BLOCKED-${index + 1}`, `Blocked task ${index + 1}`))) as NonNullable<Issue["blocks"]>;
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLElement>('[data-slot="task-detail-pane-tab"]'));
const tabDividers = Array.from(headerSlot.querySelectorAll<HTMLElement>('[data-slot="task-detail-pane-tab-divider"]'));
const subtasksLabel = subtasksTab?.querySelector<HTMLElement>('[title="Subtasks"]');
const subtasksCount = Array.from(subtasksTab?.querySelectorAll<HTMLElement>("span") ?? [])
.find((span) => span.textContent === "1");
const closeSubtasks = headerSlot.querySelector<HTMLButtonElement>('[aria-label="Close Subtasks tab"]');
const openClosedTab = headerSlot.querySelector<HTMLButtonElement>('[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<HTMLElement>('[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<typeof IssueProperties>;
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(
<QueryClientProvider client={queryClient}>
<IssueProperties
{...props}
documentDeepLink={{ tab: "artifacts", documentKey: "qa-evidence", requestId: 1 }}
/>
</QueryClientProvider>,
);
});
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<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("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<HTMLButtonElement>('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<Project[]>((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(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} issue={{ ...issue, blockedBy: [...issue.blockedBy!] }} />
</QueryClientProvider>,
));
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<HTMLAnchorElement>('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<HTMLButtonElement>('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: [
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
],
}),
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: [
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
createRelation("issue-4", "PAP-4", "Keep blocker"),
],
}),
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: [
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
createRelation("issue-4", "PAP-4", "Keep blocker"),
],
}),
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) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) 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();
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) => (createRelation(`blocking-${index + 1}`, `BLOCKING-${index + 1}`, `Blocking issue ${index + 1}`))) as NonNullable<Issue["blocks"]>;
const relatedIssues = Array.from({ length: 7 }, (_, index) => ({
id: `related-${index + 1}`,
identifier: `RELATED-${index + 1}`,
title: `Related issue ${index + 1}`,
status: "todo" as const,
priority: "medium" as const,
assigneeAgentId: null,
assigneeUserId: null,
}));
const root = renderProperties(container, {
issue: createIssue({
blocks: blocking,
relatedWork: {
outbound: relatedIssues.map((issue) => ({
issue,
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: issue.identifier }],
})),
inbound: [],
},
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
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) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) 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();
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(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-b", blockedBy: nextBlockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
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<HTMLElement>('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<HTMLButtonElement>("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<HTMLButtonElement>("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("changes only project organization while preserving the bound workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject({ name: "Target Project" })]);
const workspace = createExecutionWorkspace({ projectId: null, projectWorkspaceId: null });
const issue = createIssue({
executionWorkspaceId: workspace.id,
currentExecutionWorkspace: workspace,
});
const onUpdate = vi.fn();
const root = renderProperties(container, { issue, childIssues: [], onUpdate, inline: true });
await flush();
await act(() => findRowTrigger(container, "Project")!.click());
const project = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((button) => button.textContent?.trim() === "Target Project")!;
act(() => project.click());
expect(onUpdate).toHaveBeenLastCalledWith({ projectId: "project-1" });
const assigned = applyOptimisticIssueFieldUpdate(issue, onUpdate.mock.lastCall![0])!;
expect(assigned.executionWorkspaceId).toBe(workspace.id);
expect(assigned.currentExecutionWorkspace).toBe(workspace);
await act(() => findRowTrigger(container, "Project")!.click());
const none = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((button) => button.textContent?.trim() === "No project")!;
act(() => none.click());
expect(onUpdate).toHaveBeenLastCalledWith({ projectId: null });
const cleared = applyOptimisticIssueFieldUpdate(assigned, onUpdate.mock.lastCall![0])!;
expect(cleared.executionWorkspaceId).toBe(workspace.id);
expect(cleared.currentExecutionWorkspace).toBe(workspace);
expect(cleared.currentExecutionWorkspace?.cwd).toBe(workspace.cwd);
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)/);
for (const label of ["Started", "Completed", "Created"]) {
const labelNode = container.querySelector(`[data-property-label="${label}"]`);
const value = labelNode?.parentElement?.querySelector<HTMLElement>('[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<HTMLButtonElement>(
'button[aria-label="Copy pap-1-workspace to clipboard"]',
);
const folderCopyButton = container.querySelector<HTMLButtonElement>(
'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: createRelation("issue-22", "PAP-22", "Related task"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
],
inbound: [
{
issue: createRelation("issue-23", "PAP-23", "Mentions this task", "in_progress"),
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<HTMLButtonElement>('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: [
createRelation("issue-22", "PAP-22", "Blocker"),
],
blocks: [
createRelation("issue-33", "PAP-33", "Blocked issue"),
],
relatedWork: {
outbound: [
{
issue: createRelation("issue-22", "PAP-22", "Blocker"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
{
issue: createRelation("issue-33", "PAP-33", "Blocked issue"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }],
},
{
issue: createRelation("child-44", "PAP-44", "Child issue"),
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<HTMLElement>('[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([
seniorProductEngineer(),
]);
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([
seniorProductEngineer(),
]);
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([
seniorProductEngineer(),
]);
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(reviewPolicyStages()),
}),
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(reviewPolicyStages()),
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(reviewPolicyStages()),
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(
<QueryClientProvider client={queryClient}>
<IssueProperties issue={issue} childIssues={[]} onUpdate={vi.fn()} inline />
</QueryClientProvider>,
);
});
};
renderMonitor(createIssue({
executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }),
executionState: createExecutionState({ monitor: baseMonitorState }),
monitorAttemptCount: 1,
}));
await flush();
expect(monitorRowText()).toContain("In 2h 12m");
// 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<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({
});
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<Issue>(queryKeys.issues.detail(issue.id))?.watchdog)
.toEqual(savedWatchdog);
act(() => root.unmount());
});
it("renders an existing watchdog and removes it via the API", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const onUpdate = vi.fn();
const issue = createIssue({ watchdog: createWatchdogSummary() });
const { root, queryClient } = renderPropertiesWithQueryClient(container, {
issue,
childIssues: [],
onUpdate,
inline: true,
});
queryClient.setQueryData(queryKeys.issues.detail(issue.id), issue);
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("ClaudeCoder");
});
const trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("ClaudeCoder"));
await act(async () => {
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const removeButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove"));
expect(removeButton).toBeTruthy();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.deleteWatchdog).toHaveBeenCalledWith("issue-1");
expect(queryClient.getQueryData<Issue>(queryKeys.issues.detail(issue.id))?.watchdog)
.toBeNull();
act(() => root.unmount());
});
it("truncates the watchdog instructions one-line summary in the properties value column", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
});
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 <name> · <time>" phrasing so
// the attribution is recoverable if a long name truncates at the 320px pane
// width (PAP-14182 review fix).
const attribution = Array.from(container.querySelectorAll("span"))
.find((span) => span.getAttribute("title")?.startsWith("Archived by Gardener"));
expect(attribution).toBeTruthy();
const unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"))!;
await act(async () => {
unarchiveButton.click();
});
await flush();
expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1");
act(() => root.unmount());
});
it("surfaces unarchive failures inline", async () => {
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-9", name: "Gardener", status: "active", adapterType: "codex_local", icon: null },
]);
mockIssuesApi.unarchiveFromInbox.mockRejectedValue(new Error("Archive policy denied"));
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "agent",
archivedByAgentId: "agent-9",
archivedByRunId: "run-1",
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
let unarchiveButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"));
expect(unarchiveButton).toBeTruthy();
});
await act(async () => {
unarchiveButton!.click();
});
await flush();
await waitForAssertion(() => {
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Archive policy denied");
});
act(() => root.unmount());
});
it("does not render archive attribution for user (manual) archives", async () => {
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "user",
archivedByAgentId: null,
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Updated");
});
expect(container.textContent).not.toContain("Archived by");
expect(
Array.from(container.querySelectorAll("button")).some((button) => button.textContent?.includes("Unarchive")),
).toBe(false);
act(() => root.unmount());
});
// PAP-16506 P4: only an agent sets `reviewPolicy`, so the panel shows it and
// never offers a control. The default — a NULL column, meaning anyone with
// write access can approve — is what every issue already does, so it shows
// nothing at all rather than a row reading "Anyone" or "None".
const findApprovalsRow = () =>
container.querySelector('[data-property-label="Approvals"]')?.closest('[data-property-row="true"]') ?? null;
it("shows no approvals row on an issue that has never set a policy", async () => {
const root = renderProperties(container, {
issue: createIssue({ reviewPolicy: null }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(findApprovalsRow()).toBeNull();
expect(container.textContent).not.toContain("Review policy");
act(() => root.unmount());
});
it("shows no approvals row when the policy is the explicit default", async () => {
const root = renderProperties(container, {
issue: createIssue({ reviewPolicy: "anyone" }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(findApprovalsRow()).toBeNull();
act(() => root.unmount());
});
it("badges an opt-in constraint read-only, with no control to change it", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ reviewPolicy: "human_only" }),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const row = findApprovalsRow();
expect(row?.textContent).toContain("Human only");
// Read-only: the row is a chip, not a picker — nothing here can PATCH.
expect(row?.querySelector("button")).toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("badges a not_creator policy as 'Anyone else'", async () => {
const root = renderProperties(container, {
issue: createIssue({ reviewPolicy: "not_creator" }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(findApprovalsRow()?.textContent).toContain("Anyone else");
act(() => root.unmount());
});
it("hides the execution workspace picker without an enabled project policy", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: null })]);
const root = renderProperties(container, {
issue: createIssue({ projectId: "project-1" }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
expect(container.querySelector('[data-property-label="Execution"]')).toBeNull();
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("hides workspace selection but keeps the bound workspace accessible", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } })]);
const onUpdate = vi.fn();
const { root } = renderPropertiesWithQueryClient(container, {
issue: createIssue({ projectId: "project-1", executionWorkspaceId: "workspace-1", currentExecutionWorkspace: createExecutionWorkspace() }),
childIssues: [], onUpdate, inline: true,
}, ["workspaces.isolation"]);
await flush();
expect(container.querySelector('[data-property-label="Execution"]')).toBeNull();
expect(container.querySelector('a[href="/execution-workspaces/workspace-1"]')).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("shows the workspace picker with no bound workspace", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" },
})]);
const root = renderProperties(container, {
issue: createIssue({ projectId: "project-1" }),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await waitForAssertion(() => {
expect(findRowTrigger(container, "Execution")?.textContent).toBe("Default");
});
act(() => root.unmount());
});
it("saves the exact isolated-workspace payload", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace" },
})]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ projectId: "project-1" }),
childIssues: [],
onUpdate,
inline: true,
});
await waitForAssertion(() => expect(findRowTrigger(container, "Execution")).toBeDefined());
act(() => findRowTrigger(container, "Execution")!.click());
const isolatedOption = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("New isolated workspace"));
act(() => isolatedOption!.click());
expect(onUpdate).toHaveBeenCalledWith({
executionWorkspacePreference: "isolated_workspace",
executionWorkspaceId: null,
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: null,
},
});
act(() => root.unmount());
});
it("searches reusable workspaces and saves the selected workspace", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockProjectsApi.list.mockResolvedValue([createProject({
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace" },
})]);
const alphaWorkspace = createExecutionWorkspace({
id: "workspace-alpha",
name: "Alpha workspace",
cwd: "/tmp/paperclip/alpha",
branchName: "alpha-branch",
lastUsedAt: new Date(),
});
const betaWorkspace = createExecutionWorkspace({
id: "workspace-beta",
name: "Beta workspace",
cwd: "/tmp/paperclip/beta",
branchName: "beta-branch",
lastUsedAt: new Date(),
});
mockExecutionWorkspacesApi.list.mockResolvedValue([alphaWorkspace, betaWorkspace]);
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({ projectId: "project-1", projectWorkspaceId: "workspace-main" }),
childIssues: [],
onUpdate,
inline: true,
});
await waitForAssertion(() => expect(findRowTrigger(container, "Execution")).toBeDefined());
act(() => findRowTrigger(container, "Execution")!.click());
const reuseOption = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Reuse existing workspace"));
act(() => reuseOption!.click());
await waitForAssertion(() => {
expect(container.textContent).toContain("Recent");
expect(container.textContent).toContain("Alpha workspace");
expect(container.textContent).toContain("Beta workspace");
});
expect(mockExecutionWorkspacesApi.list).toHaveBeenCalledWith("company-1", {
projectId: "project-1",
projectWorkspaceId: "workspace-main",
reuseEligible: true,
});
const search = container.querySelector('input[aria-label="Search reusable workspaces"]') as HTMLInputElement;
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(search, "Beta");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(container.textContent).not.toContain("Alpha workspace");
expect(container.textContent).toContain("Beta workspace");
const betaOption = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Beta workspace"));
act(() => betaOption!.click());
expect(onUpdate).toHaveBeenCalledWith({
executionWorkspacePreference: "reuse_existing",
executionWorkspaceId: "workspace-beta",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: null,
},
});
act(() => root.unmount());
});
});