fix(tasks): keep anchored opens compact and stabilize saved cards

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-28 16:02:09 -05:00
1 parent 063cfd4381
commit e6166108a5
16 files changed
+347 -39

No files matched your search

+6 -2
View File
@@ -68,12 +68,16 @@ message scroller and a normal flex footer for the composer. Navigation stays at
the layout viewport bottom, behind the keyboard.
Cold task opens reveal the instruction and recent conversation independently of
plans, related metadata, and historical run activity. Show the latest human
recovery, related metadata, and historical run activity. Saved document cards,
the saved plan, and the bounded latest turn arrive before first reveal so they
cannot insert rows into an already readable conversation. Show the latest human
follow-up, recent saved replies, and text or pending requests from the latest run.
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, and load linked older comments when requested.
on return within the session. 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
mounted. A failed history request shows an inline retry without hiding readable
content. Full-history and classic views retain the bounded 15-second initial wait.
@@ -0,0 +1,39 @@
# Stable task startup and anchors
Deliver one reviewed PR. Keep cold task opens compact for every URL fragment.
Use a disposable local task with a long thread, a saved plan, document cards,
and saved PR cards, modeled on the reported staging page with synthetic text.
| Acceptance | Evidence |
| --- | --- |
| Documents, comments, artifacts and unknown anchors keep compact history | Route tests inspect recent reads and reject full-history reads |
| Older comment links still work | Fetch only the target comment; browser checks target and recent replies |
| Saved cards do not insert after first reveal | Delayed-response browser test records frames, card counts, geometry and video; demonstrate red then green |
| Fast reads remain independent of slow task enrichment | Stall full task detail while opening the local fixture |
| Refresh and More messages preserve mounted content | Browser checks reading position and composer identity |
| Reviewable handoff | Full required checks, token/maps checks, PR template, review and CI |
The task route owns expansion and targeted comment loading. The document service
owns the saved document projection. The thread owns its first coherent reveal.
No anchor may request all comments, full activity, or historical transcripts.
No deployment is included.
Evidence so far: the baseline document/plan cards changed from 0 to 1 to 4
visible cards after reveal, and thread height grew from 690 to 1,432 pixels.
The first candidate keeps all 7 saved cards present and height constant. A
second regression showed delayed PR counts changing card heights from 62 to
82 pixels; reserving that statistics line makes both states 82 pixels.
Focused UI tests: 507 pass. Focused server tests: 16 pass. Token and map checks pass.
Final browser checks, full workspace typecheck/tests/build and hosted review are
in progress. One early candidate browser run exceeded the initial 5-second dev
readiness deadline; later runs use 10 seconds. No reliable latency gain is claimed.
The initial 503 linked-comment fixture exercised the app's global reconnect and
30-second retry policy. The target-specific failure check uses a terminal 404.
Local reproduction: http://127.0.0.1:3223/TAS/issues/TAS-1#document-investigation.
Synthetic task data only; no live provider execution. Restart with
`PAPERCLIP_HOME=/tmp/paperclip-stable-task-loading PAPERCLIP_INSTANCE_ID=task-loading PAPERCLIP_UI_DEV_MIDDLEWARE=true node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts run`.
Evidence is retained under `/tmp/task-loading-proof/` until final attachment.
State: branch `codex/stable-task-anchor-loading`; baseline `063cfd4381`.
Dependencies installed locally; the existing lockfile was stale and will not be committed.
+1 -1
View File
@@ -84,7 +84,7 @@ Gaps: Bounded native search and one-page CLI local match are not complete compan
### `read-task-and-summaries`
API: `present` — GET /api/issues/{id} (optional `view=core` omits related enrichment); GET /api/companies/{companyId}/issues/summaries?ids=... .
API: `present` — GET /api/issues/{id} (optional `view=core` includes saved document/work-product cards and omits recovery and related-task enrichment); GET /api/companies/{companyId}/issues/summaries?ids=... .
Agent access: `partial` — REST/CLI issue get; native get_task_context reads CURRENT task only; call_api for eligible arbitrary task/summaries. No dedicated summaries CLI/tool.
+1 -1
View File
@@ -46,7 +46,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
### `task-detail`
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open.
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction, recent exchange and saved cards together. No URL fragment expands history; older-comment anchors fetch only their target, and document anchors open the pane. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open.
### `task-sharing`
+14 -2
View File
@@ -9,6 +9,7 @@ import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
import { activityService } from "../services/activity.js";
import { documentService } from "../services/documents.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
@@ -37,15 +38,26 @@ const support = await getEmbeddedPostgresTestSupport();
app.use(errorHandler);
return app;
}
it("returns the core instruction without related detail and keeps company checks", async () => {
it("returns the instruction and saved card projection without related detail and keeps company checks", async () => {
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ id: issueId, description: "Read this first" });
expect(response.body).not.toHaveProperty("ancestors");
expect(response.body).not.toHaveProperty("workProducts");
expect(response.body).toMatchObject({ documentSummaries: [], planDocument: null, workProducts: [] });
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
expect([403, 404]).toContain(denied.status);
});
it("includes saved plan content and document metadata in the core read", async () => {
const documents = documentService(db);
await documents.upsertIssueDocument({ issueId, key: "plan", title: "Saved plan", format: "markdown", body: "# Stable plan" });
await documents.upsertIssueDocument({ issueId, key: "evidence", title: "Saved evidence", format: "markdown", body: "Long saved evidence".repeat(1000) });
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
expect(response.status).toBe(200);
expect(response.body.planDocument).toMatchObject({ key: "plan", body: "# Stable plan" });
expect(response.body.documentSummaries).toEqual(expect.arrayContaining([expect.objectContaining({ key: "evidence", title: "Saved evidence" })]));
expect(response.body.documentSummaries.every((document: Record<string, unknown>) => !("body" in document))).toBe(true);
expect(response.body).not.toHaveProperty("ancestors");
});
it("returns a bounded recent exchange plus the latest human instruction", async () => {
const humanId = randomUUID();
await db.insert(issueComments).values([
+8 -3
View File
@@ -9353,11 +9353,16 @@ export function issueRoutes(
);
if (!issue) return;
if (!(await timing.time("authorization", () => assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true })))) return;
// The cold-open read owns only the task row. Related objects and recovery
// projections retain their existing authorized detail path.
// Saved cards are structural conversation data. Include their local
// projection before first paint, independently of slow recovery, workspace
// and related-task enrichment. This never refreshes external providers.
if (req.query.view === "core") {
const [documentPayload, workProducts] = await Promise.all([
timing.time("documents", () => documentsSvc.getIssueDocumentPayload(issue)),
timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)),
]);
res.setHeader("Server-Timing", timing.header());
res.json(issue);
res.json({ ...issue, ...documentPayload, workProducts });
return;
}
const inboxArchiveFieldsPromise =
+1 -1
View File
@@ -4254,7 +4254,7 @@ registry.registerPath({
path: "/api/issues/{id}",
tags: ["issues"],
summary: "Get an issue",
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Core task fields without related enrichment.") }) },
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Task fields with saved document and work-product cards, without recovery or related-task enrichment and without provider refresh.") }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
@@ -125,7 +125,7 @@ export function documentService(db: Db) {
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
.then((rows) => rows[0] ?? null),
db
.select(issueDocumentSelect)
.select({ ...issueDocumentSelect, latestBody: sql<string>`''` })
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id))
+8
View File
@@ -52,6 +52,14 @@ include completed requests observed during the sample, even after content paint.
## Task layout stability
`compact-startup.spec.ts` keeps a synthetic local reproduction with 24 messages,
a plan, three documents and three saved PR cards. It holds the enriched task
response, records each visible frame's card count and geometry, and rejects
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.
`layout-stability.spec.ts` exercises the real task route, including Inbox
navigation, with 160 Markdown comments and a resolved interaction. It delays
initial comments, tests failures and retry, tracks a reading anchor across
@@ -0,0 +1,168 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(60_000);
test.use({ video: "on", trace: "retain-on-failure", serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
let fixture: { id: string; identifier: string; prefix: string; oldestCommentId: string };
const fixturePath = path.resolve("test-results/compact-startup-fixture.json");
test.beforeAll(async ({ request }) => {
try {
const saved = JSON.parse(await fs.readFile(fixturePath, "utf8"));
if ((await request.get(`/api/issues/${saved.id}`)).ok()) {
fixture = saved;
const summary = await request.put(`/api/issues/${fixture.id}/documents/continuation-summary`, { data: { title: "Continuation summary", format: "markdown", body: "# Continuation summary\n\nSaved handoff evidence." } });
expect(summary.ok()).toBeTruthy();
return;
}
} catch { /* A fresh local instance needs its own synthetic task. */ }
const company = await (await request.post("/api/companies", { data: { name: "Task loading reproduction" } })).json();
const response = await request.post(`/api/companies/${company.id}/issues`, { data: {
title: "Review the latest investigation", status: "backlog", description: "Investigate the latest reported pattern and keep the saved evidence together.",
} });
expect(response.ok(), await response.text()).toBeTruthy();
const issue = await response.json();
let oldestCommentId = "";
for (let index = 0; index < 24; index++) {
const comment = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: index === 23
? "The saved update is ready. [Read the investigation](#document-investigation). The recent reply must stay in the same place while cards load."
: `Historical investigation message ${index}. ${"Saved findings and verification details. ".repeat(5)}` } });
expect(comment.ok()).toBeTruthy();
if (!index) oldestCommentId = (await comment.json()).id;
}
for (const [key, title] of [["plan", "Investigation and verification plan"], ["investigation", "Saved investigation"], ["verification", "Verification evidence"], ["handoff", "Engineering handoff"], ["continuation-summary", "Continuation summary"]]) {
const document = await request.put(`/api/issues/${issue.id}/documents/${key}`, { data: {
title, format: "markdown", body: `# ${title}\n\nInspect the original report.\n\nVerify the saved evidence.\n\nKeep the reader's position stable.`,
} });
expect(document.ok(), await document.text()).toBeTruthy();
}
for (let index = 0; index < 3; index++) {
const product = await request.post(`/api/issues/${issue.id}/work-products`, { data: {
type: "pull_request", provider: "github", title: `Investigation fix ${index + 1}`, status: "merged",
url: `https://github.com/example/reproduction/pull/${index + 1}`, metadata: { repository: "example/reproduction", number: index + 1 },
} });
expect(product.ok(), await product.text()).toBeTruthy();
}
fixture = { id: issue.id, identifier: issue.identifier, prefix: company.issuePrefix, oldestCommentId };
await fs.mkdir(path.dirname(fixturePath), { recursive: true });
await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2));
});
async function ready(page: Page) {
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 });
}
test("saved rich cards are present at first reveal and never insert during slow enrichment", async ({ page }, info) => {
await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; });
await page.addInitScript(() => {
const frames: unknown[] = [];
Object.assign(window, { compactStartupFrames: frames });
const sample = () => {
const thread = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]');
const scroller = thread?.querySelector('[data-testid="task-chat-scroller"]');
if (scroller) {
const cards = [...scroller.querySelectorAll('[data-testid="task-chat-resource-document"], [data-testid="task-chat-plan-preview"], [data-testid="task-chat-rich-work-product-pull_request"], [data-testid="task-chat-resource-deliverable"]')];
frames.push({ time: performance.now(), height: scroller.scrollHeight, cards: cards.length, boxes: cards.map(card => {
const box = card.getBoundingClientRect(); return { top: box.top, height: box.height };
}) });
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(/\/api\/issues\/[^/?]+$/, async route => { await held; await route.continue(); });
try {
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
await ready(page);
await page.waitForTimeout(250);
release();
await expect(page.getByTestId("task-chat-resource-document")).toHaveCount(3);
await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1);
await page.waitForTimeout(500);
const frames = await page.evaluate(() => (window as unknown as { compactStartupFrames: { height: number; cards: number; boxes: { top: number; height: number }[] }[] }).compactStartupFrames);
await fs.writeFile(info.outputPath("layout.json"), JSON.stringify(frames, null, 2));
await page.screenshot({ path: info.outputPath("settled.png") });
expect(frames.length).toBeGreaterThan(5);
expect(new Set(frames.map(frame => frame.cards)).size, "Cards must arrive before the conversation becomes readable").toBe(1);
expect(Math.max(...frames.map(frame => frame.height)) - Math.min(...frames.map(frame => frame.height)), "Initial content must not grow after reveal").toBeLessThanOrEqual(2);
for (let card = 0; card < frames[0].cards; card++) {
for (const dimension of ["top", "height"] as const) {
const values = frames.map(frame => frame.boxes[card][dimension]);
expect(Math.max(...values) - Math.min(...values), `Card ${card} ${dimension} stays stable after reveal`).toBeLessThanOrEqual(2);
}
}
} finally {
await fs.writeFile(info.outputPath("readiness.json"), JSON.stringify(await page.evaluate(() => performance.getEntriesByType("mark").map(entry => ({ ...entry.toJSON(), detail: (entry as PerformanceMark).detail }))), null, 2));
release();
}
});
for (const anchor of ["document", "comment", "unknown", "continuation"]) {
test(`${anchor} anchors keep compact loading and resolve their target`, async ({ page }) => {
const requests: string[] = [];
page.on("request", request => { if (request.url().includes("/api/issues/")) requests.push(request.url()); });
const hash = anchor === "comment" ? `#comment-${fixture.oldestCommentId}` : anchor === "document" ? "#document-investigation" : anchor === "continuation" ? "#document-continuation-summary" : "#unknown";
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}${hash}`);
await ready(page);
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
if (anchor === "comment") await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
if (anchor === "document") await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
if (anchor === "continuation") await expect(page.locator('[id="side-panel-content-document:continuation-summary"]')).toBeVisible();
expect(requests.some(url => /\/comments\?/.test(url))).toBe(false);
expect(requests.some(url => /\/(activity|runs)(?:\?|$)/.test(url) && !url.includes("view=recent") && !url.includes("comment-inputs"))).toBe(false);
});
}
test("opening documents and older comments keeps the mounted composer and compact window", async ({ page }) => {
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
await ready(page);
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
await editor.fill("A draft stays here while I inspect saved evidence.");
await editor.evaluate(element => { element.setAttribute("data-mount-proof", "retained"); });
await page.getByRole("link", { name: "Read the investigation", exact: true }).click();
await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
await expect(editor).toHaveText("A draft stays here while I inspect saved evidence.");
await page.getByRole("button", { name: "More messages", exact: true }).click();
await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached();
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
});
test("a failed linked comment keeps recent messages readable and retries without expanding history", async ({ page }) => {
const targetPath = `**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`;
await page.route(targetPath, route => route.fulfill({ status: 404, contentType: "application/json", body: '{"error":"Comment not found"}' }));
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`);
await ready(page);
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
await page.unroute(targetPath);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
});
test("late provider metadata keeps saved PR card geometry stable", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route("**/api/issues/*/work-products?refreshPullRequests=true", async route => {
const response = await route.fetch();
const products = await response.json();
await held;
await route.fulfill({ response, json: products.map((product: { metadata: object }) => ({ ...product, metadata: { ...product.metadata, additions: 78, deletions: 2, filesChanged: 5 } })) });
});
try {
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
await ready(page);
const cards = page.getByTestId("task-chat-rich-work-product-pull_request");
await expect(cards).toHaveCount(3);
const before = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
release();
await expect(cards.first()).toContainText("+78");
const after = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
expect(after).toEqual(before);
} finally { release(); }
});
+2 -2
View File
@@ -651,8 +651,8 @@ export const issuesApi = {
`/issues/${id}/interactions/${interactionId}/verdicts`,
{ verdicts },
),
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
getComment: (id: string, commentId: string, options?: RequestOptions) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`, options),
listFeedbackVotes: (id: string) =>
api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
+11 -4
View File
@@ -368,14 +368,21 @@ it.each([true, false])("defers hidden content until history is ready and keeps i
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
});
it.each([true, false])("keeps cold-open messages readable while history is pending (mobile=%s)", (mobile) => {
it.each([true, false])("coordinates compact cards before first reveal and retains content during refresh (mobile=%s)", async (mobile) => {
sidebarState.isMobile = mobile;
render(<TaskChatThread issueId="cold-issue" compactHistory initialHistoryPending
comments={createLongThreadComments()} historyControl={<button>More messages</button>} onAdd={async () => {}} />);
const props = { issueId: "cold-issue", compactHistory: true, comments: createLongThreadComments(), historyControl: <button>More messages</button>, onAdd: async () => {} };
render(<TaskChatThread {...props} initialHistoryPending />);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending={false} />);
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
expect(container.textContent).toContain("Thread message 1");
expect(container.textContent).toContain("More messages");
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
const editor = container.querySelector('[data-testid="mock-editor"]');
expect(editor).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending />);
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(editor);
});
it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => {
+4 -4
View File
@@ -2978,10 +2978,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: logsAreInitiallyHydrating;
});
// Hydrating a run inserts activity around its saved reply and can move the
// latest viewport substantially in the full-history view. Cold opens reveal
// the brief and recent messages independently; neither plans nor transcripts
// may hold them behind the loading screen.
const historyPending = !props.compactHistory && (initialHistoryPending || planLoading || transcriptHistoryPending);
// latest viewport substantially. Compact opens hydrate only their bounded
// recent window, but its saved cards and latest turn must arrive together so
// the first readable frame already has its final structure.
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
const scrollNavigation = useTaskChatScrollNavigation();
useEffect(() => {
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
@@ -279,7 +279,11 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
<div className="min-w-0 flex-1">
<strong className={cn("block text-sm font-medium text-foreground", expandable ? detailsOpen ? "break-words" : "line-clamp-2 break-words" : "truncate")}>{workProduct.title}</strong>
{visibleMeta.length > 0 ? <p className="mt-1 truncate text-xs text-muted-foreground">{visibleMeta.join(" · ")}</p> : null}
{statsLabel ? <p className="mt-1 whitespace-nowrap text-xs text-muted-foreground">{statsLabel}</p> : null}
{statsLabel || workProduct.type === "pull_request" ? (
// Provider refresh can supply these counts after first paint. Keep
// the line's geometry while the saved PR card is already readable.
<p className="mt-1 min-h-4 whitespace-nowrap text-xs text-muted-foreground" aria-hidden={!statsLabel}>{statsLabel || "\u00a0"}</p>
) : null}
{!compact && summary && !linklessBranch ? <p className={cn("mt-1 text-xs text-muted-foreground", detailsOpen ? "whitespace-pre-wrap break-words" : "line-clamp-2")}>{summary}</p> : null}
{expandable && detailsOpen ? (
<div className="mt-2 flex flex-col gap-1 text-xs text-muted-foreground">
+32 -3
View File
@@ -53,6 +53,7 @@ vi.mock("../api/grokBot", () => ({ grokBotApi: mockGrokBotApi }));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
getCore: vi.fn(),
getComment: vi.fn(),
recentComments: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
@@ -1830,6 +1831,32 @@ describe("IssueDetail", () => {
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
});
it.each(["#document-qa-evidence", "#document-plan", "#document-continuation-summary", "#work-product-one", "#attachment-one", "#unknown", "#comment-old-comment"])("keeps the recent conversation for anchor %s", async (hash) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockLocation.hash = hash;
mockIssuesApi.get.mockResolvedValue(createIssue());
const recent = createIssueComment({ id: "recent-comment", body: "Latest reply" });
const older = createIssueComment({ id: "old-comment", body: "Linked older reply" });
mockIssuesApi.getComment.mockResolvedValue(older);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: 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(mockActivityApi.forIssue).not.toHaveBeenCalled();
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
if (hash.startsWith("#comment-")) {
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].comments).toEqual(expect.arrayContaining([
expect.objectContaining({ id: older.id }), expect.objectContaining({ id: recent.id }),
])));
expect(mockIssuesApi.getComment).toHaveBeenCalledWith("issue-1", older.id, expect.anything());
} else {
expect(mockIssuesApi.getComment).not.toHaveBeenCalled();
}
});
it.each([false, true])("loads older history after sending from a cold view (reassign=%s)", async (reassign) => {
queryClient.setDefaultOptions({ queries: { retry: false, staleTime: 30_000 } });
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
@@ -2377,7 +2404,7 @@ describe("IssueDetail", () => {
});
});
it("routes plan to the Plan pane tab and leaves continuation-summary on its existing surface", async () => {
it("opens plan and continuation-summary in the pane without switching to full activity", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-plan";
mockIssuesApi.get.mockResolvedValue(createIssue());
@@ -2399,11 +2426,11 @@ describe("IssueDetail", () => {
await act(async () => {
renderIssueView();
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
expect(panel?.props?.documentDeepLink).toMatchObject({ tab: "document", documentKey: "continuation-summary" });
});
});
@@ -6519,6 +6546,7 @@ describe("IssueDetail", () => {
comments: [earlier, comment], latestCommentId: comment.id, hasMore: true,
});
queryClient.setQueryData(queryKeys.issues.comments(ref), { pages: [[comment, earlier]], pageParams: [null] });
queryClient.setQueryData([...queryKeys.issues.comments(ref), "linked", comment.id], comment);
}
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1" }));
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "paperclip_runner" })]);
@@ -6555,6 +6583,7 @@ describe("IssueDetail", () => {
await waitForAssertion(() => {
for (const ref of refs) {
const recent = queryClient.getQueryData<IssueRecentComments>([...queryKeys.issues.comments(ref), "recent"]);
expect(queryClient.getQueryData([...queryKeys.issues.comments(ref), "linked", comment.id])).toEqual(action === "delete" ? deleted : null);
expect(recent?.comments).toEqual(after);
expect(recent?.latestCommentId).toBe(after.at(-1)!.id);
expect(queryClient.getQueryData<{ pages: IssueComment[][] }>(queryKeys.issues.comments(ref))?.pages.flat()).toEqual([...after].reverse());
+45 -13
View File
@@ -2625,7 +2625,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
browsers={browsers}
onOpenBrowser={onOpenBrowser}
hasOlderComments={hasOlderComments}
initialHistoryPending={historyExpanded && !!issueId && (
initialHistoryPending={!!issueId && (
initialHistoryPending ||
commentsInitialLoading ||
// These responses add rows or change the composer takeover.
@@ -2634,6 +2634,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
initialMetadataPending ||
activityPending ||
linkedRunsPending ||
inputFacts.isLoading ||
!runtimeSelectionKnown)
}
initialHistoryError={
@@ -3324,12 +3325,19 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const coreIssueQuery = useQuery({
queryKey: [...queryKeys.issues.detail(issueId!), "core"],
queryFn: ({ signal }) => issuesApi.getCore(issueId!, { signal }),
queryFn: async ({ signal }) => {
const core = await issuesApi.getCore(issueId!, { signal });
const planKey = [...queryKeys.issues.documents(core.id), "plan"];
if (core.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
queryClient.setQueryData(planKey, core.planDocument);
}
return core;
},
enabled: !!issueId,
});
const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow(
issueId,
Boolean(conversation || classicTaskInterfaceEnabled || location.hash || detailTab !== "chat"),
Boolean(conversation || classicTaskInterfaceEnabled),
coreIssueQuery.data,
);
const issueQuery = useQuery({
@@ -3432,10 +3440,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
InfiniteData<IssueComment[], string | null>
>(issueId ?? "pending"),
});
const comments = useMemo(
const linkedCommentId = location.hash.startsWith("#comment-")
? location.hash.slice("#comment-".length)
: null;
const windowComments = useMemo(
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
[commentPages?.pages, recentComments.data?.comments],
);
const linkedComment = useQuery({
queryKey: [...queryKeys.issues.comments(issueId!), "linked", linkedCommentId],
queryFn: ({ signal }) => issuesApi.getComment(issue!.id, linkedCommentId!, { signal }),
enabled: !!issue && !!linkedCommentId && !historyExpanded && !windowComments.some(comment => comment.id === linkedCommentId),
});
const comments = useMemo(
() => mergeWindowComments(linkedComment.data ? [linkedComment.data] : [], windowComments),
[linkedComment.data, windowComments],
);
useLayoutEffect(() => {
beginIssueDetailNavigation();
@@ -3461,14 +3481,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
ISSUE_DETAIL_CONTENT_MEASURE,
);
}, [classicTaskInterfaceEnabled, commentsLoading, issue?.id]);
const linkedCommentId = location.hash.startsWith("#comment-")
? location.hash.slice("#comment-".length)
: null;
const linkedCommentPending = Boolean(
linkedCommentId &&
!comments.some((comment) => comment.id === linkedCommentId) &&
!commentsError &&
(commentsLoading || hasOlderComments),
!(historyExpanded ? commentsError : linkedComment.isError) &&
(historyExpanded ? commentsLoading || hasOlderComments : linkedComment.isPending),
);
const shouldPrefetchOlderComments = useMemo(
() =>
@@ -4219,6 +4236,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<IssueComment | null>(
[...queryKeys.issues.comments(ref), "linked", commentId],
current => current ? null : current,
);
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(ref),
(current) => current && { ...current, pages: removeIssueCommentFromPages(current.pages, commentId) },
@@ -4261,6 +4282,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<IssueComment | null>(
[...queryKeys.issues.comments(ref), "linked", comment.id],
current => current ? comment : current,
);
queryClient.setQueryData<
InfiniteData<IssueComment[], string | null> | undefined
>(queryKeys.issues.comments(ref), (current) =>
@@ -6279,7 +6304,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const lastMaximizeRequestKeyRef = useRef<string | null>(null);
const routeIssueDocumentDeepLink = useCallback(
(hash: string) => {
const route = resolveIssueDocumentDeepLink(hash);
const resolvedRoute = resolveIssueDocumentDeepLink(hash);
// A handoff is a saved document too. Opening it must not switch the
// conversation to full activity just because it has a legacy hash.
const route = resolvedRoute?.kind === "continuation-summary" && taskChatShellEnabled
? { kind: "properties-pane" as const, tab: "document" as const, documentKey: "continuation-summary", maximize: false }
: resolvedRoute;
if (!route) return false;
if (route.kind === "continuation-summary") {
@@ -6626,13 +6656,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
useEffect(() => {
if (
!shouldPrefetchOlderComments &&
!(linkedCommentPending && hasOlderComments && !commentsLoadingOlder)
!(historyExpanded && linkedCommentPending && hasOlderComments && !commentsLoadingOlder)
)
return;
void fetchOlderComments();
}, [
fetchOlderComments,
shouldPrefetchOlderComments,
historyExpanded,
linkedCommentPending,
hasOlderComments,
commentsLoadingOlder,
@@ -8370,13 +8401,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
workProductsLoading
}
initialHistoryError={
(historyExpanded ? commentsError : recentComments.isError) ||
(historyExpanded ? commentsError : recentComments.isError || linkedComment.isError) ||
interactionsError ||
attachmentsError ||
workProductsError
}
onRetryInitialHistory={() => {
void (historyExpanded ? refetchComments() : recentComments.refetch());
if (linkedCommentId && !historyExpanded) void linkedComment.refetch();
void refetchInteractions();
void refetchAttachments();
void refetchWorkProducts();
@@ -8384,7 +8416,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
documents={issue.documentSummaries ?? []}
workProducts={workProducts ?? []}
workProducts={workProducts ?? issue.workProducts ?? []}
attachments={attachments ?? []}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}