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