mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
ac4f13d205
commit
5a7a6b32ba
14 files changed
+442
-107
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>(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<void>(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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -311,6 +311,7 @@ function KanbanCard({
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
data-issue-row-details={issue.id}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
||||
@@ -574,6 +574,44 @@ it.each((["task", "legacy"] as const).flatMap(surface => [
|
||||
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<ComponentProps<typeof TaskChatThread>["onAdd"]>(() => new Promise<void>(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(<TaskChatThread {...props} issueAssigneeAgentId={previousAgentId} currentAssigneeValue={previousAgentId ? `agent:${previousAgentId}` : "user:board"} />);
|
||||
if (previousAgentId) {
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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(<TaskChatThread {...props} comments={[saved]} issueAssigneeAgentId="next-agent" currentAssigneeValue="agent:next-agent" />);
|
||||
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<HTMLButtonElement>('[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(<TaskChatThread {...props} comments={[saved]} issueStatus="in_progress" issueAssigneeAgentId="next-agent" currentAssigneeValue="agent:next-agent" activeRun={live} liveRuns={[live]} />);
|
||||
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",
|
||||
|
||||
@@ -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<typeof onAdd>) => {
|
||||
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 ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
<TaskChatShimmerScope items={tailItems}>
|
||||
<TaskChatRunnerHeader
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||||
status={optimisticRunnerStartup ? "queued" : tailStatus}
|
||||
execution={liveRun?.id === tailRunId ? liveRun.execution : null}
|
||||
preparationLabel={optimisticRunnerStartup && submissionTakingLonger ? "Taking longer than usual…" : undefined}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
continuedAfterSteering={paperclipRunnerTail && tailTimelineAnchors.length > 0}
|
||||
statusContent={tailRunSource?.adapterType !== "paperclip_runner" && !optimisticRunnerStartup ? (
|
||||
<TaskChatLiveRunPill status={tailStatus} execution={liveRun?.id === tailRunId ? liveRun.execution : null}
|
||||
startedAtMs={tailStartedAtMs} finishedAtMs={tailFinishedAtMs} toolSummary={tailToolSummary} />
|
||||
) : undefined}
|
||||
/>
|
||||
{tailRunSource?.adapterType === "paperclip_runner" ||
|
||||
optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
showHeader={false}
|
||||
runId={tailRunId}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
@@ -3266,17 +3288,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
/>
|
||||
) : (
|
||||
<TaskChatShimmerScope items={tailItems}>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
? liveRun.execution
|
||||
: null
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
@@ -3294,6 +3305,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
/>
|
||||
</TaskChatShimmerScope>
|
||||
)}
|
||||
</TaskChatShimmerScope>
|
||||
</div>
|
||||
) : null}
|
||||
{tailTaskCreationItems.map(item => (
|
||||
|
||||
@@ -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
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="flex min-h-8 min-w-0 items-center gap-2 pb-1 pt-2" data-testid="task-chat-runner-identity-row">
|
||||
{props.agentName ? <TaskChatAgentIdentity agentName={props.agentName} agentIcon={props.agentIcon} agent={props.agent} /> : null}
|
||||
{statusContent ?? <RunnerTurnStatus status={props.status} startedAtMs={props.startedAtMs} finishedAtMs={props.finishedAtMs}
|
||||
continuedAfterSteering={props.continuedAfterSteering} waitingLabel={runnerWaitingLabel(props)} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
}) {
|
||||
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}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-8 min-w-0 items-center gap-2 pb-1 pt-2"
|
||||
data-testid="task-chat-runner-identity-row"
|
||||
>
|
||||
{agentName ? (
|
||||
<TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} agent={agent} />
|
||||
) : null}
|
||||
<RunnerTurnStatus
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
continuedAfterSteering={continuedAfterSteering}
|
||||
waitingLabel={waitingLabel}
|
||||
/>
|
||||
</div>
|
||||
{showHeader ? <TaskChatRunnerHeader runId={runId} agentName={agentName} agentIcon={agentIcon} agent={agent}
|
||||
status={status} execution={execution} preparationLabel={preparationLabel} startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs} continuedAfterSteering={continuedAfterSteering} /> : null}
|
||||
{activityUnavailable ? (
|
||||
<div
|
||||
className="px-1 py-1 text-xs text-muted-foreground"
|
||||
|
||||
@@ -34,27 +34,49 @@ export function useIssueRowDetails(
|
||||
setVisibleIds([]);
|
||||
return;
|
||||
}
|
||||
const nodes = [...element.querySelectorAll<HTMLElement>("[data-issue-row-details]")];
|
||||
const visible = new Set<string>();
|
||||
const selector = "[data-issue-row-details]";
|
||||
const observed = new Set<HTMLElement>();
|
||||
const visible = new Set<HTMLElement>();
|
||||
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<HTMLElement>(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<string, number>();
|
||||
|
||||
@@ -8,12 +8,20 @@ interface Submission {
|
||||
attemptId: number;
|
||||
issueId: string | null;
|
||||
agentId: string;
|
||||
previousAgentId: string | null;
|
||||
runIds: Set<string>;
|
||||
clientRequestId: string;
|
||||
interruptionKey: string;
|
||||
takingLonger: boolean;
|
||||
}
|
||||
|
||||
type SubmissionAgent = Partial<Pick<Agent, "status" | "orgChainHealth">>;
|
||||
|
||||
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<Pick<Agent, "status" | "orgChainHealth">>;
|
||||
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 };
|
||||
}
|
||||
@@ -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 }) => <>
|
||||
<button onClick={() => filters.onChange({ liveOnly: true })}>Live runs only</button>
|
||||
<button onClick={() => filters.onChange({ priorities: ["high"] })}>High priority</button>
|
||||
<button onClick={() => filters.onChange({ assignees: [], statuses: [] })}>Clear assignee and status</button>
|
||||
<button onClick={() => filters.onChange({ assignees: ["first", "second"], statuses: ["todo"] })}>Change assignee and status</button>
|
||||
<button onClick={() => onViewChange("blocked")}>Needs attention</button>
|
||||
<button onClick={() => onLayoutChange("board")}>Board view</button>
|
||||
</> }));
|
||||
vi.mock("@/components/KanbanBoard", () => ({ KanbanBoard: () => null }));
|
||||
vi.mock("@/components/TaskList", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/components/TaskList")>(),
|
||||
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><CoordinatorTasks /></MemoryRouter></QueryClientProvider>));
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>(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 = <EmptyState icon={query || collection.activeFilterCount ? SearchX : ListChecks}
|
||||
message={query || collection.activeFilterCount ? "No tasks match this view" : needsAttention ? "No tasks need a decision." : "No tasks in this organization"}
|
||||
action={query || collection.activeFilterCount ? "Clear filters" : needsAttention ? undefined : "New Task"}
|
||||
@@ -176,19 +194,20 @@ export function CoordinatorTasks() {
|
||||
};
|
||||
|
||||
if (!companyId) return <EmptyState icon={ListChecks} message="Select an organization to view tasks." />;
|
||||
return <FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><section className="coordinator-task-list task-row-design-preview flex min-w-0 flex-col gap-8" aria-label="Tasks">
|
||||
return <FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><section ref={listRef} className="coordinator-task-list task-row-design-preview flex min-w-0 flex-col gap-8" aria-label="Tasks">
|
||||
<TaskListToolbar view={needsAttention ? "blocked" : "all"} viewOptions={views} onViewChange={view => 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 ? <div role="alert" className="flex items-center gap-2 text-sm text-destructive"><span>{error instanceof Error ? error.message : "Could not load tasks"}</span><Button variant="outline" size="sm" onClick={retry}>Retry</Button></div> : null}
|
||||
{rowDetailsError ? <div role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><span>Task details couldn’t load.</span><Button variant="ghost" size="sm" onClick={() => void retryRowDetails()}>Retry</Button></div> : null}
|
||||
{pending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : !error && <>
|
||||
<div hidden={collection.layout !== "list"} className="design-task-list-panel">{collection.matchingItems.length ? <TaskList key={companyId}
|
||||
items={needsAttention ? collection.matchingItems : collection.visibleItems} allItems={collection.companyItems}
|
||||
items={(needsAttention ? collection.matchingItems : collection.visibleItems).map(enrichItem)} allItems={collection.companyItems.map(enrichItem)}
|
||||
issueLinkState={issueLinkState} collapsedIds={collection.collapsedIds} onCollapsedIdsChange={collection.setCollapsedIds} /> : empty}</div>
|
||||
{collection.layout === "board" ? collection.matchingItems.length
|
||||
? <KanbanBoard showOverflowFades issues={collection.matchingItems.map(item => item.issue)} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null}
|
||||
? <KanbanBoard showOverflowFades issues={collection.matchingItems.map(item => enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null}
|
||||
{!needsAttention && tasks.hasNextPage ? <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground"><span>{items.length} tasks loaded. Load more to include them in filters.</span><Button variant="outline" size="sm" disabled={tasks.isFetchingNextPage} onClick={() => void tasks.fetchNextPage()}>{tasks.isFetchingNextPage ? "Loading…" : "Load more tasks"}</Button></div> : null}
|
||||
</>}
|
||||
</section></TruncatedTextMarqueeProvider></FlyoutAppearanceProvider>;
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user