feat: Chat leads the left nav, agent work beside chats, and a Combined Inbox + Task List flag (#15100)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The left nav is the main way people move between tasks, the inbox,
and Agent Chat
> - The nav has separate Inbox and Tasks rows that show overlapping
work, and Chat is one row among many
> - The side panel beside a chat shows the conversation's own artifacts,
not the work the agent did
> - People want Chat to be easy to find, and they want one place for
their task views
> - This pull request moves Chat to the top of Work, shows the agent's
tasks and artifacts beside each chat, and adds an experimental flag that
folds Inbox into Tasks
> - The benefit is a shorter nav and a chat view that shows what the
agent is working on. Both changes stay off until an operator enables
them

## Linked Issues or Issue Description

Refs #14706 (the secondary Agent Chat navigation this change builds on)
Refs #14848 (reopen the last visited agent chat)

**Subsystem affected**
UI navigation (left nav, mobile tab bar), Agent Chat side panel, task
list and inbox, and the company artifacts API.

**Problem or motivation**
Inbox and Tasks are two nav rows for overlapping work. Chat sits in the
top group with no clear home. The chat rail lists only agents you
already talked to, so you cannot see your other teammates there. The
side panel beside a chat shows only the conversation's own artifacts. It
does not show the tasks and files the agent made.

**Proposed solution**
With Agent Chat on, Chat leads the Work section and the rail lists every
eligible agent. The chat side panel opens on the agent's tasks as cards,
and the agent's artifacts are available from +. A new experimental flag,
Combined Inbox + Task List, makes Inbox a set of views inside Tasks.

**Alternatives considered**
Rebuilding the inbox inside the task list. Instead, `/issues` hosts the
existing Inbox component for inbox views and the existing task list for
status views, so all inbox behaviour stays the same.

**Roadmap alignment**
Agent Chat (ROADMAP.md, "Agent Chat (including CEO Chat)"). All changes
are behind experimental flags that are off by default.

## What Changed

- **Agent Chat nav (streamlined shell):** Chat is the first row of Work,
not a top-group row. Workspaces leaves the nav while Agent Chat is on.
The mobile tab bar is Home · Chat · + · Tasks · Agents. The legacy shell
keeps master's top-group Chat row.
- **Chat rail:** `AgentConversationsSidebar` lists every eligible agent.
The open chat is first, then conversations by recent activity, then the
rest of the roster alphabetically. Terminated agents and agents you left
are omitted unless you have history with them. The picker still marks
only real conversations as "Open chat".
- **Chat side panel:** a new default Tasks tab shows one card per task
the agent created, was assigned, commented on, or acted on, newest
first. It has the task list's filter popover and a sort control. **+ →
Artifacts** shows the agent's artifacts as cards. Cards open in a new
tab. Agent Chat off keeps the old Artifacts tab.
- **Artifacts API:** `GET /api/companies/:companyId/artifacts` accepts
`agentId`. The filter applies to documents, work products, and
attachments by the agent each result is attributed to. The shared
validator and the UI client carry the new parameter, and the OpenAPI
entry picks it up from the shared schema.
- **Combined Inbox + Task List flag (`enableCombinedInboxTasks`, off by
default):** new card in Settings > Experimental. The Inbox row goes away
and its badge moves to Tasks. A Views menu on `/issues` covers Mine,
Unread, Blocked, Recent, Everything, All, Active, Backlog, and Done.
Bare `/issues` opens the last-used view (default Mine). Links that carry
`assignee`, `workspace`, `participantAgentId`, or `q` open All so the
filter is kept. `/inbox/*` and
`/issues/{all,active,backlog,done,recent}` redirect to the matching
view. `/inbox/requests` stays its own page.
- **Task detail breadcrumb:** the view key now decides the source, so
quick-archive still works after a reload from an inbox view.
- **Docs:** `doc/PRODUCT.md` and `doc/SPEC.md` describe the chat rail,
the chat side panel, and the new flag.

## Verification

- `cd ui && npx vitest run --no-file-parallelism src/components/chat
src/components/task-side-panel/TaskSidePanel.test.tsx
src/components/AgentConversationsSidebar.test.tsx
src/components/Sidebar.test.tsx
src/components/SidebarCompanyMenu.test.tsx
src/components/Layout.test.tsx src/pages/AgentChats.test.tsx
src/pages/InstanceExperimentalSettings.test.tsx
src/lib/task-views.test.ts src/lib/issueDetailBreadcrumb.test.ts
src/pages/Inbox.test.tsx src/pages/Issues.test.tsx src/App.test.tsx
src/App.activity-routing.test.tsx
src/components/MobileBottomNav.test.tsx
src/components/CommandPalette.test.tsx`: 20 files, 356 tests pass.
- `cd server && npx vitest run
src/__tests__/company-artifacts-service.test.ts`: 13/13 pass, including
the new agent-filter test across all three artifact sources.
- The new rail test fails against the unmodified rail.
- `pnpm check:token-gates`: all gates clean.
- Manual: enable Agent Chat in Settings > Experimental. Open Chat. The
rail lists all agents. Open a chat. The side panel shows the agent's
tasks. Use **+ → Artifacts** to see the agent's artifacts. Then enable
Combined Inbox + Task List. The Inbox row goes away, and Tasks shows a
Views menu.
- Snapshot baselines are intentionally not updated. See
`doc/design/DECISION-SHEET.md`, "Per-change snapshot verification
demoted to dormant (Jul 13 2026)".

## Risks

- With both flags off, the app behaves like master. The only exception
is the API: it accepts a new optional query parameter.
- With Agent Chat on, the rail can list many agents in a large company.
It uses the agent list the app already loads, and search filters it.
- The Tasks panel reads at most 200 recently updated tasks per agent and
says so when it reaches the limit. The Artifacts panel reads at most 500
of the agent's artifacts.
- Combined Inbox + Task List changes what bare `/issues` opens for
people who enable it. Deep links with a task filter still open All.

## Model Used

- Claude (Anthropic), model ID `claude-opus-5-5`, through Claude Code
with tool use (shell, file edit, test runs). Extended thinking was
enabled.

## 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: scotttong <squadbot000@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-04 02:13:46 -07:00
1 parent 994d6edcdd
commit 1c07b5903b
37 files changed
+1590 -85

No files matched your search

+21 -5
View File
@@ -209,10 +209,26 @@ telemetry. See [Announcements](ANNOUNCEMENTS.md).
### Agent chat discovery
With Agent Chat enabled, the Chats sidebar always includes the company's
earliest-created agent, plus personal starred agents and up to four other recent
conversations. First use has the same compact rows as returning use. The compose
icon shares a column with stars and appears on hover or keyboard focus (always on
touch). It opens a company-wide name/role search, independent of sidebar membership.
With Agent Chat enabled, Chat is the first row of the Work section and opens a
secondary sidebar beside the primary nav. It lists every agent you can chat
with: the open conversation first, then your other conversations by recent
activity, then the rest of the roster alphabetically. Terminated agents and
agents you have left are omitted unless you have history with them. Search
filters by name, title, or role; **+** starts or reopens a conversation.
Selecting an agent opens their persistent conversation; it does not reset history
or create a task until the existing first-write flow requires one.
Beside a conversation, the side panel opens on the agent's tasks: one card per
task the agent created, was assigned, commented on, or acted on, newest first,
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.
### Combined Inbox + Task List
An opt-in experimental 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,
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
carry a task filter open All. Old `/inbox` links redirect to the matching view.
+1 -1
View File
@@ -299,7 +299,7 @@ should name the rejected action and the specific restriction.
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. Chat has a searchable secondary sidebar with agent avatars that lists every eligible agent, conversations first; adding an agent starts or reopens their single conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
### Question recipients
+10 -1
View File
@@ -147,7 +147,16 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
},
enableAgentChat: {
title: "Agent Chat",
description: "Persistent task-backed conversations that clarify goals and hand work off to tasks.",
description:
"Persistent task-backed conversations that clarify goals and hand work off to tasks. Chat leads the Work group with an agent rail, and each chat's side panel shows the agent's tasks and artifacts as cards.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableCombinedInboxTasks: {
title: "Combined Inbox + Task List",
description:
"Inbox becomes a set of views inside Tasks: one Tasks row in the nav carries the unread badge, and a Views menu reaches every inbox and task view.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
+2
View File
@@ -77,6 +77,8 @@ export interface InstanceExperimentalSettings {
enablePipelines: boolean;
enableCases: boolean;
enableAgentChat: boolean;
/** Reorganized left nav, Inbox-as-Tasks-views, and the chat agent rail + task cards. */
enableCombinedInboxTasks: boolean;
enableConferenceRoomChat: boolean;
enableClassicTaskInterface: boolean;
enableIssuePlanDecompositions: boolean;
@@ -13,6 +13,8 @@ export const companyArtifactGroupBySchema = z.enum(["none", "task", "parent_task
export const companyArtifactsQuerySchema = z.object({
kind: z.enum(["image", "video", "text", "document", "file", "all"]).optional().default("all"),
projectId: z.string().guid().optional(),
/** Only artifacts attributed to this agent (the `createdByAgent` on each result). */
agentId: z.string().guid().optional(),
q: z.string().trim().max(COMPANY_ARTIFACTS_MAX_QUERY_LENGTH).optional(),
groupBy: companyArtifactGroupBySchema.optional().default("none"),
groupIssueId: z.string().guid().optional(),
@@ -57,6 +57,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false),
enableAgentChat: z.boolean().default(false),
enableCombinedInboxTasks: z.boolean().default(false),
enableConferenceRoomChat: z.boolean().default(false),
enableClassicTaskInterface: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
@@ -500,6 +500,32 @@ describeEmbeddedPostgres("companyArtifactsService", () => {
expect(afterPrimaryCursor.artifacts.map((artifact) => artifact.title)).toEqual(["notes.txt"]);
});
it("filters every artifact source by the agent it is attributed to", async () => {
const { companyId } = await seedArtifacts();
const writerId = "21212121-2121-4121-8121-212121212121";
await db.insert(agents).values({ id: writerId, companyId, name: "Writer", role: "engineer" });
await db.update(documents).set({ createdByAgentId: writerId })
.where(eq(documents.id, "cccccccc-cccc-4ccc-8ccc-cccccccccccc"));
const storage = createStorageService({ "notes.txt": Buffer.from("notes") });
const writer = await companyArtifactsService(db, storage).list(companyId, { agentId: writerId, limit: 20 });
expect(writer.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes"]);
const original = await companyArtifactsService(db, storage).list(companyId, {
agentId: "33333333-3333-4333-8333-333333333333",
limit: 20,
});
expect(original.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut", "notes.txt"]);
expect(new Set(original.artifacts.map((artifact) => artifact.createdByAgent?.id)))
.toEqual(new Set(["33333333-3333-4333-8333-333333333333"]));
const foreign = await companyArtifactsService(db, storage).list(companyId, {
agentId: "44444444-4444-4444-8444-444444444444",
limit: 20,
});
expect(foreign.artifacts).toEqual([]);
});
it("deduplicates work product attachments beyond the work product fetch window", async () => {
const { companyId, projectId, issueId } = await seedArtifacts();
const dedupedAttachmentId = "abababab-abab-4bab-8bab-abababababab";
@@ -43,6 +43,7 @@ describe("instance settings service", () => {
enableApps: true,
enableMcpAggregators: true,
enableAgentChat: false,
enableCombinedInboxTasks: false,
enableChatConnectors: false,
enableMemoryConnectors: false,
enableConferenceRoomChat: false,
+6
View File
@@ -148,6 +148,7 @@ function buildArtifactsGroupHref(
params.set("groupIssueId", groupIssueId);
if (query.kind !== "all") params.set("kind", query.kind);
if (query.projectId) params.set("projectId", query.projectId);
if (query.agentId) params.set("agentId", query.agentId);
if (query.q) params.set("q", query.q);
return `/${encodeURIComponent(companyPrefix)}/artifacts?${params.toString()}`;
}
@@ -374,6 +375,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
if (documentCursor) documentConditions.push(documentCursor);
if (groupBy === "task" && query.groupIssueId) documentConditions.push(eq(issues.id, query.groupIssueId));
if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId));
if (query.agentId) documentConditions.push(sql`coalesce(${createdAgent.id}, ${updatedAgent.id}) = ${query.agentId}`);
if (q) {
documentConditions.push(sql`(
coalesce(${documents.title}, '') ILIKE ${q} ESCAPE '\\'
@@ -502,6 +504,9 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
workProductBaseConditions.push(projectCondition);
workProductConditions.push(projectCondition);
}
// Agent filter applies to the listed rows only; the attachment dedupe
// below stays company-wide so a work product's file never re-lists.
if (query.agentId) workProductConditions.push(eq(workProductAgent.id, query.agentId));
if (q) {
const searchCondition = sql`(
${issueWorkProducts.title} ILIKE ${q} ESCAPE '\\'
@@ -627,6 +632,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) {
if (groupBy === "task" && query.groupIssueId) attachmentConditions.push(eq(issues.id, query.groupIssueId));
if (attachmentKind) attachmentConditions.push(attachmentKind);
if (query.projectId) attachmentConditions.push(eq(issues.projectId, query.projectId));
if (query.agentId) attachmentConditions.push(eq(attachmentAgent.id, query.agentId));
if (q) {
attachmentConditions.push(sql`(
coalesce(${assets.originalFilename}, '') ILIKE ${q} ESCAPE '\\'
+2
View File
@@ -237,6 +237,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableAgentChat: parsed.data.enableAgentChat ?? false,
enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
@@ -280,6 +281,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: false,
enableCases: false,
enableAgentChat: false,
enableCombinedInboxTasks: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
+7 -5
View File
@@ -264,7 +264,9 @@ test("secondary chat navigation preserves layout, unique conversations, history,
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible();
const sidebar = page.getByRole("complementary", { name: "Chat", exact: true });
await expect(sidebar.getByText("No chats yet", { exact: true })).toBeVisible();
// The rail lists every eligible agent before any conversation exists.
const nav = sidebar.getByRole("navigation", { name: "Agent conversations" });
await expect(nav.getByRole("link")).toHaveText([/^Alpha/, /^Beta/, /^Delta/, /^Epsilon/, /^Gamma/, /^Zeta/]);
const landingBounds = await sidebar.boundingBox();
const compose = sidebar.getByRole("button", { name: "Add chat", exact: true });
const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true });
@@ -298,10 +300,10 @@ test("secondary chat navigation preserves layout, unique conversations, history,
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
const nav = sidebar.getByRole("navigation", { name: "Agent conversations" });
const alphaLink = nav.getByRole("link", { name: /^Alpha / });
const betaLink = nav.getByRole("link", { name: /^Beta / });
await expect(nav.getByRole("link")).toHaveCount(2);
// Open chat first, then other conversations, then the rest alphabetically.
await expect(nav.getByRole("link")).toHaveText([/^Alpha/, /^Beta/, /^Delta/, /^Epsilon/, /^Gamma/, /^Zeta/]);
const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]');
await editor.fill("Unsent draft for Alpha");
await betaLink.click();
@@ -316,10 +318,10 @@ test("secondary chat navigation preserves layout, unique conversations, history,
await expect(nav.getByRole("link")).toHaveCount(1);
await expect(betaLink).toBeVisible();
await search.press("Escape");
await expect(nav.getByRole("link")).toHaveCount(2);
await expect(nav.getByRole("link")).toHaveCount(6);
await page.reload();
await expect(editor).toContainText("Unsent draft for Alpha");
await expect(nav.getByRole("link")).toHaveCount(2);
await expect(nav.getByRole("link")).toHaveCount(6);
await page.getByRole("link", { name: "Chat", exact: true }).click();
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
+63 -15
View File
@@ -54,6 +54,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail";
import { CompanyActivity } from "./pages/audit/CompanyActivity";
import { AuditHub } from "./pages/audit/AuditHub";
import { Inbox } from "./pages/Inbox";
import { useCombinedInboxTasksEnabled } from "./hooks/useCombinedInboxTasksEnabled";
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
import { BoardChat } from "./pages/BoardChat";
@@ -104,6 +105,7 @@ import { NotFoundPage } from "./pages/NotFound";
import { useCompany } from "./context/CompanyContext";
import { useDialogActions, useDialogState } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
import { TASK_VIEW_PARAM, taskViewForInboxTab, type TaskViewKey } from "./lib/task-views";
import {
isOnboardingWizardActive,
onboardingStepForCompany,
@@ -145,7 +147,9 @@ function ProductionSurface({ children }: { children: ReactNode }) {
return <Suspense fallback={<PaperclipLoading />}>{children}</Suspense>;
}
function boardRoutes(streamlinedUiEnabled: boolean) {
function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: boolean) {
// Combined Inbox + Task List (PAP-670) only exists in the streamlined shell.
const mergedTasks = streamlinedUiEnabled && combinedInboxTasksEnabled;
return (
<>
<Route index element={<Navigate to="dashboard" replace />} />
@@ -303,11 +307,24 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
<Route path="issues" element={<Issues />} />
<Route path="tasks" element={<Navigate to="/issues" replace />} />
<Route path="search" element={<Search />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
{mergedTasks ? (
<>
{/* Combined Inbox + Task List: the status presets are real views, not aliases of /issues. */}
<Route path="issues/all" element={<TaskViewRedirect view="all" />} />
<Route path="issues/active" element={<TaskViewRedirect view="active" />} />
<Route path="issues/backlog" element={<TaskViewRedirect view="backlog" />} />
<Route path="issues/done" element={<TaskViewRedirect view="done" />} />
<Route path="issues/recent" element={<TaskViewRedirect view="recent" />} />
</>
) : (
<>
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
</>
)}
<Route path="chats" element={<AgentChats />} />
<Route path="chats/:agentRef" element={<AgentChat />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
@@ -423,14 +440,32 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
) : null}
<Route path="decisions" element={<WhatNeedsMe />} />
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/blocked" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
{/* Combined Inbox + Task List: Inbox is a view inside Tasks. Every /inbox/* URL still
resolves — it redirects into the matching view — and with the flag off
(or in the legacy shell) the standalone pages stay. /inbox/requests
stays its own page either way; Settings → Members links straight to it. */}
{mergedTasks ? (
<>
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<TaskViewRedirect view="mine" />} />
<Route path="inbox/recent" element={<TaskViewRedirect view="recent" />} />
<Route path="inbox/unread" element={<TaskViewRedirect view="unread" />} />
<Route path="inbox/blocked" element={<TaskViewRedirect view="blocked" />} />
<Route path="inbox/all" element={<TaskViewRedirect view="everything" />} />
<Route path="inbox/new" element={<TaskViewRedirect view="mine" />} />
</>
) : (
<>
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/blocked" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
</>
)}
<Route path="inbox/requests" element={<JoinRequestQueue />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="u/:userSlug" element={<UserProfile />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
@@ -454,7 +489,19 @@ function AppsConnectEntryRoute({
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
return streamlinedUiEnabled && combinedInboxTasksEnabled
? <TaskViewRedirect view={taskViewForInboxTab(loadLastInboxTab())} />
: <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
/** Sends a retired Inbox/Tasks URL to its view on the merged Tasks surface. */
function TaskViewRedirect({ view }: { view: TaskViewKey }) {
const location = useLocation();
const params = new URLSearchParams(location.search);
params.set(TASK_VIEW_PARAM, view);
return <Navigate to={`/issues?${params.toString()}${location.hash}`} replace />;
}
function LegacySkillStudioRedirect() {
@@ -745,6 +792,7 @@ function NoCompaniesStartPage() {
export function App() {
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
return (
<>
@@ -835,7 +883,7 @@ export function App() {
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path=":companyPrefix" element={streamlinedUiEnabled ? <Layout /> : <ProductionLayout />}>
{boardRoutes(streamlinedUiEnabled)}
{boardRoutes(streamlinedUiEnabled, combinedInboxTasksEnabled)}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
+3
View File
@@ -34,6 +34,8 @@ export type ArtifactKindFilter = Exclude<CompanyArtifactMediaKind, "empty"> | "a
export interface ListArtifactsParams {
kind?: ArtifactKindFilter;
projectId?: string;
/** Only artifacts attributed to this agent. */
agentId?: string;
q?: string;
/** Grouping mode. `none` (default) returns the flat artifact grid. */
groupBy?: CompanyArtifactGroupBy;
@@ -47,6 +49,7 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string {
const search = new URLSearchParams();
if (params?.kind && params.kind !== "all") search.set("kind", params.kind);
if (params?.projectId) search.set("projectId", params.projectId);
if (params?.agentId) search.set("agentId", params.agentId);
if (params?.q) search.set("q", params.q);
if (params?.groupBy && params.groupBy !== "none") search.set("groupBy", params.groupBy);
if (params?.groupIssueId) search.set("groupIssueId", params.groupIssueId);
@@ -10,6 +10,8 @@ import { agentRouteRef, cn } from "@/lib/utils";
export interface AgentConversationSidebarProps {
agents: Agent[];
/** Agents with a saved conversation; defaults to every listed agent. */
existingChatAgentIds?: readonly string[];
availableAgents?: Agent[];
activeId?: string;
previews?: Record<string, string>;
@@ -26,7 +28,7 @@ export interface AgentConversationSidebarProps {
}
/** Searchable navigation for one conversation per agent. */
export function AgentConversationSidebar({ agents, availableAgents = agents, activeId, previews = {}, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) {
export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) {
const navigate = useNavigate();
const [pickerOpen, setPickerOpen] = useState(false);
const [search, setSearch] = useState(initialSearch);
@@ -94,7 +96,7 @@ export function AgentConversationSidebar({ agents, availableAgents = agents, act
</Button>
</div>
<AgentChatPicker agents={availableAgents} open={pickerOpen} onOpenChange={setPickerOpen}
existingChatAgentIds={agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
existingChatAgentIds={existingChatAgentIds ?? agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry}
renderAgentIcon={agent => <AgentAvatar agent={agent} size={32} />}
onSelect={async (agent, signal) => {
if (onAddChat) await onAddChat(agent, signal);
@@ -18,6 +18,10 @@ vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(), get: state.getAgent
vi.mock("@/api/agentChats", () => ({ agentChatsApi: { list: vi.fn(), ensure: state.ensure } }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: () => ({ user: { id: "user-a" } }) } }));
vi.mock("./AgentAvatar", () => ({ AgentAvatar: () => null }));
const memberships = vi.hoisted(() => ({ agentMemberships: {} as Record<string, string> }));
vi.mock("@/api/resourceMemberships", () => ({ resourceMembershipsApi: {
listMine: async () => ({ projectMemberships: {}, agentMemberships: memberships.agentMemberships, starredProjectIds: [], starredAgentIds: [], starredDocumentIds: [] }),
} }));
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
@@ -36,6 +40,7 @@ beforeEach(() => {
state.companyId = "company-a";
state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset();
state.ensure.mockImplementation(async (_company, id) => chat(id));
memberships.agentMemberships = {};
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } });
client.setQueryData(queryKeys.agents.list("company-a"), roster);
@@ -45,8 +50,10 @@ beforeEach(() => {
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
it("adds through the server and reopens one cached conversation without a duplicate row", async () => {
await render();
expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(1);
await addChat(); await choose("bob");
expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(2);
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).not.toContain("Open chat");
await choose("bob");
expect(state.ensure).toHaveBeenCalledWith("company-a", "bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
expect(state.closeSidebar).toHaveBeenCalledWith(false);
@@ -135,3 +142,21 @@ it("keeps healthy chats usable while a historical lookup fails and retries", asy
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
});
it("lists every eligible agent: open chat, then conversations by recency, then the rest alphabetically", async () => {
const more = [
{ ...roster[0], id: "zed", name: "Zed", urlKey: "zed" },
{ ...roster[0], id: "carol", name: "carol", urlKey: "carol" },
{ ...roster[0], id: "gone", name: "Gone", urlKey: "gone", status: "terminated" },
{ ...roster[0], id: "left", name: "Left", urlKey: "left" },
] as Agent[];
memberships.agentMemberships = { left: "left" };
client.setQueryData(queryKeys.agents.list("company-a"), [...more, ...roster]);
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("bob")]);
await render();
await vi.waitFor(async () => {
await act(async () => {});
expect([...container.querySelectorAll('nav[aria-label="Agent conversations"] a')].map(link => link.getAttribute("href")))
.toEqual(["/chats/alice", "/chats/bob", "/chats/carol", "/chats/zed"]); // the open chat (alice) is pinned first
});
});
@@ -6,12 +6,14 @@ import { agentRouteRef } from "@/lib/utils";
import { useQueries } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { resourceMembershipState, useResourceMemberships } from "@/hooks/useResourceMemberships";
/** Account- and company-scoped data for the secondary chat navigation. */
export function AgentConversationsSidebar() {
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const memberships = useResourceMemberships(companyId);
const { isMobile, setSidebarOpen } = useSidebar();
const { pathname } = useLocation();
const activeRef = pathname.split("/chats/")[1]?.split("/")[0];
@@ -30,9 +32,19 @@ export function AgentConversationsSidebar() {
return agent ? [agent] : [];
});
if (active && !conversations.some(agent => agent.id === active.id)) conversations.unshift(active);
// Everyone you can chat with is listed, not only agents you've already
// talked to: conversations first by recent activity, then the rest of the
// roster alphabetically. Same eligibility rule as the Agents nav section —
// terminated agents and agents you've left are omitted.
const listedIds = new Set(conversations.map(agent => agent.id));
const teammates = roster
.filter(agent => !listedIds.has(agent.id) && agent.status !== "terminated"
&& (!memberships.isSuccess || resourceMembershipState(memberships.data, "agent", agent.id) !== "left"))
.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
const previews = Object.fromEntries((chats.data ?? []).filter(chat => chat.conversationState === "active")
.map(chat => [chat.conversationAgentId!, "Working…"]));
return <AgentConversationSidebar key={`${companyId}:${userId}`} agents={conversations} availableAgents={roster}
return <AgentConversationSidebar key={`${companyId}:${userId}`} agents={[...conversations, ...teammates]} availableAgents={roster}
existingChatAgentIds={conversations.map(agent => agent.id)}
activeId={active?.id} previews={previews}
loading={agents.isPending || chats.isPending || session.isPending}
error={agents.error ?? chats.error ?? session.error}
+4 -2
View File
@@ -1,4 +1,5 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useCombinedInboxTasksEnabled } from "@/hooks/useCombinedInboxTasksEnabled";
import { useState, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@@ -103,6 +104,7 @@ export function CommandPalette() {
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true;
useEffect(() => {
@@ -358,9 +360,9 @@ export function CommandPalette() {
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => go("/inbox")}>
<CommandItem onSelect={() => go(combinedInboxTasksEnabled ? "/issues?view=mine" : "/inbox")}>
<Inbox className="mr-2 h-4 w-4" />
Inbox
{combinedInboxTasksEnabled ? "My work" : "Inbox"}
</CommandItem>
<CommandItem onSelect={() => go("/issues")}>
<CircleDot className="mr-2 h-4 w-4" />
+51 -11
View File
@@ -250,14 +250,16 @@ function getInitialWorkspaceViewState(
initialAssignees?: string[],
initialWorkspaces?: string[],
defaultSortField?: IssueSortField,
initialStatuses?: string[],
): IssueViewState {
const initial = getInitialViewState(stored, initialAssignees, defaultSortField);
if (!initialWorkspaces) return initial;
return {
...initial,
workspaces: initialWorkspaces,
statuses: [],
};
const scoped = initialWorkspaces
? { ...initial, workspaces: initialWorkspaces, statuses: [] }
: initial;
// A status preset (Active / Backlog / Done, and All as the empty set) is the
// view's definition, so it wins over whatever the last session persisted.
// `undefined` means "no preset" and leaves the stored statuses alone.
return initialStatuses ? { ...scoped, statuses: initialStatuses } : scoped;
}
function getIssueColumnsStorageKey(key: string): string {
@@ -471,6 +473,12 @@ interface IssuesListProps {
issueLinkState?: unknown;
initialAssignees?: string[];
initialWorkspaces?: string[];
/**
* Status preset applied on entry and whenever it changes, overriding the
* persisted status filter. `[]` clears it; `undefined` leaves it alone.
* Used by the Tasks view presets (PAP-670).
*/
initialStatuses?: string[];
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
@@ -495,6 +503,11 @@ interface IssuesListProps {
rowPresentation?: IssueRowPresentation;
/** Opt in per surface while the shared collection toolbar rolls out. */
toolbarPresentation?: "legacy" | "collection";
/**
* Rendered before the create button in the toolbar's context slot — the hook
* the merged Tasks surface uses to put its Views control there (PAP-670).
*/
toolbarContext?: ReactNode;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
@@ -710,6 +723,7 @@ function StreamlinedIssuesList({
issueLinkState,
initialAssignees,
initialWorkspaces,
initialStatuses,
initialSearch,
searchFilters,
searchWithinLoadedIssues = false,
@@ -726,6 +740,7 @@ function StreamlinedIssuesList({
onLoadMoreIssues,
onSearchChange,
rowPresentation = "legacy",
toolbarContext,
toolbarPresentation = "legacy",
onUpdateIssue,
}: IssuesListProps) {
@@ -788,6 +803,7 @@ function StreamlinedIssuesList({
};
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
const initialStatusesKey = initialStatuses ? `set:${initialStatuses.join("|")}` : "";
const initialPreferencesRef = useRef<ReturnType<typeof loadIssueCollectionPreferences> | null>(null);
if (initialPreferencesRef.current === null) {
initialPreferencesRef.current = loadIssueCollectionPreferences(preferenceLocation);
@@ -795,7 +811,13 @@ function StreamlinedIssuesList({
const initialPreferences = initialPreferencesRef.current;
const [viewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(initialPreferences, initialAssignees, initialWorkspaces, defaultSortField),
getInitialWorkspaceViewState(
initialPreferences,
initialAssignees,
initialWorkspaces,
defaultSortField,
initialStatuses,
),
);
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
@@ -812,13 +834,21 @@ function StreamlinedIssuesList({
}, [initialSearch]);
// Reload view state whenever the persisted context changes.
const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`);
const prevViewStateContextKey = useRef(
`${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}::${initialStatusesKey}`,
);
useEffect(() => {
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`;
const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}::${initialStatusesKey}`;
if (prevViewStateContextKey.current !== nextContextKey) {
prevViewStateContextKey.current = nextContextKey;
const preferences = loadIssueCollectionPreferences(preferenceLocation);
setViewState(getInitialWorkspaceViewState(preferences, initialAssignees, initialWorkspaces, defaultSortField));
setViewState(getInitialWorkspaceViewState(
preferences,
initialAssignees,
initialWorkspaces,
defaultSortField,
initialStatuses,
));
setVisibleIssueColumns(preferences.columns);
}
}, [
@@ -827,6 +857,8 @@ function StreamlinedIssuesList({
initialAssigneesKey,
initialWorkspaces,
initialWorkspacesKey,
initialStatuses,
initialStatusesKey,
defaultSortField,
preferenceLocation.companyId,
preferenceLocation.collectionKey,
@@ -1726,7 +1758,15 @@ function StreamlinedIssuesList({
<IssuesToolbar
className="paperclip-task-list-toolbar"
ariaLabel={toolbarPresentation === "collection" ? "Task controls" : undefined}
context={(
context={toolbarContext ? (
<div className="flex min-w-0 items-center gap-2">
{toolbarContext}
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
</div>
) : (
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
+33 -3
View File
@@ -5,6 +5,7 @@ import {
CircleCheck,
SquarePen,
Users,
MessageCircle,
Inbox,
} from "lucide-react";
import { useCompany } from "../context/CompanyContext";
@@ -12,6 +13,8 @@ import { useDialogActions } from "../context/DialogContext";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { useCombinedInboxTasksEnabled } from "@/hooks/useCombinedInboxTasksEnabled";
import { Badge } from "@/components/ui/badge";
interface MobileBottomNavProps {
@@ -40,9 +43,16 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialogActions();
const inboxBadge = useInboxBadge(selectedCompanyId);
const { enabled: agentChatEnabled } = useAgentChatEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
// PAP-670: with both flags off the bar is the original Home · Tasks · + ·
// Agents · Inbox. Agent Chat adds Chat in the second slot (Home · Chat · + ·
// Tasks · Agents). Combined Inbox + Task List drops Inbox as a destination —
// it is a view inside Tasks, so its unread badge rides on Tasks. The grid
// tracks the live count, so the bar stays evenly divided in every mix.
const items = useMemo<MobileNavItem[]>(
() => [
() => !agentChatEnabled && !combinedInboxTasksEnabled ? [
{ type: "link", to: "/dashboard", label: "Home", icon: House },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
{ type: "action", label: "New Task", icon: SquarePen, onClick: () => openNewIssue() },
@@ -54,8 +64,25 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
icon: Inbox,
badge: inboxBadge.inbox,
},
] : [
{ type: "link", to: "/dashboard", label: "Home", icon: House },
...(agentChatEnabled
? [{ type: "link", to: "/chats", label: "Chat", icon: MessageCircle } as MobileNavItem]
: []),
{ type: "action", label: "New Task", icon: SquarePen, onClick: () => openNewIssue() },
{
type: "link",
to: "/issues",
label: "Tasks",
icon: CircleCheck,
badge: combinedInboxTasksEnabled ? inboxBadge.inbox : undefined,
},
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
...(!combinedInboxTasksEnabled
? [{ type: "link", to: "/inbox", label: "Inbox", icon: Inbox, badge: inboxBadge.inbox } as MobileNavItem]
: []),
],
[openNewIssue, inboxBadge.inbox],
[openNewIssue, inboxBadge.inbox, agentChatEnabled, combinedInboxTasksEnabled],
);
return (
@@ -66,7 +93,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
)}
aria-label="Mobile navigation"
>
<div className="grid h-16 grid-cols-5 px-1">
<div
className="grid h-16 px-1"
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
>
{items.map((item) => {
if (item.type === "action") {
const Icon = item.icon;
+120 -1
View File
@@ -78,8 +78,10 @@ vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
const mockInboxBadge = vi.hoisted(() => ({ inbox: 0, failedRuns: 0 }));
vi.mock("../hooks/useInboxBadge", () => ({
useInboxBadge: () => ({ inbox: 0, failedRuns: 0 }),
useInboxBadge: () => mockInboxBadge,
}));
vi.mock("@/plugins/slots", () => ({
@@ -114,6 +116,9 @@ vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
// Stubbed so the "no agent names in the primary nav" assertion would catch a
// regression that mounts the old per-agent chat rows alongside the Chat rail.
vi.mock("./SidebarRecentTasks", () => ({
SidebarRecentTasks: () => <div data-testid="sidebar-recent-tasks">Recent Tasks</div>,
}));
@@ -158,6 +163,8 @@ describe("Sidebar", () => {
mockSidebar.collapsed = false;
mockSidebar.collapseLocked = false;
mockSidebar.peeking = false;
mockInboxBadge.inbox = 0;
mockInboxBadge.failedRuns = 0;
});
afterEach(() => {
@@ -345,6 +352,118 @@ describe("Sidebar", () => {
});
});
it("keeps the Inbox row, Workspaces and no chat surfaces while both PAP-670 flags are off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(true);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(true);
flushSync(() => {
root.unmount();
});
});
describe("with Agent Chat on (PAP-670)", () => {
it("leads the Work group with a single Chat row and keeps agent names out of the primary nav", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true });
const root = await renderSidebar();
const chatLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/chats");
expect(chatLink?.textContent).toContain("Chat");
const workSection = chatLink?.closest("div")?.parentElement?.parentElement;
const workText = workSection?.textContent ?? "";
expect(workText.indexOf("Chat")).toBeLessThan(workText.indexOf("Tasks"));
flushSync(() => {
root.unmount();
});
});
it("needs nothing else: the Inbox row stays while Combined Inbox + Task List is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(true);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(true);
flushSync(() => {
root.unmount();
});
});
it("does not render Workspaces anywhere in the nav, even with isolated workspaces on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true, enableIsolatedWorkspaces: true });
const root = await renderSidebar();
expect(container.textContent).not.toContain("Workspaces");
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(false);
flushSync(() => {
root.unmount();
});
});
it("keeps Chat in the top group in the legacy shell, which has no Work-group Chat row", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true, enableStreamlinedUi: false });
const root = await renderSidebar();
const chatLinks = [...container.querySelectorAll("a")].filter((anchor) => anchor.getAttribute("href") === "/chats");
expect(chatLinks).toHaveLength(1);
expect(chatLinks[0]?.closest("[class*=\"group/sidebar-section\"]")).toBeNull();
flushSync(() => {
root.unmount();
});
});
});
describe("with Combined Inbox + Task List on (PAP-670)", () => {
it("keeps the Inbox row in the legacy shell, which has no merged Tasks page", async () => {
mockInboxBadge.inbox = 7;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true, enableStreamlinedUi: false });
const root = await renderSidebar();
const inboxLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/inbox");
expect(inboxLink?.textContent).toContain("7");
const tasksLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/issues");
expect(tasksLink?.textContent).not.toContain("7");
flushSync(() => {
root.unmount();
});
});
it("drops the Inbox row and moves its unread badge onto Tasks", async () => {
mockInboxBadge.inbox = 7;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(false);
const tasksLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/issues");
expect(tasksLink?.textContent).toContain("Tasks");
expect(tasksLink?.textContent).toContain("7");
flushSync(() => {
root.unmount();
});
});
it("adds no chat surfaces while Agent Chat is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true, enableAgentChat: false });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false);
flushSync(() => {
root.unmount();
});
});
});
it("does not flash the Workspaces link while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
+43 -12
View File
@@ -89,7 +89,16 @@ export function Sidebar({ children }: { children?: ReactNode }) {
const liveIssueIds = new Set(
(liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []),
);
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
// PAP-670 splits the nav reorganization across two experimental flags.
// Agent Chat: Chat leads Work as one row with its own agent rail (the
// streamlined shell only — the legacy shell keeps per-agent rows), and
// Workspaces leaves to make room. Combined Inbox + Task List: Inbox becomes
// views inside Tasks, so its row goes and its badge rides on Tasks.
const chatRail = agentChatEnabled && streamlinedUiEnabled;
// The merged Tasks page only exists in the streamlined shell, so the legacy
// shell keeps its Inbox row even with the flag on.
const combinedInboxTasks = streamlinedUiEnabled && experimentalSettings?.enableCombinedInboxTasks === true;
const showWorkspacesLink = !chatRail && experimentalSettings?.enableIsolatedWorkspaces === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
const showStatusCards = experimentalSettings?.enableStatusCards === true;
const goalsLinkPending = experimentalSettings === undefined;
@@ -166,16 +175,18 @@ export function Sidebar({ children }: { children?: ReactNode }) {
Cmd/Ctrl+K remains the keyboard path (command palette). */}
<SidebarNavItem to="/search" label="Search" icon={Search} />
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem
to="/inbox"
label="Inbox"
icon={Inbox}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
{agentChatEnabled && <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />}
{!combinedInboxTasks ? (
<SidebarNavItem
to="/inbox"
label="Inbox"
icon={Inbox}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
) : null}
{agentChatEnabled && !chatRail ? <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} /> : null}
{showDecisions ? (
<SidebarNavItem
to="/decisions"
@@ -194,7 +205,27 @@ export function Sidebar({ children }: { children?: ReactNode }) {
</div>
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} />
{/* Agent Chat: Chat leads the Work group as a single row — the
agents you talk to live in the Chat surface's own secondary rail
(ChatContextualSidebar), not in the primary nav. */}
{chatRail ? (
<SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />
) : null}
{/* Combined Inbox + Task List: Inbox is a view inside Tasks, so the
unread/failed-run badge rides on Tasks. */}
{combinedInboxTasks ? (
<SidebarNavItem
to="/issues"
label="Tasks"
icon={CircleCheck}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
) : (
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} />
)}
{streamlinedUiEnabled ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
+80
View File
@@ -0,0 +1,80 @@
import { Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TASK_VIEW_GROUPS, taskView, type TaskViewKey } from "@/lib/task-views";
import { cn } from "@/lib/utils";
/**
* The single control that replaced both the Inbox tab bar and the implicit
* "all tasks" default on Tasks (PAP-670). One menu, grouped by scope, so the
* nav does not have to grow a row every time a view is added.
*/
export function TaskViewsMenu({
value,
onChange,
badgeCount,
}: {
value: TaskViewKey;
onChange: (next: TaskViewKey) => void;
/** Unread count surfaced next to the My-work group, mirroring the nav badge. */
badgeCount?: number;
}) {
const active = taskView(value);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
aria-label={`Change view — currently ${active.label}`}
>
<span className="max-w-(--sz-160px) truncate font-medium">{active.label}</span>
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--sz-260px)">
{TASK_VIEW_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuLabel className="flex items-center justify-between gap-2">
<span>{group.label}</span>
{groupIndex === 0 && badgeCount != null && badgeCount > 0 ? (
<span className="rounded-full bg-primary px-1.5 text-(length:--text-nano) leading-tight text-primary-foreground">
{badgeCount > 99 ? "99+" : badgeCount}
</span>
) : null}
</DropdownMenuLabel>
{group.views.map((view) => {
const selected = view.key === value;
return (
<DropdownMenuItem
key={view.key}
onSelect={() => onChange(view.key)}
className="items-start gap-2"
aria-current={selected ? "true" : undefined}
>
<Check
aria-hidden="true"
className={cn("mt-0.5 size-3.5 shrink-0", selected ? "opacity-100" : "opacity-0")}
/>
<span className="min-w-0">
<span className={cn("block truncate", selected && "font-medium")}>{view.label}</span>
<span className="block text-(length:--text-nano) text-muted-foreground">{view.hint}</span>
</span>
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanyArtifact, Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AgentArtifactsPanel, AgentTasksPanel, sortAgentTasks } from "./AgentWorkPanels";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({
issuesList: vi.fn(),
artifactsList: vi.fn(),
}));
vi.mock("@/api/issues", () => ({ issuesApi: { list: api.issuesList } }));
vi.mock("@/api/artifacts", () => ({ artifactsApi: { list: api.artifactsList } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("@/components/IssueFiltersPopover", () => ({ IssueFiltersPopover: () => <button type="button">Filters</button> }));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, className, target, rel }: { to: string; children: ReactNode; className?: string; target?: string; rel?: string }) =>
<a href={to} className={className} target={target} rel={rel}>{children}</a>,
}));
function task(overrides: Partial<Issue>): Issue {
return {
id: "task",
identifier: "PAP-1",
title: "Task",
status: "todo",
priority: "medium",
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
...overrides,
} as Issue;
}
function artifact(overrides: Partial<CompanyArtifact>): CompanyArtifact {
return {
id: "artifact",
source: "document",
mediaKind: "document",
title: "report.md",
issue: { id: "issue-1", identifier: "PAP-9", title: "Issue" },
createdByAgent: { id: "agent-1", name: "CEO" },
updatedAt: "2026-09-30T00:00:00.000Z",
href: "/PAP/issues/PAP-9#document-report",
...overrides,
} as CompanyArtifact;
}
describe("agent work panels", () => {
let root: Root;
let container: HTMLDivElement;
let queryClient: QueryClient;
beforeEach(() => {
api.issuesList.mockReset();
api.artifactsList.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
act(() => root.unmount());
queryClient.clear();
container.remove();
});
async function render(node: ReactNode) {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>{node}</TooltipProvider>
</QueryClientProvider>,
);
});
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
it("lists the agent's tasks newest first as linked cards, without the chat itself", async () => {
api.issuesList.mockResolvedValue([
task({ id: "old", identifier: "PAP-1", title: "Older task", updatedAt: new Date("2026-09-01T00:00:00.000Z") }),
task({ id: "chat", identifier: "PAP-2", title: "The conversation" }),
task({ id: "new", identifier: "PAP-3", title: "Newer task", status: "in_progress", updatedAt: new Date("2026-09-20T00:00:00.000Z") }),
]);
await render(<AgentTasksPanel companyId="company-1" agentId="agent-1" excludeIssueId="chat" />);
expect(api.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ participantAgentId: "agent-1" }));
const cards = Array.from(container.querySelectorAll("a"));
expect(cards.map((card) => card.getAttribute("href"))).toEqual(["/issues/PAP-3", "/issues/PAP-1"]);
expect(cards.every((card) => card.getAttribute("target") === "_blank")).toBe(true);
expect(cards[0]?.textContent).toContain("Newer task");
expect(cards[0]?.textContent).toContain("PAP-3");
expect(container.querySelector("time")).not.toBeNull();
expect(container.textContent).not.toContain("The conversation");
});
it("sorts by status and title on request", () => {
const tasks = [
task({ id: "a", title: "Bravo", status: "done" }),
task({ id: "b", title: "Alpha", status: "in_progress" }),
];
expect(sortAgentTasks(tasks, "status", "asc").map((item) => item.id)).toEqual(["b", "a"]);
expect(sortAgentTasks(tasks, "title", "asc").map((item) => item.id)).toEqual(["b", "a"]);
});
it("asks the server for the agent's artifacts and shows filename, date and task id", async () => {
api.artifactsList.mockResolvedValue({
artifacts: [artifact({ id: "mine", title: "plan.md" })],
nextCursor: null,
});
await render(<AgentArtifactsPanel companyId="company-1" agentId="agent-1" />);
expect(api.artifactsList).toHaveBeenCalledWith("company-1", expect.objectContaining({ agentId: "agent-1" }));
const cards = Array.from(container.querySelectorAll("a"));
expect(cards).toHaveLength(1);
expect(cards[0]?.getAttribute("href")).toBe("/PAP/issues/PAP-9#document-report");
expect(cards[0]?.getAttribute("target")).toBe("_blank");
expect(cards[0]?.textContent).toContain("plan.md");
expect(cards[0]?.textContent).toContain("PAP-9");
expect(cards[0]?.textContent).toContain("Updated");
});
});
+308
View File
@@ -0,0 +1,308 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpDown, File, FileText, Image, Search, Video } from "lucide-react";
import type { CompanyArtifact, Issue } from "@paperclipai/shared";
import { artifactsApi } from "@/api/artifacts";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
import { StatusIcon } from "@/components/StatusIcon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
issueStatusOrder,
type IssueFilterState,
} from "@/lib/issue-filters";
import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { cn, formatDate, relativeTime } from "@/lib/utils";
/**
* The agent-scoped side panels shown beside an agent chat: every task the
* agent has worked on, and every artifact it produced. Both are card stacks
* so the chat's right column reads as "what this agent has been doing"
* rather than the properties of the conversation issue itself.
*/
const AGENT_TASK_LIMIT = 200;
const ARTIFACT_PAGE_SIZE = 100;
const ARTIFACT_MAX_PAGES = 5;
export type AgentTaskSortField = "updated" | "created" | "status" | "title";
const SORT_OPTIONS: ReadonlyArray<[AgentTaskSortField, string]> = [
["updated", "Last updated"],
["created", "Created"],
["status", "Status"],
["title", "Title"],
];
export function sortAgentTasks(
tasks: Issue[],
field: AgentTaskSortField,
dir: "asc" | "desc",
): Issue[] {
const sign = dir === "asc" ? 1 : -1;
return [...tasks].sort((a, b) => {
switch (field) {
case "status":
return sign * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
case "title":
return sign * a.title.localeCompare(b.title);
case "created":
return sign * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
default:
return sign * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
}
});
}
function PanelMessage({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "error" }) {
return (
<p className={cn("px-1 py-6 text-center text-sm", tone === "error" ? "text-destructive" : "text-muted-foreground")}>
{children}
</p>
);
}
const cardClassName =
"flex flex-col gap-1 rounded-lg border border-border bg-card p-3 transition-colors hover:border-foreground/20 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
export function AgentTaskCard({ task }: { task: Issue }) {
return (
<Link
// design-allow(card-pattern): navigation <Link> card; Card renders a div and would break anchor semantics
to={`/issues/${task.identifier ?? task.id}`}
disableIssueQuicklook
target="_blank"
rel="noreferrer"
data-testid="agent-task-card"
className={cardClassName}
>
<div className="flex min-w-0 items-start gap-2">
<StatusIcon
status={task.status}
blockerAttention={task.blockerAttention}
className="mt-0.5 shrink-0"
/>
<span className="min-w-0 flex-1 line-clamp-2 text-sm font-medium text-foreground">
{task.title}
</span>
</div>
<div className="flex items-center gap-1.5 pl-6 text-xs text-muted-foreground">
<span className="font-mono">{task.identifier ?? task.id.slice(0, 8)}</span>
<span aria-hidden>·</span>
<time dateTime={new Date(task.updatedAt).toISOString()} title={new Date(task.updatedAt).toLocaleString()}>
{relativeTime(task.updatedAt)}
</time>
</div>
</Link>
);
}
export function AgentTasksPanel({
companyId,
agentId,
excludeIssueId,
}: {
companyId: string;
agentId: string;
/** The conversation issue itself, which is not one of the agent's tasks. */
excludeIssueId?: string;
}) {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState<IssueFilterState>(defaultIssueFilterState);
const [sortField, setSortField] = useState<AgentTaskSortField>("updated");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const tasksQuery = useQuery({
queryKey: queryKeys.issues.listParticipatedByAgent(companyId, agentId),
queryFn: () => issuesApi.list(companyId, {
participantAgentId: agentId,
sortField: "updated",
sortDir: "desc",
limit: AGENT_TASK_LIMIT,
}),
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
});
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
const scoped = (tasksQuery.data ?? []).filter((task) => {
if (task.id === excludeIssueId) return false;
if (!needle) return true;
return task.title.toLowerCase().includes(needle)
|| (task.identifier ?? "").toLowerCase().includes(needle);
});
return sortAgentTasks(applyIssueFilters(scoped, filters), sortField, sortDir);
}, [tasksQuery.data, excludeIssueId, query, filters, sortField, sortDir]);
const activeFilterCount = countActiveIssueFilters(filters);
const total = (tasksQuery.data ?? []).filter((task) => task.id !== excludeIssueId).length;
// Search and filters run on the most recently updated tasks only.
const capped = (tasksQuery.data?.length ?? 0) >= AGENT_TASK_LIMIT;
return (
<section className="flex flex-col gap-3" aria-label="Agent tasks">
<div className="flex items-center gap-1">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tasks"
aria-label="Search tasks"
className="h-8 pl-7 text-sm"
/>
</div>
<IssueFiltersPopover
state={filters}
onChange={(patch) => setFilters((current) => ({ ...current, ...patch }))}
activeFilterCount={activeFilterCount}
projects={projectsQuery.data?.map((project) => ({ id: project.id, name: project.name }))}
enableExternalObjectFilters={false}
buttonVariant="outline"
iconOnly
presentation="streamlined"
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Sort" aria-label="Sort tasks">
<ArrowUpDown className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0">
<div className="space-y-0.5 p-2">
{SORT_OPTIONS.map(([field, label]) => (
<button
key={field}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
sortField === field ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => {
if (sortField === field) setSortDir(sortDir === "asc" ? "desc" : "asc");
else {
setSortField(field);
setSortDir(field === "updated" || field === "created" ? "desc" : "asc");
}
}}
>
<span>{label}</span>
{sortField === field ? (
<span className="text-xs text-muted-foreground">{sortDir === "asc" ? "↑" : "↓"}</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{tasksQuery.isPending ? (
<PanelMessage>Loading tasks…</PanelMessage>
) : tasksQuery.isError ? (
<PanelMessage tone="error">Could not load this agent's tasks.</PanelMessage>
) : total === 0 ? (
<PanelMessage>This agent hasn't worked on any tasks yet.</PanelMessage>
) : visible.length === 0 ? (
<PanelMessage>
{capped
? `No tasks match these filters among the ${AGENT_TASK_LIMIT} most recently updated.`
: "No tasks match these filters."}
</PanelMessage>
) : (
<div className="flex flex-col gap-2">
{visible.map((task) => <AgentTaskCard key={task.id} task={task} />)}
{capped ? (
<p className="px-1 text-xs text-muted-foreground">
Showing the {AGENT_TASK_LIMIT} most recently updated tasks.
</p>
) : null}
</div>
)}
</section>
);
}
function ArtifactKindIcon({ artifact }: { artifact: CompanyArtifact }) {
const className = "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground";
switch (artifact.mediaKind) {
case "image": return <Image className={className} aria-hidden />;
case "video": return <Video className={className} aria-hidden />;
case "text":
case "document": return <FileText className={className} aria-hidden />;
default: return <File className={className} aria-hidden />;
}
}
export function AgentArtifactCard({ artifact }: { artifact: CompanyArtifact }) {
return (
<Link
// design-allow(card-pattern): navigation <Link> card; Card renders a div and would break anchor semantics
to={artifact.href}
disableIssueQuicklook
target="_blank"
rel="noreferrer"
data-testid="agent-artifact-card"
className={cardClassName}
>
<div className="flex min-w-0 items-start gap-2">
<ArtifactKindIcon artifact={artifact} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground" title={artifact.title}>
{artifact.title}
</span>
</div>
<div className="flex items-center gap-1.5 pl-6 text-xs text-muted-foreground">
<span>Updated {formatDate(artifact.updatedAt)}</span>
<span aria-hidden>·</span>
<span className="font-mono">{artifact.issue.identifier}</span>
</div>
</Link>
);
}
/**
* This agent's artifacts, newest first, filtered on the server. Pages are
* capped so an unusually prolific agent can't stall the panel; the cap only
* ever drops its oldest artifacts.
*/
async function listAgentArtifacts(companyId: string, agentId: string) {
const artifacts: CompanyArtifact[] = [];
let cursor: string | undefined;
for (let page = 0; page < ARTIFACT_MAX_PAGES; page += 1) {
const response = await artifactsApi.list(companyId, { agentId, limit: ARTIFACT_PAGE_SIZE, cursor });
artifacts.push(...response.artifacts);
if (!response.nextCursor) return { artifacts, truncated: false };
cursor = response.nextCursor;
}
return { artifacts, truncated: true };
}
export function AgentArtifactsPanel({ companyId, agentId }: { companyId: string; agentId: string }) {
const artifactsQuery = useQuery({
queryKey: queryKeys.artifacts.byAgent(companyId, agentId),
queryFn: () => listAgentArtifacts(companyId, agentId),
});
if (artifactsQuery.isPending) return <PanelMessage>Loading artifacts…</PanelMessage>;
if (artifactsQuery.isError) return <PanelMessage tone="error">Could not load this agent's artifacts.</PanelMessage>;
const { artifacts, truncated } = artifactsQuery.data;
if (artifacts.length === 0) return <PanelMessage>This agent hasn't produced any artifacts yet.</PanelMessage>;
return (
<section className="flex flex-col gap-2" aria-label="Agent artifacts">
{artifacts.map((artifact) => <AgentArtifactCard key={artifact.id} artifact={artifact} />)}
{truncated ? (
<p className="px-1 text-xs text-muted-foreground">Showing this agent's {artifacts.length} most recent artifacts.</p>
) : null}
</section>
);
}
@@ -7,6 +7,8 @@ import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelDocumentTab,
taskPanelPropertiesTab,
writeTaskSidePanelState,
@@ -72,6 +74,18 @@ vi.mock("@/components/task-detail/TaskDetailRelationsPanel", () => ({
),
}));
const agentChat = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({
useAgentChatEnabled: () => ({ enabled: agentChat.enabled, loaded: true }),
}));
vi.mock("@/components/chat/AgentWorkPanels", () => ({
AgentTasksPanel: ({ agentId, excludeIssueId }: { agentId: string; excludeIssueId?: string }) => (
<div>{`Agent tasks ${agentId} excluding ${excludeIssueId}`}</div>
),
AgentArtifactsPanel: ({ agentId }: { agentId: string }) => <div>{`Agent artifacts ${agentId}`}</div>,
}));
vi.mock("@/components/WorkspaceFileBrowser", () => ({
WorkspaceFileBrowser: () => <div>Files browser</div>,
}));
@@ -221,6 +235,82 @@ describe("TaskSidePanel", () => {
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.id).toBe(`side-panel-tab-browser:${next}`);
});
it("opens an agent chat on the agent's tasks, not the conversation's artifacts", async () => {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
const selected = container.querySelector('[role="tab"][aria-selected="true"]');
expect(selected?.getAttribute("data-side-panel-tab-target")).toBe("agent-tasks");
expect(selected?.textContent).toContain("Tasks");
expect(container.textContent).toContain("Agent tasks agent-1 excluding task-1");
expect(container.textContent).not.toContain("Artifacts content");
});
it("keeps an Artifacts-only layout the user chose", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["artifacts"]);
});
it("falls back to Artifacts when Agent Chat is off and only the Tasks tab was saved", async () => {
agentChat.enabled = false;
try {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelAgentTasksTab()], activeTabId: "agent-tasks" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Artifacts content");
} finally {
agentChat.enabled = true;
}
});
it("moves a chat's stored Artifacts-only default onto the agent's tasks", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" },
launcherOpen: false,
userInteracted: false,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["agent-tasks"]);
});
it("offers the agent's artifacts from the + launcher in an agent chat", async () => {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }), streamlinedTabs: true }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifactsItem = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => item.textContent?.includes("Artifacts"));
await act(async () => artifactsItem?.click());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Agent artifacts agent-1");
expect(container.textContent).not.toContain("Artifacts content");
});
it("keeps a chat on the conversation's own Artifacts while Agent Chat is off", async () => {
agentChat.enabled = false;
try {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Artifacts content");
expect(container.textContent).not.toContain("Agent tasks");
} finally {
agentChat.enabled = true;
}
});
it("opens Properties on first visit", async () => {
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
@@ -24,6 +24,7 @@ import {
FileText,
FolderOpen,
Lightbulb,
ListChecks,
ListTree,
Plus,
SlidersHorizontal,
@@ -32,6 +33,8 @@ import { fileResourcesApi } from "@/api/file-resources";
import { IssueProperties } from "@/components/IssueProperties";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
import { IssuePropertiesPlansTab } from "@/components/issue-properties/IssuePropertiesPlansTab";
import { TaskDetailSubtasksPanel } from "@/components/task-detail/TaskDetailRelationsPanel";
@@ -67,6 +70,7 @@ import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelBrowserTab,
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelAttachmentTab,
taskPanelDocumentTab,
@@ -125,6 +129,7 @@ function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
case "properties": return <SlidersHorizontal />;
case "subtasks": return <ListTree />;
case "artifacts": return <Box />;
case "agent-tasks": return <ListChecks />;
case "attachment": return <FileText />;
case "files-browser": return <FolderOpen />;
case "workspace-file": return <FileCode2 />;
@@ -258,6 +263,12 @@ export function TaskSidePanel({
onSkillOpened,
}: TaskSidePanelProps) {
const handleScroll = useScrollbarWhileScrolling();
// Agent Chat: agent chats swap the conversation issue's own panels for
// agent-scoped ones — the agent's tasks lead, and Artifacts lists the
// agent's output. With the flag off a chat keeps the issue's own panels.
const { enabled: agentChatEnabled } = useAgentChatEnabled();
const conversationAgentId = agentChatEnabled ? issue.conversationAgentId ?? null : null;
const showRelatedTasks = showSubtasksTab && !conversationAgentId;
const viewer = useTaskSidePanelFileRouting();
const { data: documentsData } = useIssueDocuments(issue.id);
const documents = documentsData ?? EMPTY_ISSUE_DOCUMENTS;
@@ -267,7 +278,7 @@ export function TaskSidePanel({
);
const taskCount = tasksTab?.count ?? childIssues.length;
const taskLabel = tasksTab ? "Tasks" : "Subtasks";
const initialSubtasksAvailableRef = useRef(showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true));
const initialSubtasksAvailableRef = useRef(showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true));
const subtasksDismissedRef = useRef(
restoredRef.current?.userInteracted === true
&& restoredRef.current.state.tabs.length === 0,
@@ -282,7 +293,22 @@ export function TaskSidePanel({
const handledDocumentRequestRef = useRef<number | undefined>(undefined);
const initialState = useMemo(() => {
const restored = restoredRef.current?.state;
let tabs = restored?.tabs ?? (issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
let tabs = restored?.tabs ?? (conversationAgentId
? [taskPanelAgentTasksTab()]
: issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
// A tab saved while Agent Chat was on has nothing to render without it;
// a chat left with no tabs falls back to its Agent-Chat-off default.
if (!conversationAgentId) {
const kept = tabs.filter((tab) => tab.payload.kind !== "agent-tasks");
if (kept.length === 0 && tabs.length > 0 && issue.conversationAgentId) tabs = [taskPanelArtifactsTab()];
else tabs = kept;
}
// Chats used to open on Artifacts by default; move an untouched default
// onto the agent's tasks. An Artifacts tab the user chose to keep stays.
if (conversationAgentId && !restoredRef.current?.userInteracted
&& tabs.length === 1 && tabs[0]!.id === "artifacts") {
tabs = [taskPanelAgentTasksTab()];
}
if (!initialSubtasksAvailableRef.current) {
tabs = tabs.filter((tab) => tab.payload.kind !== "subtasks");
} else if (!subtasksDismissedRef.current) {
@@ -323,7 +349,7 @@ export function TaskSidePanel({
}
}, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]);
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
const subtasksAvailable = showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true);
const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
useEffect(() => {
@@ -542,7 +568,11 @@ export function TaskSidePanel({
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab, browsersQuery.data]);
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
const primary: SidePanelLauncherItem[] = [
const primary: SidePanelLauncherItem[] = conversationAgentId ? [
{ id: "agent-tasks", label: "Tasks", description: "Tasks this agent worked on", icon: <ListChecks />, alreadyOpen: controller.tabs.some((tab) => tab.id === "agent-tasks") },
{ id: "artifacts", label: "Artifacts", description: "Files this agent produced", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
] : [
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
{ id: "artifacts", label: "Artifacts", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
@@ -597,7 +627,7 @@ export function TaskSidePanel({
});
}
return sections;
}, [browsersQuery.data, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
}, [browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
@@ -611,6 +641,7 @@ export function TaskSidePanel({
});
}
else if (item.id === "artifacts") controller.openTab(taskPanelArtifactsTab());
else if (item.id === "agent-tasks") controller.openTab(taskPanelAgentTasksTab());
else if (item.id === "files") {
controller.openTab(taskPanelFilesTab());
viewer.openBrowse();
@@ -704,6 +735,12 @@ export function TaskSidePanel({
issueLinkState={issueLinkState}
/>
);
} else if (activeTab.payload.kind === "agent-tasks") {
content = conversationAgentId ? (
<AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
content = <AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />;
} else if (activeTab.payload.kind === "artifacts") {
content = <TextAttachmentContext.Provider value={(id, title) => {
markInteracted();
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolveCombinedInboxTasksEnabled } from "./useCombinedInboxTasksEnabled";
describe("resolveCombinedInboxTasksEnabled", () => {
it("is on only when opted in", () => {
expect(resolveCombinedInboxTasksEnabled({ enableCombinedInboxTasks: true })).toBe(true);
expect(resolveCombinedInboxTasksEnabled({ enableCombinedInboxTasks: false })).toBe(false);
expect(resolveCombinedInboxTasksEnabled(undefined)).toBe(false);
});
it("stays off in the legacy shell, which has no merged Tasks page", () => {
expect(resolveCombinedInboxTasksEnabled({ enableCombinedInboxTasks: true, enableStreamlinedUi: false })).toBe(false);
});
});
@@ -0,0 +1,47 @@
import { useContext } from "react";
import type { InstanceExperimentalSettings } from "@paperclipai/shared";
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
import { resolveStreamlinedUiEnabled } from "./useStreamlinedUiEnabled";
/** On only when opted in and the streamlined shell (which hosts it) is on. */
export function resolveCombinedInboxTasksEnabled(
settings: Partial<Pick<InstanceExperimentalSettings, "enableCombinedInboxTasks" | "enableStreamlinedUi">> | null | undefined,
): boolean {
return settings?.enableCombinedInboxTasks === true
&& resolveStreamlinedUiEnabled({ enableStreamlinedUi: settings.enableStreamlinedUi ?? true });
}
let detachedClient: QueryClient | null = null;
function getDetachedClient(): QueryClient {
detachedClient ??= new QueryClient();
return detachedClient;
}
/**
* Combined Inbox + Task List (Settings > Experimental): Inbox becomes views
* inside Tasks — no Inbox nav row, the unread badge on Tasks, a Views menu on
* /issues and /inbox/* redirects. Off by default — and off when rendered outside a query
* client, as the app root is in some harnesses — so every surface renders as
* before until an instance opts in. The merged Tasks page only exists in the
* streamlined shell, so the legacy shell always reports it off.
*/
export function useCombinedInboxTasksEnabled(): { enabled: boolean; loaded: boolean } {
const contextClient = useContext(QueryClientContext);
const query = useQuery(
{
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: contextClient != null,
},
contextClient ?? getDetachedClient(),
);
if (!contextClient) return { enabled: false, loaded: true };
return {
enabled: resolveCombinedInboxTasksEnabled(query.data),
loaded: query.isFetched,
};
}
+14 -1
View File
@@ -1,4 +1,5 @@
import type { Issue } from "@paperclipai/shared";
import { TASK_VIEW_PARAM, normalizeTaskViewKey, taskView } from "./task-views";
type IssueDetailSource = "issues" | "inbox";
@@ -129,13 +130,25 @@ function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null
return href && href.startsWith("/") ? href : null;
}
export function isInboxBackedTaskViewHref(href: string): boolean {
const query = href.slice(href.indexOf("?") + 1);
if (!href.includes("?")) return false;
const view = new URLSearchParams(query).get(TASK_VIEW_PARAM);
const key = normalizeTaskViewKey(view);
return key !== null && taskView(key).surface === "inbox";
}
function inferIssueDetailSource(
state: Partial<IssueDetailLocationState> | null,
breadcrumb: IssueDetailBreadcrumb | null,
): IssueDetailSource | null {
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
if (!breadcrumb) return null;
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
if (breadcrumb.href.includes("/inbox")) return "inbox";
// Since PAP-670 the inbox is a set of views on /issues, so the href alone no
// longer separates the two sources — the view key does.
if (isInboxBackedTaskViewHref(breadcrumb.href)) return "inbox";
if (breadcrumb.label === "Inbox") return "inbox";
if (breadcrumb.label === "Tasks" || breadcrumb.href.includes("/issues")) return "issues";
return null;
}
+4
View File
@@ -337,6 +337,8 @@ export const queryKeys = {
"plugin-operations",
originKindPrefix,
] as const,
listParticipatedByAgent: (companyId: string, agentId: string) =>
["issues", companyId, "participated-by-agent", agentId] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listCreatedFromIssue: (companyId: string, issueId: string) =>
@@ -570,6 +572,8 @@ export const queryKeys = {
groupBy ?? "none",
groupIssueId ?? "",
] as const,
byAgent: (companyId: string, agentId: string) =>
["artifacts", companyId, "by-agent", agentId] as const,
},
budgets: {
overview: (companyId: string) =>
+7
View File
@@ -34,6 +34,7 @@ export type TaskSidePanelTabPayload =
| { kind: "properties" }
| { kind: "subtasks" }
| { kind: "artifacts" }
| { kind: "agent-tasks" }
| { kind: "attachment"; attachmentId: string }
| { kind: "skill"; skillId: string }
| { kind: "issue-document"; documentKey: string }
@@ -94,6 +95,7 @@ function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
if (kind === "properties") return { kind };
if (kind === "subtasks") return { kind };
if (kind === "artifacts") return { kind };
if (kind === "agent-tasks") return { kind };
if (kind === "attachment") {
return typeof input.attachmentId === "string" && input.attachmentId.length > 0 ? { kind, attachmentId: input.attachmentId } : null;
}
@@ -225,6 +227,11 @@ export function taskPanelSubtasksTab(): SidePanelTabRecord<TaskSidePanelTabPaylo
return { id: "subtasks", type: "subtasks", label: "Subtasks", closable: true, contentMode: "padded", payload: { kind: "subtasks" } };
}
/** Agent chats only: every task the conversation's agent has worked on. */
export function taskPanelAgentTasksTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "agent-tasks", type: "agent-tasks", label: "Tasks", closable: true, contentMode: "padded", payload: { kind: "agent-tasks" } };
}
export function taskPanelArtifactsTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "artifacts", type: "artifacts", label: "Artifacts", closable: true, contentMode: "padded", payload: { kind: "artifacts" } };
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TASK_VIEW,
TASK_VIEW_GROUPS,
TASK_VIEW_KEYS,
isTaskViewKey,
normalizeTaskViewKey,
resolveInitialTaskView,
taskView,
taskViewForInboxTab,
taskViewPath,
} from "./task-views";
describe("task views registry (PAP-670)", () => {
it("covers every legacy Inbox tab and task status preset exactly once", () => {
const keys = TASK_VIEW_GROUPS.flatMap((group) => group.views.map((view) => view.key));
expect(keys).toEqual([...TASK_VIEW_KEYS]);
expect(new Set(keys).size).toBe(keys.length);
});
it("routes My-work views to the inbox surface with a tab, and organization views to the task list with statuses", () => {
const [myWork, organization] = TASK_VIEW_GROUPS;
expect(myWork.views.map((view) => view.inboxTab)).toEqual(["mine", "unread", "blocked", "recent", "all"]);
for (const view of myWork.views) {
expect(view.surface).toBe("inbox");
expect(view.statuses).toBeUndefined();
}
for (const view of organization.views) {
expect(view.surface).toBe("issues");
expect(view.inboxTab).toBeUndefined();
expect(Array.isArray(view.statuses)).toBe(true);
}
expect(taskView("all").statuses).toEqual([]);
expect(taskView("active").statuses).toEqual(["todo", "in_progress", "in_review", "blocked"]);
expect(taskView("backlog").statuses).toEqual(["backlog"]);
expect(taskView("done").statuses).toEqual(["done", "cancelled"]);
});
it("every view carries a label and a hint for the Views menu", () => {
for (const key of TASK_VIEW_KEYS) {
expect(taskView(key).label.length).toBeGreaterThan(0);
expect(taskView(key).hint.length).toBeGreaterThan(0);
}
});
it("normalizes unknown view keys to null rather than guessing", () => {
expect(normalizeTaskViewKey("blocked")).toBe("blocked");
expect(normalizeTaskViewKey("everything")).toBe("everything");
expect(normalizeTaskViewKey("nonsense")).toBeNull();
expect(normalizeTaskViewKey(null)).toBeNull();
expect(normalizeTaskViewKey("")).toBeNull();
expect(isTaskViewKey("mine")).toBe(true);
expect(isTaskViewKey("Mine")).toBe(false);
});
it("maps the retired /inbox/* tabs onto views — /inbox/all becomes Everything, not All tasks", () => {
expect(taskViewForInboxTab("mine")).toBe("mine");
expect(taskViewForInboxTab("recent")).toBe("recent");
expect(taskViewForInboxTab("unread")).toBe("unread");
expect(taskViewForInboxTab("blocked")).toBe("blocked");
expect(taskViewForInboxTab("all")).toBe("everything");
expect(taskViewForInboxTab("requests")).toBe(DEFAULT_TASK_VIEW);
expect(taskViewForInboxTab(null)).toBe(DEFAULT_TASK_VIEW);
});
it("addresses views on the Tasks route", () => {
expect(taskViewPath("everything")).toBe("/issues?view=everything");
});
});
describe("resolveInitialTaskView", () => {
it("prefers an explicit ?view=", () => {
expect(resolveInitialTaskView("blocked", false, "done")).toBe("blocked");
expect(resolveInitialTaskView("backlog", true, "done")).toBe("backlog");
});
it("falls back to the last-used view on a bare /issues", () => {
expect(resolveInitialTaskView(null, false, "backlog")).toBe("backlog");
expect(resolveInitialTaskView("nonsense", false, "backlog")).toBe("backlog");
expect(resolveInitialTaskView(null, false, DEFAULT_TASK_VIEW)).toBe("mine");
});
it("opens All tasks when an explicit inbox view carries an organization filter", () => {
// Inbox views can't apply ?participantAgentId= and friends.
expect(resolveInitialTaskView("mine", true, "mine")).toBe("all");
expect(resolveInitialTaskView("everything", true, "mine")).toBe("all");
});
it("opens All tasks for an organization-scoped deep link so its filter is not silently dropped", () => {
// /issues?assignee=… and friends mean the org task list; landing them on a
// My-work view would drop the filter they carry.
expect(resolveInitialTaskView(null, true, "mine")).toBe("all");
expect(resolveInitialTaskView(null, true, "blocked")).toBe("all");
});
});
+152
View File
@@ -0,0 +1,152 @@
import type { InboxTab } from "./inbox";
/**
* The single view registry behind the merged Tasks surface (PAP-670).
*
* Inbox stopped being its own nav row and its five tabs became views inside
* Tasks, alongside the status presets that used to live only inside the task
* list's filter popover. Every view is addressed as `/issues?view=<key>`.
*
* Each view declares which surface renders it:
* - `inbox` — the attention-ordered inbox list (unread state, archive,
* date groups, mixed approval/failed-run/join-request rows).
* - `issues` — the task collection list (board/list modes, filters, columns).
*
* Views are *not* reimplemented on the other surface: pointing a view at the
* component that already owns those behaviours is what keeps them intact.
*/
export const TASK_VIEW_KEYS = [
"mine",
"unread",
"blocked",
"recent",
"everything",
"all",
"active",
"backlog",
"done",
] as const;
export type TaskViewKey = (typeof TASK_VIEW_KEYS)[number];
export type TaskViewSurface = "inbox" | "issues";
export interface TaskView {
key: TaskViewKey;
label: string;
surface: TaskViewSurface;
/** Which inbox tab renders this view (`inbox` surface only). */
inboxTab?: InboxTab;
/** Status filter applied on entry (`issues` surface only). */
statuses?: string[];
/** One-line description shown in the Views menu. */
hint: string;
}
export interface TaskViewGroup {
label: string;
views: TaskView[];
}
export const TASK_VIEW_GROUPS: TaskViewGroup[] = [
{
label: "My work",
views: [
{ key: "mine", label: "Mine", surface: "inbox", inboxTab: "mine", hint: "Assigned to you or waiting on you" },
{ key: "unread", label: "Unread", surface: "inbox", inboxTab: "unread", hint: "Tasks you touched with new activity" },
{ key: "blocked", label: "Blocked", surface: "inbox", inboxTab: "blocked", hint: "Blocked tasks, grouped by blocker" },
{ key: "recent", label: "Recent", surface: "inbox", inboxTab: "recent", hint: "Everything you touched lately" },
{ key: "everything", label: "Everything", surface: "inbox", inboxTab: "all", hint: "Tasks, approvals, failed runs and join requests" },
],
},
{
label: "Organization",
views: [
{ key: "all", label: "All tasks", surface: "issues", statuses: [], hint: "Every task in the organization" },
{ key: "active", label: "Active", surface: "issues", statuses: ["todo", "in_progress", "in_review", "blocked"], hint: "Open tasks, running or not" },
{ key: "backlog", label: "Backlog", surface: "issues", statuses: ["backlog"], hint: "Not started yet" },
{ key: "done", label: "Done", surface: "issues", statuses: ["done", "cancelled"], hint: "Completed and cancelled" },
],
},
];
const VIEWS_BY_KEY = new Map<TaskViewKey, TaskView>(
TASK_VIEW_GROUPS.flatMap((group) => group.views.map((view) => [view.key, view] as const)),
);
export const DEFAULT_TASK_VIEW: TaskViewKey = "mine";
export const TASK_VIEW_PARAM = "view";
export const TASK_LAST_VIEW_KEY = "paperclip:tasks:last-view";
export function isTaskViewKey(value: unknown): value is TaskViewKey {
return typeof value === "string" && VIEWS_BY_KEY.has(value as TaskViewKey);
}
export function taskView(key: TaskViewKey): TaskView {
return VIEWS_BY_KEY.get(key)!;
}
/** Resolves a `?view=` value, falling back to `null` so callers can pick a default. */
export function normalizeTaskViewKey(value: string | null | undefined): TaskViewKey | null {
return isTaskViewKey(value) ? value : null;
}
export function taskViewPath(key: TaskViewKey): string {
return `/issues?${TASK_VIEW_PARAM}=${key}`;
}
/** Inbox tab → view key, for redirecting the retired `/inbox/*` URLs. */
const VIEW_BY_INBOX_TAB: Record<InboxTab, TaskViewKey> = {
mine: "mine",
unread: "unread",
blocked: "blocked",
recent: "recent",
all: "everything",
};
export function taskViewForInboxTab(tab: string | null | undefined): TaskViewKey {
return (tab && tab in VIEW_BY_INBOX_TAB)
? VIEW_BY_INBOX_TAB[tab as InboxTab]
: DEFAULT_TASK_VIEW;
}
export function loadLastTaskView(): TaskViewKey {
try {
return normalizeTaskViewKey(window.localStorage.getItem(TASK_LAST_VIEW_KEY)) ?? DEFAULT_TASK_VIEW;
} catch {
return DEFAULT_TASK_VIEW;
}
}
export function saveLastTaskView(key: TaskViewKey): void {
try {
window.localStorage.setItem(TASK_LAST_VIEW_KEY, key);
} catch {
/* Navigation still works without storage; the default view just returns. */
}
}
/**
* Query params that only the organization task list understands. A deep link
* carrying one of these (the dashboard's "assigned to me", a workspace
* drill-in, a search hand-off) must not land on a My-work view, so it resolves
* to `All tasks` instead of the user's last-used view.
*/
export const ORGANIZATION_SCOPED_PARAMS = [
"assignee",
"workspace",
"participantAgentId",
"q",
] as const;
/** Resolves which view a `/issues` visit opens. */
export function resolveInitialTaskView(
requested: string | null,
hasOrganizationScopedParam: boolean,
lastUsed: TaskViewKey,
): TaskViewKey {
const explicit = normalizeTaskViewKey(requested);
// Inbox views can't apply organization filters, so a link carrying one
// opens All tasks rather than silently dropping the filter.
if (hasOrganizationScopedParam && (!explicit || taskView(explicit).surface === "inbox")) return "all";
return explicit ?? lastUsed;
}
+39 -15
View File
@@ -789,12 +789,35 @@ function InboxCollectionToolbar({
);
}
export function Inbox() {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
return streamlinedUiEnabled ? <StreamlinedInbox /> : <LegacyInbox />;
/**
* PAP-670: the inbox stopped being its own page and became the "My work" half
* of Tasks. `Tasks` hosts this component and drives it through these props, so
* every inbox behaviour (unread state, archive, date groups, the mixed
* approval / failed-run / join-request rows) survives the merge by construction
* rather than being reimplemented on the task list.
*
* With no props it is still the standalone `/inbox/*` page, which the legacy
* (non-streamlined) shell continues to use.
*/
export interface InboxSurfaceProps {
/** Which view to render. Falls back to the last path segment when absent. */
tab?: InboxTab;
/** Replaces the inbox tab bar in the toolbar's context slot. */
toolbarContext?: ReactNode;
/** Breadcrumb and back-link label; "Inbox" when standalone, "Tasks" when hosted. */
surfaceLabel?: string;
}
function StreamlinedInbox() {
export function Inbox(props: InboxSurfaceProps = {}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
return streamlinedUiEnabled ? <StreamlinedInbox {...props} /> : <LegacyInbox />;
}
function StreamlinedInbox({
tab: tabOverride,
toolbarContext,
surfaceLabel = "Inbox",
}: InboxSurfaceProps) {
const streamlinedUiEnabled = true;
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -828,7 +851,7 @@ function StreamlinedInbox() {
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
const pathTab: InboxTab =
pathSegment === "mine"
|| pathSegment === "recent"
|| pathSegment === "all"
@@ -836,15 +859,16 @@ function StreamlinedInbox() {
|| pathSegment === "blocked"
? pathSegment
: "mine";
const tab: InboxTab = tabOverride ?? pathTab;
const canArchiveFromTab = isMineInboxTab(tab);
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Inbox",
surfaceLabel,
`${location.pathname}${location.search}${location.hash}`,
"inbox",
),
[location.pathname, location.search, location.hash],
[surfaceLabel, location.pathname, location.search, location.hash],
);
const { data: session } = useQuery({
@@ -879,8 +903,8 @@ function StreamlinedInbox() {
});
useEffect(() => {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
setBreadcrumbs([{ label: surfaceLabel }]);
}, [setBreadcrumbs, surfaceLabel]);
useEffect(() => {
saveLastInboxTab(tab);
@@ -2328,7 +2352,7 @@ function StreamlinedInbox() {
}, [selectedIndex]);
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select an organization to view inbox." />;
return <EmptyState icon={InboxIcon} message={`Select an organization to view ${surfaceLabel.toLowerCase()}.`} />;
}
const hasRunFailures = failedRuns.length > 0;
@@ -2397,8 +2421,8 @@ function StreamlinedInbox() {
<div className="space-y-6">
<InboxCollectionToolbar
streamlined={streamlinedUiEnabled}
ariaLabel="Inbox controls"
context={(
ariaLabel={`${surfaceLabel} controls`}
context={toolbarContext ?? (
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
@@ -2416,7 +2440,7 @@ function StreamlinedInbox() {
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
placeholder={`Search ${surfaceLabel.toLowerCase()}…`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
@@ -2504,7 +2528,7 @@ function StreamlinedInbox() {
}));
}}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
title="Choose which columns stay visible"
iconOnly
/>
<Popover>
@@ -2630,7 +2654,7 @@ function StreamlinedInbox() {
}));
}}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
title="Choose which columns stay visible"
iconOnly
rowPresentation={streamlinedUiEnabled ? "task" : "legacy"}
/>
@@ -84,6 +84,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enablePipelines: false,
enableCases: false,
enableAgentChat: false,
enableCombinedInboxTasks: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
@@ -225,6 +226,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
}
});
it("defaults Combined Inbox + Task List off and persists an explicit toggle in both directions", async () => {
await renderPage();
const selector = 'button[aria-label="Toggle combined inbox and task list experimental setting"]';
expect(container.textContent).toContain("Combined Inbox + Task List");
expect(container.textContent).not.toContain("Agent Chat v2");
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
for (const enabled of [true, false]) {
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableCombinedInboxTasks: enabled });
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled));
}
});
it("does not offer a retired MCP aggregators toggle", async () => {
await renderPage();
expect(container.querySelector('button[aria-label="Toggle MCP aggregators experimental setting"]')).toBeNull();
+13 -1
View File
@@ -295,7 +295,7 @@ export function InstanceExperimentalSettings() {
<ExperimentalToggleCard
title="Agent Chat"
description="Talk to each agent in one ongoing conversation. Clarify goals and create tasks for execution."
description="Talk to each agent in one ongoing conversation. Clarify goals and create tasks for execution. Chat leads the Work group and opens an agent rail beside the nav, and each chat's side panel shows the agent's tasks and artifacts as cards."
footnote="Turning this off preserves conversations and lets active runs finish, but prevents new messages."
checked={experimentalQuery.data?.enableAgentChat ?? false}
onCheckedChange={(checked) => toggleMutation.mutate({ enableAgentChat: checked })}
@@ -351,6 +351,18 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle chat connectors experimental setting"
/>
<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."
checked={experimentalQuery.data?.enableCombinedInboxTasks ?? false}
onCheckedChange={(checked) => toggleMutation.mutate({ enableCombinedInboxTasks: checked })}
disabled={toggleMutation.isPending}
settingKey="enableCombinedInboxTasks"
managed={managedKeys.enableCombinedInboxTasks}
ariaLabel="Toggle combined inbox and task list experimental setting"
/>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
<ExperimentalToggleCard
title="Conference Room Chat"
+100 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
import { useLocation, useSearchParams } from "@/lib/router";
import type { ReactNode } from "react";
import { useLocation, useNavigate, useSearchParams } from "@/lib/router";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -13,9 +14,25 @@ import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
import { TaskViewsMenu } from "../components/TaskViewsMenu";
import { Button } from "@/components/ui/button";
import { CircleDot, Plus } from "lucide-react";
import type { Issue } from "@paperclipai/shared";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { useCombinedInboxTasksEnabled } from "../hooks/useCombinedInboxTasksEnabled";
import { useDialogActions } from "../context/DialogContext";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Inbox } from "./Inbox";
import {
ORGANIZATION_SCOPED_PARAMS,
TASK_VIEW_PARAM,
loadLastTaskView,
normalizeTaskViewKey,
resolveInitialTaskView,
saveLastTaskView,
taskView,
type TaskViewKey,
} from "../lib/task-views";
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
const ISSUES_PAGE_SIZE = 100;
@@ -65,7 +82,86 @@ export function buildIssuesSearchUrl(currentHref: string, search: string): strin
return `${url.pathname}${url.search}${url.hash}`;
}
/**
* Tasks — the single task surface after PAP-670 merged Inbox into it.
*
* This component only resolves `?view=` to a view and hands off: My-work views
* render the inbox list, organization views render the task collection. Both
* get the same Views control in their toolbar, so the switch reads as one page.
*/
export function Issues() {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
// 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 streamlinedUiEnabled && combinedInboxTasksEnabled ? <StreamlinedTasks /> : <OrganizationIssues />;
}
function StreamlinedTasks() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialogActions();
const inboxBadge = useInboxBadge(selectedCompanyId);
const requestedView = searchParams.get(TASK_VIEW_PARAM);
const hasOrganizationScopedParam = ORGANIZATION_SCOPED_PARAMS.some(
(param) => (searchParams.get(param) ?? "").length > 0,
);
// Read the stored view once per mount so a later write can't yank the view
// out from under the user mid-session.
const [lastUsedView] = useState<TaskViewKey>(() => loadLastTaskView());
const view = resolveInitialTaskView(requestedView, hasOrganizationScopedParam, lastUsedView);
const definition = taskView(view);
// Make the resolved view addressable without dropping the params that
// brought the user here — and correct a requested view that was overridden
// (an inbox view carrying an organization filter opens All tasks).
useEffect(() => {
if (normalizeTaskViewKey(requestedView) === view) return;
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set(TASK_VIEW_PARAM, view);
return next;
}, { replace: true });
}, [requestedView, view, setSearchParams]);
const selectView = useCallback((next: TaskViewKey) => {
saveLastTaskView(next);
// A view switch starts clean: the previous view's search and filters are
// its own, not the new view's.
navigate(`/issues?${TASK_VIEW_PARAM}=${next}`);
}, [navigate]);
const viewsMenu = (
<TaskViewsMenu value={view} onChange={selectView} badgeCount={inboxBadge.inbox} />
);
if (definition.surface === "inbox") {
return (
<Inbox
tab={definition.inboxTab}
surfaceLabel="Tasks"
toolbarContext={(
<div className="flex min-w-0 items-center gap-2">
{viewsMenu}
<Button size="sm" variant="outline" aria-label="New Task" onClick={() => openNewIssue()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Task</span>
</Button>
</div>
)}
/>
);
}
return <OrganizationIssues toolbarContext={viewsMenu} initialStatuses={definition.statuses} />;
}
function OrganizationIssues({
toolbarContext,
initialStatuses,
}: { toolbarContext?: ReactNode; initialStatuses?: string[] } = {}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const issuesPresentation = resolveIssuesPresentation(streamlinedUiEnabled);
const { selectedCompanyId } = useCompany();
@@ -223,6 +319,8 @@ export function Issues() {
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
initialStatuses={initialStatuses}
toolbarContext={toolbarContext}
initialSearch={syncedSearch}
onSearchChange={handleSearchChange}
enableRoutineVisibilityFilter