mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
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:
29 files changed
+398
-88
No files matched your search
+15
-1
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user