From 045c22dd81d434ffb266ebb46ca19520f088ac61 Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 28 Oct 2026 09:55:01 -0700 Subject: [PATCH] fix: preserve server task matches and completed review details Keep server search authoritative while retaining local filters and local attention search. Preserve expandable verdicts and reasons in completed item reviews, including when Coordinator is enabled. Co-Authored-By: Paperclip --- .../TaskChatCompactInteractionCard.tsx | 1 + .../TaskChatItemReviewReceipt.test.tsx | 41 +++++++++++++++++++ ui/src/hooks/useTaskListCollection.test.tsx | 11 +++++ ui/src/hooks/useTaskListCollection.ts | 8 ++-- ui/src/pages/CoordinatorTasks.test.tsx | 19 +++++++++ ui/src/pages/CoordinatorTasks.tsx | 1 + 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatItemReviewReceipt.test.tsx diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx index a1b63b65801..c258d9c3aa8 100644 --- a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -1956,6 +1956,7 @@ export function TaskChatCompactInteractionCard({ if (coordinatorEnabled && presentation === "timeline" && ["accepted", "answered", "rejected"].includes(interaction.status) && interaction.kind !== "connection_intent" + && interaction.kind !== "request_item_verdicts" && !(interaction.kind === "request_confirmation" && (interaction.payload.toolAction || interaction.payload.secretProposal || interaction.payload.connectionAuthorization))) { return ; } diff --git a/ui/src/components/task-chat/TaskChatItemReviewReceipt.test.tsx b/ui/src/components/task-chat/TaskChatItemReviewReceipt.test.tsx new file mode 100644 index 00000000000..e604bb8f1e3 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatItemReviewReceipt.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { completeRequestItemVerdictsInteraction } from "@/fixtures/issueThreadInteractionFixtures"; +import { TaskChatInteractionCard } from "./TaskChatInteractionCard"; + +const preference = vi.hoisted(() => ({ enabled: false })); +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => preference })); + +describe("completed item-review receipts", () => { + it.each([false, true])("retains inspectable verdicts and reasons with Coordinator enabled=%s", enabled => { + preference.enabled = enabled; + const interaction = completeRequestItemVerdictsInteraction; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + flushSync(() => root.render( + + )); + const disclosure = host.querySelector('details[data-testid="task-chat-interaction-receipt"]'); + expect(disclosure).not.toBeNull(); + expect(disclosure!.open).toBe(false); + disclosure!.querySelector("summary")!.click(); + expect(disclosure!.open).toBe(true); + for (const result of interaction.result!.items) { + const label = interaction.payload.items.find(item => item.id === result.id)!.label; + const row = [...disclosure!.querySelectorAll("li")].find(item => item.textContent?.includes(label)); + expect(row?.textContent).toContain(result.verdict); + if (result.reason) expect(row?.textContent).toContain(result.reason); + } + expect(host.querySelector('a[href^="#comment-interaction-response:"]')).toBeNull(); + } finally { + flushSync(() => root.unmount()); + host.remove(); + } + }); +}); diff --git a/ui/src/hooks/useTaskListCollection.test.tsx b/ui/src/hooks/useTaskListCollection.test.tsx index 6075cd53520..6efaad4f51e 100644 --- a/ui/src/hooks/useTaskListCollection.test.tsx +++ b/ui/src/hooks/useTaskListCollection.test.tsx @@ -79,6 +79,17 @@ describe("shared task-list collection behavior", () => { expect(collection.matchingItems).toHaveLength(3); }); + it("trusts server search while preserving company, status, and view filters", () => { + render({ query: "document-only match", searchMode: "server" }); + expect(collection.query).toBe("document-only match"); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["parent", "child", "grandchild"]); + act(() => collection.updateFilters({ statuses: ["in_review"] })); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["child"]); + expect(collection.visibleItems.map(item => item.issue.id)).toEqual(["parent", "child"]); + act(() => { collection.updateFilters({ statuses: [] }); collection.setView("unread"); }); + expect(collection.matchingItems.map(item => item.issue.id)).toEqual(["child"]); + }); + it("keeps view preferences isolated and clears the active collection without losing layout", () => { act(() => collection.setView("unread")); act(() => collection.setLayout("board")); diff --git a/ui/src/hooks/useTaskListCollection.ts b/ui/src/hooks/useTaskListCollection.ts index dbccff83b58..97a8debee7f 100644 --- a/ui/src/hooks/useTaskListCollection.ts +++ b/ui/src/hooks/useTaskListCollection.ts @@ -8,7 +8,7 @@ const expanded: ReadonlySet = new Set(); /** Shared collection behavior for the proposed task UI. Supply accessible data; * this hook scopes presentation, and does not replace server authorization. */ -export function useTaskListCollection({ items, companyId, userId, viewStorageKey, initialQuery = "", query: controlledQuery, onQueryChange, view: controlledView, initialFilters }: { +export function useTaskListCollection({ items, companyId, userId, viewStorageKey, initialQuery = "", query: controlledQuery, onQueryChange, view: controlledView, initialFilters, searchMode = "local" }: { items: readonly TaskListItem[]; companyId: string | null; userId: string | null; @@ -18,6 +18,8 @@ export function useTaskListCollection({ items, companyId, userId, viewStorageKey onQueryChange?: (query: string) => void; view?: TaskListView; initialFilters?: Partial; + /** Server results may match comments, documents, or normalized identifiers. */ + searchMode?: "local" | "server"; }) { const [queryState, setQueryState] = useState(initialQuery); const query = controlledQuery ?? queryState; @@ -44,8 +46,8 @@ export function useTaskListCollection({ items, companyId, userId, viewStorageKey const normalizedQuery = query.trim().toLocaleLowerCase(); const matchingItems = companyItems.filter(item => filteredIds.has(item.issue.id) && matchesTaskListView(view, item.issue, Boolean(item.unread), userId) - && [item.issue.title, item.issue.identifier, item.issue.description, item.createdBy, item.assignee, item.issue.project?.name, - ...(item.issue.labels ?? []).map(label => label.name)].some(value => value?.toLocaleLowerCase().includes(normalizedQuery))); + && (searchMode === "server" || [item.issue.title, item.issue.identifier, item.issue.description, item.createdBy, item.assignee, item.issue.project?.name, + ...(item.issue.labels ?? []).map(label => label.name)].some(value => value?.toLocaleLowerCase().includes(normalizedQuery)))); const visibleItems = includeTaskListAncestors(companyItems, matchingItems); const parentIds = new Set(companyItems.flatMap(item => item.issue.parentId ? [item.issue.parentId] : [])); const allExpanded = ![...parentIds].some(id => collapsedIds.has(id)); diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 611b7fb2fdf..44afb53f88c 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -62,6 +62,25 @@ describe("Coordinator tasks using the proposed table", () => { expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ q: "launch", participantAgentId: "agent", workspaceId: "workspace", offset: 0 }), expect.anything()); expect(host.querySelector('[data-task-id="search-result"]')).not.toBeNull(); }); + it.each(["phrase found only in a comment", "PAP 7", "launch summaries"])("retains server matches for %s without a second local text filter", async query => { + mocks.list.mockResolvedValue([createIssue({ id: "server-match", identifier: "PAP-7", title: "Launch summary", description: "Saved brief", companyId: "company", parentId: null })]); + await mount(`/issues?q=${encodeURIComponent(query)}`); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ q: query }), expect.anything()); + expect(host.querySelector('[data-task-id="server-match"]')).not.toBeNull(); + }); + it("still applies assignee filters to server search matches", async () => { + mocks.list.mockResolvedValue([ + createIssue({ id: "chosen", companyId: "company", parentId: null, assigneeAgentId: "chosen-agent" }), + createIssue({ id: "other", companyId: "company", parentId: null, assigneeAgentId: "other-agent" }), + ]); + await mount("/issues?q=comment-only&assignee=chosen-agent"); + expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual(["chosen"]); + }); + it("keeps local search for the hydrated Needs attention collection", async () => { + await mount("/issues?view=needs_attention&q=no-matching-decision"); + expect(host.querySelectorAll("[data-task-id]")).toHaveLength(0); + expect(host.textContent).toContain("No tasks match this view"); + }); it("keeps inaccessible or failed pending tasks visible as an error, not a false all-clear", async () => { mocks.get.mockRejectedValue(new Error("Task unavailable")); await mount("/issues?view=needs_attention"); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 4973f82411f..e97940c0657 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -88,6 +88,7 @@ export function CoordinatorTasks() { assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? null, })); const collection = useTaskListCollection({ items, companyId, userId, query, onQueryChange: setQuery, view: "all", + searchMode: needsAttention ? "local" : "server", viewStorageKey: taskListViewStorageKey("coordinator", userId ?? "local-board"), initialFilters: { assignees: params.get("assignee") ? [params.get("assignee")!] : [] }, });