diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 8b5f0a89403..dc54b088d8a 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -423,9 +423,23 @@ with the task list's filters and a sort control. The agent's artifacts are a second card stack available from the panel's **+** menu. Both open in a new tab so the conversation stays open. +### Coordinator Inbox and personal task views + +Coordinator navigation uses **Chat**, **Tasks**, and **Inbox**. Inbox uses the +inbox icon and keeps the existing decision feed, its count, and decision history +at `/decisions`. The previous Inbox destination under More is removed. + +Tasks defaults to **Mine**, with a saved view per person and company. Mine uses +the previous Inbox's personal participation rules, mixed attention items, +read/unread title weights, keyboard shortcuts, archive and undo, mobile +swipe-to-archive, grouping, and saved columns. Its existing preferences carry +over. Unread, Recent, Blocked, and Everything are also task views. Organization +views retain the task table and board. The table saves column selection and +order per person and company. Old `/inbox/*` links redirect to their Tasks view. + ### Combined Inbox + Task List -An opt-in experimental setting (`enableCombinedInboxTasks`, off by default) +In the Classic workspace, an opt-in setting (`enableCombinedInboxTasks`, off by default) folds Inbox into Tasks. The Inbox nav row goes away and its unread badge moves to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread, Blocked, Recent, Everything) and the task-status views (All, Active, Backlog, diff --git a/maps/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md index f012b258899..e2018600e3c 100644 --- a/maps/feature-map/agent-chat.md +++ b/maps/feature-map/agent-chat.md @@ -103,12 +103,12 @@ Manual: With the personal Coordinator workspace enabled, discuss a vague request until the agent suggests a task. Confirm that no execution task exists before acceptance. Accept it, stay in chat, and open the created card on demand. An explicit request to create a task may create it directly under normal authority. -Approve a work product or answer a question in its task or Needs You, then return +Approve a work product or answer a question in its task or Inbox, then return to the originating chat. Its compact update should link to the exact recorded decision and reviewed artifact, without needing another coordinator turn. Approval of artifact quality alone must not publish it or complete the task. -Coordinator task clarification: create a task with only a description. When material context is missing, native task guidance asks one focused canonical question inside that task. Its actionable form is also projected into Needs You and the originating standing chat; direct user-created tasks appear in the assigned agent’s standing chat. Answer on either surface and verify the pending card/count clear and a compact recorded answer replaces it in chat. This projection is event-refreshed and does not wake another agent merely to repeat the question. Current direct-task questions survive a chat reset; their answered receipts start at the current chat boundary. The task brief remains editable context, not another stored question. Model choice and actual resumed execution still require live verification. +Coordinator task clarification: create a task with only a description. When material context is missing, native task guidance asks one focused canonical question inside that task. Its actionable form is also projected into Inbox and the originating standing chat; direct user-created tasks appear in the assigned agent’s standing chat. Answer on either surface and verify the pending card/count clear and a compact recorded answer replaces it in chat. This projection is event-refreshed and does not wake another agent merely to repeat the question. Current direct-task questions survive a chat reset; their answered receipts start at the current chat boundary. The task brief remains editable context, not another stored question. Model choice and actual resumed execution still require live verification. ### `conference-room` diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index b141ba71387..988f700ae83 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -24,14 +24,13 @@ Agent access: [companion](../agent-access-map/inbox-search.md). ### `inbox` -Use Inbox links or the corresponding task views when the combined list is enabled. +In Coordinator, open Tasks → Mine, Unread, Blocked, Recent, or Everything. Old `/inbox/*` links redirect to the matching task view. Classic uses Inbox links or the task views when its combined list is enabled. ### `decisions` Open `/decisions`, then a queue at `/decisions/queues/:key` and its source item. -With **Settings → Experimental → Coordinator workspace** enabled, `/decisions` -is **Needs You**. Pending cards use the existing attention feed and canonical +In Coordinator, `/decisions` is **Inbox**. Pending cards use the existing attention feed and canonical resolvers. **Tasks → Needs attention** projects the same feed as distinct tasks. Search, filter by agent/project, sort, and group by task/agent/project apply to both open decisions and the visible Recent Decisions receipts. Successful @@ -43,7 +42,7 @@ reduced motion settles immediately, and revisiting history does not replay it. T 24-hour display window is an experimental assumption, not data deletion. Resolving an artifact decision does not itself complete its task. If the task remains in review without a maintained reviewer or continuation, its separate -canonical stalled-review item stays in Needs You and Tasks → Needs attention. +canonical stalled-review item stays in Inbox and Tasks → Needs attention. Coordinator presents the existing recovery actions with **Mark task done** as the task-level verdict, distinct from the saved artifact receipt. @@ -65,7 +64,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed ### `inbox` -Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks. +Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. [Coordinator Tasks browser checks](../../tests/e2e/coordinator-inbox-views.spec.ts) exercise personal views, read state, archive, keyboard undo, mobile swipes, and saved columns. Automated: [Issue service regressions](../../server/src/__tests__/issues-service.test.ts) cover exclusion of historical automatic read cleanup from user participation, @@ -109,13 +108,13 @@ company/private-task isolation, and private v1 targets. cover pending question forms and confirmations in persistent agent chats, removal after an answer with retained history, and company/task-visibility/addressee checks. Idle chats without structured requests remain absent. The -[Needs You component test](../../ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx) +[Inbox component test](../../ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx) checks that a chat-sourced canonical question reaches its original resolver and context. It also checks simultaneous resize/light and delayed movement to Recent Decisions. [Shared card-motion tests](../../ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx) cover reduced-motion settlement and returning to a resolved card without replay. The [decision-motion stories](../../ui/storybook/stories/design-proposals/coordinator-decision-motion.stories.tsx) -exercise real Needs You/resolver components with isolated approval, question, and failed-save responses. +exercise real Inbox/resolver components with isolated approval, question, and failed-save responses. [Projection helpers](../../ui/src/lib/coordinator-attention.test.ts) cover distinct pending task membership and presentation-only recency decay. Browser verification must additionally approve copy, approve a visual, and answer a question; confirm diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index daac13d15d8..4f54b24dae3 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1355,11 +1355,12 @@ test.describe("Failed chat task and exact run retry", () => { ); }); - for (const surface of ["agent run", "Inbox", "Legacy Inbox"] as const) { + for (const surface of ["agent run", "Inbox", "Coordinator Tasks", "Legacy Inbox"] as const) { for (const outcome of ["queued", "deferred", "denied"] as const) { test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({ page, }, testInfo) => { + const usesTaskRetry = surface === "Inbox" || surface === "Coordinator Tasks"; const now = Date.now(); const run = { id: failedRunId, @@ -1413,7 +1414,11 @@ test.describe("Failed chat task and exact run retry", () => { const url = new URL(route.request().url()); const pathname = url.pathname; if (pathname === "/api/workspace-preferences/me") { - await fulfill(route, { userId: "local-board", coordinatorWorkspaceEnabled: surface !== "Legacy Inbox", updatedAt: null }); + await fulfill(route, { + userId: "local-board", + coordinatorWorkspaceEnabled: surface === "Coordinator Tasks", + updatedAt: null, + }); return; } if (pathname === "/api/instance/settings/experimental") { @@ -1460,7 +1465,7 @@ test.describe("Failed chat task and exact run retry", () => { }); return; } - const retryPath = surface === "Inbox" + const retryPath = usesTaskRetry ? `/api/issues/${issue.id}/retry` : `/api/agents/${seed.agentId}/wakeup`; if (pathname === retryPath) { @@ -1482,7 +1487,7 @@ test.describe("Failed chat task and exact run retry", () => { // The durable action exists, but no run has been admitted yet. await fulfill( route, - surface === "Inbox" + usesTaskRetry ? { requestId: actionId, accepted: true, @@ -1542,7 +1547,7 @@ test.describe("Failed chat task and exact run retry", () => { await expect(retry).toHaveCount(1); await retry.click(); await expect.poll(() => requests.length).toBe(1); - expect(requests[0]).toEqual(surface === "Inbox" + expect(requests[0]).toEqual(usesTaskRetry ? { companyId: null, body: { @@ -1582,7 +1587,9 @@ test.describe("Failed chat task and exact run retry", () => { ? new RegExp( `/${seed.prefix}/agents/(${seed.agentId}|maya)/runs/${failedRunId}$`, ) - : new RegExp(`${startPath}$`), + : surface === "Coordinator Tasks" + ? new RegExp(`/${seed.prefix}/issues\\?view=everything$`) + : new RegExp(`${startPath}$`), ); await expect(retry).toBeEnabled(); await testInfo.attach(`${surface}-retry-denied`, { diff --git a/tests/e2e/coordinator-inbox-views.spec.ts b/tests/e2e/coordinator-inbox-views.spec.ts new file mode 100644 index 00000000000..575ce660482 --- /dev/null +++ b/tests/e2e/coordinator-inbox-views.spec.ts @@ -0,0 +1,100 @@ +import path from "node:path"; +import { expect, test, type APIResponse } from "@playwright/test"; + +async function json(response: APIResponse) { + expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true); + return response.json(); +} + +test.use({ hasTouch: true }); +for (const mobile of [false, true]) test(`Mine retains Inbox behavior inside Tasks (${mobile ? "mobile" : "desktop"})`, async ({ page, request }) => { + test.setTimeout(90_000); + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 900 }); + await page.addInitScript(() => { + if (!localStorage.getItem("paperclip:inbox:issue-columns")) localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "project", "updated"])); + }); + const company = await json(await request.post("/api/companies", { data: { name: "Personal Tasks verification" } })); + const preferences = await json(await request.get("/api/workspace-preferences/me")); + const preferencePath = `/api/companies/${company.id}/workspace-preferences/me`; + try { + await json(await request.put(preferencePath, { data: { coordinatorWorkspaceEnabled: true } })); + const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: { + name: "Inbox activity fixture", adapterType: "process", adapterConfig: { + command: process.execPath, args: [path.resolve("tests/e2e/fixtures/inbox-activity.mjs")], + }, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } }, + } })); + const unread = await json(await request.post(`/api/companies/${company.id}/issues`, { data: { + title: "Unread personal task", status: "todo", assigneeAgentId: agent.id, + } })); + const read = await json(await request.post(`/api/companies/${company.id}/issues`, { data: { title: "Read personal task", status: "todo" } })); + await expect.poll(async () => { + const comments = await json(await request.get(`/api/issues/${unread.id}/comments`)); + return comments.some((comment: { authorAgentId: string | null }) => comment.authorAgentId === agent.id); + }, { timeout: 30_000 }).toBe(true); + await json(await request.delete(`/api/issues/${unread.id}/read`)); + await json(await request.post(`/api/issues/${read.id}/read`, { data: {} })); + await page.goto(`/${company.issuePrefix}/inbox/mine`); + await expect(page).toHaveURL(/\/issues\?view=mine$/); + const mine = page.getByRole("region", { name: "Tasks", exact: true }); + const unreadRow = mine.locator(`[data-issue-id="${unread.id}"]`); + const readRow = mine.locator(`[data-issue-id="${read.id}"]`); + await expect(unreadRow).toHaveAttribute("data-unread", "true"); + await expect(unreadRow.locator('[data-slot="task-row-title"]')).toHaveClass(/font-semibold/); + await expect(readRow.locator('[data-slot="task-row-title"]')).not.toHaveClass(/font-semibold/); + await expect(page.getByRole("link", { name: /^Inbox(?:,|$)/ }).first()).toHaveAttribute("href", `/${company.issuePrefix}/decisions`); + + await page.getByRole("button", { name: "Columns", exact: true }).click(); + const creatorColumn = page.getByRole("menuitemcheckbox", { name: /^Kicked off by/ }); + await creatorColumn.click(); + await expect(creatorColumn).toBeChecked(); + await page.keyboard.press("Escape"); + await page.locator('button[title="Group"]').click(); + await page.getByRole("button", { name: "Project", exact: true }).click(); + await page.keyboard.press("Escape"); + await page.reload(); + await expect(unreadRow).toBeVisible(); + await expect(mine.getByText("No project", { exact: true }).first()).toBeVisible(); + await page.getByRole("button", { name: "Columns", exact: true }).click(); + await expect(creatorColumn).toBeChecked(); + await page.keyboard.press("Escape"); + await page.locator('button[title="Group"]').click(); + await page.getByRole("button", { name: "None", exact: true }).click(); + await page.keyboard.press("Escape"); + + if (mobile) { + await unreadRow.scrollIntoViewIfNeeded(); + const bounds = await unreadRow.boundingBox(); + expect(bounds).not.toBeNull(); + const session = await page.context().newCDPSession(page); + const x = bounds!.x + bounds!.width * 0.85; + const y = bounds!.y + bounds!.height / 2; + await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); + for (let step = 1; step <= 6; step++) await session.send("Input.dispatchTouchEvent", { + type: "touchMove", touchPoints: [{ x: x - bounds!.width * 0.1 * step, y }], + }); + await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await session.detach(); + await expect(unreadRow).toHaveCount(0); + } else { + await unreadRow.hover(); + await page.keyboard.press("r"); + await expect(unreadRow).not.toHaveAttribute("data-unread", "true"); + await page.keyboard.press("U"); + await expect(unreadRow).toHaveAttribute("data-unread", "true"); + const archiveSaved = page.waitForResponse(response => response.url().endsWith(`/api/issues/${unread.id}/inbox-archive`) && response.request().method() === "POST"); + const archiveRefreshed = page.waitForResponse(response => response.url().includes(`/api/companies/${company.id}/inbox/mine`) && response.request().method() === "GET"); + await page.keyboard.press("a"); + expect((await archiveSaved).ok()).toBe(true); + await archiveRefreshed; + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(unreadRow).toHaveCount(0); + await page.keyboard.press("u"); + await expect(unreadRow).toBeVisible(); + await page.getByRole("link", { name: "Inbox", exact: true }).click(); + await expect(page.getByRole("region", { name: "Inbox", exact: true }).getByRole("heading", { name: "Inbox", exact: true })).toBeVisible(); + } + } finally { + await request.put(preferencePath, { data: { coordinatorWorkspaceEnabled: preferences.coordinatorWorkspaceEnabled } }); + await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } }); + } +}); diff --git a/tests/e2e/fixtures/inbox-activity.mjs b/tests/e2e/fixtures/inbox-activity.mjs new file mode 100644 index 00000000000..9b637927c5f --- /dev/null +++ b/tests/e2e/fixtures/inbox-activity.mjs @@ -0,0 +1,13 @@ +const headers = { + Authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, + "X-Paperclip-Run-Id": process.env.PAPERCLIP_RUN_ID, + "Content-Type": "application/json", +}; +const base = `${process.env.PAPERCLIP_API_URL}/api`; +const runResponse = await fetch(`${base}/heartbeat-runs/${process.env.PAPERCLIP_RUN_ID}`, { headers }); +if (!runResponse.ok) throw new Error(`Run read failed: ${runResponse.status}`); +const run = await runResponse.json(); +const response = await fetch(`${base}/issues/${run.contextSnapshot.issueId}/comments`, { + method: "POST", headers, body: JSON.stringify({ body: "An update from another actor." }), +}); +if (!response.ok) throw new Error(`Comment failed: ${response.status}`); diff --git a/tests/perf/collections/startup.spec.ts b/tests/perf/collections/startup.spec.ts index ff9ca8eb8c8..1d585190003 100644 --- a/tests/perf/collections/startup.spec.ts +++ b/tests/perf/collections/startup.spec.ts @@ -126,12 +126,12 @@ test("Coordinator Inbox retains aggregate Mine reads when Streamlined UI is disa expect(reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true")).toEqual([]); }); -test("Needs You owns one complete feed and no redundant summary read", async ({ page }) => { +test("Inbox owns one complete feed and no redundant summary read", async ({ page }) => { await coordinatorPreference(page); const reads: URL[] = []; page.on("request", request => { if (request.url().includes("/api/")) reads.push(new URL(request.url())); }); await page.goto(`/${fixture.company.issuePrefix}/decisions`); - await expect(page.getByRole("main").getByRole("heading", { name: "Needs You", exact: true })).toBeVisible(); + await expect(page.getByRole("main").getByRole("heading", { name: "Inbox", exact: true })).toBeVisible(); await expect.poll(() => reads.filter(url => url.pathname.endsWith("/attention") && url.searchParams.get("all") === "true").length).toBe(1); expect(reads.filter(url => url.pathname.endsWith("/attention/summary"))).toEqual([]); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d3cd95fca23..321cb819c01 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -461,9 +461,8 @@ function AppsConnectEntryRoute({ } function InboxRootRedirect() { - const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); - return streamlinedUiEnabled && combinedInboxTasksEnabled + return combinedInboxTasksEnabled ? : ; } diff --git a/ui/src/components/CoordinatorSidebar.test.tsx b/ui/src/components/CoordinatorSidebar.test.tsx index 9c8757d0c73..e2eef3c39d6 100644 --- a/ui/src/components/CoordinatorSidebar.test.tsx +++ b/ui/src/components/CoordinatorSidebar.test.tsx @@ -156,7 +156,7 @@ async function renderSidebar() { } const decisionLink = () => container.querySelector('a[href="/decisions"]')!; -describe("Coordinator Needs You badge", () => { +describe("Coordinator Inbox badge", () => { it("counts every canonical outstanding decision even when none arrived today", async () => { // Personal page filters and completed receipts are separate cache entries. client.setQueryData([...coordinatorAttentionQueryKey("company"), { search: "no match" }], feed([])); @@ -179,13 +179,13 @@ describe("Coordinator Needs You badge", () => { await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); }); await flush(); expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull(); - expect(decisionLink().textContent).toBe("Needs you"); + expect(decisionLink().textContent).toBe("Inbox"); }); it("announces the full outstanding count when the sidebar is collapsed", async () => { fixture.collapsed = true; await renderSidebar(); - expect(decisionLink().getAttribute("aria-label")).toBe("Needs you, 2 decisions"); + expect(decisionLink().getAttribute("aria-label")).toBe("Inbox, 2 decisions"); }); }); @@ -211,7 +211,7 @@ describe("Coordinator collection reads", () => { expect(fixture.attention).not.toHaveBeenCalled(); }); - it("reuses the page-owned complete feed on Needs You without a second summary read", async () => { + it("reuses the page-owned complete feed on Inbox without a second summary read", async () => { fixture.pathname = "/COO/decisions"; client.setQueryData(coordinatorAttentionQueryKey("company"), feed([pending("review")])); await renderSidebar(); diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index fc9b9304910..ffaad1c889e 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Inbox, Bell, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug } from "lucide-react"; +import { Inbox, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, SquarePen, Unplug } from "lucide-react"; import type { Project } from "@paperclipai/shared"; import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount"; import { issuesApi } from "@/api/issues"; @@ -27,7 +27,6 @@ import { taskStatusIconProps } from "@/lib/task-status-presentation"; const moreDestinations = [ { label: "Search", to: "/search", icon: Search }, - { label: "Inbox", to: "/inbox", icon: Inbox }, { label: "Dashboard", to: "/dashboard", icon: LayoutDashboard }, { label: "Projects", to: "/projects", icon: FolderOpen }, { label: "Routines", to: "/routines", icon: Repeat }, @@ -126,7 +125,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) { {!rail && } - + {pinnedDestinations.map(({ label, to, icon }) =>
{!rail && } diff --git a/ui/src/components/MobileBottomNav.coordinator.test.tsx b/ui/src/components/MobileBottomNav.coordinator.test.tsx index 146690700c9..0040e4b33f9 100644 --- a/ui/src/components/MobileBottomNav.coordinator.test.tsx +++ b/ui/src/components/MobileBottomNav.coordinator.test.tsx @@ -21,8 +21,8 @@ it("uses the canonical outstanding count on the phone as well as the desktop", a const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); try { await act(() => root.render()); - expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("3Needs you"); + expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("3Inbox"); await act(async () => { client.setQueryData(key, { totalCount: 0, deskBadgeCount: 0 }); await new Promise(resolve => setTimeout(resolve, 1)); }); - expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("Needs you"); + expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("Inbox"); } finally { await act(() => root.unmount()); host.remove(); client.clear(); } }); diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index 7e579de781c..bae95ba21c6 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -9,7 +9,6 @@ import { Users, MessageCircle, Inbox, - Bell, } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -60,7 +59,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { () => coordinatorWorkspaceEnabled ? [ { type: "link", to: "/chats", label: "Chat", icon: MessageCircle }, { type: "link", to: "/issues", label: "Tasks", icon: CircleCheck }, - { type: "link", to: "/decisions", label: "Needs you", icon: Bell, badge: attentionCount }, + { type: "link", to: "/decisions", label: "Inbox", icon: Inbox, badge: attentionCount }, { type: "action", label: "New task", icon: SquarePen, onClick: () => openNewIssue() }, ] : !agentChatEnabled && !combinedInboxTasksEnabled ? [ { type: "link", to: "/dashboard", label: "Home", icon: House }, diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index 5ae4e6b2f9a..73e4c168cde 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared"; import { ChevronDown, ChevronRight, GitBranch } from "lucide-react"; import { buildIssueTree } from "../lib/issue-tree"; import { issueFilterLabel } from "../lib/issue-filters"; -import { allTaskListColumns, defaultTaskListColumns, defaultTaskListSort, isTaskListColumnSort, taskListOrderToIssueSort, type TaskListColumnId, type TaskListSort, type TaskListOrder } from "../lib/task-list"; +import { defaultTaskListColumns, defaultTaskListSort, isTaskListColumnSort, taskListOrderToIssueSort, type TaskListColumnId, type TaskListSort, type TaskListOrder } from "../lib/task-list"; import { sortIssues } from "../lib/issue-sort"; import { timeAgo } from "../lib/timeAgo"; import { IssueRow, type IssueRowDensity } from "./IssueRow"; @@ -13,6 +13,8 @@ import { TaskRowGroup } from "./TaskRowGroup"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { useTaskRowBudget } from "../hooks/useTaskRowBudget"; +import { useTaskListColumnPreferences } from "../hooks/useTaskListColumnPreferences"; +import type { TaskCollectionPreferenceLocation } from "../lib/task-collection-preferences"; export type TaskListItem = { issue: Issue; @@ -38,7 +40,7 @@ export function includeTaskListAncestors(items: readonly TaskListItem[], matches } /** Opt-in collection built from the reviewed row, hierarchy and column primitives. */ -export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey, sort: controlledSort, onSortChange }: { +export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey, preferenceLocation, sort: controlledSort, onSortChange }: { items: readonly TaskListItem[]; allItems?: readonly TaskListItem[]; initialColumns?: readonly TaskListColumnId[]; @@ -50,12 +52,12 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask issueLinkState?: unknown; /** Company/person/view/filter identity; membership changes do not reset it. */ collectionKey?: string; + preferenceLocation?: TaskCollectionPreferenceLocation; sort?: TaskListOrder; onSortChange?: (sort: TaskListSort | undefined) => void; }) { const localKey = useId(); - const [columns, setColumns] = useState(initialColumns); - const [columnOrder, setColumnOrder] = useState(allTaskListColumns); + const { columns, order: columnOrder, setColumns, setOrder: setColumnOrder } = useTaskListColumnPreferences(preferenceLocation, initialColumns); const [localSort, setLocalSort] = useState(); const sort = controlledSort ?? (onSortChange ? undefined : localSort) ?? defaultTaskListSort; const setSort = (next: TaskListSort | undefined) => { diff --git a/ui/src/components/TaskListToolbar.tsx b/ui/src/components/TaskListToolbar.tsx index c6d5c54ed47..66f65491f00 100644 --- a/ui/src/components/TaskListToolbar.tsx +++ b/ui/src/components/TaskListToolbar.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, Ref } from "react"; +import type { ComponentProps, ReactNode, Ref } from "react"; import { ChevronDown, CirclePlus, Columns3, List, ListTree } from "lucide-react"; import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { Button } from "./ui/button"; @@ -9,10 +9,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { taskListViews as views, type TaskListView } from "@/lib/task-list-views"; /** Canonical proposed task toolbar; review under Components / Toolbars / Tasks. */ -export function TaskListToolbar({ view, onViewChange, viewOptions = views, query, onQueryChange, searchRef, layout, onLayoutChange, allExpanded, onToggleExpanded, onNewTask, filters }: { +export function TaskListToolbar({ view, onViewChange, viewOptions = views, viewControl, query, onQueryChange, searchRef, layout, onLayoutChange, allExpanded, onToggleExpanded, onNewTask, filters }: { view: TaskListView; onViewChange: (view: TaskListView) => void; viewOptions?: readonly { value: TaskListView; label: string }[]; + viewControl?: ReactNode; query: string; onQueryChange: (query: string) => void; searchRef?: Ref; @@ -25,7 +26,7 @@ export function TaskListToolbar({ view, onViewChange, viewOptions = views, query }) { return (
- + {viewControl ?? Views @@ -33,7 +34,7 @@ export function TaskListToolbar({ view, onViewChange, viewOptions = views, query {viewOptions.map(option => {option.label})} - + }
{[{ value: "list", label: "List view", icon: List }, { value: "board", label: "Board view", icon: Columns3 }].map(({ value, label, icon: Icon }) => ( diff --git a/ui/src/components/TaskViewsMenu.tsx b/ui/src/components/TaskViewsMenu.tsx index d61add6d04f..f90c3795f22 100644 --- a/ui/src/components/TaskViewsMenu.tsx +++ b/ui/src/components/TaskViewsMenu.tsx @@ -20,11 +20,13 @@ export function TaskViewsMenu({ value, onChange, badgeCount, + coordinator = false, }: { value: TaskViewKey; onChange: (next: TaskViewKey) => void; /** Unread count surfaced next to the My-work group, mirroring the nav badge. */ badgeCount?: number; + coordinator?: boolean; }) { const active = taskView(value); return ( @@ -52,7 +54,7 @@ export function TaskViewsMenu({ ) : null} - {group.views.map((view) => { + {group.views.filter(view => coordinator || !view.coordinatorOnly).map((view) => { const selected = view.key === value; return ( { setHistoryOpen(false); closePanel(); }, [closePanel]); const showHistory = () => { setHistoryOpen(true); if (!isMobile) setPanelVisible(true); }; - useEffect(() => { setBreadcrumbs([{ label: "Needs you" }]); }, [setBreadcrumbs]); + useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); useEffect(() => { setSettling([]); setHistoryOpen(false); setNow(Date.now()); closePanel(); return closePanel; @@ -198,14 +198,14 @@ export function CoordinatorNeedsYou() { }; 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
+ return
{isMobile ? { if (!open) closeHistory(); }}> Decision history
: null} -

Needs you

+

Inbox

setAgentFilter(event.target.value)} className="w-auto">{(agentQuery.data ?? []).map((agent) => )} diff --git a/ui/src/hooks/useCombinedInboxTasksEnabled.render.test.tsx b/ui/src/hooks/useCombinedInboxTasksEnabled.render.test.tsx index 87da041af84..e9af83ec9e5 100644 --- a/ui/src/hooks/useCombinedInboxTasksEnabled.render.test.tsx +++ b/ui/src/hooks/useCombinedInboxTasksEnabled.render.test.tsx @@ -10,18 +10,18 @@ vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } })); vi.mock("@/api/workspacePreferences", () => ({ workspacePreferencesApi: { get: vi.fn(), update: vi.fn() } })); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn() } })); -it("retains standalone Inbox in Coordinator and restores the combined preference after opting out", async () => { +it.each([false, true])("hosts Inbox views in Coordinator independently of the Classic preference (%s)", async combined => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "viewer" } }); client.setQueryData(queryKeys.workspacePreferences.mine("viewer"), { userId: "viewer", coordinatorWorkspaceEnabled: true }); - client.setQueryData(queryKeys.instance.experimentalSettings, { enableStreamlinedUi: true, enableCombinedInboxTasks: true }); + client.setQueryData(queryKeys.instance.experimentalSettings, { enableStreamlinedUi: true, enableCombinedInboxTasks: combined }); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); function Probe() { const setting = useCombinedInboxTasksEnabled(); return

{setting.loaded ? String(setting.enabled) : "loading"}

; } try { flushSync(() => root.render()); - await vi.waitFor(() => expect(host.textContent).toBe("false")); - flushSync(() => client.setQueryData(queryKeys.workspacePreferences.mine("viewer"), { userId: "viewer", coordinatorWorkspaceEnabled: false })); await vi.waitFor(() => expect(host.textContent).toBe("true")); - expect(client.getQueryData(queryKeys.instance.experimentalSettings)).toMatchObject({ enableCombinedInboxTasks: true }); + flushSync(() => client.setQueryData(queryKeys.workspacePreferences.mine("viewer"), { userId: "viewer", coordinatorWorkspaceEnabled: false })); + await vi.waitFor(() => expect(host.textContent).toBe(String(combined))); + expect(client.getQueryData(queryKeys.instance.experimentalSettings)).toMatchObject({ enableCombinedInboxTasks: combined }); } finally { flushSync(() => root.unmount()); client.clear(); host.remove(); } }); diff --git a/ui/src/hooks/useCombinedInboxTasksEnabled.ts b/ui/src/hooks/useCombinedInboxTasksEnabled.ts index 41ce6c802c9..b78fe6de5e0 100644 --- a/ui/src/hooks/useCombinedInboxTasksEnabled.ts +++ b/ui/src/hooks/useCombinedInboxTasksEnabled.ts @@ -43,8 +43,7 @@ export function useCombinedInboxTasksEnabled(): { enabled: boolean; loaded: bool if (!contextClient) return { enabled: false, loaded: true }; return { - // Coordinator keeps Inbox under More and owns its separate Tasks/Needs You IA. - enabled: !coordinator.enabled && resolveCombinedInboxTasksEnabled(query.data), - loaded: query.isFetched && coordinator.loaded, + enabled: coordinator.enabled || resolveCombinedInboxTasksEnabled(query.data), + loaded: coordinator.loaded && (coordinator.enabled || query.isFetched), }; } diff --git a/ui/src/hooks/useTaskListColumnPreferences.test.tsx b/ui/src/hooks/useTaskListColumnPreferences.test.tsx new file mode 100644 index 00000000000..88658a69dad --- /dev/null +++ b/ui/src/hooks/useTaskListColumnPreferences.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { useTaskListColumnPreferences } from "./useTaskListColumnPreferences"; +import { allTaskListColumns, defaultTaskListColumns } from "@/lib/task-list"; + +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +const host = document.createElement("div"); +const root = createRoot(host); +let controls: ReturnType; +function Probe({ user = "viewer", company = "company" }) { + controls = useTaskListColumnPreferences({ companyId: company, collectionKey: `coordinator-tasks:${user}` }, defaultTaskListColumns); + return {JSON.stringify({ columns: controls.columns, order: controls.order })}; +} +afterEach(async () => { await act(() => root.render(null)); localStorage.clear(); vi.restoreAllMocks(); }); +it("retains columns and their order across remounts, while isolating each person and company", async () => { + await act(() => root.render()); + await act(() => { controls.setColumns(["task", "project"]); controls.setOrder([...allTaskListColumns].reverse()); }); + await act(() => root.render(null)); + await act(() => root.render()); + expect(controls.columns).toEqual(["task", "project"]); + expect(controls.order).toEqual([...allTaskListColumns].reverse()); + await act(() => root.render()); + expect(controls.columns).toEqual(defaultTaskListColumns); + await act(() => root.render()); + expect(controls.columns).toEqual(defaultTaskListColumns); + await act(() => root.render()); + expect(controls.columns).toEqual(["task", "project"]); + await act(() => { controls.setOrder(allTaskListColumns); controls.setColumns(defaultTaskListColumns); }); + await act(() => root.render(null)); + await act(() => root.render()); + expect(controls.columns).toEqual(defaultTaskListColumns); + expect(controls.order).toEqual(allTaskListColumns); +}); +it("keeps the current controls usable when browser storage is unavailable", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage denied"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage denied"); }); + await act(() => root.render()); + expect(controls.columns).toEqual(defaultTaskListColumns); + await act(() => controls.setColumns(["task", "assignee"])); + expect(controls.columns).toEqual(["task", "assignee"]); +}); diff --git a/ui/src/hooks/useTaskListColumnPreferences.ts b/ui/src/hooks/useTaskListColumnPreferences.ts new file mode 100644 index 00000000000..157affa4fd4 --- /dev/null +++ b/ui/src/hooks/useTaskListColumnPreferences.ts @@ -0,0 +1,43 @@ +import { useRef, useState } from "react"; +import { allTaskListColumns, type TaskListColumnId } from "@/lib/task-list"; +import { loadTaskCollectionPreferences, saveTaskCollectionPreferences, type TaskCollectionPreferenceLocation } from "@/lib/task-collection-preferences"; + +function normalizeColumns(value: unknown, fallback: readonly TaskListColumnId[]): TaskListColumnId[] { + return Array.isArray(value) ? [...new Set(value.filter((id): id is TaskListColumnId => allTaskListColumns.includes(id)))] : [...fallback]; +} + +export function useTaskListColumnPreferences(location: TaskCollectionPreferenceLocation | undefined, initialColumns: readonly TaskListColumnId[]) { + const key = JSON.stringify(location ?? null); + const load = () => { + const fallback = { key, columns: [...initialColumns], order: allTaskListColumns }; + if (!location) return fallback; + try { + const saved = loadTaskCollectionPreferences({ + ...location, + defaultColumns: [...initialColumns], + defaultViewState: { order: allTaskListColumns }, + normalizeColumns: value => normalizeColumns(value, initialColumns), + normalizeViewState: value => { + const order = normalizeColumns((value as { order?: unknown } | null)?.order, allTaskListColumns); + return { order: [...order, ...allTaskListColumns.filter(id => !order.includes(id))] }; + }, + }); + return { key, columns: saved.columns, order: saved.viewState.order }; + } catch { + return fallback; + } + }; + const [state, setState] = useState(load); + const current = state.key === key ? state : load(); + const latest = useRef(current); + latest.current = current; + const update = (patch: { columns?: readonly TaskListColumnId[]; order?: readonly TaskListColumnId[] }) => { + const next = { ...latest.current, ...patch, columns: [...(patch.columns ?? latest.current.columns)] }; + latest.current = next; + setState(next); + try { + if (location) saveTaskCollectionPreferences(location, { columns: next.columns, viewState: { order: next.order } }); + } catch {} + }; + return { columns: current.columns, order: current.order, setColumns: (columns: readonly TaskListColumnId[]) => update({ columns }), setOrder: (order: readonly TaskListColumnId[]) => update({ order }) }; +} diff --git a/ui/src/lib/task-list-views.ts b/ui/src/lib/task-list-views.ts index 9e9e558162b..2556d1b82e4 100644 --- a/ui/src/lib/task-list-views.ts +++ b/ui/src/lib/task-list-views.ts @@ -1,7 +1,7 @@ import type { Issue } from "@paperclipai/shared"; export const taskListViews = [ - { value: "inbox", label: "Inbox" }, + { value: "inbox", label: "Mine" }, { value: "unread", label: "Unread" }, { value: "blocked", label: "Needs attention" }, { value: "all", label: "All tasks" }, diff --git a/ui/src/lib/task-views.ts b/ui/src/lib/task-views.ts index 1d9af545f74..8d5a995cace 100644 --- a/ui/src/lib/task-views.ts +++ b/ui/src/lib/task-views.ts @@ -21,6 +21,7 @@ export const TASK_VIEW_KEYS = [ "blocked", "recent", "everything", + "needs_attention", "all", "active", "backlog", @@ -40,6 +41,7 @@ export interface TaskView { statuses?: string[]; /** One-line description shown in the Views menu. */ hint: string; + coordinatorOnly?: boolean; } export interface TaskViewGroup { @@ -61,6 +63,7 @@ export const TASK_VIEW_GROUPS: TaskViewGroup[] = [ { label: "Organization", views: [ + { key: "needs_attention", label: "Needs attention", surface: "issues", statuses: [], hint: "Tasks with pending decisions", coordinatorOnly: true }, { key: "all", label: "All tasks", surface: "issues", statuses: [], hint: "Every task in the organization" }, { key: "active", label: "Active", surface: "issues", statuses: ["todo", "in_progress", "monitoring", "in_review", "blocked"], hint: "Open tasks, running or not" }, { key: "backlog", label: "Backlog", surface: "issues", statuses: ["backlog"], hint: "Not started yet" }, diff --git a/ui/src/pages/CoordinatorTaskViews.tsx b/ui/src/pages/CoordinatorTaskViews.tsx new file mode 100644 index 00000000000..227fda7f520 --- /dev/null +++ b/ui/src/pages/CoordinatorTaskViews.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Plus } from "lucide-react"; +import { TaskViewsMenu } from "@/components/TaskViewsMenu"; +import { Button } from "@/components/ui/button"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; +import { loadTaskListView, taskListViewStorageKey } from "@/lib/task-list-views"; +import { ORGANIZATION_SCOPED_PARAMS, normalizeTaskViewKey, resolveInitialTaskView, taskView, type TaskViewKey } from "@/lib/task-views"; +import { Navigate, useLocation, useNavigate, useSearchParams } from "@/lib/router"; +import { CoordinatorTasks } from "./CoordinatorTasks"; +import { Inbox } from "./Inbox"; + +function loadView(key: string, userId: string | null): TaskViewKey { + try { + const saved = normalizeTaskViewKey(localStorage.getItem(key)); + if (saved) return saved; + const previous = userId ? loadTaskListView(taskListViewStorageKey("coordinator", userId), undefined, "inbox") : "inbox"; + return previous === "inbox" ? "mine" : previous === "blocked" ? "needs_attention" : previous; + } catch { + return "mine"; + } +} + +export function CoordinatorTaskViews() { + const { selectedCompanyId } = useCompany(); + const { userId, loaded } = useCoordinatorWorkspaceEnabled(); + const { openNewIssue } = useDialogActions(); + const navigate = useNavigate(); + const location = useLocation(); + const [params] = useSearchParams(); + const key = JSON.stringify(["paperclip:coordinator-task-view:v2", selectedCompanyId, userId]); + const [preference, setPreference] = useState(() => ({ key, view: loadView(key, userId) })); + const savedView = preference.key === key ? preference.view : loadView(key, userId); + const requested = params.get("view") === "inbox" ? "mine" : params.get("view"); + const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "status", "label", "sortField", "sortDir"].some(param => params.getAll(param).some(Boolean)); + const view = resolveInitialTaskView(requested, organizationScoped, savedView); + const definition = taskView(view); + useEffect(() => { + if (!loaded || !userId || !selectedCompanyId) return; + try { localStorage.setItem(key, view); } catch {} + setPreference(current => current.key === key && current.view === view ? current : { key, view }); + }, [key, loaded, userId, selectedCompanyId, view]); + if ((loaded || organizationScoped) && params.get("view") !== view) { + const next = new URLSearchParams(params); + next.set("view", view); + return ; + } + const views = navigate(`/issues?view=${next}`)} />; + if (definition.surface === "issues") return ; + return
{views}
} + />
; +} diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 98c401bea66..86b0273fcef 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -1,5 +1,5 @@ import { readTaskListSortParams, taskListOrderToIssueSort, type TaskListSort } from "@/lib/task-list"; -import { useDeferredValue, useEffect, useRef, useState } from "react"; +import { useDeferredValue, useEffect, useRef, useState, type ReactNode } from "react"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { ListChecks, SearchX } from "lucide-react"; @@ -38,7 +38,7 @@ const pageSize = 200; /** Coordinator's existing IA and attention feed, rendered with the production * components reviewed in Design proposals / Pages / Tasks. */ -export function CoordinatorTasks() { +export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } = {}) { const { selectedCompanyId: companyId } = useCompany(); const { userId, loaded: identitySettled } = useCoordinatorWorkspaceEnabled(); const { openNewIssue } = useDialogActions(); @@ -189,7 +189,7 @@ export function CoordinatorTasks() { workspaceIds.includes(issue.executionWorkspaceId ?? "") || workspaceIds.includes(issue.projectWorkspaceId ?? "")) : recordsWithContext; const items: TaskListItem[] = [...new Map(scopedRecords.map(issue => [issue.id, issue])).values()].map(issue => ({ issue: { ...issue, project: issue.project ?? projectById.get(issue.projectId ?? "") }, - unread: unreadIds.has(issue.id), + unread: unreadIds.has(issue.id) || issue.isUnreadForMe === true, createdBy: issue.createdByAgentId ? agentNames.get(issue.createdByAgentId) ?? "Unknown agent" : users.get(issue.createdByUserId ?? "") ?? "Board", assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? null, })); @@ -277,7 +277,7 @@ export function CoordinatorTasks() { if (!companyId) return ; return
- openNewIssue()} filters={{ enableRoutineTasksFilter: true, state: collection.filters, onChange: updateFilters, activeFilterCount: collection.activeFilterCount, @@ -287,6 +287,7 @@ export function CoordinatorTasks() { {pending ?

Loading tasks…

: !error && <> diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index dc56e1a5f80..737614b6864 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -368,8 +368,8 @@ export function InstanceExperimentalSettings() { toggleMutation.mutate({ enableCombinedInboxTasks: checked })} disabled={toggleMutation.isPending} diff --git a/ui/src/pages/Issues.coordinator-routing.test.tsx b/ui/src/pages/Issues.coordinator-routing.test.tsx index db3c661c140..7d926b3d0f2 100644 --- a/ui/src/pages/Issues.coordinator-routing.test.tsx +++ b/ui/src/pages/Issues.coordinator-routing.test.tsx @@ -1,18 +1,27 @@ // @vitest-environment jsdom import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; -import { MemoryRouter, Route, Routes, useLocation, useParams } from "react-router-dom"; -import { expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { beforeEach, expect, it, vi } from "vitest"; import { Issues } from "./Issues"; import { Inbox } from "./Inbox"; +const mountedRoutes = vi.hoisted(() => ({ organization: [] as string[] })); + vi.mock("../context/DesignSandboxContext", () => ({ useDesignSandbox: () => false })); -vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true }) })); +vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, loaded: true, userId: "viewer" }) })); vi.mock("../hooks/useStreamlinedUiEnabled", () => ({ useStreamlinedUiEnabled: () => ({ enabled: true }) })); vi.mock("../hooks/useCombinedInboxTasksEnabled", () => ({ useCombinedInboxTasksEnabled: () => ({ enabled: false }) })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", selectedCompany: { issuePrefix: "ORG" } }) })); -vi.mock("./CoordinatorTasks", () => ({ CoordinatorTasks: () =>

Coordinator tasks

})); -vi.mock("./Inbox", () => ({ Inbox: () => { const { tab } = useParams(); return

Inbox owner:{tab}

; } })); +vi.mock("./CoordinatorTasks", () => ({ CoordinatorTasks: () => { + const location = useLocation(); + mountedRoutes.organization.push(`${location.search}${location.hash}`); + return

Coordinator tasks

; +} })); +vi.mock("./Inbox", () => ({ Inbox: ({ tab, surfaceLabel }: { tab: string; surfaceLabel: string }) =>

{surfaceLabel} Inbox owner:{tab}

})); +vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) })); + +beforeEach(() => { localStorage.clear(); mountedRoutes.organization = []; }); function Location() { const location = useLocation(); return {location.pathname}{location.search}{location.hash}; } function render(url: string) { @@ -22,31 +31,66 @@ function render(url: string) { )); return { host, cleanup: () => { flushSync(() => root.unmount()); host.remove(); } }; } -it.each([["mine", "mine"], ["blocked", "blocked"], ["recent", "recent"], ["everything", "all"]])("opens saved %s links in the original Inbox owner", async (view, tab) => { +it.each([["mine", "mine"], ["inbox", "mine"], ["unread", "unread"], ["blocked", "blocked"], ["recent", "recent"], ["everything", "all"]])("hosts saved %s links in Tasks with the original personal-work owner", async (view, tab) => { const { host, cleanup } = render(`/ORG/issues?view=${view}&sort=oldest#saved`); try { await vi.waitFor(() => expect(host.textContent).toContain(`Inbox owner:${tab}`)); - expect(host.querySelector("output")?.textContent).toBe(`/ORG/inbox/${tab}?sort=oldest#saved`); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe(`/ORG/issues?view=${view === "inbox" ? "mine" : view}&sort=oldest#saved`)); expect(host.textContent).not.toContain("Coordinator tasks"); } finally { cleanup(); } }); -it.each(["inbox", "unread", "needs_attention", "all"])("keeps the %s task view in Coordinator", view => { +it.each(["needs_attention", "all"])("keeps the %s task view in Coordinator", view => { const { host, cleanup } = render(`/ORG/issues?view=${view}`); try { expect(host.textContent).toContain("Coordinator tasks"); expect(host.textContent).not.toContain("Inbox owner"); } finally { cleanup(); } }); -it.each(["workspace=workspace", "originKind=routine_execution"])("keeps organization-scoped task links out of Inbox: %s", filter => { +it.each(["workspace=workspace", "originKind=routine_execution"])("keeps organization-scoped task links out of Inbox: %s", async filter => { const { host, cleanup } = render(`/ORG/issues?view=mine&${filter}`); - try { expect(host.textContent).toContain("Coordinator tasks"); expect(host.textContent).not.toContain("Inbox owner"); } + try { await vi.waitFor(() => expect(host.textContent).toContain("Coordinator tasks")); expect(host.textContent).not.toContain("Inbox owner"); } finally { cleanup(); } }); +it.each(["sortField=workflow", "sortDir=asc", "sortField=workflow&sortDir=asc"])("keeps sort-only organization links on the task collection: %s", async sort => { + const { host, cleanup } = render(`/ORG/issues?${sort}#saved`); + try { + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe(`/ORG/issues?${sort}&view=all#saved`)); + expect(host.textContent).toContain("Coordinator tasks"); + expect(host.textContent).not.toContain("Inbox owner"); + } finally { cleanup(); } +}); + it.each(["mine", "blocked", "recent", "everything"])("keeps shared %s label links on the Coordinator collection", async view => { const url = `/ORG/issues?view=${view}&label=qa&label=review&sortField=taskId&sortDir=asc&keep=value#saved`; const { host, cleanup } = render(url); try { await vi.waitFor(() => expect(host.textContent).toContain("Coordinator tasks")); expect(host.textContent).not.toContain("Inbox owner"); - expect(host.querySelector("output")?.textContent).toBe(url.replace(`view=${view}`, "view=all")); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe(url.replace(`view=${view}`, "view=all"))); + } finally { cleanup(); } +}); + +it("defaults Tasks to Mine and remembers a deliberate organization view", async () => { + const first = render("/ORG/issues"); + try { + await vi.waitFor(() => expect(first.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=mine")); + expect(first.host.textContent).toContain("Tasks Inbox owner:mine"); + } finally { first.cleanup(); } + const selected = render("/ORG/issues?view=done"); + try { + await vi.waitFor(() => expect(localStorage.getItem(JSON.stringify(["paperclip:coordinator-task-view:v2", "company", "viewer"]))).toBe("done")); + } finally { selected.cleanup(); } + const returned = render("/ORG/issues"); + try { + await vi.waitFor(() => expect(returned.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=done")); + expect(returned.host.textContent).toContain("Coordinator tasks"); + } finally { returned.cleanup(); } +}); + +it.each(["mine", "blocked", "recent", "everything"])("normalizes a filtered %s link before mounting its task collection", async view => { + const { host, cleanup } = render(`/ORG/issues?view=${view}&label=qa&keep=value#saved`); + try { + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe("/ORG/issues?view=all&label=qa&keep=value#saved")); + expect(mountedRoutes.organization.length).toBeGreaterThan(0); + expect(mountedRoutes.organization.every(route => new URLSearchParams(route.split("#")[0]).get("view") === "all")).toBe(true); } finally { cleanup(); } }); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index f99a97d1c46..0ebf1ceff1f 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,12 +1,12 @@ import { QueryErrorState, useQueryView } from "@/components/QueryView"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled"; -import { CoordinatorTasks } from "./CoordinatorTasks"; +import { CoordinatorTaskViews } from "./CoordinatorTaskViews"; import { useDesignSandbox } from "../context/DesignSandboxContext"; import { DesignSandboxTasks } from "./DesignSandboxTasks"; import { useEffect, useMemo, useCallback, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { Navigate, useLocation, useNavigate, useSearchParams } from "@/lib/router"; +import { useLocation, useNavigate, useSearchParams } from "@/lib/router"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -102,23 +102,9 @@ export function Issues() { const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled(); - const [params] = useSearchParams(); - const location = useLocation(); - const requestedView = normalizeTaskViewKey(params.get(TASK_VIEW_PARAM)); - const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "label"].some(param => params.getAll(param).some(Boolean)); - if (!designSandbox && coordinatorEnabled && params.getAll("label").some(Boolean) && requestedView && requestedView !== "unread" && taskView(requestedView).surface === "inbox") { - const next = new URLSearchParams(params); - next.set(TASK_VIEW_PARAM, "all"); - return ; - } - if (!designSandbox && coordinatorEnabled && !organizationScoped && requestedView && requestedView !== "unread" && taskView(requestedView).surface === "inbox") { - const next = new URLSearchParams(params); next.delete(TASK_VIEW_PARAM); - const search = next.toString(); - return ; - } // The merged surface is Combined Inbox + Task List only. With the flag off, and always in // the legacy shell, Tasks is the plain task list and Inbox keeps its pages. - return designSandbox ? : coordinatorEnabled ? : streamlinedUiEnabled && combinedInboxTasksEnabled ? : ; + return designSandbox ? : coordinatorEnabled ? : streamlinedUiEnabled && combinedInboxTasksEnabled ? : ; } function StreamlinedTasks() { diff --git a/ui/storybook/stories/design-proposals/coordinator-decision-motion.stories.tsx b/ui/storybook/stories/design-proposals/coordinator-decision-motion.stories.tsx index bc7a80f6446..da8560aa586 100644 --- a/ui/storybook/stories/design-proposals/coordinator-decision-motion.stories.tsx +++ b/ui/storybook/stories/design-proposals/coordinator-decision-motion.stories.tsx @@ -49,7 +49,7 @@ const meta = { parameters: { layout: "fullscreen", paperclip: { capabilities: ["inbox-search/decisions"], home: "design-proposals", status: "proposed", implementation: "production", boundary: "fixtures", exception: "Coordinator experimental motion is staged with Design proposals for review, using the implemented decision components.", - notes: "Real Needs You and canonical resolver components with isolated API responses. Approve or answer to compare simultaneous card resize and contained Gather Light, then the move to Recent Decisions. Failed saves retain the form. Reload resets fixtures; no real decision or agent execution occurs.", + notes: "Real Coordinator Inbox and canonical resolver components with isolated API responses. Approve or answer to compare simultaneous card resize and contained Gather Light, then the move to Recent Decisions. Failed saves retain the form. Reload resets fixtures; no real decision or agent execution occurs.", } }, beforeEach: () => fixtures(), } satisfies Meta; diff --git a/ui/storybook/stories/design-proposals/coordinator-tasks.stories.tsx b/ui/storybook/stories/design-proposals/coordinator-tasks.stories.tsx index decee0f0da7..895011dcd8d 100644 --- a/ui/storybook/stories/design-proposals/coordinator-tasks.stories.tsx +++ b/ui/storybook/stories/design-proposals/coordinator-tasks.stories.tsx @@ -26,7 +26,7 @@ const meta = { status: "proposed", home: "design-proposals", exception: "Coordinator experimental workspace in an isolated worktree, awaiting review.", implementation: "production", boundary: "fixtures", - notes: "Mounts the actual CoordinatorSidebar, CoordinatorTasks and NewIssueDialog with the shared proposed task table, toolbar and sidebar styles. New task above Chat opens the task form. Keeps Tasks, Needs You, More and Recents. Needs attention uses the canonical pending-decision feed and hydrates its task IDs; it does not infer attention from task status. DesignPageShell supplies fixture-only desktop chrome; the real Layout owns responsive shell and account placement. API results are synthetic and writes are unsupported; task submission, task-detail, agent execution and cross-surface resolution require the running app. No global proposal theme is enabled, matching the narrowly scoped production opt-in.", + notes: "Mounts the actual CoordinatorSidebar, CoordinatorTasks and NewIssueDialog with the shared proposed task table, toolbar and sidebar styles. New task above Chat opens the task form. Keeps Tasks, Inbox, More and Recents. This table specimen does not mount the personal Mine view or exercise its triage behavior. Needs attention uses the canonical pending-decision feed and hydrates its task IDs; it does not infer attention from task status. DesignPageShell supplies fixture-only desktop chrome; the real Layout owns responsive shell and account placement. API results are synthetic and writes are unsupported; task submission, task-detail, agent execution and cross-surface resolution require the running app. No global proposal theme is enabled, matching the narrowly scoped production opt-in.", }, }, beforeEach: () => prepareFixtureApi([