[codex] feat(watchdog): add task watchdog control plane (#8339)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task lifecycle and recovery subsystems decide when agent work is still productive, stalled, or ready for review. > - Existing recovery paths can observe stopped or incomplete work, but there was no first-class per-task watchdog model with scoped review permissions. > - Watchdog follow-ups also need strict boundaries so recovery/status-only runs cannot mutate approvals or perform deliverable work. > - This pull request adds the task watchdog data model, API/service layer, scheduler/review flow, adapter wake context, UI configuration surfaces, and docs. > - The branch has been rebased onto current `paperclipai/paperclip` `master`; the watchdog migration is now ordered after master's latest migrations as `0104_issue_watchdogs`. > - The benefit is a more explicit task-review loop that preserves Paperclip's single-assignee and governance invariants while making stalled work easier to route. ## Linked Issues or Issue Description No linked GitHub issue. Paperclip task: [PAP-11275](/PAP/issues/PAP-11275). ## Problem or motivation Task recovery needs a first-class watchdog path that can inspect stopped work and create scoped follow-ups without bypassing normal task ownership. Board/UI users need a way to configure watchdogs on tasks and see watchdog-related live work. Recovery/status-only runs must remain limited to status reporting and must not create approvals, link approvals, or submit approval comments. ## Proposed solution Add a task-watchdog data model, scheduler/classifier, scoped mutation guard, adapter wake context, API/UI configuration surfaces, and documentation so watchdog agents can review stopped task subtrees under explicit boundaries. ## Alternatives considered Reuse the existing recovery-action flow only. That would keep stopped-work detection implicit, make per-task watchdog assignment harder to expose in the UI, and would not provide a durable scoped-review issue for stalled task trees. ## Roadmap alignment This is Paperclip control-plane lifecycle infrastructure for task execution and recovery. I checked `ROADMAP.md`; this PR does not duplicate an existing planned core item. ## What Changed - Added issue watchdog schema, migration, shared contracts, validators, CRUD API, and service support. - Added task watchdog scheduler/classifier behavior, scoped mutation enforcement, adapter wake context, and default watchdog mandate guidance. - Added UI surfaces for configuring watchdogs on new/existing tasks, viewing watchdog activity, and exposing the experimental setting. - Added docs for the user-facing task watchdog workflow and implementation semantics. - Gated new-task watchdog setup behind `enableTaskWatchdogs` and blocked cheap status-only recovery runs from approval mutations. - Rebased onto current `master` and renumbered the idempotent watchdog migration from the branch-local `0102_issue_watchdogs` slot to `0104_issue_watchdogs`. - Addressed Greptile feedback by loading watchdog classifier input with a recursive subtree query and centralizing the watchdog origin-kind constant. - Added and updated focused server/UI tests for watchdog routes, scheduler/classifier behavior, scope boundaries, live task visibility, settings, and new issue dialog behavior. ## Verification - `pnpm vitest run server/src/__tests__/task-watchdogs-scheduler.test.ts server/src/__tests__/task-watchdogs-classifier.test.ts` - `pnpm vitest run server/src/__tests__/approval-routes-idempotency.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts` - `pnpm vitest run ui/src/components/NewIssueDialog.test.tsx` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check` - Verified the PR diff does not include `pnpm-lock.yaml` or `.github/workflows`. ## Risks - Medium risk: this introduces a new task lifecycle surface touching DB schema, server routes/services, adapter wake context, and UI task configuration. - Watchdog scheduling behavior depends on the new experimental setting and runtime context checks behaving consistently across local and production agents. - The watchdog migration is idempotent (`IF NOT EXISTS` / duplicate-object guards) so users who tried the previous branch-local migration number should not get duplicate-object failures. - CI and the second Greptile pass are pending after the latest review-fix push. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-class coding agent in the Paperclip workspace. Exact runtime model id and context window were not exposed to the agent; tool use and local command execution were enabled. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots — N/A per Paperclip task instruction: do not add screenshots/images to this PR unless they are specifically part of the work. - [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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { InboxIssueMetaLeading } from "./IssueColumns";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void): void {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function makeIssue(overrides: Partial<Issue>): Issue {
|
||||
return {
|
||||
id: "issue-id",
|
||||
identifier: "PAP-1",
|
||||
status: "in_progress",
|
||||
blockerAttention: false,
|
||||
...overrides,
|
||||
} as unknown as Issue;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root!.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderLeading(element: React.ReactElement): string {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root!.render(element));
|
||||
return container.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("InboxIssueMetaLeading live state", () => {
|
||||
it("shows the own Live chip for a running issue and never the subtree chip", () => {
|
||||
const text = renderLeading(
|
||||
<InboxIssueMetaLeading
|
||||
issue={makeIssue({ id: "child", identifier: "PAP-2", status: "in_progress" })}
|
||||
isLive
|
||||
subtreeLiveCount={3}
|
||||
/>,
|
||||
);
|
||||
expect(text).toContain("Live");
|
||||
expect(text).not.toContain("live below");
|
||||
});
|
||||
|
||||
it("shows the distinct subtree chip for a done parent with live descendants", () => {
|
||||
const text = renderLeading(
|
||||
<InboxIssueMetaLeading
|
||||
issue={makeIssue({ id: "parent", identifier: "PAP-1", status: "done" })}
|
||||
isLive={false}
|
||||
subtreeLiveCount={2}
|
||||
/>,
|
||||
);
|
||||
// The done parent must NOT borrow the running child's "Live" chip.
|
||||
expect(text).toContain("2 live below");
|
||||
expect(text).not.toMatch(/(^|[^a-z])Live([^a-z]|$)/);
|
||||
});
|
||||
|
||||
it("renders no live treatment when the issue and its subtree are idle", () => {
|
||||
const text = renderLeading(
|
||||
<InboxIssueMetaLeading
|
||||
issue={makeIssue({ id: "idle", identifier: "PAP-3", status: "done" })}
|
||||
isLive={false}
|
||||
subtreeLiveCount={0}
|
||||
/>,
|
||||
);
|
||||
expect(text).not.toContain("Live");
|
||||
expect(text).not.toContain("live below");
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,7 @@ export function IssueColumnPicker({
|
||||
export function InboxIssueMetaLeading({
|
||||
issue,
|
||||
isLive,
|
||||
subtreeLiveCount = 0,
|
||||
showStatus = true,
|
||||
showIdentifier = true,
|
||||
statusSlot,
|
||||
@@ -143,6 +144,7 @@ export function InboxIssueMetaLeading({
|
||||
}: {
|
||||
issue: Issue;
|
||||
isLive: boolean;
|
||||
subtreeLiveCount?: number;
|
||||
showStatus?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
statusSlot?: ReactNode;
|
||||
@@ -191,6 +193,26 @@ export function InboxIssueMetaLeading({
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{!isLive && subtreeLiveCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||
"border-border bg-transparent",
|
||||
)}
|
||||
title={`${subtreeLiveCount} sub-task${subtreeLiveCount === 1 ? "" : "s"} running below`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full border",
|
||||
"border-muted-foreground/60 bg-transparent",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden text-[11px] font-medium text-muted-foreground sm:inline">
|
||||
{subtreeLiveCount} live below
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,12 +30,18 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
createLabel: vi.fn(),
|
||||
upsertWatchdog: vi.fn(),
|
||||
deleteWatchdog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
@@ -58,6 +64,10 @@ vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
@@ -386,7 +396,12 @@ describe("IssueProperties", () => {
|
||||
name: "New label",
|
||||
color: "#6366f1",
|
||||
}));
|
||||
mockIssuesApi.upsertWatchdog.mockResolvedValue({});
|
||||
mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true });
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -533,6 +548,40 @@ describe("IssueProperties", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides watchdog setup controls while the experimental flag is off", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("Watchdog");
|
||||
expect(container.textContent).not.toContain("Set watchdog");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows watchdog setup controls when the experimental flag is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Watchdog");
|
||||
expect(container.textContent).toContain("Set watchdog");
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("passes blocker attention to the sidebar status icon", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
@@ -1439,4 +1488,170 @@ describe("IssueProperties", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
const watchdogAgent = {
|
||||
id: "agent-1",
|
||||
name: "ClaudeCoder",
|
||||
role: "",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "active",
|
||||
orgChainHealth: { status: "ok" },
|
||||
} as unknown as Parameters<typeof mockAgentsApi.list.mockResolvedValue>[0][number];
|
||||
|
||||
function createWatchdogSummary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "watchdog-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
watchdogAgentId: "agent-1",
|
||||
instructions: "Keep the tree moving.",
|
||||
status: "active",
|
||||
watchdogIssueId: null,
|
||||
lastObservedFingerprint: null,
|
||||
lastReviewedFingerprint: null,
|
||||
lastTriggeredAt: null,
|
||||
lastCompletedAt: null,
|
||||
triggerCount: 0,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
...overrides,
|
||||
} as unknown as NonNullable<Issue["watchdog"]>;
|
||||
}
|
||||
|
||||
it("shows the empty watchdog state and saves a new watchdog via the API", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({ watchdog: null }),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
let trigger: HTMLButtonElement | undefined;
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Watchdog");
|
||||
trigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Set watchdog"));
|
||||
expect(trigger).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Choose the agent through the inline selector, then save.
|
||||
let agentOption: HTMLElement | undefined;
|
||||
await waitForAssertion(() => {
|
||||
agentOption = Array.from(container.querySelectorAll("button, [role='option']"))
|
||||
.find((node) => node.textContent?.includes("ClaudeCoder")) as HTMLElement | undefined;
|
||||
expect(agentOption).toBeTruthy();
|
||||
});
|
||||
// Open the selector if the option is not yet visible, then click it.
|
||||
await act(async () => {
|
||||
agentOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const instructions = Array.from(container.querySelectorAll("textarea"))
|
||||
.find((node) => node.getAttribute("placeholder")?.includes("watchdog"));
|
||||
expect(instructions).toBeTruthy();
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
|
||||
setter.call(instructions!, "Watch the deploy");
|
||||
instructions!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => /Set watchdog|Update/.test(button.textContent ?? "") && button.closest("[class*='space-y']"));
|
||||
const finalSave = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === "Set watchdog" && button !== trigger) ?? saveButton;
|
||||
expect(finalSave).toBeTruthy();
|
||||
await act(async () => {
|
||||
finalSave!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.upsertWatchdog).toHaveBeenCalledWith(
|
||||
"issue-1",
|
||||
expect.objectContaining({ agentId: "agent-1" }),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders an existing watchdog and removes it via the API", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({ watchdog: createWatchdogSummary() }),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("ClaudeCoder");
|
||||
});
|
||||
|
||||
const trigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("ClaudeCoder"));
|
||||
await act(async () => {
|
||||
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const removeButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Remove"));
|
||||
expect(removeButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.deleteWatchdog).toHaveBeenCalledWith("issue-1");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("links to the generated watchdog task when one exists", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({ watchdog: createWatchdogSummary({ watchdogIssueId: "issue-wd" }) }),
|
||||
childIssues: [
|
||||
createIssue({
|
||||
id: "issue-wd",
|
||||
identifier: "PAP-42",
|
||||
title: "Watchdog: Parent issue",
|
||||
originKind: "task_watchdog",
|
||||
}),
|
||||
],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const link = Array.from(container.querySelectorAll("a"))
|
||||
.find((anchor) => anchor.getAttribute("href") === "/issues/issue-wd");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.textContent).toContain("PAP-42");
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AdapterModel } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -47,8 +48,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2, ScanEye } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import {
|
||||
@@ -394,6 +396,11 @@ export function IssueProperties({
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
/** When a run is live, a selection is staged here until the operator confirms
|
||||
@@ -424,6 +431,9 @@ export function IssueProperties({
|
||||
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
const [watchdogOpen, setWatchdogOpen] = useState(false);
|
||||
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
|
||||
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
|
||||
const normalizedBlockedBySearch = blockedBySearch.trim();
|
||||
|
||||
const { data: session } = useQuery({
|
||||
@@ -922,6 +932,160 @@ export function IssueProperties({
|
||||
issue.executionPolicy?.monitor?.notes,
|
||||
issue.executionPolicy?.monitor?.serviceName,
|
||||
]);
|
||||
// Re-sync watchdog editor inputs when the persisted watchdog changes (and reset on close).
|
||||
useEffect(() => {
|
||||
if (watchdogOpen) return;
|
||||
setWatchdogAgentInput(issue.watchdog?.watchdogAgentId ?? "");
|
||||
setWatchdogInstructionsInput(issue.watchdog?.instructions ?? "");
|
||||
}, [issue.watchdog?.watchdogAgentId, issue.watchdog?.instructions, watchdogOpen]);
|
||||
|
||||
const watchdogAgentOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
(agents ?? [])
|
||||
.filter(isAgentTaskTarget)
|
||||
.map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
const upsertWatchdog = useMutation({
|
||||
mutationFn: (data: { agentId: string; instructions: string | null }) =>
|
||||
issuesApi.upsertWatchdog(issue.id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
setWatchdogOpen(false);
|
||||
},
|
||||
});
|
||||
const deleteWatchdog = useMutation({
|
||||
mutationFn: () => issuesApi.deleteWatchdog(issue.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
setWatchdogOpen(false);
|
||||
},
|
||||
});
|
||||
const saveWatchdog = () => {
|
||||
if (!watchdogAgentInput) return;
|
||||
upsertWatchdog.mutate({
|
||||
agentId: watchdogAgentInput,
|
||||
instructions: watchdogInstructionsInput.trim() || null,
|
||||
});
|
||||
};
|
||||
const removeWatchdog = () => {
|
||||
if (issue.watchdog) {
|
||||
deleteWatchdog.mutate();
|
||||
} else {
|
||||
setWatchdogOpen(false);
|
||||
}
|
||||
setWatchdogAgentInput("");
|
||||
setWatchdogInstructionsInput("");
|
||||
};
|
||||
const watchdogMutationError =
|
||||
upsertWatchdog.error instanceof Error
|
||||
? upsertWatchdog.error.message
|
||||
: deleteWatchdog.error instanceof Error
|
||||
? deleteWatchdog.error.message
|
||||
: null;
|
||||
const watchdogIssueRef = (childIssues ?? []).find(
|
||||
(child) => child.id === issue.watchdog?.watchdogIssueId,
|
||||
);
|
||||
const watchdogTrigger = issue.watchdog ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-sm">
|
||||
{(() => {
|
||||
const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId);
|
||||
return agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
||||
})()}
|
||||
<span className="truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
|
||||
{issue.watchdog.instructions?.trim() ? (
|
||||
<span className="truncate text-muted-foreground">· {issue.watchdog.instructions.trim()}</span>
|
||||
) : null}
|
||||
{issue.watchdog.status === "disabled" ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">(disabled)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Set watchdog</span>
|
||||
);
|
||||
const watchdogContent = (
|
||||
<div className="space-y-3 p-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
|
||||
<InlineEntitySelector
|
||||
value={watchdogAgentInput}
|
||||
options={watchdogAgentOptions}
|
||||
placeholder="Select agent"
|
||||
noneLabel="No watchdog agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found."
|
||||
onChange={setWatchdogAgentInput}
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Select agent</span>;
|
||||
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
|
||||
return (
|
||||
<>
|
||||
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
|
||||
return (
|
||||
<>
|
||||
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
Instructions <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={watchdogInstructionsInput}
|
||||
onChange={(event) => setWatchdogInstructionsInput(event.target.value)}
|
||||
placeholder="What should the watchdog watch for and how should it keep work moving?"
|
||||
rows={4}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
{watchdogIssueRef ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Watchdog task:{" "}
|
||||
<Link to={`/issues/${watchdogIssueRef.id}`} className="text-primary hover:underline">
|
||||
{watchdogIssueRef.identifier ?? "View task"}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{watchdogMutationError ? (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs text-destructive">
|
||||
{watchdogMutationError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
disabled={deleteWatchdog.isPending || (!issue.watchdog && !watchdogAgentInput)}
|
||||
onClick={removeWatchdog}
|
||||
>
|
||||
{deleteWatchdog.isPending ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!watchdogAgentInput || upsertWatchdog.isPending}
|
||||
onClick={saveWatchdog}
|
||||
>
|
||||
{upsertWatchdog.isPending ? "Saving…" : issue.watchdog ? "Update" : "Set watchdog"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
|
||||
? T extends { monitor?: infer M | null } | null | undefined
|
||||
@@ -2130,6 +2294,32 @@ export function IssueProperties({
|
||||
{monitorContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{taskWatchdogsEnabled ? (
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Watchdog"
|
||||
open={watchdogOpen}
|
||||
onOpenChange={setWatchdogOpen}
|
||||
triggerContent={watchdogTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
|
||||
extra={
|
||||
watchdogIssueRef ? (
|
||||
<Link
|
||||
to={`/issues/${watchdogIssueRef.id}`}
|
||||
className="ml-1 inline-flex shrink-0 items-center gap-0.5 rounded-full border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
title="Open watchdog task"
|
||||
>
|
||||
<ScanEye className="h-3 w-3" />
|
||||
{watchdogIssueRef.identifier ?? "Task"}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{watchdogContent}
|
||||
</PropertyPicker>
|
||||
) : null}
|
||||
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
|
||||
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
|
||||
import {
|
||||
InboxIssueMetaLeading,
|
||||
InboxIssueTrailingColumns,
|
||||
@@ -920,6 +921,10 @@ export function IssuesList({
|
||||
[isolatedWorkspacesEnabled],
|
||||
);
|
||||
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||
const subtreeLiveCounts = useMemo(
|
||||
() => collectSubtreeLiveCounts(issues, liveIssueIds ?? new Set<string>()),
|
||||
[issues, liveIssueIds],
|
||||
);
|
||||
const visibleTrailingIssueColumns = useMemo(
|
||||
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||
@@ -1808,6 +1813,7 @@ export function IssuesList({
|
||||
<InboxIssueMetaLeading
|
||||
issue={issue}
|
||||
isLive={liveIssueIds?.has(issue.id) === true}
|
||||
subtreeLiveCount={subtreeLiveCounts.get(issue.id) ?? 0}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Identity } from "./Identity";
|
||||
import type { Issue, IssueStatus } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
||||
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
|
||||
|
||||
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
|
||||
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
|
||||
@@ -76,6 +77,7 @@ function KanbanColumn({
|
||||
issues,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
subtreeLiveCounts,
|
||||
compactCards = false,
|
||||
collapsed = false,
|
||||
visibleCount,
|
||||
@@ -86,6 +88,7 @@ function KanbanColumn({
|
||||
issues: Issue[];
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
subtreeLiveCounts?: ReadonlyMap<string, number>;
|
||||
compactCards?: boolean;
|
||||
collapsed?: boolean;
|
||||
visibleCount: number;
|
||||
@@ -151,6 +154,7 @@ function KanbanColumn({
|
||||
issue={issue}
|
||||
agents={agents}
|
||||
isLive={liveIssueIds?.has(issue.id)}
|
||||
subtreeLiveCount={subtreeLiveCounts?.get(issue.id) ?? 0}
|
||||
compact={compactCards}
|
||||
/>
|
||||
))}
|
||||
@@ -180,12 +184,14 @@ function KanbanCard({
|
||||
issue,
|
||||
agents,
|
||||
isLive,
|
||||
subtreeLiveCount = 0,
|
||||
isOverlay,
|
||||
compact = false,
|
||||
}: {
|
||||
issue: Issue;
|
||||
agents?: Agent[];
|
||||
isLive?: boolean;
|
||||
subtreeLiveCount?: number;
|
||||
isOverlay?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
@@ -252,6 +258,15 @@ function KanbanCard({
|
||||
{compact ? "Live" : null}
|
||||
</span>
|
||||
)}
|
||||
{!isLive && subtreeLiveCount > 0 && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
title={`${subtreeLiveCount} sub-task${subtreeLiveCount === 1 ? "" : "s"} running below`}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full border border-muted-foreground/60" aria-hidden="true" />
|
||||
{subtreeLiveCount} live below
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${compact ? "mb-1.5 text-xs" : "mb-2 text-sm"} leading-snug line-clamp-2`}>{issue.title}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -314,6 +329,11 @@ export function KanbanBoard({
|
||||
[activeId, issues]
|
||||
);
|
||||
|
||||
const subtreeLiveCounts = useMemo(
|
||||
() => collectSubtreeLiveCounts(issues, liveIssueIds ?? new Set<string>()),
|
||||
[issues, liveIssueIds],
|
||||
);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
@@ -355,6 +375,7 @@ export function KanbanBoard({
|
||||
issues={columnIssues[status] ?? []}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
subtreeLiveCounts={subtreeLiveCounts}
|
||||
compactCards={compactCards}
|
||||
collapsed={collapsedStatusSet.has(status)}
|
||||
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
|
||||
|
||||
@@ -993,6 +993,86 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("reveals the watchdog editor from the overflow menu", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
// The watchdog row is hidden until the menu item is toggled on.
|
||||
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).toBeNull();
|
||||
|
||||
const watchdogMenuItem = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Watchdog");
|
||||
expect(watchdogMenuItem).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
watchdogMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Set watchdog");
|
||||
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).not.toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the configured watchdog from a restored draft", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"paperclip:issue-draft",
|
||||
JSON.stringify({
|
||||
title: "Watched task",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeValue: "",
|
||||
reviewerValue: "",
|
||||
approverValue: "",
|
||||
watchdogAgentId: "agent-9",
|
||||
watchdogInstructions: "Keep it moving",
|
||||
projectId: "",
|
||||
assigneeModelOverride: "",
|
||||
assigneeThinkingEffort: "",
|
||||
assigneeChrome: false,
|
||||
workMode: "standard",
|
||||
}),
|
||||
);
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Keep it moving");
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Task"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
await vi.waitFor(() => {
|
||||
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Watched task",
|
||||
watchdog: { agentId: "agent-9", instructions: "Keep it moving" },
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// PAP-139/PAP-140: work-mode labels and status hues branch on the Conference
|
||||
// Room Chat experimental flag — OFF (default) must match master exactly.
|
||||
describe("Conference Room Chat flag parity (PAP-140)", () => {
|
||||
@@ -1030,10 +1110,15 @@ describe("NewIssueDialog", () => {
|
||||
});
|
||||
|
||||
it("uses NUX work-mode labels and brand status hues when the flag is on", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableConferenceRoomChat: true,
|
||||
enableIsolatedWorkspaces: false,
|
||||
});
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
await waitForAssertion(() => {
|
||||
expect(workModeOption("standard")?.textContent).toContain("Agent mode");
|
||||
});
|
||||
|
||||
expect(workModeOption("standard")?.textContent).toContain("Agent mode");
|
||||
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
|
||||
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ScanEye,
|
||||
} from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { issueStatusText, issueStatusTextClassic, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||
@@ -84,6 +86,8 @@ interface IssueDraft {
|
||||
assigneeValue: string;
|
||||
reviewerValue: string;
|
||||
approverValue: string;
|
||||
watchdogAgentId?: string;
|
||||
watchdogInstructions?: string;
|
||||
assigneeId?: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
@@ -420,6 +424,10 @@ export function NewIssueDialog() {
|
||||
const [approverValue, setApproverValue] = useState("");
|
||||
const [showReviewerRow, setShowReviewerRow] = useState(false);
|
||||
const [showApproverRow, setShowApproverRow] = useState(false);
|
||||
const [watchdogAgentId, setWatchdogAgentId] = useState("");
|
||||
const [watchdogInstructions, setWatchdogInstructions] = useState("");
|
||||
const [showWatchdogRow, setShowWatchdogRow] = useState(false);
|
||||
const [watchdogEditorOpen, setWatchdogEditorOpen] = useState(false);
|
||||
const [participantMenuOpen, setParticipantMenuOpen] = useState(false);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
@@ -650,6 +658,8 @@ export function NewIssueDialog() {
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
watchdogAgentId,
|
||||
watchdogInstructions,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelLane,
|
||||
@@ -668,6 +678,8 @@ export function NewIssueDialog() {
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
watchdogAgentId,
|
||||
watchdogInstructions,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
@@ -704,6 +716,8 @@ export function NewIssueDialog() {
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
watchdogAgentId,
|
||||
watchdogInstructions,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelLane,
|
||||
@@ -769,6 +783,9 @@ export function NewIssueDialog() {
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setShowWatchdogRow(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -797,6 +814,9 @@ export function NewIssueDialog() {
|
||||
setApproverValue(draft.approverValue ?? "");
|
||||
setShowReviewerRow(!!(draft.reviewerValue));
|
||||
setShowApproverRow(!!(draft.approverValue));
|
||||
setWatchdogAgentId(draft.watchdogAgentId ?? "");
|
||||
setWatchdogInstructions(draft.watchdogInstructions ?? "");
|
||||
setShowWatchdogRow(!!(draft.watchdogAgentId));
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(
|
||||
hasExplicitProjectWorkspaceId
|
||||
@@ -839,6 +859,9 @@ export function NewIssueDialog() {
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setShowWatchdogRow(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -896,6 +919,9 @@ export function NewIssueDialog() {
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setShowWatchdogRow(false);
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
@@ -924,6 +950,9 @@ export function NewIssueDialog() {
|
||||
setApproverValue("");
|
||||
setShowReviewerRow(false);
|
||||
setShowApproverRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setShowWatchdogRow(false);
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelLane("primary");
|
||||
@@ -997,6 +1026,9 @@ export function NewIssueDialog() {
|
||||
: {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
...(executionPolicy ? { executionPolicy } : {}),
|
||||
...(taskWatchdogsEnabled && watchdogAgentId
|
||||
? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1088,6 +1120,7 @@ export function NewIssueDialog() {
|
||||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
@@ -1136,6 +1169,19 @@ export function NewIssueDialog() {
|
||||
],
|
||||
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
|
||||
);
|
||||
const watchdogAgentOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds).map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const selectedWatchdogAgent = useMemo(
|
||||
() => (watchdogAgentId ? (agents ?? []).find((agent) => agent.id === watchdogAgentId) ?? null : null),
|
||||
[agents, watchdogAgentId],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
orderedProjects.map((project) => ({
|
||||
@@ -1448,7 +1494,7 @@ export function NewIssueDialog() {
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
|
||||
title="Add reviewer or approver"
|
||||
title={taskWatchdogsEnabled ? "Add reviewer, approver, or watchdog" : "Add reviewer or approver"}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1482,6 +1528,29 @@ export function NewIssueDialog() {
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Approver
|
||||
</button>
|
||||
{taskWatchdogsEnabled && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
showWatchdogRow && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (showWatchdogRow) {
|
||||
setShowWatchdogRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setWatchdogEditorOpen(false);
|
||||
} else {
|
||||
setShowWatchdogRow(true);
|
||||
setWatchdogEditorOpen(true);
|
||||
}
|
||||
setParticipantMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ScanEye className="h-3 w-3" />
|
||||
Watchdog
|
||||
</button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -1576,6 +1645,96 @@ export function NewIssueDialog() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watchdog row */}
|
||||
{taskWatchdogsEnabled && showWatchdogRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ScanEye className="h-3.5 w-3.5" /></span>
|
||||
<Popover open={watchdogEditorOpen} onOpenChange={setWatchdogEditorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors min-w-0"
|
||||
title="Configure watchdog"
|
||||
>
|
||||
{selectedWatchdogAgent ? (
|
||||
<>
|
||||
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-foreground">{selectedWatchdogAgent.name}</span>
|
||||
{watchdogInstructions.trim() ? (
|
||||
<span className="truncate text-muted-foreground">· {watchdogInstructions.trim()}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Set watchdog</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-3 space-y-3" align="start">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-foreground">Watchdog agent</div>
|
||||
<InlineEntitySelector
|
||||
value={watchdogAgentId}
|
||||
options={watchdogAgentOptions}
|
||||
placeholder="Select agent"
|
||||
noneLabel="No watchdog agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found."
|
||||
onChange={setWatchdogAgentId}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
<>
|
||||
{selectedWatchdogAgent ? (
|
||||
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select agent</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
const agent = (agents ?? []).find((a) => a.id === option.id);
|
||||
return (
|
||||
<>
|
||||
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-foreground">Instructions <span className="font-normal text-muted-foreground">(optional)</span></div>
|
||||
<Textarea
|
||||
value={watchdogInstructions}
|
||||
onChange={(event) => setWatchdogInstructions(event.target.value)}
|
||||
placeholder="What should the watchdog watch for and how should it keep work moving?"
|
||||
rows={4}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setShowWatchdogRow(false);
|
||||
setWatchdogEditorOpen(false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<Button type="button" size="sm" className="h-7 text-xs" onClick={() => setWatchdogEditorOpen(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSubIssueMode ? (
|
||||
|
||||
Reference in New Issue
Block a user