feat(ui): rename Needs you to Inbox and move Mine into Tasks (#19363)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Coordinator gives people navigation for conversations, tasks, and
decisions.
> - The decision entry is called Needs you, while a separate Inbox
remains under More.
> - Coordinator's personal task filter does not retain the full behavior
of the original Mine view.
> - This pull request names the decision entry Inbox and hosts the
original personal work views inside Tasks.
> - People keep their triage tools and saved choices without moving
between two task destinations.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Coordinator navigation and the personal Mine view in Tasks.

**Subsystem affected**

UI navigation, task view routing, task collection preferences, and
browser coverage.

**Current behavior**

Needs you opens the decision feed. More contains another Inbox
destination. The default Coordinator task filter matches task creators
and assignees. It does not use the full personal participation rules,
archive behavior, shortcuts, grouping, or saved columns of the original
Mine view. The organization task table also resets its column choices on
reload.

**Proposed behavior**

Inbox opens the same decision feed with the inbox icon. Tasks defaults
to Mine and remembers a person's selected view per company. Mine uses
the existing personal Inbox implementation and preferences. It retains
read/unread title weights, keyboard navigation, read/unread shortcuts,
archive and undo, mobile swipe-to-archive, grouping, and saved columns.
Old Inbox links open the matching task view. The organization task table
saves its columns and order per person and company.

**Reason and benefit**

People get Chat, Tasks, and Inbox as distinct navigation entries.
Personal work remains a view within Tasks without losing the existing
triage behavior.

**Breaking changes**

Coordinator no longer offers the previous Inbox destination under More.
Old Inbox URLs redirect into Tasks and retain query parameters and
fragments. The Needs you label becomes Inbox; its decision feed, count,
history, and resolution paths stay in place. Classic retains its
separate optional combined-list setting.

**Additional context**

Refs: #19360, the workspace-defaults PR now integrated from master.
Refs: #10620, related Inbox attention ordering. Duplicate searches found
no open PR for this navigation and Mine integration. Roadmap review
found no separate new execution feature in this scope.

## What Changed

- Rename Coordinator's desktop and mobile decision entry and page to
Inbox. Use the inbox icon. Remove the old Inbox item under More. Clarify
that the combined-list experiment applies only to Classic.
- Add one Coordinator task-view router. Host Mine, Unread, Recent,
Blocked, and Everything through the existing Inbox owner. Keep
organization views in the task table and board. Normalize legacy
filtered URLs before mounting a collection so clearing the last filter
preserves the current layout and local filters.
- Reuse the existing Mine API, participation, read state, archive cache,
undo, keyboard, mobile gesture, grouping, and preference paths. Existing
personal column choices carry over.
- Save organization task-table columns and order per person and company.
Keep controls usable if browser storage fails.
- Update navigation tests, routing and persistence regressions,
exact-run retry browser coverage for each workspace variant, product
documentation, and the affected Storybook notes. Keep existing story
IDs.

## Verification

- Preference, routing, navigation, task table, and inherited Inbox
regression tests pass in focused runs. After integrating master and
addressing the review finding, all 187 focused tests, UI typecheck, and
token gates pass.
- Full build and repository typecheck pass after integrating master. The
full UI suite previously passed: 894 files, 11,169 tests passed, and one
skipped. All 14 desktop/mobile triage and exact-run retry browser cases,
including a fresh production UI build, pass after integrating master.
- The full repository test suite was not run locally for this UI-only
change; full PR CI is running.
- Browser checks use a fresh isolated local instance and a deterministic
process agent for real run-attributed unread activity. They exercise
legacy URLs, title weights, saved grouping and columns, read/unread
shortcuts, archive/undo, and a real mobile touch gesture.
- Earlier browser fixtures were corrected: own activity is read, agent
comments need valid run attribution, and undo uses `u` after the archive
refresh completes. The Inbox content heading is scoped separately from
the layout heading.
- The final settings wording correction passes all 63 settings tests.
- The [next CI browser
run](https://github.com/paperclipai/paperclip/actions/runs/42137377960/job/127629100535)
caught an old URL assertion for denied run retries. The test now pins
each workspace variant and covers Coordinator Tasks explicitly. The old
expectation fails deterministically for Coordinator; all 12 retry cases
pass with the corrected destination, preserving exact run identity and
truthful queued, deferred, and denied feedback.
- Token gates, map checks, and Storybook catalog checks pass. All 30
map/catalog script tests pass. Storybook play tests were not run.
- Greptile reviewed [the final
commit](https://github.com/paperclipai/paperclip/commit/5f2f3789b83af259f694f379afb769465688fb72)
at **5/5** with no new actionable findings. Its sort-only link finding
is fixed and resolved: three cases fail before the fix and pass after
it. `sortField` and `sortDir` keep organization links on the
organization table. [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/42139668774)
is running.
- [CI exposed a routing
race](https://github.com/paperclipai/paperclip/actions/runs/42135293421/job/127621981587)
when removing the last label from a legacy Everything link. Four
deterministic regressions failed before the fix and now pass:
collections only mount after URL normalization. The existing end-to-end
component cases confirm local filters, board layout, fragments, and
history survive the change.
- The [initial server shard 4
run](https://github.com/paperclipai/paperclip/actions/runs/42131163087/job/127608751620)
failed an unchanged monitor-drain idleness assertion: two active
operations remained instead of zero. The complete scheduling suite
passes locally (54 tests). One isolated CI retry passed, including its
dependent verification gate. The original timing failure's root cause is
not established.

## Risks

- Mine includes the existing tasks, approvals, failed runs, and join
requests, preserving the original personal participation and attention
rules.
- Shared URLs with organization filters or table sort parameters still
open the organization task collection. Task back-links retain their
original fragments.
- Saved preferences are browser-local. Mine keeps its existing storage
keys; the organization table and selected Coordinator view use
person/company scopes.
- No database or server API contract changes are included. Existing
company access, personal participation, and decision permissions retain
their owners.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and browser automation. The exact serving revision and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-11-05 17:07:24 -08:00
1 parent 46414fcfa4
commit 4550253dc9
29 files changed
+398 -88

No files matched your search

+15 -1
View File
@@ -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,
+2 -2
View File
@@ -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`
+6 -7
View File
@@ -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
+13 -6
View File
@@ -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`, {
+100
View File
@@ -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<void>(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" } });
}
});
+13
View File
@@ -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}`);
+2 -2
View File
@@ -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([]);
});
+1 -2
View File
@@ -461,9 +461,8 @@ function AppsConnectEntryRoute({
}
function InboxRootRedirect() {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
return streamlinedUiEnabled && combinedInboxTasksEnabled
return combinedInboxTasksEnabled
? <TaskViewRedirect view={taskViewForInboxTab(loadLastInboxTab())} />
: <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
@@ -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();
+2 -3
View File
@@ -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 && <button type="button" aria-label="New task" title="New task" onClick={() => openNewIssue()}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity group-hover/coordinator-new:opacity-100 group-focus-within/coordinator-new:opacity-100 pointer-coarse:opacity-100 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring"><Plus className="size-4" /></button>}
</div>
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Needs you" icon={Bell} badge={attentionCount} badgeLabel="decisions" />
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Inbox" icon={Inbox} badge={attentionCount} badgeLabel="decisions" />
{pinnedDestinations.map(({ label, to, icon }) => <div key={to} className="group/coordinator-pin relative">
<SidebarNavItem to={to} label={label} icon={icon} className={cn("design-sidebar-item", !rail && "pr-9")} />
{!rail && <button type="button" aria-label={`Unpin ${label}`} title={`Unpin ${label}`} onClick={() => pinnedNavigation.toggle(to)} className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
@@ -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(<QueryClientProvider client={client}><MobileBottomNav visible /></QueryClientProvider>));
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(); }
});
+1 -2
View File
@@ -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 },
+6 -4
View File
@@ -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<TaskListSort>();
const sort = controlledSort ?? (onSortChange ? undefined : localSort) ?? defaultTaskListSort;
const setSort = (next: TaskListSort | undefined) => {
+5 -4
View File
@@ -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<HTMLInputElement>;
@@ -25,7 +26,7 @@ export function TaskListToolbar({ view, onViewChange, viewOptions = views, query
}) {
return (
<div data-canonical-toolbar="tasks" className="design-page-toolbar" role="toolbar" aria-label="Task list controls">
<DropdownMenu>
{viewControl ?? <DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline" className="design-toolbar-button design-view-trigger" aria-label={`Task view: ${viewOptions.find(option => option.value === view)!.label}`}>{viewOptions.find(option => option.value === view)!.label}<ChevronDown aria-hidden /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="start" className="task-list-options" aria-label="Task views">
<DropdownMenuLabel asChild><FlyoutHeader className="task-list-options-heading">Views</FlyoutHeader></DropdownMenuLabel>
@@ -33,7 +34,7 @@ export function TaskListToolbar({ view, onViewChange, viewOptions = views, query
{viewOptions.map(option => <DropdownMenuRadioItem className="task-list-options-item" key={option.value} value={option.value}>{option.label}</DropdownMenuRadioItem>)}
</FlyoutOptions></DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu>}
<ToolbarSearch query={query} onQueryChange={onQueryChange} inputRef={searchRef} label="Search tasks" placeholder="Search tasks…" />
<div className="design-view-toggle" role="group" aria-label="Task layout">
{[{ value: "list", label: "List view", icon: List }, { value: "board", label: "Board view", icon: Columns3 }].map(({ value, label, icon: Icon }) => (
+3 -1
View File
@@ -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({
</span>
) : null}
</DropdownMenuLabel>
{group.views.map((view) => {
{group.views.filter(view => coordinator || !view.coordinatorOnly).map((view) => {
const selected = view.key === value;
return (
<DropdownMenuItem
@@ -171,7 +171,7 @@ export function CoordinatorNeedsYou() {
const filtersActive = Boolean(query.trim() || agentFilter || projectFilter);
const closeHistory = useCallback(() => { 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 <p className="text-sm text-muted-foreground">Choose a company to see its decisions.</p>;
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Needs you">
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Inbox">
{isMobile ? <Sheet open={historyOpen} onOpenChange={open => { if (!open) closeHistory(); }}>
<SheetContent side="bottom" aria-describedby={undefined} className="mobile-task-side-panel inset-0 h-dvh max-h-dvh w-full max-w-none gap-0 border-0 p-0 pt-(--sz-safe-top) pb-(--sz-safe-bottom)">
<SheetHeader><SheetTitle>Decision history</SheetTitle></SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-4"><HistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} onClose={closeHistory} mobile /></div>
</SheetContent>
</Sheet> : null}
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Needs you</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Inbox</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
<div data-canonical-toolbar="coordinator-decisions" role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search decisions or task IDs…" />
<NativeSelect aria-label="Filter decisions by agent" value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} className="w-auto"><option value="">All agents</option>{(agentQuery.data ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
@@ -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 <p>{setting.loaded ? String(setting.enabled) : "loading"}</p>; }
try {
flushSync(() => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
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(); }
});
+2 -3
View File
@@ -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),
};
}
@@ -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<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement("div");
const root = createRoot(host);
let controls: ReturnType<typeof useTaskListColumnPreferences>;
function Probe({ user = "viewer", company = "company" }) {
controls = useTaskListColumnPreferences({ companyId: company, collectionKey: `coordinator-tasks:${user}` }, defaultTaskListColumns);
return <output>{JSON.stringify({ columns: controls.columns, order: controls.order })}</output>;
}
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(<Probe />));
await act(() => { controls.setColumns(["task", "project"]); controls.setOrder([...allTaskListColumns].reverse()); });
await act(() => root.render(null));
await act(() => root.render(<Probe />));
expect(controls.columns).toEqual(["task", "project"]);
expect(controls.order).toEqual([...allTaskListColumns].reverse());
await act(() => root.render(<Probe user="someone-else" />));
expect(controls.columns).toEqual(defaultTaskListColumns);
await act(() => root.render(<Probe company="another-company" />));
expect(controls.columns).toEqual(defaultTaskListColumns);
await act(() => root.render(<Probe />));
expect(controls.columns).toEqual(["task", "project"]);
await act(() => { controls.setOrder(allTaskListColumns); controls.setColumns(defaultTaskListColumns); });
await act(() => root.render(null));
await act(() => root.render(<Probe />));
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(<Probe />));
expect(controls.columns).toEqual(defaultTaskListColumns);
await act(() => controls.setColumns(["task", "assignee"]));
expect(controls.columns).toEqual(["task", "assignee"]);
});
@@ -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 }) };
}
+1 -1
View File
@@ -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" },
+3
View File
@@ -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" },
+56
View File
@@ -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 <Navigate to={`/issues?${next.toString()}${location.hash}`} replace />;
}
const views = <TaskViewsMenu coordinator value={view} onChange={next => navigate(`/issues?view=${next}`)} />;
if (definition.surface === "issues") return <CoordinatorTasks viewControl={views} />;
return <section className="coordinator-task-list" aria-label="Tasks"><Inbox
tab={definition.inboxTab}
surfaceLabel="Tasks"
toolbarContext={<div className="flex min-w-0 items-center gap-2">{views}<Button variant="outline" size="sm" onClick={() => openNewIssue()}><Plus className="size-4" />New Task</Button></div>}
/></section>;
}
+5 -4
View File
@@ -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 <EmptyState icon={ListChecks} message="Select an organization to view tasks." />;
return <FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><section ref={listRef} className="coordinator-task-list task-row-design-preview flex min-w-0 flex-col gap-8" aria-label="Tasks">
<TaskListToolbar view={view} onViewChange={setView}
<TaskListToolbar view={view} onViewChange={setView} viewControl={viewControl}
query={collection.query} onQueryChange={collection.setQuery} layout={collection.layout} onLayoutChange={collection.setLayout}
allExpanded={collection.allExpanded} onToggleExpanded={collection.toggleExpanded} onNewTask={() => openNewIssue()}
filters={{ enableRoutineTasksFilter: true, state: collection.filters, onChange: updateFilters, activeFilterCount: collection.activeFilterCount,
@@ -287,6 +287,7 @@ export function CoordinatorTasks() {
{pending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : !error && <>
<div hidden={collection.layout !== "list"} className="design-task-list-panel">{collection.matchingItems.length ? <TaskList key={companyId}
items={(needsAttention ? collection.matchingItems : collection.visibleItems).map(enrichItem)} allItems={collection.companyItems.map(enrichItem)}
preferenceLocation={userId ? { companyId, collectionKey: `coordinator-tasks:${userId}` } : undefined}
collectionKey={JSON.stringify([companyId, userId, needsAttention, deferredQuery, participantAgentId, workspaceIds, collection.view, collection.filters])}
sort={sort} onSortChange={setSort}
issueLinkState={issueLinkState} collapsedIds={collection.collapsedIds} onCollapsedIdsChange={collection.setCollapsedIds} /> : empty}</div>
@@ -368,8 +368,8 @@ export function InstanceExperimentalSettings() {
<ExperimentalToggleCard
title="Combined Inbox + Task List"
description="Fold Inbox into Tasks. One Tasks row carries the unread badge, and a Views menu reaches every inbox view (Mine, Unread, Blocked, Recent, Everything) and every task view."
footnote="Old Inbox links redirect to the matching view. Turning this off restores the separate Inbox; no data changes."
description="In the Classic workspace, fold Inbox into Tasks. One Tasks row carries the unread badge, and a Views menu reaches every personal and organization task view."
footnote="Coordinator always includes personal views in Tasks. For Classic, turning this off restores its separate Inbox. No data changes."
checked={experimentalQuery.data?.enableCombinedInboxTasks ?? false}
onCheckedChange={(checked) => toggleMutation.mutate({ enableCombinedInboxTasks: checked })}
disabled={toggleMutation.isPending}
@@ -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: () => <p>Coordinator tasks</p> }));
vi.mock("./Inbox", () => ({ Inbox: () => { const { tab } = useParams(); return <p>Inbox owner:{tab}</p>; } }));
vi.mock("./CoordinatorTasks", () => ({ CoordinatorTasks: () => {
const location = useLocation();
mountedRoutes.organization.push(`${location.search}${location.hash}`);
return <p>Coordinator tasks</p>;
} }));
vi.mock("./Inbox", () => ({ Inbox: ({ tab, surfaceLabel }: { tab: string; surfaceLabel: string }) => <p>{surfaceLabel} Inbox owner:{tab}</p> }));
vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
beforeEach(() => { localStorage.clear(); mountedRoutes.organization = []; });
function Location() { const location = useLocation(); return <output>{location.pathname}{location.search}{location.hash}</output>; }
function render(url: string) {
@@ -22,31 +31,66 @@ function render(url: string) {
</Routes></MemoryRouter>));
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(); }
});
+3 -17
View File
@@ -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 <Navigate to={`/issues?${next.toString()}${location.hash}`} replace />;
}
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 <Navigate to={`/inbox/${taskView(requestedView).inboxTab}${search ? `?${search}` : ""}${location.hash}`} replace />;
}
// 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 ? <DesignSandboxTasks /> : coordinatorEnabled ? <CoordinatorTasks /> : streamlinedUiEnabled && combinedInboxTasksEnabled ? <StreamlinedTasks /> : <OrganizationIssues />;
return designSandbox ? <DesignSandboxTasks /> : coordinatorEnabled ? <CoordinatorTaskViews /> : streamlinedUiEnabled && combinedInboxTasksEnabled ? <StreamlinedTasks /> : <OrganizationIssues />;
}
function StreamlinedTasks() {
@@ -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<typeof DecisionMotion>;
@@ -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([