diff --git a/server/src/__tests__/chat-project-tools.test.ts b/server/src/__tests__/chat-project-tools.test.ts index a9abea9f8dc..70347309f96 100644 --- a/server/src/__tests__/chat-project-tools.test.ts +++ b/server/src/__tests__/chat-project-tools.test.ts @@ -127,6 +127,16 @@ const support = await getEmbeddedPostgresTestSupport(); } }); + it("persists required output review supplied through MCP creation", async () => { + const f = await server.fixture({ conversation: true }); + const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] }; + const result = await callProjectTool({ name: "create_task", arguments: { + title: "MCP review gate", projectId: f.projectId, idempotencyKey: "mcp-quality-gate", requiredQualityReview: requirement, + }, apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true }) as any; + const [task] = await server.db.select().from(issues).where(eq(issues.id, result.id)); + expect(task.executionPolicy).toMatchObject({ requiredQualityReview: requirement }); + }); + it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => { const f = await server.fixture({ conversation: true }); const svc = issueService(server.db); diff --git a/server/src/__tests__/decision-history.test.ts b/server/src/__tests__/decision-history.test.ts index 857c224b388..b3bfa53af50 100644 --- a/server/src/__tests__/decision-history.test.ts +++ b/server/src/__tests__/decision-history.test.ts @@ -2,11 +2,17 @@ import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, decisions, decisionTargetIssues, heartbeatRuns, issues, issueThreadInteractions } from "@paperclipai/db"; -import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget } from "../services/decision-history.js"; +import { decisionHistoryService, decodeDecisionHistoryCursor, decisionHistoryHref, decisionHistoryTarget, itemVerdictsHistoryLabel } from "../services/decision-history.js"; import { documentService } from "../services/documents.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; describe("decision receipt navigation", () => { + it("preserves all verdict outcomes in a mixed review summary", () => { + expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "approve" }, { verdict: "reject" }, { verdict: "defer" }] })) + .toBe("1 approved · 1 rejected · 1 deferred"); + expect(itemVerdictsHistoryLabel({ outcome: "resolved", items: [{ verdict: "reject" }] })).toBe("1 rejected"); + expect(itemVerdictsHistoryLabel({ outcome: "cancelled", items: [{ verdict: "approve" }] })).toBeNull(); + }); it("pins the reviewed document and interaction and ignores arbitrary custom URLs", () => { const target = decisionHistoryTarget({ target: { type: "issue_document", key: "launch copy", revisionId: "old-revision", href: "https://untrusted.example" } }); expect(decisionHistoryHref("PAP", "PAP-1", "issue_thread_interaction", "interaction", target)).toBe("/PAP/issues/PAP-1#document-launch%20copy&revision=old-revision&interaction=interaction"); @@ -33,6 +39,21 @@ const support = await getEmbeddedPostgresTestSupport(); }, 30_000); afterAll(async () => { vi.unstubAllEnvs(); await temp?.cleanup(); }); + it("projects mixed review verdicts into history instead of generic answers", async () => { + const companyId = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Review history", issuePrefix: "VRD" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Reviewed outputs" }); + await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_item_verdicts", status: "answered", + resolvedAt: new Date(), resolvedByUserId: "reviewer", payload: { version: 1, prompt: "Review outputs", items: [] }, + result: { version: 1, outcome: "resolved", complete: true, items: [ + { id: "copy", verdict: "approve", resolvedAt: new Date().toISOString() }, + { id: "art", verdict: "reject", resolvedAt: new Date().toISOString() }, + { id: "extra", verdict: "defer", resolvedAt: new Date().toISOString() }, + ] } }); + const page = await decisionHistoryService(db).list(companyId, { actor: { type: "board", userId: "reviewer" }, userId: "reviewer" }); + expect(page.items[0]?.label).toBe("1 approved · 1 rejected · 1 deferred"); + }); + it("returns a cross-task document receipt whose exact revision belongs to the target, not the origin", async () => { const companyId = randomUUID(), originId = randomUUID(), documentOwnerId = randomUUID(), interactionId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Cross-task review", issuePrefix: "XDR" }); diff --git a/server/src/services/decision-history.ts b/server/src/services/decision-history.ts index 25d1663e3e3..291bfec2251 100644 --- a/server/src/services/decision-history.ts +++ b/server/src/services/decision-history.ts @@ -7,6 +7,17 @@ import { badRequest } from "../errors.js"; type HistoryOptions = DecisionHistoryQuery & { actor: AuthorizationActor; userId: string; issueIds?: string[] }; type Cursor = { at: string; id: string; sort: "newest" | "oldest" }; +export function itemVerdictsHistoryLabel(result: unknown): string | null { + if (!result || typeof result !== "object" || !("outcome" in result) || result.outcome !== "resolved" + || !("items" in result) || !Array.isArray(result.items)) return null; + const counts = { approve: 0, reject: 0, defer: 0 }; + for (const item of result.items) { + if (item?.verdict === "approve" || item?.verdict === "reject" || item?.verdict === "defer") counts[item.verdict as keyof typeof counts]++; + } + return [counts.approve ? `${counts.approve} approved` : null, counts.reject ? `${counts.reject} rejected` : null, + counts.defer ? `${counts.defer} deferred` : null].filter(Boolean).join(" · ") || "Review recorded"; +} + export function decodeDecisionHistoryCursor(value: string | undefined, sort: Cursor["sort"]): Cursor | null { if (!value) return null; try { @@ -84,7 +95,7 @@ export function decisionHistoryService(db: Db) { agentId: agents.id, agentName: agents.name, projectId: projects.id, projectName: projects.name, title: issueThreadInteractions.title, outcome: issueThreadInteractions.status, resolvedAt: issueThreadInteractions.resolvedAt, resolvedByUserId: issueThreadInteractions.resolvedByUserId, resolvedByAgentId: issueThreadInteractions.resolvedByAgentId, - payload: issueThreadInteractions.payload, result: issueThreadInteractions.result }) + kind: issueThreadInteractions.kind, payload: issueThreadInteractions.payload, result: issueThreadInteractions.result }) .from(issueThreadInteractions).innerJoin(issues, and(eq(issues.id, issueThreadInteractions.issueId), eq(issues.companyId, companyId))) .leftJoin(agents, and(eq(agents.id, issueThreadInteractions.createdByAgentId), eq(agents.companyId, companyId))) .leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, companyId))) @@ -108,12 +119,12 @@ export function decisionHistoryService(db: Db) { ...conditions(decisionTime, decisionKey, decisions.title))) .orderBy(order(decisionTime), order(decisionKey)).limit(limit + 1), ]); - const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ payload, result, ...row }) => { + const interactionItems: DecisionHistoryItem[] = interactionRows.map(({ kind, payload, result, ...row }) => { const target = decisionHistoryTarget(payload); const resultOutcome = result && "outcome" in result ? String(result.outcome) : row.outcome; const labels: Record = { accepted: "Approved", rejected: "Changes requested", answered: "Answers recorded", cancelled: "Cancelled", expired: "Expired", failed: "Failed", stale_target: "Superseded", superseded_by_newer_request: "Superseded", superseded_by_comment: "Answered in task" }; return { ...row, title: row.title ?? row.issueTitle, resolvedAt: new Date(row.resolvedAt!).toISOString(), - id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target, + id: `issue_thread_interaction:${row.sourceId}`, sourceKind: "issue_thread_interaction", label: (kind === "request_item_verdicts" ? itemVerdictsHistoryLabel(result) : null) ?? labels[resultOutcome] ?? labels[row.outcome] ?? "Decision recorded", target, href: decisionHistoryHref(company.prefix, row.issueIdentifier ?? row.issueId, "issue_thread_interaction", row.sourceId, target) }; }); const decisionItems: DecisionHistoryItem[] = decisionRows.map(({ chosenOptionId, options: choices, ...row }) => ({ diff --git a/server/src/services/project-tools.ts b/server/src/services/project-tools.ts index 7a6f5a328f7..843ff71cabc 100644 --- a/server/src/services/project-tools.ts +++ b/server/src/services/project-tools.ts @@ -1,4 +1,4 @@ -import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared"; +import { projectDiscoverySchema, createProjectSchema, createIssueSchema, setIssueTitleSchema, requiredQualityReviewSchema } from "@paperclipai/shared"; import { z } from "zod"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js"; import { badRequest } from "../errors.js"; @@ -42,6 +42,9 @@ export async function callProjectTool(input: { body = createIssueSchema.parse({ title: args.title, description: args.description, priority: args.priority, projectId: args.projectId, initialPlan: args.initialPlan, + ...(args.requiredQualityReview !== undefined ? { + executionPolicy: { requiredQualityReview: requiredQualityReviewSchema.parse(args.requiredQualityReview) }, + } : {}), assigneeAgentId: args.assigneeActorId ?? (args.assigneeUserId ? null : input.agentId), assigneeUserId: args.assigneeUserId, parentId: input.conversation ? null : input.issueId, diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 0c1b55d28dc..47650550be5 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -1148,6 +1148,16 @@ describe("IssueThreadInteractionCard", () => { ); }); + it("links a bound review item to its exact document without requiring href", () => { + const host = renderCard({ interaction: { ...pendingRequestItemVerdictsInteraction, + payload: { ...pendingRequestItemVerdictsInteraction.payload, items: [{ id: "copy", label: "Reviewed copy", target: { + type: "issue_document", issueId: "document-owner", key: "copy", revisionId: "reviewed-v1", + } }] }, + } }); + expect(host.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href")) + .toBe(`/issues/document-owner#document-copy&revision=reviewed-v1&interaction=${pendingRequestItemVerdictsInteraction.id}`); + }); + it("submits an approve verdict once a draft is marked and applied", async () => { const onSubmitInteractionVerdicts = vi.fn(async () => undefined); const host = renderCard({ diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 1c66ed14a9f..746f08e02c9 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -6,6 +6,7 @@ import { formatAssigneeUserLabel } from "../lib/assignees"; import { describeInteractionAudience, type InteractionAudienceDescription } from "../lib/interaction-audience"; import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; import { buildDocumentAnnotationHash } from "../lib/document-annotation-hash"; +import { itemVerdictHref } from "@/lib/item-verdict-links"; import { isInteractionPreparingApproval, buildSuggestedTaskTree, @@ -3239,8 +3240,8 @@ function VerdictConsequenceChip({ verdict }: { verdict: RequestItemVerdictValue ); } -function ItemVerdictDeepLink({ item }: { item: RequestItemVerdictsItem }) { - const href = item.href ? normalizeRequestConfirmationTargetHref(item.href) : null; +function ItemVerdictDeepLink({ item, interaction }: { item: RequestItemVerdictsItem; interaction: RequestItemVerdictsInteraction }) { + const href = itemVerdictHref(item, interaction); if (!href) return null; const isInternal = href.startsWith("/") || href.startsWith("#"); const className = @@ -3516,7 +3517,7 @@ function RequestItemVerdictsCard({
{item.label} - +
{item.description ? (

{item.description}

diff --git a/ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx b/ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx index 5d69529401b..cb43df674b7 100644 --- a/ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx +++ b/ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx @@ -7,7 +7,7 @@ import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared"; import { CoordinatorNeedsYou } from "./CoordinatorNeedsYou"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() })); +const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], historyFail: false, feedFail: false, openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: fixture.breadcrumbs }) })); vi.mock("@/context/PanelContext", () => ({ usePanel: () => ({ openPanel: fixture.openPanel, closePanel: fixture.closePanel, setPanelVisible: fixture.setPanelVisible }) })); @@ -16,8 +16,8 @@ vi.mock("@/lib/router", () => ({ useNavigate: () => fixture.navigate })); vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } })); vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } })); vi.mock("@/api/attention", () => ({ attentionApi: { - list: async () => ({ items: [...fixture.pending] }), - history: async () => ({ items: [...fixture.history], nextCursor: null }), + list: async () => { if (fixture.feedFail) throw new Error("Feed unavailable"); return { items: [...fixture.pending] }; }, + history: async () => { if (fixture.historyFail) throw new Error("History unavailable"); return { items: [...fixture.history], nextCursor: null }; }, } })); vi.mock("motion/react", () => ({ LayoutGroup: ({ children }: { children: ReactNode }) => children, @@ -42,6 +42,7 @@ let container: HTMLDivElement; beforeEach(() => { fixture.pending = ["1", "2", "3"].map((id) => ({ id, sourceKind: "issue_thread_interaction", inlineResolvable: true, subject: { id, kind: "interaction", title: `Review ${id}`, metadata: { issueId: `task-${id}` } }, relatedIssue: { id: `task-${id}`, identifier: `PAP-${id}`, title: `Task ${id}`, href: `/PAP/issues/PAP-${id}` }, activityAt: new Date().toISOString() } as unknown as AttentionItem)); fixture.history = []; vi.clearAllMocks(); + fixture.historyFail = false; fixture.feedFail = false; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -52,6 +53,17 @@ async function click(text: string) { await act(async () => button!.click()); await flush(); } describe("Coordinator Needs You", () => { + it.each([false, true])("does not restore a saved decision when history refresh fails (stale feed: %s)", async staleFeed => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => root.render()); + await flush(); + fixture.historyFail = true; fixture.feedFail = staleFeed; + await click("Resolve 1"); await flush(); + expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(false); + expect(container.textContent).toContain("Recent decisions could not be loaded"); + expect(container.textContent).toContain("Resolve 2"); + client.clear(); + }); it("sorts, groups, and filters the recent decisions shown beneath the toolbar", async () => { fixture.pending = []; const now = Date.now(); diff --git a/ui/src/components/coordinator/CoordinatorNeedsYou.tsx b/ui/src/components/coordinator/CoordinatorNeedsYou.tsx index b8414cbe163..35ffe212d31 100644 --- a/ui/src/components/coordinator/CoordinatorNeedsYou.tsx +++ b/ui/src/components/coordinator/CoordinatorNeedsYou.tsx @@ -89,6 +89,8 @@ export function CoordinatorNeedsYou() { const [historyOpen, setHistoryOpen] = useState(false); const [now, setNow] = useState(Date.now); const [settling, setSettling] = useState([]); + const [savedIds, setSavedIds] = useState>(() => new Set()); + useEffect(() => { setSettling([]); setSavedIds(new Set()); }, [selectedCompanyId, userId]); const [renderLimit, setRenderLimit] = useState(40); const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!), queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true }); @@ -122,8 +124,8 @@ export function CoordinatorNeedsYou() { const receipts = new Set(recentItems.map((item) => item.id)); const items = new Map((feed.data?.items ?? []).map((item) => [`${item.sourceKind}:${item.subject.id}`, item])); for (const item of settling) if (!items.has(`${item.sourceKind}:${item.subject.id}`)) items.set(`${item.sourceKind}:${item.subject.id}`, item); - return [...items].filter(([key]) => !receipts.has(key) || settlingIds.has(key)).map(([, item]) => item); - }, [feed.data, settling, settlingIds, recentItems]); + return [...items].filter(([key]) => settlingIds.has(key) || (!receipts.has(key) && !savedIds.has(key))).map(([, item]) => item); + }, [feed.data, settling, settlingIds, recentItems, savedIds]); const filtered = useMemo(() => pendingItems.filter((item) => { const agentId = String(item.subject.metadata?.createdByAgentId ?? item.subject.metadata?.originAgentId ?? ""); if (agentFilter && agentId !== agentFilter && item.originAgentName !== agentMap.get(agentFilter)?.name) return false; @@ -161,7 +163,15 @@ export function CoordinatorNeedsYou() { const timer = window.setTimeout(() => setNow(Date.now()), Math.max(0, next - Date.now()) + 1); return () => window.clearTimeout(timer); }, [recentItems]); - const onResolved = (item: AttentionItem) => { setSettling((current) => current.some((entry) => entry.id === item.id) ? current : [...current, item]); void recent.refetch(); void feed.refetch(); }; + const onResolved = (item: AttentionItem) => { + const id = `${item.sourceKind}:${item.subject.id}`; + setSavedIds(current => new Set([...current, id])); + setSettling(current => current.some(entry => entry.id === item.id) ? current : [...current, item]); + void recent.refetch().then(result => { + if (result.isError) setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id)); + }); + void feed.refetch(); + }; const duration = typeof document === "undefined" ? 0 : parseCssTimeMs(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-base")) / 1_000; if (!selectedCompanyId) return

Choose a company to see its decisions.

; return
diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx index 4cd8cc7ff82..5f2ed841192 100644 --- a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -1,3 +1,4 @@ +import { itemVerdictHref } from "@/lib/item-verdict-links"; import { useEffect, useMemo, @@ -1641,9 +1642,7 @@ function ItemVerdictsCard({ ); const resolved = resolvedById.get(item.id); const draft = drafts.get(item.id); - const itemHref = item.href - ? normalizeRequestConfirmationTargetHref(item.href) - : null; + const itemHref = itemVerdictHref(item, interaction); const rejectionNeedsConfirmation = Boolean( !resolved && draft && diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 194855c99c5..9cbc26bfd28 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -838,6 +838,15 @@ describe("TaskChatInteractionCard", () => { }]); }); + it("opens the bound output revision from a compact review item", () => { + const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload, + items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }], + } }; + flushSync(() => root.render( undefined} />)); + expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href")) + .toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`); + }); + it("paginates item verdicts instead of expanding the whole review set", async () => { flushSync(() => { root.render( diff --git a/ui/src/lib/item-verdict-links.test.ts b/ui/src/lib/item-verdict-links.test.ts new file mode 100644 index 00000000000..5e671488bf4 --- /dev/null +++ b/ui/src/lib/item-verdict-links.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { itemVerdictHref } from "./item-verdict-links"; + +describe("review output links", () => { + const interaction = { issueId: "review-task", id: "review" }; + it("pins the exact document owner and revision ahead of a supplied display URL", () => { + expect(itemVerdictHref({ id: "copy", label: "Copy", href: "https://example.com/latest", target: { + type: "issue_document", issueId: "output-task", key: "launch copy", revisionId: "reviewed-v2", + } }, interaction)).toBe("/issues/output-task#document-launch%20copy&revision=reviewed-v2&interaction=review"); + }); + it("opens the immutable attachment in its owning task", () => { + expect(itemVerdictHref({ id: "art", label: "Artwork", target: { type: "issue_attachment", issueId: "owner", attachmentId: "asset-file", assetId: "asset", sha256: "hash" } }, interaction)) + .toBe("/issues/owner#interaction-review&attachment=asset-file"); + }); + it("keeps safe legacy links and rejects unsafe custom URLs", () => { + expect(itemVerdictHref({ id: "legacy", label: "Legacy", href: "javascript:alert(1)" }, interaction)).toBeNull(); + expect(itemVerdictHref({ id: "legacy", label: "Legacy", href: "/issues/old" }, interaction)).toBe("/issues/old"); + }); +}); diff --git a/ui/src/lib/item-verdict-links.ts b/ui/src/lib/item-verdict-links.ts new file mode 100644 index 00000000000..90424b64916 --- /dev/null +++ b/ui/src/lib/item-verdict-links.ts @@ -0,0 +1,17 @@ +import type { RequestItemVerdictsItem } from "@paperclipai/shared"; +import { buildDocumentAnnotationHash } from "./document-annotation-hash"; +import { normalizeRequestConfirmationTargetHref } from "./issue-thread-interactions"; + +/** The bound output takes precedence over an optional display URL. */ +export function itemVerdictHref(item: RequestItemVerdictsItem, interaction: { issueId: string; id: string }): string | null { + const target = item.target; + if (target?.type === "issue_document") { + return `/issues/${encodeURIComponent(target.issueId ?? interaction.issueId)}${buildDocumentAnnotationHash({ + documentKey: target.key, revisionId: target.revisionId, interactionId: interaction.id, threadId: null, commentId: null, + })}`; + } + if (target?.type === "issue_attachment") { + return `/issues/${encodeURIComponent(target.issueId ?? interaction.issueId)}#interaction-${encodeURIComponent(interaction.id)}&attachment=${encodeURIComponent(target.attachmentId)}`; + } + return item.href ? normalizeRequestConfirmationTargetHref(item.href) : null; +} diff --git a/ui/src/pages/AgentChat.test.tsx b/ui/src/pages/AgentChat.test.tsx index 4708b55d633..dab56c458f9 100644 --- a/ui/src/pages/AgentChat.test.tsx +++ b/ui/src/pages/AgentChat.test.tsx @@ -9,14 +9,14 @@ import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { AgentChat } from "./AgentChat"; -const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), getChat: vi.fn(), ensure: vi.fn(), session: vi.fn() })); +const api = vi.hoisted(() => ({ enabled: true, list: vi.fn(), get: vi.fn(), getChat: vi.fn(), ensure: vi.fn(), session: vi.fn() })); const agentId = "18c5ad50-9d70-4e24-bd82-337e1d45a414"; vi.mock("@/api/agents", () => ({ agentsApi: { list: api.list, get: api.get } })); vi.mock("@/api/agentChats", () => ({ agentChatsApi: { get: api.getChat, ensure: api.ensure } })); vi.mock("@/api/auth", () => ({ authApi: { getSession: api.session } })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-a" }) })); -vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) })); -vi.mock("@/lib/router", () => ({ useParams: () => ({ agentRef: "18c5ad50-9d70-4e24-bd82-337e1d45a414" }) })); +vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: api.enabled, loaded: true }) })); +vi.mock("@/lib/router", () => ({ useParams: () => ({ agentRef: "18c5ad50-9d70-4e24-bd82-337e1d45a414" }), Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children} })); vi.mock("./IssueDetail", () => ({ TaskDetailSurface: ({ conversation }: { conversation: { agent: Agent } }) =>
History for {conversation.agent.name}
})); let root: Root; @@ -42,6 +42,7 @@ async function failRoster(status: number, cached = true) { } beforeEach(() => { vi.clearAllMocks(); + api.enabled = true; client = new QueryClient({ defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: Infinity, gcTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "viewer-a" } }); api.session.mockResolvedValue({ user: { id: "viewer-a" } }); @@ -51,6 +52,15 @@ beforeEach(() => { }); afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); }); +it("explains when Agent Chat is disabled without creating a conversation", async () => { + api.enabled = false; + api.list.mockResolvedValue([{ id: agentId, companyId: "company-a", name: "Helper" }]); + render(); + await waitFor(() => expect(container.textContent).toContain("Agent Chat is disabled")); + expect(api.ensure).not.toHaveBeenCalled(); + expect(api.getChat).not.toHaveBeenCalled(); +}); + it.each(["cached_refetch_failed", "fresh"])("opens historical chat from an available roster (%s)", async state => { if (state === "cached_refetch_failed") await failRoster(429); else client.setQueryData(queryKeys.agents.list("company-a"), []); diff --git a/ui/src/pages/AgentChat.tsx b/ui/src/pages/AgentChat.tsx index 92236cfa0e9..e3b8b6db139 100644 --- a/ui/src/pages/AgentChat.tsx +++ b/ui/src/pages/AgentChat.tsx @@ -86,7 +86,7 @@ export function AgentChat() { if (!enabled && !chat.data) return (

- Agent Chat is off for this instance. An administrator can enable it in{" "} + Agent Chat is disabled for this instance. An administrator can enable it in{" "} Experimental settings. {" "}Your workspace preference does not change Chat availability. Existing history remains available through task links.