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")!] : [] }, });