Files
PaperClipAI/ui/src/components/IssueProperties.test.tsx
T
DottaandPaperclip 92b238137b Connect and configure GitHub Copilot through Apps (#15705)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps and AI Connections store credentials and govern access to agent
runtimes.
> - Users need a supported saved-token connection and config values that
survive save and reopen.
> - The release must preserve saved-token access and task lifecycle
guarantees.
> - This pull request will connect Copilot through the normal Apps and
AI Connections path.
> - The benefit is a reviewable part of the Copilot production release.

## Linked Issues or Issue Description

Refs #15701 and #15703. Supersedes setup work in #15562. Base: #15703.
Next: #15706.

## What Changed

- Require configuration update authority before Copilot
credential-bearing model discovery; default to the saved environment and
preserve explicit selection for editors.

- Keep Copilot discovery passive during reconnect reconciliation;
refresh on explicit opening, account/environment changes and reconnect.

- Add GitHub AI token connections with encrypted storage, ownership,
grants and audit logging.
- Keep repository access separate from Copilot runtime authentication.
- Probe the selected environment without sending a model prompt and
preserve authenticated model selection through save/reopen.
- Keep fresh execution admission consistent with normal Copilot setup;
persist task model choices without changing provider or permission
policy.
- Refresh authenticated models after reconnecting the same saved Copilot
account, including recovery from a previous authentication error.
- Apply the additive provider-default constraint migration through the
normal generated migration workflow.

## Verification

Runtime #15701 is merged at `d855e550ad8d2a267312983f26457aabf5393c3a`.
History #15703 is merged at `df80616d98119cbf5abd40549794d9980a5fabf6`.
Remaining source
[`42bcbd61299655f7e37aabe6bb5f19bba770ad1d`](https://github.com/paperclipai/paperclip/commit/42bcbd61299655f7e37aabe6bb5f19bba770ad1d)
contains the four-file authorization repair. The remaining PR trees
preserve six nonoverlapping files from the newer public mainline
managed-settings fix and the CI-owned lockfile. No unrelated provider is
enabled.

The security finding is repaired: existing-agent discovery requires
`agent_config:update` before connection selection or token resolution.
The saved environment is the default. Authorized editors can select a
prospective environment. Both denied board-viewer and agent-reader
regressions fail on the original source. All49 route tests and server
typecheck pass with the fix. Denied requests perform no connection
selection, credential resolution or probe. Current-head security scans
pass on both remaining PRs; the repaired thread is resolved.

Copilot CLI1.0.88 and profile47 retain digest
`sha256:55044aa235e88b3fe36ec8a556ee1e3382590870aab907e4462ef7d64af49cea`.
The [qualification
record](https://github.com/paperclipai/paperclip/blob/42bcbd61299655f7e37aabe6bb5f19bba770ad1d/doc/plans/2026-10-20-copilot-qualification-v47.json)
reports20/20 Product cells,154 assertions and7/7 protocol cases,46
assertions against frozen normal installed source
`c2616370800f5f6df45246b0890ba35a7cd2faab`. The exact qualification
model is `gpt-5.6-luna`. Every passing Product case confirms cleanup.
Original failures remain recorded; automatic behavior retries are
disabled. The route-only authorization repair changes no bound
runtime/profile bytes and does not relabel the frozen campaign.

The ordinary installed remote journey passed saved-token selection,
model discovery, normal UI save/reopen, metadata-only Test, completed
task, accepted receipt and exact19-byte browser download. An open model
catalog caused no additional discovery requests during117seconds. Actual
installed runtime smokes passed on macOS ARM64, macOS x64 under Rosetta
and native Linux x64 on Daytona. Raw token browser entry was not
exercised; encrypted connection creation used the normal API.

The first Daytona file attempt timed out during delayed semantic
dispatch; its precise stalled layer remains unresolved. Independent
ordinary remote delivery and the other19 Product cells passed before one
instrumented recheck passed8/8 with unchanged source,prompt,assertions
and deadlines. No causal latency repair is claimed.

Before the authorization repair, all four final PR heads passed required
verify/E2E and fresh Greptile5/5 reviews with zero unresolved threads.
Current-head CI/review is running for the two repaired heads. Local
focused checks retain129 connection,59 runner,72 shared-eval and88
recovery/workspace passes, plus migration safety and token gates. The
additive Copilot migration is361; released master history is preserved.

[Cloud normal-package
run39414239704](https://github.com/paperclipai/paperclip/actions/runs/39414239704)
builds the pre-security source4f3f. All four native producers and Linux
package assembly pass; the final clean installed consumers are
finishing. All18 retained archives match their hashes;37,863 regular
files contain zero selected credentials. That package does not contain
the later route repair. The actual final published package must include
it and pass the local/Daytona live canaries. The frozen Daytona image
remains published and independently verified. No separate full-image
scan is claimed. Docker builds run in the cloud.

**Remaining:** current-head CI/review, setup and qualification merges,
final publication, and ordinary local/Daytona canaries against the
actual shipped package. The user explicitly authorizes the maintainer
review override; CI remains a merge gate. No production deployment is
claimed. Provider USD remains unknown and GitHub-attributed.

## Risks

Bad credentials, missing entitlement, runtime installation and
unavailable models must remain distinct failures. A connection must not
adopt ambient credentials. The constraint migration changes accepted
provider values, with no credential-storage migration.

Current-master NewAgent fixture extraction is retained. Copilot cases
use that shared support and test both passive discovery and explicit
reconnect under the same setup lifecycle.

The onboarding retry regression shares the component suite’s
saved-connection, authenticated-model, draft, agent-list, hire and
render lifecycle fixtures. Keeping it beside the existing onboarding
failures makes adoption versus duplicate hire observable under the same
lifecycle; this is the concrete cohesion reason for the existing large
test file.

## Model Used

OpenAI Codex, based on GPT-6. The exact serving model ID and context
window are not exposed in this task. Used for code changes, review and
tool-assisted verification.

## 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
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 07:02:22 -05:00

3737 lines
138 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", async importOriginal => ({
...await importOriginal<typeof import("./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", async importOriginal => ({
...await importOriginal<typeof import("./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.each([false, true])("shows the workspace detail link only when its destination is enabled: %s", async enabled => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: enabled });
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();
expect(container.textContent).not.toContain("View workspace tasks");
await vi.waitFor(() => {
const workspaceLink = Array.from(container.querySelectorAll("a")).find(link => link.textContent?.trim() === "View workspace");
if (enabled) expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
else expect(workspaceLink).toBeUndefined();
});
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("discovers Copilot overrides with the assignee connection and environment", async () => {
const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" };
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Copilot", role: "engineer", status: "active",
adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1",
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
runtimeConfig: { aiConnection: binding } }]);
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]);
const { root, queryClient } = renderPropertiesWithQueryClient(container, {
issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-5.6-luna" } } }),
childIssues: [], onUpdate: vi.fn(), inline: true,
});
await flush();
await flush();
expect(mockAgentsApi.adapterModels).not.toHaveBeenCalled();
const modelTrigger = findRowTrigger(container, "Model");
expect(modelTrigger).toBeDefined();
await act(async () => modelTrigger!.click());
await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", {
environmentId: "daytona-1", provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", aiConnection: binding,
}));
await act(async () => { await queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false }); });
expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1);
await act(async () => modelTrigger!.click());
await act(async () => modelTrigger!.click());
await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2));
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();
await vi.waitFor(() => 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());
});
});