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({