mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
52e75dc4d6
commit
045c22dd81
6 files changed
+78
-3
No files matched your search
@@ -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 <CoordinatorInteractionReceipt interaction={interaction} />;
|
||||
}
|
||||
|
||||
@@ -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(<TooltipProvider><ThemeProvider>
|
||||
<TaskChatInteractionCard presentation="timeline" item={{ id: `interaction:${interaction.id}`, kind: "interaction", interaction }} />
|
||||
</ThemeProvider></TooltipProvider>));
|
||||
const disclosure = host.querySelector<HTMLDetailsElement>('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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -8,7 +8,7 @@ const expanded: ReadonlySet<string> = 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<IssueFilterState>;
|
||||
/** 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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")!] : [] },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user