From 5a7a6b32baf0ae7c1445d1bdbaf7903dc7497173 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:03:03 -0500 Subject: [PATCH] fix(ui): restore compact Coordinator task loading and responder continuity (#18312) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People open tasks from their personal workspace and send replies to agents. > - Cold task opens should load recent conversation before older history. > - Classic settings could expand history before the Coordinator preference arrived. > - Coordinator lists also read full tasks, and response startup used the previous assignee. > - This pull request restores compact reads and keeps the selected responder visible through runtime discovery. > - People can read and reply without waiting for unrelated history or losing response feedback. ## Linked Issues or Issue Description Related: #17593, #17751, #17756, #17885, #17983, and #18092. **What happened?** A cold task could select Classic full history while its personal workspace preference was pending. The full-history choice stayed cached after Coordinator became active. Coordinator Tasks used full task reads. Needs attention fetched one enriched task per pending task. Sending to a newly selected agent could show no responder, show the previous assignee, or clear feedback when assignment changed. Legacy runtime discovery then replaced the startup identity with a generic activity line. **Expected behavior** Wait for the workspace preference before selecting Classic history. Show the task instruction and recent conversation with **More messages** for older content. Read collection indexes and fetch diagnostics for visible rows. Keep the selected responder mounted from Send through saved input and runtime startup. **Steps to reproduce** 1. Enable Classic task UI and Coordinator. Delay the personal workspace preference on a cold task open. 2. Open Coordinator Tasks or its Needs attention view with many pending tasks. 3. Open a task assigned to a person. Select another agent in the composer and send a message. 4. Let the message save before runtime reads return. Then allow a legacy run to appear. ## What Changed - Wait for task interface settings before persisting Classic history expansion. - Use lightweight indexes for Coordinator Tasks. Resolve pending task IDs in batches of at most 100. Keep search and participant filters on the server. Enrich at most 50 visible rows through the existing hook without replacing list membership or order. - Preserve an error when a pending task cannot load, instead of showing a false all-clear. - Register board cards for row enrichment. Observe newly mounted cards after column expansion or pagination, and track visibility by element so hidden list rows cannot clear visible board cards with the same task ID. - Track the recipient of each submitted message through reassignment. A later draft's recipient does not change the response already starting. - Share a stable response header across startup, native runtime, and legacy runtime rendering. Keep the existing transcript renderers. - Add regression tests, production-browser checks, design guidance, and feature-map notes. ## Verification - Red/green: delayed Coordinator preference started a full comments read before the fix. It now retains compact history and still expands on request. - Red/green: sends from human or other-agent assignment showed no identity or the wrong identity. Both now retain the selected responder through assignment, acknowledgement, later draft selection, and native/legacy startup. - In a 105-task Needs attention fixture, the previous code made 105 enriched detail requests. The fixed code makes two bounded index requests. - All 659 final affected tests pass across task detail, thread, runner turn, history window, Inbox, board, row enrichment, and Coordinator collection suites. - Review follow-up: the production board lost its next-step badge before card registration. The regression now passes, along with column pagination. The focused 82-test enrichment run and 371-test header/thread run also pass. - All 12 compact-startup browser checks pass against the production UI and an isolated PostgreSQL container. They cover delayed preferences, list navigation, saved-card geometry, document/comment anchors, history expansion, draft retention, retry, and actual legacy process startup. The final deterministic process case recorded 118 frames with zero missing or replaced responder identities. It does not call a model provider. - Full workspace build and typecheck pass. Final UI build and typecheck pass after the shared-header change. Token gates, map checks, module boundaries, and diff checks pass. - The full UI rerun before the board review follow-up passed all 10,034 tests across 844 files. The 659 affected tests pass after that follow-up. The first full UI run passed 10,030 tests and timed out in one unchanged IssuesList large-tree test. Its two variants pass in an isolated rerun and in the full rerun. Those later passes establish recovery; the earlier timeout's cause remains unproven. - The full `pnpm test:run` attempt was stopped after repeated embedded PostgreSQL initialization failures in server fixtures. No complete local server-suite pass is claimed. Browser verification recovered with a disposable container database. - A later avatar browser rerun reused the previous same-title task and its cancelled run through the existing 48-hour title deduplication. That fixture never reached fresh runtime startup. The test now uses a unique title and checks its initial assignment/status; a full 12-case rerun and a second avatar run on the same instance pass. The failed trace is retained locally. - Final commit `7a18372da3` has 55 successful checks, two intentionally skipped Storybook checks, and one neutral Paperclip Review. All required verification gates are green. Greptile is 5/5 with no actionable findings or open review threads. The advisory Paperclip review paused without a complete assessment; no passing Paperclip assessment is claimed. - These checks establish bounded reads and stable rendering. No reliable staging latency benchmark or staging deployment is claimed. ## Risks - Collection projections omit task bodies. Detail navigation must retain its normal core/full task reads. Search remains server-side. - Startup feedback must end on rejection, unavailable agents, terminal outcomes, or a visible run. Existing cases cover those transitions. - The shared header changes the legacy live-turn layout to retain identity beside its existing status line. - No migration, mutation permission, provider protocol, or execution authority change. ## Model Used OpenAI Codex, GPT-6 family. The exact serving deployment ID and context-window size are not exposed in this session. The model used reasoning, code editing, shell tools, automated Chromium checks, and manual browser inspection. ## 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 (focused suites and browser checks; full-suite limitations are recorded above) - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- DESIGN.md | 6 +- maps/feature-map/tasks.md | 5 + tests/perf/issue-detail/README.md | 7 + .../perf/issue-detail/compact-startup.spec.ts | 122 ++++++++++++++++++ ui/src/components/KanbanBoard.tsx | 1 + ui/src/components/TaskChatThread.test.tsx | 38 ++++++ ui/src/components/TaskChatThread.tsx | 80 +++++++----- .../task-chat/TaskChatRunnerTurn.tsx | 56 +++++--- ui/src/hooks/useIssueRowDetails.ts | 50 +++++-- ui/src/hooks/useRunnerSubmission.ts | 29 +++-- ui/src/pages/CoordinatorTasks.test.tsx | 82 ++++++++++-- ui/src/pages/CoordinatorTasks.tsx | 49 ++++--- ui/src/pages/IssueDetail.test.tsx | 20 +++ ui/src/pages/IssueDetail.tsx | 4 +- 14 files changed, 442 insertions(+), 107 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 963d5f12124..1b740713f13 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -75,7 +75,9 @@ follow-up, recent saved replies, and text or pending requests from the latest ru Put older messages and diagnostic activity behind **More messages**. Fetch those only when expanded; never download all history just to calculate a count. Retain messages and runs observed while the task stays open, preserve expansion -on return within the session. No URL fragment expands history. Document links, +on return within the session. Wait for the personal workspace preference before +selecting Classic full history; loading that preference must not expand a +Coordinator task. No URL fragment expands history. Document links, including continuation summaries, open their pane. Older comment links fetch only the requested comment and preserve the recent conversation. Background refreshes and history expansion keep the conversation and composer @@ -95,6 +97,8 @@ Keep the runner turn's avatar, name, status header and current-activity slot in place from message submission through queued admission and native startup. Saving the human comment does not end startup feedback. Keep the identity mounted until its run appears or a newer execution outcome ends admission. +Use the sent message's selected recipient through reassignment. Choosing the +recipient for a later draft must not change the response already starting. Runtime discovery must not replace the turn with a generic spinner or a "run started" receipt. Queued and running headers use the same spacing. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 3e196076d38..cfac7d124d6 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -35,6 +35,11 @@ visible-row details independently when verifying `tasks/organize`: row heights, title bounds, and the first-row origin should remain stable. Test nested expansion, scrolling to more rows, and detail/back navigation; a collection projection must never suppress the full task instruction fetch. +Coordinator Tasks uses the same index and visible-row enrichment. Needs attention +resolves pending task IDs in batches of at most 100; it does not fetch enriched +task detail for every decision. Search and participant filters stay on the server. +Cold Coordinator detail must stay compact when Classic settings arrive before +the personal workspace preference. Verify explicit history expansion afterward. ### `new-task` diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 0b95c660338..1077987582c 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -59,6 +59,13 @@ cards inserted after the conversation becomes readable. It also checks document, older-comment and unknown anchors without full comments/activity/run reads. Run it with the same loopback-only URL override below. The fixture URL is saved in `test-results/compact-startup-fixture.json` for manual inspection and reuse. +It also checks a delayed Coordinator preference with Classic enabled, index-based +Coordinator list navigation, and the responder identity through reassignment, +comment acknowledgement and legacy runtime discovery. The last case uses a +deterministic local process, records identity continuity on each frame, and saves +startup/running screenshots. It does not call a model provider. +Each send fixture uses a unique task title so repeated runs cannot reuse a +previous task and its cancelled run through ordinary task deduplication. `layout-stability.spec.ts` exercises the real task route, including Inbox navigation, with 160 Markdown comments and a resolved interaction. It delays diff --git a/tests/perf/issue-detail/compact-startup.spec.ts b/tests/perf/issue-detail/compact-startup.spec.ts index cd2f083e841..55ab9737a03 100644 --- a/tests/perf/issue-detail/compact-startup.spec.ts +++ b/tests/perf/issue-detail/compact-startup.spec.ts @@ -52,6 +52,59 @@ async function ready(page: Page) { await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 }); } +async function coordinatorPreference(page: Page, waitForPreference = Promise.resolve()) { + await page.route("**/api/instance/settings/experimental", async route => { + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true } }); + }); + await page.route("**/api/workspace-preferences/me", async route => { + await waitForPreference; + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } }); + }); +} + +test("a delayed Coordinator preference cannot expand a cold task through Classic settings", async ({ page }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await coordinatorPreference(page, held); + const historyRequests: string[] = []; + page.on("request", request => { + const url = new URL(request.url()); + if (/\/api\/issues\/[^/]+\/(comments|activity|runs)$/.test(url.pathname) && + !["recent", "comment-inputs"].includes(url.searchParams.get("view") ?? "")) historyRequests.push(url.pathname + url.search); + }); + try { + await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`); + await expect(page.getByTestId("issue-detail-header")).toContainText("Review the latest investigation"); + expect(historyRequests).toEqual([]); + release(); + await ready(page); + await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible(); + expect(historyRequests).toEqual([]); + await page.getByRole("button", { name: "More messages", exact: true }).click(); + await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached(); + expect(historyRequests.length).toBeGreaterThan(0); + } finally { release(); } +}); + +test("Coordinator task rows use index reads and open a compact conversation", async ({ page }) => { + await coordinatorPreference(page); + const listReads: string[] = []; + page.on("request", request => { + const url = new URL(request.url()); + if (/\/api\/companies\/[^/]+\/issues$/.test(url.pathname)) listReads.push(url.searchParams.get("view") ?? "full"); + }); + await page.goto(`/${fixture.prefix}/issues`); + const row = page.locator("[data-inbox-issue-link]", { hasText: "Review the latest investigation" }).first(); + await expect(row).toBeVisible(); + expect(listReads).toContain("index"); + expect(listReads).not.toContain("full"); + await row.click(); + await ready(page); + await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible(); +}); + test("core rich cards reveal before duplicate reads and stay stable during slow enrichment", async ({ page }, info) => { await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; }); await page.addInitScript(() => { @@ -182,3 +235,72 @@ test("late provider metadata keeps saved PR card geometry stable", async ({ page expect(after).toEqual(before); } finally { release(); } }); + +test("the selected responder stays mounted through comment acknowledgement and legacy startup", async ({ page, request }, info) => { + const source = await (await request.get(`/api/issues/${fixture.id}`)).json(); + const agentResponse = await request.post(`/api/companies/${source.companyId}/agents`, { data: { + name: "Startup responder", role: "engineer", adapterType: "process", + adapterConfig: { command: process.execPath, args: ["-e", "setTimeout(() => console.log('Fixture finished.'), 12000)"] }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } }, + } }); + expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy(); + const agent = await agentResponse.json(); + const issueResponse = await request.post(`/api/companies/${source.companyId}/issues`, { data: { + title: `Keep the selected responder visible ${Date.now()}`, status: "in_review", assigneeUserId: "local-board", + description: "This disposable process fixture verifies the send transition without a model provider.", + } }); + expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy(); + const issue = await issueResponse.json(); + // Repeated runs must not adopt the previous fixture through same-title dedup. + expect(issue).toMatchObject({ status: "in_review", assigneeUserId: "local-board", assigneeAgentId: null }); + await coordinatorPreference(page); + await page.addInitScript(() => { + const frames: { visible: boolean; same: boolean }[] = []; + let first: Element | null = null; + Object.assign(window, { responderFrames: frames }); + const sample = () => { + const identity = document.querySelector('[data-testid="task-chat-live-transcript"] [data-testid="task-chat-agent-identity"]'); + first ??= identity; + if (first) frames.push({ visible: !!identity && identity.getBoundingClientRect().height > 0, same: first === identity }); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let holdRuntime = false; + await page.route(/\/api\/issues\/[^/]+\/(active-run|live-runs|runs)(\?|$)/, async route => { + if (holdRuntime && route.request().method() === "GET") await held; + await route.continue(); + }); + try { + await page.goto(`/${fixture.prefix}/issues/${issue.identifier}`); + await ready(page); + await page.getByTestId("task-chat-composer-assignee").click(); + await page.locator(`[data-assignee-identity="agent:${agent.id}"]`).click(); + await page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]').fill("Keep your identity visible through startup."); + holdRuntime = true; + const saved = page.waitForResponse(response => response.request().method() === "PATCH" && + [issue.id, issue.identifier].some(ref => new URL(response.url()).pathname === `/api/issues/${ref}`)); + await page.getByTestId("task-chat-composer-send").click(); + expect((await saved).ok()).toBeTruthy(); + const identity = page.getByTestId("task-chat-live-transcript").getByTestId("task-chat-agent-identity"); + await expect(identity).toHaveText(agent.name); + await page.screenshot({ path: info.outputPath("startup.png") }); + release(); + await expect(page.getByTestId("task-chat-live-run-pill")).toBeVisible(); + await expect(identity).toHaveText(agent.name); + await page.screenshot({ path: info.outputPath("running.png") }); + const frames = await page.evaluate(() => (window as unknown as { responderFrames: { visible: boolean; same: boolean }[] }).responderFrames); + await fs.writeFile(info.outputPath("avatar-frames.json"), JSON.stringify(frames, null, 2)); + expect(frames.length).toBeGreaterThan(2); + expect(frames.every(frame => frame.visible && frame.same), "The responder must remain visible in the same mounted identity").toBe(true); + } finally { + release(); + const active = await request.get(`/api/issues/${issue.id}/active-run`); + if (active.ok()) { + const run = await active.json(); + if (run?.id) await request.post(`/api/heartbeat-runs/${run.id}/cancel`); + } + } +}); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 1dfd808a010..ee020d5b672 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -311,6 +311,7 @@ function KanbanCard({ return ( [ expect(container.textContent).not.toContain("Taking longer than usual"); }); +it.each([null, "previous-agent"].flatMap(previousAgentId => ["paperclip_runner", "process"].map(adapterType => ({ previousAgentId, adapterType }))))("keeps the selected responder mounted across reassignment, acknowledgement and $adapterType startup (previous=$previousAgentId)", async ({ previousAgentId, adapterType }) => { + let acknowledge!: () => void; + const onAdd = vi.fn["onAdd"]>(() => new Promise(resolve => { acknowledge = resolve; })); + const props = { + issueId: "issue", issueStatus: "in_review", currentUserId: "board", comments: [], onAdd, enableReassign: true, + suggestedAssigneeValue: "agent:next-agent", + reassignOptions: [{ id: "agent:next-agent", label: "Next worker" }, { id: "agent:previous-agent", label: "Previous worker" }], + agentMap: new Map([ + ["previous-agent", { id: "previous-agent", name: "Previous worker", adapterType: "paperclip_runner" } as Agent], + ["next-agent", { id: "next-agent", name: "Next worker", adapterType } as Agent], + ]), + }; + render(); + if (previousAgentId) { + await act(async () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!.click()); + await act(async () => document.querySelector('[data-assignee-identity="agent:next-agent"]')!.closest("button")!.click()); + } + await act(async () => editorCallbacks.change("Please check this.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + expect(onAdd.mock.calls[0]?.[2]).toEqual({ assigneeAgentId: "next-agent", assigneeUserId: null }); + const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); + expect(identity?.textContent).toBe("Next worker"); + const saved = { id: "saved-message", companyId: "company", issueId: "issue", authorType: "user" as const, + authorAgentId: null, authorUserId: "board", body: "Please check this.", presentation: null, metadata: null, + clientRequestId: onAdd.mock.calls[0]?.[4], createdAt: new Date(), updatedAt: new Date() }; + render(); + await act(async () => acknowledge()); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + expect(container.textContent).toContain("Queued"); + // Choosing a recipient for the next draft does not change the sent request. + await act(async () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!.click()); + await act(async () => document.querySelector('[data-assignee-identity="agent:previous-agent"]')!.closest("button")!.click()); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + const live = { ...nativeRunningRun(), runtimeMode: adapterType === "paperclip_runner" ? "native" as const : "legacy" as const, adapterType, agentId: "next-agent", agentName: "Next worker" }; + render(); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); +}); + it("prepares comment-link history and its composer before reveal", async () => { const props = { issueId: "linked-issue", diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 973d92c1d24..a2d7bc49cf7 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -39,7 +39,7 @@ import { } from "@/components/transcript/useLiveRunTranscripts"; import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts"; import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail"; -import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; +import { TaskChatRunnerHeader, TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; import { TaskChatTurnStatusIsland, taskChatTurnStatusModel, @@ -2459,12 +2459,31 @@ export function TaskChatThread(props: TaskChatThreadProps) { // pill's own second-tick keeps the elapsed readout moving. `running` follows // tailStreaming: true while live, false through the settle gap — so the tail // renders identically either side of the run finishing. + const { + pending: runnerSubmissionPending, + agentId: submissionAgentId, + takingLonger: submissionTakingLonger, + begin: beginSubmission, + reject: rejectSubmission, + blocked: startupBlocked, + } = useRunnerSubmission({ + issueId: issueId ?? null, + agentId: issueAssigneeAgentId, + agent: agentMap?.get(issueAssigneeAgentId ?? ""), + blocked: Boolean(composerPause), + comments, + issueStatus, + runId: liveRun?.id ?? null, + linkedRuns, + interruption: props.interruption, + onRefresh: props.onRefreshLatestComments, + }); const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName; const tailAgentId = liveRun?.agentId ?? linkedRunMetaById.get(tailRunId ?? "")?.agentId ?? - issueAssigneeAgentId; + submissionAgentId ?? issueAssigneeAgentId; const tailAgent = tailAgentId ? agentMap?.get(tailAgentId) : undefined; const visibleTailAgentName = tailAgentName ?? tailAgent?.name ?? null; const visibleTailAgentIcon = tailAgent?.icon ?? null; @@ -2705,29 +2724,14 @@ export function TaskChatThread(props: TaskChatThreadProps) { [handleRuntimeRequestDecision, onSkipInteraction], ); const runtimeComposerDisabledReason = composerDisabledReason ?? undefined; - const hasAgentAssignee = Boolean(issueAssigneeAgentId); - const { - pending: runnerSubmissionPending, - takingLonger: submissionTakingLonger, - begin: beginSubmission, - reject: rejectSubmission, - blocked: startupBlocked, - } = useRunnerSubmission({ - issueId: issueId ?? null, - agentId: issueAssigneeAgentId, - agent: agentMap?.get(issueAssigneeAgentId ?? ""), - blocked: Boolean(composerPause), - comments, - issueStatus, - runId: liveRun?.id ?? null, - linkedRuns, - interruption: props.interruption, - onRefresh: props.onRefreshLatestComments, - }); + const hasAgentAssignee = Boolean(submissionAgentId ?? issueAssigneeAgentId); const handleThreadAdd = useCallback( async (...args: Parameters) => { args[4] ??= crypto.randomUUID(); - const attempt = beginSubmission(args[4]); + const attempt = beginSubmission(args[4], args[2] ? { + agentId: args[2].assigneeAgentId, + agent: agentMap?.get(args[2].assigneeAgentId ?? ""), + } : undefined); try { await onAdd(...args); if (selectedPendingInput?.kind === "durable" && selectedPendingInput.interaction.kind === "ask_user_questions") { @@ -2740,7 +2744,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { throw error; } }, - [beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput], + [beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput, agentMap], ); const optimisticRunnerStartup = hasAgentAssignee && @@ -3228,9 +3232,27 @@ export function TaskChatThread(props: TaskChatThreadProps) { <> {tailRunId || optimisticRunnerStartup ? (
+ + 0} + statusContent={tailRunSource?.adapterType !== "paperclip_runner" && !optimisticRunnerStartup ? ( + + ) : undefined} + /> {tailRunSource?.adapterType === "paperclip_runner" || optimisticRunnerStartup ? ( ) : ( - )} +
) : null} {tailTaskCreationItems.map(item => ( diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 34c7d46dbb5..e24cb09c4b7 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, type ReactNode } from "react"; import type { ExecutionProjection } from "@paperclipai/shared"; import { useSecondTick } from "@/hooks/useSecondTick"; import { cn } from "@/lib/utils"; @@ -114,6 +114,35 @@ function RunnerCurrentActivityTail({ status, waitingLabel }: { status: string; w ; } +interface RunnerHeaderProps { + runId?: string | null; + agentName?: string | null; + agentIcon?: string | null; + agent?: import("../AgentAvatar").AvatarAgent; + status: string; + execution?: ExecutionProjection | null; + preparationLabel?: string; + startedAtMs: number | null; + finishedAtMs?: number | null; + continuedAfterSteering?: boolean; +} + +function runnerWaitingLabel({ runId, status, execution, preparationLabel }: RunnerHeaderProps) { + if (isTerminalRunStatus(status)) return null; + if (execution?.phase === "waiting_for_provider") return execution.label; + if (!runId && preparationLabel) return preparationLabel; + return status === "queued" ? execution?.label ?? "Queued" : null; +} + +/** One identity stays mounted while admission discovers the run's renderer. */ +export function TaskChatRunnerHeader({ statusContent, ...props }: RunnerHeaderProps & { statusContent?: ReactNode }) { + return
+ {props.agentName ? : null} + {statusContent ?? } +
; +} + export function TaskChatRunnerTurn({ runId, agentName, @@ -129,6 +158,7 @@ export function TaskChatRunnerTurn({ allowSummaryFallback = true, suppressFinal = false, continuedAfterSteering = false, + showHeader = true, onRuntimeRequestDecision, }: { /** Stable identity used to clear replay-latched final text for the next turn. */ @@ -149,15 +179,15 @@ export function TaskChatRunnerTurn({ suppressFinal?: boolean; /** The visible tail resumes the same native run after an accepted steer. */ continuedAfterSteering?: boolean; + /** The task thread keeps a shared header across native and legacy discovery. */ + showHeader?: boolean; onRuntimeRequestDecision?: ( item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision, ) => void | Promise; }) { const terminal = isTerminalRunStatus(status); - const waitingLabel = !terminal && execution?.phase === "waiting_for_provider" ? execution.label - : !runId && !terminal ? preparationLabel ?? (status === "queued" ? execution?.label ?? "Queued" : null) - : status === "queued" ? execution?.label ?? "Queued" : null; + const waitingLabel = runnerWaitingLabel({ runId, status, execution, preparationLabel, startedAtMs }); const yielded = items.some( (item) => item.kind === "protocol" && @@ -216,21 +246,9 @@ export function TaskChatRunnerTurn({ data-testid="task-chat-runner-turn" data-phase={status === "queued" ? "startup" : undefined} > -
- {agentName ? ( - - ) : null} - -
+ {showHeader ? : null} {activityUnavailable ? (
("[data-issue-row-details]")]; - const visible = new Set(); + const selector = "[data-issue-row-details]"; + const observed = new Set(); + const visible = new Set(); const publish = () => { - const next = [...visible].sort().slice(0, 50); + const next = [...new Set([...visible].map(node => node.dataset.issueRowDetails!))].sort().slice(0, 50); setVisibleIds((previous) => previous.join(",") === next.join(",") ? previous : next); }; - if (typeof IntersectionObserver === "undefined") { - for (const node of nodes.slice(0, 50)) visible.add(node.dataset.issueRowDetails!); - publish(); - return; - } - const observer = new IntersectionObserver((entries) => { + const observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver((entries) => { for (const entry of entries) { - const id = (entry.target as HTMLElement).dataset.issueRowDetails!; - if (entry.isIntersecting) visible.add(id); - else visible.delete(id); + const node = entry.target as HTMLElement; + if (!observed.has(node)) continue; + if (entry.isIntersecting) visible.add(node); + else visible.delete(node); } publish(); }); - for (const node of nodes) observer.observe(node); - return () => observer.disconnect(); + const syncRows = () => { + const nodes = new Set(element.querySelectorAll(selector)); + for (const node of observed) if (!nodes.has(node)) { + observer?.unobserve(node); + observed.delete(node); + visible.delete(node); + } + for (const node of nodes) if (!observed.has(node)) { + observed.add(node); + observer?.observe(node); + } + if (!observer) { + visible.clear(); + for (const node of [...nodes].filter(node => !node.closest("[hidden]")).slice(0, 50)) visible.add(node); + } + publish(); + }; + syncRows(); + // Board pagination and collapsed lanes can mount rows without changing the + // collection. Observe only added/removed row markers, not badge/text updates. + const mutations = new MutationObserver(records => { + if (records.some(record => [...record.addedNodes, ...record.removedNodes].some(node => + node instanceof Element && (node.matches(selector) || node.querySelector(selector)), + ))) syncRows(); + }); + mutations.observe(element, { childList: true, subtree: true }); + return () => { observer?.disconnect(); mutations.disconnect(); }; }, [companyId, root, rowIdentity, enabled]); const versions = new Map(); diff --git a/ui/src/hooks/useRunnerSubmission.ts b/ui/src/hooks/useRunnerSubmission.ts index 9d362b26cbe..33f39b1493a 100644 --- a/ui/src/hooks/useRunnerSubmission.ts +++ b/ui/src/hooks/useRunnerSubmission.ts @@ -8,12 +8,20 @@ interface Submission { attemptId: number; issueId: string | null; agentId: string; + previousAgentId: string | null; runIds: Set; clientRequestId: string; interruptionKey: string; takingLonger: boolean; } +type SubmissionAgent = Partial>; + +function agentBlocksStartup(agent: SubmissionAgent | undefined) { + return (agent?.status !== undefined && !isAgentStatusInvokable(agent.status)) || + agent?.orgChainHealth?.status === "invalid_org_chain"; +} + export function useRunnerSubmission({ issueId, agentId, @@ -28,7 +36,7 @@ export function useRunnerSubmission({ }: { issueId: string | null; agentId: string | null; - agent?: Partial>; + agent?: SubmissionAgent; blocked?: boolean; comments: readonly IssueChatComment[]; issueStatus?: string; @@ -44,9 +52,10 @@ export function useRunnerSubmission({ const interruptionKey = JSON.stringify([ interruption?.intentId, interruption?.state, interruption?.observedRunId, ]); + // A reassignment can still be saving. The previous assignee's availability + // does not decide whether the selected recipient can respond. const startupBlocked = blocked || - (agent?.status !== undefined && !isAgentStatusInvokable(agent.status)) || - agent?.orgChainHealth?.status === "invalid_org_chain"; + ((!submission || submission.agentId === agentId) && agentBlocksStartup(agent)); const savedComment = submission && comments.find(comment => comment.clientRequestId === submission.clientRequestId && !comment.clientStatus); const acknowledgedAt = savedComment ? new Date(savedComment.createdAt).getTime() : null; @@ -58,7 +67,7 @@ export function useRunnerSubmission({ )) : []; const settled = Boolean(submission && ( runId || startupBlocked || isTerminalIssueStatus(issueStatus) || - submission.agentId !== agentId || + (submission.agentId !== agentId && submission.previousAgentId !== agentId) || (submission.issueId && submission.issueId !== issueId) || (interruption && interruption.state !== "working" && interruptionKey !== submission.interruptionKey && @@ -87,15 +96,17 @@ export function useRunnerSubmission({ return () => window.clearTimeout(timeout); }, [pending, attemptId]); - const begin = useCallback((clientRequestId: string) => { - const next: Submission | null = agentId && !startupBlocked ? { - issueId, agentId, interruptionKey, clientRequestId, + const begin = useCallback((clientRequestId: string, recipient?: { agentId: string | null; agent?: SubmissionAgent }) => { + const targetAgentId = recipient ? recipient.agentId : agentId; + const targetAgent = recipient ? recipient.agent : agent; + const next: Submission | null = targetAgentId && !blocked && !agentBlocksStartup(targetAgent) ? { + issueId, agentId: targetAgentId, previousAgentId: agentId, interruptionKey, clientRequestId, attemptId: ++nextAttemptId.current, takingLonger: false, runIds: new Set(linkedRuns.map(run => run.runId)), } : null; setSubmission(next); return next?.attemptId ?? null; - }, [agentId, issueId, interruptionKey, linkedRuns, startupBlocked]); + }, [agentId, agent, blocked, issueId, interruptionKey, linkedRuns]); const reject = useCallback((attempt: number | null, error: unknown) => { setSubmission(current => { @@ -105,5 +116,5 @@ export function useRunnerSubmission({ }); }, []); - return { pending, takingLonger, begin, reject, blocked: startupBlocked }; + return { pending, agentId: pending ? submission.agentId : null, takingLonger, begin, reject, blocked: startupBlocked }; } diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 989e45e606a..4426202ae58 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -13,7 +13,7 @@ import { queryKeys } from "@/lib/queryKeys"; const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), attention: vi.fn(), breadcrumbs: vi.fn(), liveRuns: vi.fn() })); vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: mocks.liveRuns } })); -vi.mock("@/api/issues", () => ({ issuesApi: { list: mocks.list, get: mocks.get, listLabels: async () => [], update: vi.fn() } })); +vi.mock("@/api/issues", () => ({ issuesApi: { list: mocks.list, listCompact: mocks.list, get: mocks.get, listLabels: async () => [], update: vi.fn() } })); vi.mock("@/api/attention", () => ({ attentionApi: { list: mocks.attention } })); vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } })); vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } })); @@ -22,14 +22,14 @@ vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: " vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) })); vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.breadcrumbs }) })); -vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[] }) => void }; onViewChange: (view: "blocked") => void }) => <> +vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[] }) => void }; onViewChange: (view: "blocked") => void; onLayoutChange: (layout: "board") => void }) => <> + })); -vi.mock("@/components/KanbanBoard", () => ({ KanbanBoard: () => null })); vi.mock("@/components/TaskList", async importOriginal => ({ ...await importOriginal(), TaskList: ({ items, emptyState }: { items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) => @@ -45,20 +45,70 @@ describe("Coordinator tasks using the proposed table", () => { mocks.liveRuns.mockResolvedValue([]); mocks.attention.mockResolvedValue({ items: [decision, { ...decision, subject: { kind: "interaction", id: "approval" } }] }); mocks.get.mockResolvedValue(pending); - mocks.list.mockResolvedValue([createIssue({ id: "unrelated-review", companyId: "company", status: "in_review", parentId: null })]); + mocks.list.mockImplementation(async (_company, filters) => filters.q ? [] : filters.ids ? [pending] : [createIssue({ id: "unrelated-review", companyId: "company", status: "in_review", parentId: null })]); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); - afterEach(() => { flushSync(() => root.unmount()); client.clear(); host.remove(); }); + afterEach(() => { flushSync(() => root.unmount()); client.clear(); host.remove(); vi.unstubAllGlobals(); }); async function mount(url: string) { flushSync(() => root.render()); await vi.waitFor(() => expect(host.textContent).not.toContain("Loading tasks")); } + it("loads the task index without descriptions or historical diagnostics", async () => { + await mount("/issues"); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ projection: "index", limit: 200 }), expect.anything()); + expect(mocks.get).not.toHaveBeenCalled(); + }); + + it("loads diagnostics for visible production board cards and retains their next-step badges", async () => { + vi.stubGlobal("IntersectionObserver", class { + constructor(private callback: IntersectionObserverCallback) {} + observe(target: Element) { this.callback([{ target, isIntersecting: !target.closest("[hidden]") } as IntersectionObserverEntry], this as unknown as IntersectionObserver); } + unobserve() {} + disconnect() {} + }); + const index = createIssue({ id: "board-task", companyId: "company", status: "in_progress", parentId: null, listProjection: "index" }); + const details = { ...index, listProjection: "rows", successfulRunHandoff: { + required: true, hasLiveContinuation: false, state: "required", sourceRunId: "source-run", correctiveRunId: null, + assigneeAgentId: null, detectedProgressSummary: null, createdAt: null, + } }; + mocks.list.mockImplementation(async (_company, filters) => filters.projection === "rows" ? [details] : [index]); + await mount("/issues"); + flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Board view")!.click()); + await vi.waitFor(() => expect(host.querySelector('[aria-label="Needs next step"]')).not.toBeNull()); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ projection: "rows", ids: [index.id] }), expect.anything()); + }); + + it("enriches cards mounted by board pagination without replacing the collection", async () => { + const indexes = Array.from({ length: 12 }, (_, index) => createIssue({ id: `board-task-${index}`, companyId: "company", status: "in_progress", parentId: null, listProjection: "index" })); + mocks.list.mockImplementation(async (_company, filters) => filters.projection === "rows" + ? indexes.filter(issue => filters.ids.includes(issue.id)).map(issue => ({ ...issue, successfulRunHandoff: { + required: issue.id === "board-task-10", hasLiveContinuation: false, + } })) : indexes); + await mount("/issues"); + flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Board view")!.click()); + await vi.waitFor(() => expect(mocks.list.mock.calls.some(call => call[1].projection === "rows")).toBe(true)); + expect(host.querySelector('[aria-label="Needs next step"]')).toBeNull(); + flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent?.trim() === "Show 2 more")!.click()); + await vi.waitFor(() => expect(host.querySelector('[aria-label="Needs next step"]')).not.toBeNull()); + expect(mocks.list.mock.calls.filter(call => call[1].projection === "index")).toHaveLength(1); + }); + + it("batches pending task reads instead of fetching one enriched detail per decision", async () => { + const ids = Array.from({ length: 105 }, (_, index) => `task-${index}`); + mocks.attention.mockResolvedValue({ items: ids.map(id => ({ ...decision, relatedIssue: { id } })) }); + mocks.list.mockImplementation(async (_company, filters) => filters.ids.map((id: string) => createIssue({ id, companyId: "company", parentId: null }))); + await mount("/issues?view=needs_attention"); + expect(mocks.get).not.toHaveBeenCalled(); + expect(mocks.list).toHaveBeenCalledTimes(2); + expect(mocks.list.mock.calls.map(call => call[1].ids.length)).toEqual([100, 5]); + expect(mocks.list.mock.calls.every(call => call[1].projection === "index")).toBe(true); + }); it("hydrates distinct pending task IDs independently of status and the first task-list page", async () => { await mount("/issues?view=needs_attention"); expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual(["pending"]); - expect(mocks.get).toHaveBeenCalledTimes(1); - expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.get).not.toHaveBeenCalled(); + expect(mocks.list).toHaveBeenCalledExactlyOnceWith("company", expect.objectContaining({ ids: [pending.id], projection: "index" }), expect.anything()); // The same cache update used by Needs You removes the task only after its // last decision has resolved; no parallel status-based queue is involved. flushSync(() => client.setQueryData(coordinatorAttentionQueryKey("company"), { items: [decision] })); @@ -187,25 +237,23 @@ describe("Coordinator tasks using the proposed table", () => { it("retains workspace scope when switching to Needs attention", async () => { const other = createIssue({ id: "other-workspace", companyId: "company", parentId: null, executionWorkspaceId: "other-workspace" }); mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: other.id } }] }); - mocks.get.mockImplementation(async id => id === pending.id ? pending : other); + mocks.list.mockResolvedValue([pending, other]); await mount(`/issues?view=needs_attention&workspace=${pending.executionWorkspaceId}`); expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual([pending.id]); - expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [pending.id, other.id], projection: "index" }), expect.anything()); }); it("retains authoritative participant scope in Needs attention, including tasks assigned to another agent", async () => { const participated = { ...pending, assigneeAgentId: "another-assignee" }; const unrelated = createIssue({ id: "unrelated-agent", companyId: "company", parentId: null }); mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: unrelated.id } }] }); - mocks.get.mockImplementation(async id => id === pending.id ? participated : unrelated); mocks.list.mockImplementation(async (_company, filters) => filters.participantAgentId === "participant" && (!filters.ids || filters.ids[0] === pending.id) ? [participated] : []); await mount("/issues?participantAgentId=participant"); flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Needs attention")!.click()); - await vi.waitFor(() => expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [unrelated.id], participantAgentId: "participant" }), expect.anything())); + await vi.waitFor(() => expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [pending.id, unrelated.id], participantAgentId: "participant" }), expect.anything())); await vi.waitFor(() => expect(host.textContent).not.toContain("Loading tasks")); expect([...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id"))).toEqual([pending.id]); expect(mocks.get).not.toHaveBeenCalled(); - for (const id of [pending.id, unrelated.id]) expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [id], participantAgentId: "participant" }), expect.anything()); - expect(mocks.list).toHaveBeenCalledTimes(3); + expect(mocks.list).toHaveBeenCalledTimes(2); }); it.each([["active", "in_progress"], ["backlog", "backlog"], ["done", "done"]] as const)("preserves the existing %s task preset", async (view, status) => { mocks.list.mockResolvedValue([createIssue({ id: "matching", companyId: "company", parentId: null, status }), createIssue({ id: "other", companyId: "company", parentId: null, status: "blocked" })]); @@ -214,9 +262,15 @@ describe("Coordinator tasks using the proposed table", () => { expect(ids).toEqual(view === "active" ? ["matching", "other"] : ["matching"]); }); it("keeps inaccessible or failed pending tasks visible as an error, not a false all-clear", async () => { - mocks.get.mockRejectedValue(new Error("Task unavailable")); + mocks.list.mockRejectedValue(new Error("Task unavailable")); await mount("/issues?view=needs_attention"); expect(host.querySelector('[role="alert"]')?.textContent).toContain("Task unavailable"); expect(host.textContent).not.toContain("No tasks need a decision."); }); + it("does not report an all-clear when the index omits a pending task", async () => { + mocks.list.mockResolvedValue([]); + await mount("/issues?view=needs_attention"); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Some tasks with pending decisions couldn’t load."); + expect(host.textContent).not.toContain("No tasks need a decision."); + }); }); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 31f2b07be52..5864b927966 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -19,6 +19,7 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useTaskListCollection } from "@/hooks/useTaskListCollection"; +import { useIssueRowDetails } from "@/hooks/useIssueRowDetails"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { collectLiveIssueIds } from "@/lib/liveIssueIds"; import { buildCompanyUserLabelMap } from "@/lib/company-members"; @@ -29,6 +30,7 @@ import { queryKeys } from "@/lib/queryKeys"; import { useLocation, useSearchParams } from "@/lib/router"; import { taskListViewStorageKey } from "@/lib/task-list-views"; import { normalizeTaskViewKey, taskView } from "@/lib/task-views"; +import type { Issue } from "@paperclipai/shared"; const pageSize = 200; const views = [{ value: "all", label: "All tasks" }, { value: "blocked", label: "Needs attention" }] as const; @@ -41,6 +43,7 @@ export function CoordinatorTasks() { const { openNewIssue } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); const client = useQueryClient(); + const listRef = useRef(null); const location = useLocation(); const [params, setParams] = useSearchParams(); const urlAssignee = JSON.stringify(params.getAll("assignee")); @@ -69,10 +72,10 @@ export function CoordinatorTasks() { useEffect(() => { setBreadcrumbs([{ label: "Tasks" }]); }, [setBreadcrumbs]); const tasks = useInfiniteQuery({ - queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", userId, deferredQuery, participantAgentId, workspaceIds], + queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", "index", userId, deferredQuery, participantAgentId, workspaceIds], queryFn: async ({ pageParam, signal }) => { - const pages = await Promise.all((workspaceIds.length ? workspaceIds : [undefined]).map(workspaceId => issuesApi.list(companyId!, { - limit: pageSize, offset: pageParam, includeAgentCreatedTasks: true, includeRoutineExecutions: true, + const pages = await Promise.all((workspaceIds.length ? workspaceIds : [undefined]).map(workspaceId => issuesApi.listCompact(companyId!, { + projection: "index", limit: pageSize, offset: pageParam, includeAgentCreatedTasks: true, includeRoutineExecutions: true, sortField: "updated", sortDir: "desc", q: deferredQuery || undefined, participantAgentId, workspaceId, }, { signal }))); return { items: pages.flat(), hasNextPage: pages.some(page => page.length === pageSize) }; @@ -83,14 +86,22 @@ export function CoordinatorTasks() { }); const attention = useQuery({ queryKey: coordinatorAttentionQueryKey(companyId!), queryFn: () => attentionApi.list(companyId!, { all: true }), enabled: !!companyId && needsAttention }); - // Hydrate every pending task, including those outside the first list page. - // Do not substitute blocked/in-review status for an actual pending decision. + // Resolve every pending task in bounded index batches, including tasks outside + // the first list page. Keep full task enrichment out of the collection read. const attentionIds = needsAttention ? [...pendingDecisionTaskIds(attention.data?.items ?? [])] : []; - const attentionTasks = useQueries({ queries: attentionIds.map(id => ({ - queryKey: participantAgentId ? [...queryKeys.issues.list(companyId!), "coordinator-attention-participant", userId, participantAgentId, id] : queryKeys.issues.detail(id), - queryFn: async ({ signal }: { signal: AbortSignal }) => participantAgentId - ? (await issuesApi.list(companyId!, { ids: [id], participantAgentId, includeAgentCreatedTasks: true, includeRoutineExecutions: true }, { signal }))[0] ?? null - : issuesApi.get(id, { signal }), + const attentionBatches = Array.from({ length: Math.ceil(attentionIds.length / 100) }, (_, index) => attentionIds.slice(index * 100, (index + 1) * 100)); + const attentionTasks = useQueries({ queries: attentionBatches.map(ids => ({ + queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, ids], + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const rows = await issuesApi.listCompact(companyId!, { + ids, projection: "index", limit: 100, q: deferredQuery || undefined, participantAgentId, + includeAgentCreatedTasks: true, includeRoutineExecutions: true, + }, { signal }); + if (!deferredQuery && !participantAgentId && ids.some(id => !rows.some(row => row.id === id))) { + throw new Error("Some tasks with pending decisions couldn’t load."); + } + return rows; + }, enabled: !!companyId, })) }); const agents = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId }); @@ -103,7 +114,7 @@ export function CoordinatorTasks() { const users = buildCompanyUserLabelMap(directory.data?.users); const agentNames = new Map(agents.data?.map(agent => [agent.id, agent.name])); const projectById = new Map(projects.data?.map(project => [project.id, project])); - const records = needsAttention ? attentionTasks.flatMap(task => task.data ? [task.data] : []) : tasks.data?.pages.flatMap(page => page.items) ?? []; + const records = (needsAttention ? attentionTasks.flatMap(task => task.data ?? []) : tasks.data?.pages.flatMap(page => page.items) ?? []) as Issue[]; const scopedRecords = needsAttention && workspaceIds.length ? records.filter(issue => workspaceIds.includes(issue.executionWorkspaceId ?? "") || workspaceIds.includes(issue.projectWorkspaceId ?? "")) : records; const items: TaskListItem[] = [...new Map(scopedRecords.map(issue => [issue.id, issue])).values()].map(issue => ({ @@ -112,7 +123,7 @@ export function CoordinatorTasks() { assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? null, })); const collection = useTaskListCollection({ items, companyId, userId, query, onQueryChange: setQuery, view: "all", - searchMode: needsAttention ? "local" : "server", + searchMode: "server", liveIssueIds: collectLiveIssueIds(liveRuns.data, records), viewStorageKey: taskListViewStorageKey("coordinator", userId ?? "local-board"), initialFilters: { assignees: params.getAll("assignee"), statuses: initialStatuses }, @@ -162,6 +173,13 @@ export function CoordinatorTasks() { ?? (collection.filters.liveOnly ? liveRuns.error : null); const pending = (needsAttention ? attention.isPending || attentionTasks.some(task => task.isPending) : tasks.isPending) || (collection.filters.liveOnly && liveRuns.isPending); + const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( + companyId, listRef, + `${collection.visibleItems.map(item => item.issue.id).join(",")}:${[...collection.collapsedIds].join(",")}:${collection.layout}`, + !pending && !error, + records, + ); + const enrichItem = (item: TaskListItem): TaskListItem => ({ ...item, issue: enrichIssueRow(item.issue) }); const empty = ; - return
+ return
setView(view === "blocked")} query={collection.query} onQueryChange={collection.setQuery} layout={collection.layout} onLayoutChange={collection.setLayout} allExpanded={collection.allExpanded} onToggleExpanded={collection.toggleExpanded} onNewTask={() => openNewIssue()} filters={{ state: collection.filters, onChange: updateFilters, activeFilterCount: collection.activeFilterCount, agents: agents.data ?? [], projects: projects.data ?? [], labels: labels.data ?? [], currentUserId: userId, creators: collection.creators }} /> {error ?
{error instanceof Error ? error.message : "Could not load tasks"}
: null} + {rowDetailsError ?
Task details couldn’t load.
: null} {pending ?

Loading tasks…

: !error && <> {collection.layout === "board" ? collection.matchingItems.length - ? item.issue)} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} + ? enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} {!needsAttention && tasks.hasNextPage ?
{items.length} tasks loaded. Load more to include them in filters.
: null} }
; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 0f79bffee9b..cf1f8a1fcc5 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1576,6 +1576,26 @@ describe("IssueDetail", () => { expect(container.querySelector('[data-testid="task-chat-thread"]')).toBeNull(); }); + it("keeps a cold Coordinator task compact when Classic settings arrive before the workspace preference", async () => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + coordinatorPreference.loaded = false; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false, enableClassicTaskInterface: true }); + mockIssuesApi.get.mockResolvedValue(createIssue()); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled()); + await flushReact(); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + expect(mockActivityApi.forIssue).not.toHaveBeenCalled(); + expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled(); + + Object.assign(coordinatorPreference, { enabled: true, loaded: true }); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ compactHistory: true })); + expect(mockIssuesApi.recentComments).toHaveBeenCalled(); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(false); + }); + it("does not post after the account changes while comment queries cancel", async () => { mockAuthApi.getSession.mockResolvedValue({ user: { id: "account-a" } }); mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Account A retained text" })); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0bd3b0f196e..e5b8d9effcf 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3345,7 +3345,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS }); const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow( issueId, - Boolean(conversation || classicTaskInterfaceEnabled), + // Classic settings can resolve before the personal workspace preference. + // Do not persist full-history expansion for a surface we have not chosen. + Boolean(conversation || (taskInterfaceSettingsLoaded && classicTaskInterfaceEnabled)), coreIssueQuery.data, ); const issueQuery = useQuery({