From 1c07b5903b1b11139b1e1ce052a3cd4885865d90 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 4 Oct 2026 02:13:46 -0700 Subject: [PATCH] feat: Chat leads the left nav, agent work beside chats, and a Combined Inbox + Task List flag (#15100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude Opus 5.5 --- doc/PRODUCT.md | 26 +- doc/SPEC.md | 2 +- packages/shared/src/feature-catalog.ts | 11 +- packages/shared/src/types/instance.ts | 2 + packages/shared/src/validators/artifact.ts | 2 + packages/shared/src/validators/instance.ts | 1 + .../company-artifacts-service.test.ts | 26 ++ .../instance-settings-service.test.ts | 1 + server/src/services/company-artifacts.ts | 6 + server/src/services/instance-settings.ts | 2 + tests/e2e/agent-chat-sessions.spec.ts | 12 +- ui/src/App.tsx | 78 ++++- ui/src/api/artifacts.ts | 3 + .../components/AgentConversationSidebar.tsx | 6 +- .../AgentConversationsSidebar.test.tsx | 29 +- .../components/AgentConversationsSidebar.tsx | 14 +- ui/src/components/CommandPalette.tsx | 6 +- ui/src/components/IssuesList.tsx | 62 +++- ui/src/components/MobileBottomNav.tsx | 36 +- ui/src/components/Sidebar.test.tsx | 121 ++++++- ui/src/components/Sidebar.tsx | 55 +++- ui/src/components/TaskViewsMenu.tsx | 80 +++++ .../components/chat/AgentWorkPanels.test.tsx | 128 ++++++++ ui/src/components/chat/AgentWorkPanels.tsx | 308 ++++++++++++++++++ .../task-side-panel/TaskSidePanel.test.tsx | 90 +++++ .../task-side-panel/TaskSidePanel.tsx | 47 ++- .../useCombinedInboxTasksEnabled.test.ts | 14 + ui/src/hooks/useCombinedInboxTasksEnabled.ts | 47 +++ ui/src/lib/issueDetailBreadcrumb.ts | 15 +- ui/src/lib/queryKeys.ts | 4 + ui/src/lib/task-side-panel-state.ts | 7 + ui/src/lib/task-views.test.ts | 97 ++++++ ui/src/lib/task-views.ts | 152 +++++++++ ui/src/pages/Inbox.tsx | 54 ++- .../InstanceExperimentalSettings.test.tsx | 15 + ui/src/pages/InstanceExperimentalSettings.tsx | 14 +- ui/src/pages/Issues.tsx | 102 +++++- 37 files changed, 1590 insertions(+), 85 deletions(-) create mode 100644 ui/src/components/TaskViewsMenu.tsx create mode 100644 ui/src/components/chat/AgentWorkPanels.test.tsx create mode 100644 ui/src/components/chat/AgentWorkPanels.tsx create mode 100644 ui/src/hooks/useCombinedInboxTasksEnabled.test.ts create mode 100644 ui/src/hooks/useCombinedInboxTasksEnabled.ts create mode 100644 ui/src/lib/task-views.test.ts create mode 100644 ui/src/lib/task-views.ts diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 15ab685410..502b1d42dd 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -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. diff --git a/doc/SPEC.md b/doc/SPEC.md index ab162e3df9..d2003e1080 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -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 diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index f2c404a3bd..457172b2fa 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -147,7 +147,16 @@ export const INSTANCE_FEATURE_CATALOG: Record { 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"; diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 31b6d1751b..32ce6a2c01 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -43,6 +43,7 @@ describe("instance settings service", () => { enableApps: true, enableMcpAggregators: true, enableAgentChat: false, + enableCombinedInboxTasks: false, enableChatConnectors: false, enableMemoryConnectors: false, enableConferenceRoomChat: false, diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts index 8b75a1015f..621e90ee03 100644 --- a/server/src/services/company-artifacts.ts +++ b/server/src/services/company-artifacts.ts @@ -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 '\\' diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 2e7d555f0c..1191d7afab 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -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, diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index 185d78548a..44a6084b1b 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -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(); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 706991339c..1b1b1eeed3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 }>{children}; } -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 ( <> } /> @@ -303,11 +307,24 @@ function boardRoutes(streamlinedUiEnabled: boolean) { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + {mergedTasks ? ( + <> + {/* Combined Inbox + Task List: the status presets are real views, not aliases of /issues. */} + } /> + } /> + } /> + } /> + } /> + + ) : ( + <> + } /> + } /> + } /> + } /> + } /> + + )} } /> } /> } /> @@ -423,14 +440,32 @@ function boardRoutes(streamlinedUiEnabled: boolean) { ) : null} } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* 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 ? ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )} } /> - } /> } /> } /> } /> @@ -454,7 +489,19 @@ function AppsConnectEntryRoute({ } function InboxRootRedirect() { - return ; + const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); + const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled(); + return streamlinedUiEnabled && combinedInboxTasksEnabled + ? + : ; +} + +/** 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 ; } 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() { } /> } /> : }> - {boardRoutes(streamlinedUiEnabled)} + {boardRoutes(streamlinedUiEnabled, combinedInboxTasksEnabled)} } /> diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts index e0653245d3..7d7fa0e157 100644 --- a/ui/src/api/artifacts.ts +++ b/ui/src/api/artifacts.ts @@ -34,6 +34,8 @@ export type ArtifactKindFilter = Exclude | "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); diff --git a/ui/src/components/AgentConversationSidebar.tsx b/ui/src/components/AgentConversationSidebar.tsx index 1d32fee7da..a500d2e863 100644 --- a/ui/src/components/AgentConversationSidebar.tsx +++ b/ui/src/components/AgentConversationSidebar.tsx @@ -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; @@ -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 agent.id)} loading={loading} error={error} onRetry={onRetry} + existingChatAgentIds={existingChatAgentIds ?? agents.map(agent => agent.id)} loading={loading} error={error} onRetry={onRetry} renderAgentIcon={agent => } onSelect={async (agent, signal) => { if (onAddChat) await onAddChat(agent, signal); diff --git a/ui/src/components/AgentConversationsSidebar.test.tsx b/ui/src/components/AgentConversationsSidebar.test.tsx index 9f442f3903..b45eeceda8 100644 --- a/ui/src/components/AgentConversationsSidebar.test.tsx +++ b/ui/src/components/AgentConversationsSidebar.test.tsx @@ -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 })); +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 + }); +}); diff --git a/ui/src/components/AgentConversationsSidebar.tsx b/ui/src/components/AgentConversationsSidebar.tsx index 049de33ff4..bb173be4a1 100644 --- a/ui/src/components/AgentConversationsSidebar.tsx +++ b/ui/src/components/AgentConversationsSidebar.tsx @@ -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 agent.id)} activeId={active?.id} previews={previews} loading={agents.isPending || chats.isPending || session.isPending} error={agents.error ?? chats.error ?? session.error} diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 25f3d2ace6..85dc925a18 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -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() { Dashboard - go("/inbox")}> + go(combinedInboxTasksEnabled ? "/issues?view=mine" : "/inbox")}> - Inbox + {combinedInboxTasksEnabled ? "My work" : "Inbox"} go("/issues")}> diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index f5718914b4..da467d40fd 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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; 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) => 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 | null>(null); if (initialPreferencesRef.current === null) { initialPreferencesRef.current = loadIssueCollectionPreferences(preferenceLocation); @@ -795,7 +811,13 @@ function StreamlinedIssuesList({ const initialPreferences = initialPreferencesRef.current; const [viewState, setViewState] = useState(() => - getInitialWorkspaceViewState(initialPreferences, initialAssignees, initialWorkspaces, defaultSortField), + getInitialWorkspaceViewState( + initialPreferences, + initialAssignees, + initialWorkspaces, + defaultSortField, + initialStatuses, + ), ); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(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({ + {toolbarContext} + + + ) : ( + + + {TASK_VIEW_GROUPS.map((group, groupIndex) => ( +
+ {groupIndex > 0 ? : null} + + {group.label} + {groupIndex === 0 && badgeCount != null && badgeCount > 0 ? ( + + {badgeCount > 99 ? "99+" : badgeCount} + + ) : null} + + {group.views.map((view) => { + const selected = view.key === value; + return ( + onChange(view.key)} + className="items-start gap-2" + aria-current={selected ? "true" : undefined} + > + + ); + })} +
+ ))} +
+ + ); +} diff --git a/ui/src/components/chat/AgentWorkPanels.test.tsx b/ui/src/components/chat/AgentWorkPanels.test.tsx new file mode 100644 index 0000000000..d845f95298 --- /dev/null +++ b/ui/src/components/chat/AgentWorkPanels.test.tsx @@ -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: () => })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, className, target, rel }: { to: string; children: ReactNode; className?: string; target?: string; rel?: string }) => + {children}, +})); + +function task(overrides: Partial): 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 { + 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( + + {node} + , + ); + }); + 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(); + + 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(); + + 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"); + }); +}); diff --git a/ui/src/components/chat/AgentWorkPanels.tsx b/ui/src/components/chat/AgentWorkPanels.tsx new file mode 100644 index 0000000000..2e7c636108 --- /dev/null +++ b/ui/src/components/chat/AgentWorkPanels.tsx @@ -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 ( +

+ {children} +

+ ); +} + +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 ( + 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} + > +
+ + + {task.title} + +
+
+ {task.identifier ?? task.id.slice(0, 8)} + · + +
+ + ); +} + +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(defaultIssueFilterState); + const [sortField, setSortField] = useState("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 ( +
+
+
+ + setQuery(event.target.value)} + placeholder="Search tasks" + aria-label="Search tasks" + className="h-8 pl-7 text-sm" + /> +
+ setFilters((current) => ({ ...current, ...patch }))} + activeFilterCount={activeFilterCount} + projects={projectsQuery.data?.map((project) => ({ id: project.id, name: project.name }))} + enableExternalObjectFilters={false} + buttonVariant="outline" + iconOnly + presentation="streamlined" + /> + + + + + +
+ {SORT_OPTIONS.map(([field, label]) => ( + + ))} +
+
+
+
+ + {tasksQuery.isPending ? ( + Loading tasks… + ) : tasksQuery.isError ? ( + Could not load this agent's tasks. + ) : total === 0 ? ( + This agent hasn't worked on any tasks yet. + ) : visible.length === 0 ? ( + + {capped + ? `No tasks match these filters among the ${AGENT_TASK_LIMIT} most recently updated.` + : "No tasks match these filters."} + + ) : ( +
+ {visible.map((task) => )} + {capped ? ( +

+ Showing the {AGENT_TASK_LIMIT} most recently updated tasks. +

+ ) : null} +
+ )} +
+ ); +} + +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 ; + case "video": return