perf(tasks): show recent conversation before historical activity (#17593)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task page must show the instruction and current conversation
quickly.
> - The current page waits for related task data and historical run
transcripts.
> - These reads grow with task history, even when the reader only needs
the latest answer.
> - This pull request gives cold opens a small initial view and loads
older history on demand.
> - Messages and runs observed while the page stays open remain visible.
> - The benefit is a readable task before historical diagnostics finish
loading.

## Linked Issues or Issue Description

Related: #14667 and #16229. The first changes the reveal gate and scroll
anchors. The second adds general history pagination. This PR adds a core
task read and a folded recent-conversation view. It preserves the
default full-read API contracts. The shared files will need coordination
if either related PR lands first.

**What existing behavior does this improve?**

Cold opening an ordinary task with a long conversation or large run
logs.

**Current behavior**

Task enrichment and historical transcripts delay the readable
conversation. A cold open can fetch several megabytes of tool activity
before revealing saved messages.

**Proposed behavior**

Show the task instruction, recent saved replies, latest human input, and
the latest run's conversation data first. Fetch older messages and full
run activity when the reader chooses **More messages**. Retain new
messages while the page stays open.

## What Changed

- Add an authorized core issue view that skips related enrichment. Keep
enrichment on the existing detail read. Share task mutation, rollback
and invalidation updates with the core cache.
- Add a bounded recent-comment read. Preserve the first human
instruction when no description exists, including legacy rows with a
NULL author type. Keep company and task access checks and comment
redaction.
- Add optional recent activity and run-summary views. Fetch original
input receipts for visible comments separately so delivered messages
stay out of the queue. Reconcile watched unfinished runs after a
reconnect beyond the preview window, removing deleted runs after
reconciliation.
- Filter the latest native run's initial events to assistant messages,
request lifecycles and terminal results. Full event reads remain
available on expansion. Legacy fallback reads use a small tail extended
to the first complete record. HTTP cancellation aborts the storage read.
- Retain observed messages across cursor catch-up and new runs. Apply
local cancellation and deletion to both caches and their task aliases.
Refetch locally seeded history pages on expansion. Preserve expansion
across identifier and UUID links. Keep deep links, classic tasks and
Agent Chat on their existing full-history path.
- Update API documentation, design guidance, capability maps and
regression coverage.

## Verification

- Final acceptance on `9ea0b15ae44d9f6d2c9a9f824b4835f299f92979`: all CI
gates passed, including server/UI tests, all eight browser shards,
runner checks, full typecheck and build. [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/40326990328).
GitHub reports 55 successful checks, two intentionally skipped checks,
and one neutral Paperclip Review check described below. No checks are
pending or failed; Greptile is 5/5 and no review threads remain open.
- October 26 merge update: merged current `master`. The only conflict
was in task-page imports; both the compact-history hook and the new
live-update hooks remain. All CI tests and build jobs passed on
`044fd6733d`. Fresh review found canceled-comment cache drift, legacy
human-comment filtering, send-then-expand truncation, and partial legacy
log records. These defects were fixed in `af3d66a5bd`.
- Review-fix verification: 470 tests pass across ten affected suites,
including all six PostgreSQL cold-open tests without a helper override.
Cancellation/delete and send-then-expand regression tests failed before
the fixes and pass afterward. Coverage includes task identifier/UUID
reopen, stale in-flight reads, legacy NULL author types, local-file and
object-storage tails cut inside UTF-8 text, route forwarding, and
request cancellation. Full workspace typecheck and production build,
token gates, map validation and diff checks pass.
- Follow-up review found that a failed expanded legacy read lost its
record-alignment state on retry. `9ea0b15ae4` commits the expanded
window only after a successful read. The new regression fails before the
fix and passes afterward; all 19 transcript tests, UI typecheck, UI
build and token gates pass. Greptile reviewed this commit at 5/5 with no
actionable findings, and all review threads are resolved.
- The final Paperclip Review check ended neutral because its execution
did not complete; no passing Paperclip assessment is claimed. Its
earlier four concrete findings were fixed and regression-tested.
Greptile and the security scans completed successfully on the final
commit.
- The preceding `af3d66a5bd` CI run was canceled when superseded by the
retry fix. Its server shard 11 check briefly reported failure, but the
job API records cancellation and its test step passed all 1,750 tests.
This superseded run is not used as final CI acceptance.
- 549 focused tests pass on the final implementation across nine task
UI, activity-route, OpenAPI and PostgreSQL suites. These cover pending
history, desktop/mobile visibility, expansion, retained runs, message
bursts, cancellation recovery and queued-comment removal in both views.
- Legacy expansion keeps the latest answer visible while fetching and
deduplicating older output.
- Five PostgreSQL tests pass for core access denial, recent comments,
missing-description instructions, event paging and bounded activity/run
reads. macOS could not start another PostgreSQL cluster because it
exhausted its semaphore limit. These tests used a separate disposable
database on an existing local server through a temporary test setup
override. The helper was restored afterward.
- Full workspace typecheck and build pass with the final state fixes, as
do token gates and capability-map validation.
- Token gates, capability-map validation and `git diff --check` pass.
- Browser proof used a disposable task with 80 comments and six large
run histories. The cold view showed the instruction and recent exchange.
**More messages** revealed older comments. Navigation away and back
preserved expansion.
- Local HTTP sample: core 50 ms, recent comments 24 ms, recent run
summaries 17 ms. The latest native conversation response was 839 JSON
characters / 8 ms; its full event response was 1,099,005 / 86 ms.
Browser content paint was 1.34 seconds with development assets cached.
These are local observations, not staging latency claims.
- The first CI run passed every job except four board-send browser cases
and the review threshold. Those browser fixtures only mocked the old
comments endpoint; they now serve both recent and full reads, retaining
all message and attachment assertions. Both initial review findings were
fixed with regression tests. The next CI run passed every job. The
deeper review identified core-cache mutation, older delivery receipt and
retained run-status gaps; those are now fixed with regression coverage.
Final review is 5/5 with zero unresolved findings. Final-head CI
initially failed one unchanged runner shutdown test
(`runnerd-codex-transport.test.ts`, timeout-attachment case) with
`NativeSessionCloseUnrecoverableError`; all other CI jobs passed. The
same runner code passed on 971a762f80. A targeted local reproduction
also failed during provider drain. The failure is outside the changed
task loading paths; its underlying cause remains unresolved. The single
failed-job retry passed on the same head; this demonstrates recovery,
not a root-cause fix. All CI gates passed on the previous implementation
head `9004f94f28`: [CI run and
retry](https://github.com/paperclipai/paperclip/actions/runs/40192334629).
- `pnpm test:run` was attempted and stopped after repeated embedded
PostgreSQL startup failures. The native session suite recorded 15
database-related failures; other database suites could not start. No
complete local suite pass is claimed.

## Risks

- The compact view intentionally omits older diagnostics until
expansion. Existing open questions and approvals still use the current
interaction and run paths.
- Previously observed comment and activity rows remain cached while
open. The newest preview and incoming cursor pages refresh; this does
not add a general historical-edit synchronization protocol.
- Recent views are optional. Existing full API callers retain their
response shape and behavior. No migration is required.
- A legacy tail can exceed the requested byte limit by the prefix of its
first log record. Backward reads stop at the preceding record boundary
and honor cancellation.
- Staging has not been deployed or qualified with this change. Instance
startup latency is separate from task history loading.
- The related open PRs touch the same task-history files.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository editing, shell
tools and browser testing. The exact serving model identifier and
context-window size are not exposed in this session.

## 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 — 549 affected task tests;
the full local suite and unrelated runner failure are documented 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:
DottaandPaperclip authored and GitHub committed 2026-10-26 08:11:39 -05:00
1 parent 5d2515e520
commit a1f77821b9
38 files changed
+1188 -129

No files matched your search

+10 -7
View File
@@ -63,13 +63,16 @@ reversals and Safari's edge bounce. It glides out and eases back in with shared
motion tokens; the task composer follows the same motion. Keep page padding
stable while the navigation moves, and honor reduced-motion preferences.
Task conversations reveal their initial comments, interaction cards, plan, and
relevant run history together after positioning the latest message. Keep the
mobile loading surface at a stable viewport height while that history loads;
concealed content must not stretch the document. Background refreshes keep an
already revealed conversation and composer mounted and visible.
Bound the initial wait to 15 seconds. If a request stalls, reveal the available
conversation and composer with a notice that some history is still loading.
Cold task opens reveal the instruction and recent conversation independently of
plans, related metadata, and historical run activity. 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.
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.
Only the viewer's own human comments sit on the right in blue bubbles. Agents
and other humans sit on the left on the page surface, with their own name and
+7
View File
@@ -1902,6 +1902,13 @@ Required UX behaviors:
- conflict toasts on atomic checkout failure
- no silent background failures; every failed run visible in UI
Cold opens of ordinary tasks show the instruction and recent conversation before
related metadata and historical run activity. Earlier messages and diagnostics
load through **More messages**. Messages observed while the task stays open remain
visible; deep links still load their target. Compact reads retain the full reads'
authorization and redaction rules. Agent Chat and the classic task interface keep
their existing history behavior.
Regular task chats and Agent Chat keep requests as compact, reopenable entries
at their original position in the feed. Dismissing any composer request clears
its form and pending reminder, including completion reviews, confirmations,
+46
View File
@@ -0,0 +1,46 @@
# Task cold-open performance
Branch: `codex/task-cold-open`. Owner: task detail UI and existing issue/run read services.
Endpoint: tested, reviewable PR; deployment and staging acceptance are separate.
Cold opens show the instruction and recent exchange without hydrating historical
run diagnostics. Full issue enrichment stays on its existing authorized route.
The core view skips enrichment. Recent comments retain the latest human input
and four recent replies, plus the first human instruction when no description
exists. Recent activity preserves queue/steering metadata. Recent run summaries
select the latest run; conversation event reads exclude tools and traces.
Original comment/steering facts are read separately for visible comments, with
at most two facts per comment and 100 comment IDs per request. A reconnect that
leaves a watched unfinished run outside the recent window reconciles run summaries.
Core snapshots share task-detail mutation updates, rollback and invalidation.
The browser retains messages and runs observed while open. New native runs use
the normal event path. Expansion fetches full history, preserves existing rows,
and remains expanded on return in the session. Deep links and classic/Agent Chat
retain their full-history behavior. No database migration or permissions change.
| Case | Evidence |
| --- | --- |
| Cold task | Page tests hold enrichment/history pending while recent messages render; desktop/mobile thread tests keep content and composer visible. |
| Long history | Disposable app with 80 comments and six large run histories; cold network uses recent comments, recent runs, and one conversation-events request. Expansion reveals older comments. |
| Active page | Burst catch-up tests retain 60 messages across cursor pages; new-run tests retain previously visible runs. |
| Native history | Real PostgreSQL test filters tool payloads, preserves request lifecycles and message deltas, and pages by sequence. Expansion test resets cursor without duplicates. |
| Access | Core and preview route tests reject a foreign authenticated tenant. |
| Compatibility | Existing task UI, full event reads, and full activity/run reads remain covered. |
Measurements on the disposable local fixture: core read 50 ms; recent comments
24 ms; recent run summaries 17 ms. Latest native conversation events: 839 JSON
characters / 8 ms versus full latest-run events: 1,099,005 / 86 ms. These are local
HTTP samples, not staging latency promises. Browser navigation showed the initial
content paint at 1.34 seconds, with development assets already cached.
Verification: affected UI/API tests, real PostgreSQL tests, workspace typecheck,
production build, token gates, and map checks. The full suite was attempted but
this host exhausts macOS PostgreSQL semaphores. Targeted database tests were run
against a separate disposable database on an existing local server; the temporary
test setup override was restored before typecheck/build.
Related open PRs: #14667 changes the reveal gate and scroll anchors; #16229 adds
general history pagination. This change adds a core read and a folded cold-open
projection, while preserving the default full API contracts. Coordinate those
shared files if either PR lands first.
+2 -2
View File
@@ -209,8 +209,8 @@ Live latency verification remains separate from the existing access baseline.
API: `present` — representative operation IDs:
- `GET /api/heartbeat-runs/{runId}`.
- `GET /api/heartbeat-runs/{runId}/log`.
- `GET /api/heartbeat-runs/{runId}/events`.
- `GET /api/heartbeat-runs/{runId}/log` (optional `alignStartToRecord=true` extends a tail backward to its first complete record; this can exceed `limitBytes` by that record's prefix and retains the same access/redaction checks).
- `GET /api/heartbeat-runs/{runId}/events` (optional `view=conversation` returns assistant messages and request lifecycles through the same access/redaction checks; `afterSeq` remains a sequence cursor).
- `GET /api/heartbeat-runs/{runId}/workspace-operations`.
- `GET /api/heartbeat-runs/{runId}/issues`.
- `GET /api/companies/{companyId}/heartbeat-runs`.
+5 -3
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}; GET /api/companies/{companyId}/issues/summaries?ids=... .
API: `present` — GET /api/issues/{id} (optional `view=core` omits related 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.
@@ -254,7 +254,7 @@ Read, append/retry, author-delete or queued-cancel messages, attach allowed file
### `read-history-comment`
API: `present` — GET /api/issues/{id}/comments and /comments/{commentId}.
API: `present` — GET /api/issues/{id}/comments and /comments/{commentId}; GET /api/issues/{id}/recent-comments returns four latest comments plus the latest human comment, a cursor, and `hasMore`. The compact read also includes the first human instruction when there is no description. Cold task views use `GET /api/issues/{id}/activity?view=recent` (50 rows) and `/runs?view=recent` (two summaries) without changing the default full-read contracts. It uses the same issue authorization and run/trust redaction.
Agent access: `available` — REST/call_api, CLI issue comments/comment:get; native get_task_history CURRENT task only, declared limit 1..200 but production clamps to 1..100 (default 50).
@@ -264,7 +264,9 @@ Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7
Verification: `not-run` — Read multipage history and exact comment, reload same content; reject inaccessible task, foreign/mismatched comment and inspect trust redaction. Request native limit 200 and confirm at most 100 comments.
Gaps: Native bounded history is not an arbitrary-task/paginated API substitute.
The same authorized activity route supports `view=comment-inputs&commentIds=<comma-separated UUIDs>` for original comment/steering facts. Each request accepts at most 100 comment IDs and returns at most two facts per comment, scoped to the accessible issue and company. This preserves delivered-input state when its receipt is older than the recent activity window.
Gaps: Native bounded history is not an arbitrary-task/paginated API substitute. The new UI preview route has no dedicated native tool or CLI command. Existing full comment reads remain available.
### `append-retry-comment`
+4 -2
View File
@@ -39,7 +39,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.
Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** loads earlier messages and run activity; incoming messages remain visible while the task stays open.
### `task-sharing`
@@ -87,7 +87,9 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re
### `task-detail`
Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
Manual: Open a long task with slow historical requests. Read the instruction and recent reply before those requests finish. Edit the title/status while enrichment is pending and verify saves and rollbacks. Expand **More messages**, verify scroll position and no duplicate replies, return to the task, and follow an older comment link. Leave a task open across new messages and runs and confirm none disappear. Reconnect after several turns and verify watched runs settle. Reopen after steering with more than 50 newer activity rows; the delivered message must not reappear in the queue.
Automated: [cold-open reads](../../server/src/__tests__/task-cold-open.test.ts) cover bounded reads, event filtering and company denial. [Task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
Failure markers have no dashed divider rules. An adjacent run error and
no-live-execution-path notice show only the last error, with its details and
+1
View File
@@ -1131,6 +1131,7 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueRecentComments,
IssueQueuedCommentEntry,
IssueQueuedCommentProtocol,
IssueQueuedCommentQueue,
+1
View File
@@ -732,6 +732,7 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueRecentComments,
IssueQueuedCommentEntry,
IssueQueuedCommentProtocol,
IssueQueuedCommentQueue,
+7
View File
@@ -1720,3 +1720,10 @@ export interface IssueAttachment {
openPath?: string;
downloadPath?: string;
}
/** Bounded cold-open window; the complete chronological feed stays paginated. */
export interface IssueRecentComments {
latestCommentId: string | null;
comments: IssueComment[];
hasMore: boolean;
}
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockActivityService = vi.hoisted(() => ({
list: vi.fn(),
forIssue: vi.fn(),
inputFactsForIssue: vi.fn(),
runsForIssue: vi.fn(),
issuesForRun: vi.fn(),
create: vi.fn(),
@@ -152,6 +153,22 @@ describe("activity routes", () => {
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
});
it("reads bounded input facts with the same issue access checks", async () => {
const commentId = "11111111-1111-4111-8111-111111111111";
mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1" });
mockActivityService.inputFactsForIssue.mockResolvedValue([{ action: "issue.queued_comment_steered" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get(`/api/issues/TEST-1/activity?view=comment-inputs&commentIds=${commentId}`));
expect(res.status).toBe(200);
expect(mockActivityService.inputFactsForIssue).toHaveBeenCalledWith("company-1", "issue-1", [commentId]);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
const invalid = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity?view=comment-inputs&commentIds=invalid"));
expect(invalid.status).toBe(400);
const oversized = await requestApp(app, baseUrl => request(baseUrl).get(`/api/issues/TEST-1/activity?view=comment-inputs&commentIds=${Array(101).fill(commentId).join(",")}`));
expect(oversized.status).toBe(400);
expect(mockActivityService.inputFactsForIssue).toHaveBeenCalledTimes(1);
});
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
mockIssueService.getById.mockResolvedValue(issue);
@@ -161,6 +178,9 @@ describe("activity routes", () => {
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
expect(res.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
const inputs = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity?view=comment-inputs"));
expect(inputs.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.inputFactsForIssue).not.toHaveBeenCalled();
});
it("returns redacted all-actors rows to a basic company reader", async () => {
@@ -614,10 +614,10 @@ describe("agent live run routes", () => {
});
});
it("uses narrow run log metadata lookups for log polling", async () => {
it.each([false, true])("uses narrow run log metadata lookups (aligned tail: %s)", async (aligned) => {
const res = await requestApp(await createApp(), (baseUrl) =>
request(baseUrl).get(
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log?offset=12&limitBytes=64",
`/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log?offset=12&limitBytes=64${aligned ? "&alignStartToRecord=true" : ""}`,
),
);
@@ -635,6 +635,8 @@ describe("agent live run routes", () => {
{
offset: 12,
limitBytes: 64,
signal: expect.any(AbortSignal),
...(aligned ? { alignStartToRecord: true } : {}),
},
);
expect(res.body).toEqual({
+125
View File
@@ -0,0 +1,125 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
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";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
let fixture: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
beforeAll(async () => {
fixture = await startEmbeddedPostgresTestDatabase("task-cold-open-");
db = createDb(fixture.connectionString);
await db.insert(companies).values({ id: companyId, name: "Cold open", issuePrefix: `T${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Original instruction", description: "Read this first", identifier: `T${companyId.slice(0, 6)}-1`, issueNumber: 1 });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
}, 30_000);
afterAll(async () => { await fixture?.cleanup(); });
function app(allowedCompany = companyId) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", userId: "local-board", source: allowedCompany === companyId ? "local_implicit" : "cloud_tenant", companyIds: [allowedCompany], isInstanceAdmin: allowedCompany === companyId };
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
it("returns the core instruction 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");
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
expect([403, 404]).toContain(denied.status);
});
it("returns a bounded recent exchange plus the latest human instruction", async () => {
const humanId = randomUUID();
await db.insert(issueComments).values([
{ id: humanId, companyId, issueId, body: "Human direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(0) },
...Array.from({ length: 20 }, (_, i) => ({ companyId, issueId, authorType: "agent" as const, authorAgentId: agentId, body: `Reply ${i}`, createdAt: new Date(1000 + i) })),
]);
const response = await request(app()).get(`/api/issues/${issueId}/recent-comments`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.hasMore).toBe(true);
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual(["Human direction", "Reply 19", "Reply 18", "Reply 17", "Reply 16"]);
expect(response.body.latestCommentId).toBe(response.body.comments[1].id);
expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${issueId}/recent-comments`)).status);
});
it.each(["user", null] as const)("preserves initial and latest human instructions without a description (authorType=%s)", async (authorType) => {
const bareIssueId = randomUUID();
await db.insert(issues).values({ id: bareIssueId, companyId, title: "No description" });
await db.insert(issueComments).values([
{ companyId, issueId: bareIssueId, body: "Human direction", authorType, authorUserId: "local-board", createdAt: new Date(0) },
{ companyId, issueId: bareIssueId, body: "Newest direction", authorType, authorUserId: "local-board", createdAt: new Date(50_000) },
...Array.from({ length: 6 }, (_, i) => ({ companyId, issueId: bareIssueId, body: `New reply ${i}`, authorType: "agent" as const, authorAgentId: agentId, createdAt: new Date(60_000 + i) })),
]);
const response = await request(app()).get(`/api/issues/${bareIssueId}/recent-comments`);
expect(response.status).toBe(200);
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual([
"Human direction", "Newest direction", "New reply 5", "New reply 4", "New reply 3", "New reply 2",
]);
});
it("loads the original input receipts outside the recent activity window and ignores replay receipts", async () => {
const commentId = randomUUID();
const receiptIssueId = randomUUID();
await db.insert(issues).values({ id: receiptIssueId, companyId, title: "Steering receipts" });
await db.insert(activityLog).values([
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(1), details: { commentId, targetRunId: runId } },
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(2), details: { commentId, targetRunId: runId, duplicate: true } },
...Array.from({ length: 51 }, () => ({ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.updated" })),
]);
const activity = activityService(db);
expect((await activity.forIssue(receiptIssueId, 50)).some(row => row.action === "issue.queued_comment_steered")).toBe(false);
const facts = await activity.inputFactsForIssue(companyId, receiptIssueId, [commentId]);
expect(facts).toHaveLength(1);
expect(facts[0].createdAt).toEqual(new Date(1));
expect(await activity.inputFactsForIssue(companyId, receiptIssueId, [randomUUID()])).toEqual([]);
expect(await activity.inputFactsForIssue(randomUUID(), receiptIssueId, [commentId])).toEqual([]);
expect(await activity.inputFactsForIssue(companyId, randomUUID(), [commentId])).toEqual([]);
});
it("pages conversation events without tools, including identity-less deltas and requests", async () => {
const event = (seq: number, eventType: string, payload: Record<string, unknown>, itemId?: string) => ({
companyId, runId, agentId, seq, eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType, itemId, payload } },
});
await db.insert(heartbeatRunEvents).values([
event(1, "item.started", { kind: "toolCall", arguments: "large tool arguments" }, "tool"),
event(2, "item.completed", { kind: "toolCall", output: "x".repeat(100_000) }, "tool"),
event(3, "item.started", { kind: "agentMessage" }, "message"),
event(4, "item.delta", { text: "Current progress" }, "message"),
event(5, "runtime_request.opened", { requestId: "request", prompt: "Which option?" }),
event(6, "runtime_request.resolved", { requestId: "request" }),
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
event(8, "turn.completed", {}),
]);
const service = createHeartbeatRunState(db);
const first = await service.listEvents(runId, 0, 2, true);
expect(first.map(e => e.seq)).toEqual([3, 4]);
const rest = await service.listEvents(runId, 4, 1000, true);
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]);
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
expect(await service.listEvents(runId)).toHaveLength(8);
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
const activity = activityService(db);
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
companyId, actorId: "test", action: "issue.updated", entityType: "issue", entityId: issueId,
})));
expect(await activity.forIssue(issueId, 50)).toHaveLength(50);
expect(await activity.forIssue(issueId)).toHaveLength(55);
expect(await activity.runsForIssue(companyId, issueId, null, 1)).toHaveLength(1);
expect(await activity.runsForIssue(companyId, issueId)).toHaveLength(2);
});
});
+13 -3
View File
@@ -355,7 +355,15 @@ export function activityRoutes(db: Db) {
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.forIssue(issue.id);
if (req.query.view === "comment-inputs") {
const parsed = z.array(z.string().uuid()).max(100).safeParse(
typeof req.query.commentIds === "string" && req.query.commentIds ? req.query.commentIds.split(",") : [],
);
if (!parsed.success) throw badRequest("commentIds must contain at most 100 UUIDs");
res.json(await svc.inputFactsForIssue(issue.companyId, issue.id, parsed.data));
return;
}
const result = await (req.query.view === "recent" ? svc.forIssue(issue.id, 50) : svc.forIssue(issue.id));
res.json(result);
});
@@ -364,8 +372,10 @@ export function activityRoutes(db: Db) {
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
if (!issue) return;
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.runsForIssue(issue.companyId, issue.id,
req.actor.type === "board" ? getActorInfo(req).actorId : null);
const viewerUserId = req.actor.type === "board" ? getActorInfo(req).actorId : null;
const result = await (req.query.view === "recent"
? svc.runsForIssue(issue.companyId, issue.id, viewerUserId, 2)
: svc.runsForIssue(issue.companyId, issue.id, viewerUserId));
res.json(result);
});
+20 -7
View File
@@ -7558,7 +7558,11 @@ export function agentRoutes(
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const cursor = Number.isFinite(afterSeq) ? afterSeq : 0;
const pageLimit = Number.isFinite(limit) ? limit : 200;
const events = await (req.query.view === "conversation"
? heartbeat.listEvents(runId, cursor, pageLimit, true)
: heartbeat.listEvents(runId, cursor, pageLimit));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
@@ -7578,13 +7582,22 @@ export function agentRoutes(
const offset = Number(req.query.offset ?? 0);
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
const result = await heartbeat.readLog(run, {
offset: Number.isFinite(offset) ? offset : 0,
limitBytes,
});
const controller = new AbortController();
const cancelRead = () => controller.abort();
res.once("close", cancelRead);
try {
const result = await heartbeat.readLog(run, {
offset: Number.isFinite(offset) ? offset : 0,
limitBytes,
...(req.query.alignStartToRecord === "true" ? { alignStartToRecord: true } : {}),
signal: controller.signal,
});
res.set("Cache-Control", "no-cache, no-store");
res.json(await runRedactions.redactForRun(run.companyId, run.id, result));
res.set("Cache-Control", "no-cache, no-store");
res.json(await runRedactions.redactForRun(run.companyId, run.id, result));
} finally {
res.off("close", cancelRead);
}
});
router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => {
+25
View File
@@ -9317,6 +9317,13 @@ 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.
if (req.query.view === "core") {
res.setHeader("Server-Timing", timing.header());
res.json(issue);
return;
}
const inboxArchiveFieldsPromise =
req.actor.type === "board" && req.actor.userId
? timing.time("inbox", () => svc.getActiveInboxArchiveFields(issue, req.actor.userId!))
@@ -16274,6 +16281,24 @@ export function issueRoutes(
res.json(result);
});
router.get("/issues/:id/recent-comments", async (req, res) => {
const issue = await getAccessibleResource(req, res, getIssueById(req, req.params.id as string), "Issue not found");
if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return;
const [recent, human, firstInstruction] = await Promise.all([
svc.listComments(issue.id, { order: "desc", limit: 5 }),
svc.listComments(issue.id, { order: "desc", limit: 1, humanOnly: true }),
issue.description?.trim()
? Promise.resolve([])
: svc.listComments(issue.id, { order: "asc", limit: 1, humanOnly: true }),
]);
const comments = [...new Map([...firstInstruction, ...human, ...recent.slice(0, 4)].map((comment) => [comment.id, comment])).values()];
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, {
comments,
latestCommentId: recent[0]?.id ?? null,
hasMore: recent.length > 4,
}));
});
router.get("/issues/:id/comments", async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(
+30 -6
View File
@@ -4317,7 +4317,7 @@ registry.registerPath({
path: "/api/issues/{id}",
tags: ["issues"],
summary: "Get an issue",
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("core").optional().describe("Core task fields without related enrichment.") }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
@@ -4624,6 +4624,16 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/recent-comments",
tags: ["issues"],
summary: "Read a bounded recent task exchange",
description: "Returns four recent comments, the latest human comment, and the first human instruction when the task has no description. Includes latestCommentId and hasMore. Uses the same authorization and redaction as full comment reads.",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/comments",
@@ -6080,8 +6090,11 @@ registry.registerPath({
path: "/api/issues/{id}/activity",
tags: ["activity"],
summary: "List activity for an issue",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
request: { params: z.object({ id: z.string() }), query: z.object({
view: z.enum(["recent", "comment-inputs"]).optional().describe("Recent activity (at most 50 rows), or original comment/steering facts for selected comments."),
commentIds: z.string().optional().describe("For comment-inputs: up to 100 comma-separated comment UUIDs. Returns at most two original facts per comment."),
}) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
@@ -6089,7 +6102,7 @@ registry.registerPath({
path: "/api/issues/{id}/runs",
tags: ["activity"],
summary: "List runs for an issue",
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: z.string() }), query: z.object({ view: z.literal("recent").optional().describe("At most two recent run summaries.") }) },
responses: { 200: r.ok(), 401: r.unauthorized },
});
@@ -7821,7 +7834,11 @@ registry.registerPath({
path: "/api/heartbeat-runs/{runId}/events",
tags: ["runs"],
summary: "Get events for a heartbeat run",
request: { params: z.object({ runId: heartbeatRunIdParamSchema }) },
request: { params: z.object({ runId: heartbeatRunIdParamSchema }), query: z.object({
view: z.literal("conversation").optional().describe("Assistant text and request lifecycle events, excluding historical tool activity."),
afterSeq: z.coerce.number().int().min(0).optional(),
limit: z.coerce.number().int().min(1).max(1000).optional(),
}) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
@@ -7830,7 +7847,14 @@ registry.registerPath({
path: "/api/heartbeat-runs/{runId}/log",
tags: ["runs"],
summary: "Get log for a heartbeat run",
request: { params: z.object({ runId: heartbeatRunIdParamSchema }) },
request: {
params: z.object({ runId: heartbeatRunIdParamSchema }),
query: z.object({
offset: z.coerce.number().int().min(0).optional(),
limitBytes: z.coerce.number().int().min(1).max(1024 * 1024).optional(),
alignStartToRecord: z.enum(["true", "false"]).optional().describe("Extend the tail backward to include its first complete log record. The response can exceed limitBytes by that record's prefix."),
}),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
+25 -6
View File
@@ -396,8 +396,24 @@ export function activityService(db: Db) {
.then((rows) => rows.map((r) => r.activityLog));
},
forIssue: (issueId: string) =>
db
inputFactsForIssue: async (companyId: string, issueId: string, commentIds: string[]) => {
if (!commentIds.length) return [];
const commentId = sql<string>`${activityLog.details}->>'commentId'`;
return db.selectDistinctOn([commentId, activityLog.action])
.from(activityLog)
.where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, issueId),
inArray(commentId, commentIds),
inArray(activityLog.action, ["issue.queued_comment_steered", "issue.comment_added"]),
sql`${activityLog.details}->>'duplicate' is distinct from 'true'`,
))
.orderBy(commentId, activityLog.action, asc(activityLog.createdAt), asc(activityLog.id));
},
forIssue: (issueId: string, limit?: number) => {
const query = db
.select()
.from(activityLog)
.where(
@@ -407,11 +423,13 @@ export function activityService(db: Db) {
sql`${activityLog.companyId} = (select company_id from issues where id = ${issueId})`),
),
)
.orderBy(desc(activityLog.createdAt)),
.orderBy(desc(activityLog.createdAt), desc(activityLog.id));
return limit === undefined ? query : query.limit(limit);
},
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null) => {
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number) => {
scheduleRunLivenessBackfill(companyId, issueId);
const runs = await db
const runsQuery = db
.select({
runId: heartbeatRuns.id,
runtimeMode: heartbeatRuns.runtimeMode,
@@ -473,7 +491,8 @@ export function activityService(db: Db) {
),
),
)
.orderBy(desc(heartbeatRuns.createdAt));
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
const runs = await (limit === undefined ? runsQuery : runsQuery.limit(limit));
if (runs.length === 0) return runs;
const runIds = runs.map((run) => run.runId);
+1 -1
View File
@@ -4017,7 +4017,7 @@ export function heartbeatService(
logStore: string | null;
logRef: string | null;
},
opts?: { offset?: number; limitBytes?: number },
opts?: { offset?: number; limitBytes?: number; alignStartToRecord?: boolean; signal?: AbortSignal },
) => {
const run =
typeof runOrLookup === "string"
@@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { heartbeatRunEvents } from "@paperclipai/db";
/**
* Canonical PRP conversation data, excluding historical tool activity.
* Keep request lifecycles so a cold open cannot hide a question or approval.
* Item identity may occur on started/completed rather than each text delta.
*/
export function conversationEventCondition(runId: string) {
return sql`(
${heartbeatRunEvents.eventType} like 'runtime_request.%'
or ${heartbeatRunEvents.eventType} in ('run.result.accepted', 'run.terminal', 'turn.completed', 'turn.failed', 'turn.cancelled', 'turn.interrupted')
or (
${heartbeatRunEvents.eventType} in ('item.started', 'item.delta', 'item.completed')
and (lower(replace(coalesce(${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,kind}', ${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
or coalesce(
${heartbeatRunEvents.payload} #>> '{prpEvent,itemId}',
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,itemId}',
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,item,id}',
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,id}'
) in (
select coalesce(e.payload #>> '{prpEvent,itemId}', e.payload #>> '{prpEvent,payload,itemId}', e.payload #>> '{prpEvent,payload,item,id}', e.payload #>> '{prpEvent,payload,id}')
from heartbeat_run_events e
where e.run_id = ${runId}
and e.event_type in ('item.started', 'item.delta', 'item.completed')
and lower(replace(coalesce(e.payload #>> '{prpEvent,payload,kind}', e.payload #>> '{prpEvent,payload,item,kind}', e.payload #>> '{prpEvent,payload,item,type}', ''), '_', '')) in ('agentmessage', 'assistantmessage')
))
)
)`;
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { conversationEventCondition } from "./conversation-events.js";
import { deriveTaskKey } from "./run-preparation.js";
import { retryIdempotentDatabaseOperation } from "../../database-retry.js";
import { isConversation } from "../agent-conversations.js";
@@ -1962,7 +1963,7 @@ export function createHeartbeatRunState(db: Db) {
};
},
listEvents: (runId: string, afterSeq = 0, limit = 200) =>
listEvents: (runId: string, afterSeq = 0, limit = 200, conversationOnly = false) =>
db
.select()
.from(heartbeatRunEvents)
@@ -1970,6 +1971,7 @@ export function createHeartbeatRunState(db: Db) {
and(
eq(heartbeatRunEvents.runId, runId),
gt(heartbeatRunEvents.seq, afterSeq),
conversationOnly ? conversationEventCondition(runId) : undefined,
),
)
.orderBy(asc(heartbeatRunEvents.seq))
+5
View File
@@ -12342,6 +12342,7 @@ export function issueService(db: Db) {
afterCommentId?: string | null;
order?: "asc" | "desc";
limit?: number | null;
humanOnly?: boolean;
},
) => {
const order = opts?.order === "asc" ? "asc" : "desc";
@@ -12352,6 +12353,10 @@ export function issueService(db: Db) {
: null;
const conditions = [eq(issueComments.issueId, issueId)];
if (opts?.humanOnly) conditions.push(or(
eq(issueComments.authorType, "user"),
and(isNull(issueComments.authorType), isNotNull(issueComments.authorUserId)),
)!);
if (afterCommentId) {
// Guard: reject non-UUID cursors before hitting the DB to avoid Postgres type errors.
if (!isUuidLike(afterCommentId)) return [];
+20
View File
@@ -68,6 +68,26 @@ afterEach(async () => {
const begin = { companyId: "co1", agentId: "ag1", runId: "run1" };
describe("createDurableRunLogStore", () => {
it.each([false, true])("reads a complete long final record from a compact tail (S3 fallback: %s)", async (remote) => {
const { provider, objects, calls } = createMemoryProvider();
const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } });
const handle = await store.begin(begin);
await store.append(handle, { stream: "stdout", chunk: "older tool output ".repeat(100_000), ts: "t1" });
const answer = "Complete report 中文🧪\n".repeat(8_000);
await store.append(handle, { stream: "stdout", chunk: answer, ts: "t2" });
await store.finalize(handle);
const bytes = objects.get(handle.logRef)!;
// Cut inside a multibyte character to exercise byte alignment, not just
// ASCII JSON reconstruction. The earlier large record must stay omitted.
const offset = bytes.lastIndexOf(Buffer.from("🧪"), bytes.length - 32_000) + 1;
if (remote) await fs.rm(baseDir, { recursive: true, force: true });
const result = await store.read(handle, { offset, limitBytes: bytes.length - offset, alignStartToRecord: true });
expect(result.content.trim().split("\n").map((line) => JSON.parse(line).chunk)).toEqual([answer]);
expect(result.nextOffset).toBe(bytes.length);
expect(await store.read(handle, { offset: result.nextOffset })).toMatchObject({ content: "" });
expect(calls.get).toBeLessThan(12);
});
it("keeps store id 'local_file' so downstream coupling (feedback, casts) is unchanged", async () => {
const { provider } = createMemoryProvider();
const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } });
+38 -12
View File
@@ -19,6 +19,8 @@ export interface RunLogHandle {
export interface RunLogReadOptions {
offset?: number;
limitBytes?: number;
/** Include the start of the first NDJSON record when reading a log tail. */
alignStartToRecord?: boolean;
signal?: AbortSignal;
}
@@ -27,6 +29,11 @@ export interface RunLogReadResult {
nextOffset?: number;
}
interface RunLogByteRange {
bytes: Buffer;
nextOffset?: number;
}
export interface RunLogFinalizeSummary {
// Null means a stalled write prevented a verified final snapshot. Callers
// can still settle the run without recording a false byte count or hash.
@@ -230,7 +237,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
offset: number,
limitBytes: number,
signal?: AbortSignal,
): Promise<RunLogReadResult | null> {
): Promise<RunLogByteRange | null> {
signal?.throwIfAborted();
const start = Math.max(0, offset);
// Read one extra byte to discover whether another page exists. A single
@@ -249,9 +256,8 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
}
signal?.throwIfAborted();
const bytes = Buffer.concat(chunks);
const content = bytes.subarray(0, limitBytes).toString("utf8");
const nextOffset = bytes.length > limitBytes ? start + limitBytes : undefined;
return { content, nextOffset };
return { bytes: bytes.subarray(0, limitBytes), nextOffset };
}
async function readS3Range(
@@ -259,7 +265,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
offset: number,
limitBytes: number,
signal?: AbortSignal,
): Promise<RunLogReadResult> {
): Promise<RunLogByteRange> {
signal?.throwIfAborted();
if (!s3) throw notFound("Run log not found");
const key = s3Key(logRef);
@@ -273,7 +279,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
// must short-circuit to an empty read instead of clamping end up to
// start and requesting `bytes=total-total`.
const end = Math.min(start + limitBytes - 1, total - 1);
if (total === 0 || start > end) return { content: "", nextOffset: start < total ? start : undefined };
if (total === 0 || start > end) return { bytes: Buffer.alloc(0), nextOffset: start < total ? start : undefined };
const result = await s3.provider.getObject({ objectKey: key, range: { start, end }, signal });
// Destroy a body that stalls after headers arrive, including a body
@@ -284,9 +290,8 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
signal?.throwIfAborted();
const content = Buffer.concat(chunks).toString("utf8");
const nextOffset = end + 1 < total ? end + 1 : undefined;
return { content, nextOffset };
return { bytes: Buffer.concat(chunks), nextOffset };
}
async function sha256File(filePath: string): Promise<string> {
@@ -435,12 +440,33 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
opts?.signal?.throwIfAborted();
if (handle.store !== "local_file") throw notFound("Run log not found");
const absPath = resolveWithin(basePath, handle.logRef);
const offset = opts?.offset ?? 0;
const offset = Math.max(0, opts?.offset ?? 0);
const limitBytes = opts?.limitBytes ?? 256_000;
const local = await readLocalRange(absPath, offset, limitBytes, opts?.signal);
if (local) return local;
// Local file gone (pod rolled) -> serve from the S3 mirror if configured.
return readS3Range(handle.logRef, offset, limitBytes, opts?.signal);
const readRange = async (start: number, length: number) =>
await readLocalRange(absPath, start, length, opts?.signal)
// Local file gone (pod rolled) -> serve from the S3 mirror if configured.
?? await readS3Range(handle.logRef, start, length, opts?.signal);
const result = await readRange(offset, limitBytes);
if (!opts?.alignStartToRecord || offset === 0 || result.bytes.length === 0) {
return { content: result.bytes.toString("utf8"), nextOffset: result.nextOffset };
}
// Walk back only to the preceding record boundary. Keep bytes intact
// until decoding so a tail beginning inside UTF-8 text is not corrupted.
const parts = [result.bytes];
let before = offset;
while (before > 0) {
const start = Math.max(0, before - 32_000);
const prefix = await readRange(start, before - start);
if (prefix.bytes.length !== before - start) throw new Error("Run log changed while reading its tail");
const boundary = prefix.bytes.lastIndexOf(10);
parts.unshift(prefix.bytes.subarray(boundary + 1));
if (boundary >= 0) break;
before = start;
}
return {
content: Buffer.concat(parts).toString("utf8"),
nextOffset: result.nextOffset ?? offset + result.bytes.length,
};
},
async flushInflightMirrors() {
+6 -6
View File
@@ -1042,7 +1042,7 @@ test.describe("Board send delivery refresh", () => {
);
await page.route(
new RegExp(
`/api/issues/(${issue.id}|${issue.identifier})/comments(?:\\?|$)`,
`/api/issues/(${issue.id}|${issue.identifier})/(?:recent-comments|comments)(?:\\?|$)`,
),
async (route) => {
if (
@@ -1050,9 +1050,7 @@ test.describe("Board send delivery refresh", () => {
route.request().url().includes(`/issues/${issue.identifier}/`)
)
canonicalCommentReadsAfterSend += 1;
await fulfill(
route,
sends
const comments = sends
? [
{
id: publicationId,
@@ -1066,8 +1064,10 @@ test.describe("Board send delivery refresh", () => {
updatedAt: new Date().toISOString(),
},
]
: [],
);
: [];
await fulfill(route, route.request().url().includes("/recent-comments")
? { comments, latestCommentId: comments.at(-1)?.id ?? null, hasMore: false }
: comments);
},
);
await page.route(
+10
View File
@@ -80,7 +80,17 @@ export const activityApi = {
const qs = params.toString();
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`, options);
},
recentForIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity?view=recent`),
inputFactsForIssue: async (issueId: string, commentIds: string[]) => {
const batches: Promise<ActivityEvent[]>[] = [];
for (let start = 0; start < commentIds.length; start += 100) {
const params = new URLSearchParams({ view: "comment-inputs", commentIds: commentIds.slice(start, start + 100).join(",") });
batches.push(api.get<ActivityEvent[]>(`/issues/${issueId}/activity?${params}`));
}
return (await Promise.all(batches)).flat();
},
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
recentRunsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs?view=recent`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
};
+7 -2
View File
@@ -138,12 +138,17 @@ export const heartbeatsApi = {
);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
conversationEvents: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?view=conversation&afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
options,
),
events: (runId: string, afterSeq = 0, limit = 200, options?: RequestOptions) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
options,
),
log: (runId: string, offset = 0, limitBytes = 256000, options?: RequestOptions) =>
log: (runId: string, offset = 0, limitBytes = 256000, options?: RequestOptions & { alignStartToRecord?: boolean }) =>
api.get<{
runId: string;
store: string;
@@ -151,7 +156,7 @@ export const heartbeatsApi = {
content: string;
nextOffset?: number;
}>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}${options?.alignStartToRecord ? "&alignStartToRecord=true" : ""}`,
options,
),
workspaceOperations: (runId: string) =>
+5
View File
@@ -21,6 +21,7 @@ import type {
IssueAttachment,
IssueCostSummary,
IssueComment,
IssueRecentComments,
IssueQueuedCommentQueue,
IssueDocument,
IssueLabel,
@@ -247,6 +248,8 @@ export const issuesApi = {
createLabel: (companyId: string, data: { name: string; color: string }) =>
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
getCore: (id: string, options?: RequestOptions) => api.get<Issue>(`/issues/${id}?view=core`, options),
recentComments: (id: string, options?: RequestOptions) => api.get<IssueRecentComments>(`/issues/${id}/recent-comments`, options),
get: (id: string, options?: RequestOptions) =>
options
? api.get<Issue>(`/issues/${id}`, options)
@@ -394,6 +397,7 @@ export const issuesApi = {
order?: "asc" | "desc";
limit?: number;
},
options?: RequestOptions,
) => {
const params = new URLSearchParams();
if (filters?.after) params.set("after", filters.after);
@@ -402,6 +406,7 @@ export const issuesApi = {
const qs = params.toString();
return api.get<IssueComment[]>(
`/issues/${id}/comments${qs ? `?${qs}` : ""}`,
options,
);
},
getQueuedComments: (id: string) =>
+10
View File
@@ -361,6 +361,16 @@ 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) => {
sidebarState.isMobile = mobile;
render(<TaskChatThread issueId="cold-issue" compactHistory initialHistoryPending
comments={createLongThreadComments()} historyControl={<button>More messages</button>} onAdd={async () => {}} />);
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();
});
it("defers initial history for a saved conversation with its own draft key", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
+24 -11
View File
@@ -23,6 +23,7 @@ import {
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
IssueAssigneePausedNotice,
@@ -422,6 +423,8 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean {
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
conversationMode?: boolean;
creationActivity?: ActivityEvent[];
compactHistory?: boolean;
historyControl?: ReactNode;
initialHistoryPending?: boolean;
initialHistoryError?: boolean;
onRetryInitialHistory?: () => void;
@@ -898,6 +901,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
for (const r of linkedRuns ?? []) {
map.set(r.runId, {
id: r.runId,
createdAt: r.createdAt,
status: r.status,
adapterType: r.adapterType ?? "",
runtimeMode: r.runtimeMode,
@@ -908,6 +912,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
for (const r of liveRuns ?? []) {
map.set(r.id, {
id: r.id,
createdAt: r.createdAt,
status: r.status,
adapterType: r.adapterType,
runtimeMode: r.runtimeMode,
@@ -919,6 +924,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
if (activeRun) {
map.set(activeRun.id, {
id: activeRun.id,
createdAt: activeRun.createdAt,
status: activeRun.status,
adapterType: activeRun.adapterType,
runtimeMode: activeRun.runtimeMode,
@@ -929,9 +935,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return [...map.values()];
}, [linkedRuns, liveRuns, activeRun]);
const openedAt = useMemo(() => Date.now(), [issueId]);
const nativeRuns = useMemo(
() => runs.filter((run) => run.runtimeMode === "native"),
[runs],
() => runs.filter((run) => run.runtimeMode === "native").map((run) => ({
...run,
conversationOnly: Boolean(props.compactHistory && (!run.createdAt || new Date(run.createdAt).getTime() <= openedAt)),
})),
[runs, props.compactHistory, openedAt],
);
const {
transcriptByRun: nativeTranscriptByRun,
@@ -942,6 +952,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
retry: retryNativeEvents,
} = useNativeRunTranscripts(nativeRuns);
const logRuns = useMemo(() => runs.filter((run) =>
(!nativeRuns.some((nativeRun) => nativeRun.id === run.id && nativeRun.conversationOnly) ||
nativeTranscriptErrorsByRun.has(run.id) ||
(hydratedNativeRunIds.has(run.id) && (nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)) && (
run.runtimeMode !== "native" ||
// Active runs still need the websocket log stream. Only settled native
// history can skip the legacy transport when event history is available.
@@ -949,14 +962,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
nativeTranscriptErrorsByRun.has(run.id) ||
(hydratedNativeRunIds?.has(run.id) &&
(nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)
), [runs, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
)), [runs, nativeRuns, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
const {
transcriptByRun: logTranscriptByRun,
isInitialHydrating: logsAreInitiallyHydrating,
hydratedRunIds: hydratedLogRunIds,
errorsByRun: logErrorsByRun,
retry: retryLogs,
} = useLiveRunTranscripts({ runs: logRuns, companyId });
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: 32_000 } : {}) });
const fallbackByRunRef = useRef(
new Map<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
);
@@ -2813,8 +2826,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
);
const renderBrief = useCallback(
() => issueBrief ? <TaskChatDescriptionBubble brief={issueBrief} /> : null,
[issueBrief],
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{props.historyControl}</> : null,
[issueBrief, props.historyControl],
);
const assignedAgentForNotice = useMemo(() => {
@@ -2968,10 +2981,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: logsAreInitiallyHydrating;
});
// Hydrating a run inserts activity around its saved reply and can move the
// latest viewport substantially. Wait for the initial comment window's run
// history and plan before revealing; older runs outside that window do not
// delay it, and the latch below keeps subsequent refreshes visible.
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
// 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);
const scrollNavigation = useTaskChatScrollNavigation();
useEffect(() => {
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
@@ -3124,7 +3137,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatThreadView
items={items}
attachments={attachments}
header={threadHeaderWithBlockers}
header={<>{threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}</>}
renderInteraction={renderInteraction}
renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
@@ -130,6 +130,82 @@ describe("useLiveRunTranscripts", () => {
}
});
it("expands a compact log tail without hiding or duplicating the latest output", async () => {
const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" });
const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 100_000 });
let resolveExpanded!: (value: ReturnType<typeof result>) => void;
logMock.mockResolvedValueOnce(result(`${row(2, "Latest answer")}\n`));
logMock.mockImplementationOnce(() => new Promise(resolve => { resolveExpanded = resolve; }));
function Harness({ limit, bytes = 100_000 }: { limit: number; bytes?: number }) {
useLiveRunTranscripts({
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }],
enableRealtimeUpdates: false,
logReadLimitBytes: limit,
});
return null;
}
const root = createRoot(document.createElement("div"));
const chunks = () => buildTranscriptMock.mock.calls.at(-1)?.[0];
try {
await act(async () => root.render(<Harness limit={32_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 68_000, 32_000, expect.objectContaining({ alignStartToRecord: true }));
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
await act(async () => root.render(<Harness limit={256_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 256_000, expect.anything());
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
await act(async () => resolveExpanded(result(`${row(1, "Earlier activity")}\n${row(2, "Latest answer")}\n`)));
expect(chunks()).toEqual([
expect.objectContaining({ chunk: "Earlier activity" }),
expect.objectContaining({ chunk: "Latest answer" }),
]);
await act(async () => root.render(<Harness limit={256_000} bytes={120_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 100_000, 256_000, expect.anything());
} finally {
await act(async () => root.unmount());
}
});
it("keeps record alignment pending when an expanded log read needs retry", async () => {
const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" });
const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 500_000 });
const earlier = row(1, "Earlier activity");
const recent = row(2, "Latest answer");
logMock.mockResolvedValueOnce(result(`${recent}\n`));
logMock.mockRejectedValueOnce(new Error("Temporary storage failure"));
logMock.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[3] as { alignStartToRecord?: boolean };
return result(`${options.alignStartToRecord ? earlier : earlier.slice(15)}\n${recent}\n`);
});
let latest!: ReturnType<typeof useLiveRunTranscripts>;
function Harness({ limit, bytes = 500_000 }: { limit: number; bytes?: number }) {
latest = useLiveRunTranscripts({
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }],
enableRealtimeUpdates: false,
logReadLimitBytes: limit,
});
return null;
}
const root = createRoot(document.createElement("div"));
const chunks = () => buildTranscriptMock.mock.calls.at(-1)?.[0];
try {
await act(async () => root.render(<Harness limit={32_000} />));
await act(async () => root.render(<Harness limit={256_000} />));
expect(latest.errorsByRun.get("run-1")?.message).toBe("Temporary storage failure");
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
await act(async () => latest.retry());
expect(chunks()).toEqual([
expect.objectContaining({ chunk: "Earlier activity" }),
expect.objectContaining({ chunk: "Latest answer" }),
]);
expect(logMock).toHaveBeenLastCalledWith("run-1", 244_000, 256_000, expect.objectContaining({ alignStartToRecord: true }));
expect(latest.errorsByRun.size).toBe(0);
await act(async () => root.render(<Harness limit={256_000} bytes={520_000} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 500_000, 256_000, expect.not.objectContaining({ alignStartToRecord: true }));
} finally {
await act(async () => root.unmount());
}
});
it("waits for a connecting socket to open before closing it during cleanup", async () => {
function Harness() {
useLiveRunTranscripts({
@@ -46,6 +46,7 @@ const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
export interface RunTranscriptSource {
createdAt?: string | Date;
id: string;
status: string;
adapterType: string;
@@ -142,6 +143,7 @@ export function useLiveRunTranscripts({
const trimmedSeqFloorByRunRef = useRef(new Map<string, number>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const logReadLimitByRunRef = useRef(new Map<string, number>());
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
// PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
// wall-clock deadline (ms) after which their buffer may be pruned. A run still
@@ -268,6 +270,7 @@ export function useLiveRunTranscripts({
for (const runId of logOffsetByRunRef.current.keys()) {
if (!retainedRunIds.has(runId)) {
logOffsetByRunRef.current.delete(runId);
logReadLimitByRunRef.current.delete(runId);
}
}
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
@@ -311,14 +314,37 @@ export function useLiveRunTranscripts({
return;
}
inFlightRunIds.add(run.id);
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
let alignStartToRecord = !logOffsetByRunRef.current.has(run.id);
let offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
let rewinding = false;
const previousReadLimit = logReadLimitByRunRef.current.get(run.id);
if (previousReadLimit !== undefined && logReadLimitBytes > previousReadLimit) {
// Expanding a compact tail must reread the earlier range. Retain the
// visible chunks; merging by sequence deduplicates the overlapping tail.
const expandedOffset = resolveInitialLogOffset(run, logReadLimitBytes);
if (expandedOffset < offset) {
alignStartToRecord = true;
offset = expandedOffset;
rewinding = true;
}
}
try {
const result = await readTranscriptRequest(
(signal) => heartbeatsApi.log(run.id, offset, logReadLimitBytes, { signal }),
(signal) => heartbeatsApi.log(run.id, offset, logReadLimitBytes, {
signal,
...(alignStartToRecord && offset > 0 ? { alignStartToRecord: true } : {}),
}),
controller.signal,
);
if (cancelled) return;
// Commit the expanded window only after success so retries still align
// its first record and failed reads leave the previous parser intact.
logReadLimitByRunRef.current.set(run.id, logReadLimitBytes);
if (rewinding) {
pendingLogRowsByRunRef.current.delete(run.id);
pendingLogRowsByRunRef.current.delete(`${run.id}:records`);
}
setErrorsByRun((previous) => {
if (!previous.has(run.id)) return previous;
const next = new Map(previous);
@@ -7,9 +7,10 @@ import { useNativeRunTranscripts } from "./useNativeRunTranscripts";
import { TRANSCRIPT_REQUEST_TIMEOUT_MS } from "./read-transcript-request";
const eventsMock = vi.hoisted(() => vi.fn());
const conversationEventsMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/heartbeats", () => ({
heartbeatsApi: { events: eventsMock },
heartbeatsApi: { events: eventsMock, conversationEvents: conversationEventsMock },
}));
function Probe() {
@@ -49,6 +50,29 @@ describe("useNativeRunTranscripts", () => {
vi.useRealTimers();
});
it("expands from sequence zero without duplicating the preview or withholding new runs", async () => {
let state: ReturnType<typeof useNativeRunTranscripts>;
const event = (seq: number, text: string) => ({
id: `event-${seq}`, runId: "old", companyId: "company", seq, eventType: "item.completed", createdAt: new Date().toISOString(),
payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType: "item.completed", runId: "old", schemaVersion: 1, emittedAt: new Date().toISOString(), itemId: `item-${seq}`, payload: { kind: "agentMessage", text } } },
});
conversationEventsMock.mockResolvedValue([event(3, "Latest answer")]);
eventsMock.mockImplementation((id) => Promise.resolve(id === "old" ? [event(1, "Earlier answer"), event(3, "Latest answer")] : []));
function Window({ compact }: { compact: boolean }) {
state = useNativeRunTranscripts([
{ id: "old", runtimeMode: "native", status: "succeeded", conversationOnly: compact },
{ id: "new", runtimeMode: "native", status: "succeeded" },
]);
return null;
}
await act(async () => { root.render(<Window compact />); });
expect(conversationEventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object));
expect(eventsMock).toHaveBeenCalledWith("new", 0, 1000, expect.any(Object));
await act(async () => { root.render(<Window compact={false} />); });
expect(eventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object));
expect(state!.transcriptByRun.get("old")?.filter((entry) => entry.kind === "assistant").map((entry) => entry.text)).toEqual(["Earlier answer", "Latest answer"]);
});
it("exposes event transport failures and retries terminal runs until recovery", async () => {
eventsMock
.mockRejectedValueOnce(new Error("event endpoint unavailable"))
@@ -11,6 +11,7 @@ const EVENT_POLL_INTERVAL_MS = 2_000;
export interface NativeRunTranscriptSource {
id: string;
status: string;
conversationOnly?: boolean;
runtimeMode?: "legacy" | "native";
}
@@ -26,7 +27,7 @@ function isLive(status: string): boolean {
export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource[]) {
const nativeRunsKey = runs
.filter((run) => run.runtimeMode === "native")
.map((run) => `${run.id}:${run.status}`)
.map((run) => `${run.id}:${run.status}:${Boolean(run.conversationOnly)}`)
.sort()
.join(",");
const nativeRuns = useMemo(
@@ -43,8 +44,14 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
const retry = useCallback(() => setRetryGeneration((value) => value + 1), []);
const projectionCacheRef = useRef(new Map<string, { events: HeartbeatRunEvent[]; transcript: TranscriptEntry[] }>());
const cursorByRunRef = useRef(new Map<string, number>());
const conversationOnlyByRunRef = useRef(new Map<string, boolean>());
useEffect(() => {
for (const run of nativeRuns) {
const mode = Boolean(run.conversationOnly);
if (conversationOnlyByRunRef.current.get(run.id) !== mode) cursorByRunRef.current.delete(run.id);
conversationOnlyByRunRef.current.set(run.id, mode);
}
let cancelled = false;
const controller = new AbortController();
const timers = new Set<number>();
@@ -71,7 +78,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
const incoming: HeartbeatRunEvent[] = [];
for (;;) {
const page = await readTranscriptRequest(
(signal) => heartbeatsApi.events(run.id, cursor, EVENT_PAGE_SIZE, { signal }),
(signal) => (run.conversationOnly ? heartbeatsApi.conversationEvents : heartbeatsApi.events)(run.id, cursor, EVENT_PAGE_SIZE, { signal }),
controller.signal,
);
if (cancelled) return;
@@ -89,7 +96,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource
cursorByRunRef.current.set(run.id, cursor);
if (incoming.length > 0) setEventsByRun((previous) => {
const next = new Map(previous);
next.set(run.id, [...(previous.get(run.id) ?? []), ...incoming]);
next.set(run.id, [...new Map([...(previous.get(run.id) ?? []), ...incoming].map((event) => [event.seq, event])).values()].sort((a, b) => a.seq - b.seq));
return next;
});
setErrorsByRun((previous) => {
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, it, vi } from "vitest";
import type { IssueComment } from "@paperclipai/shared";
import { useIssueHistoryWindow } from "./useIssueHistoryWindow";
import { queryKeys } from "@/lib/queryKeys";
const api = vi.hoisted(() => ({ recentComments: vi.fn(), listComments: vi.fn() }));
vi.mock("@/api/issues", () => ({ issuesApi: api }));
it.each([true, false])("retains a burst larger than the preview window (initially empty: %s)", async (empty) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const container = document.createElement("div");
const root = createRoot(container);
const comment = (n: number) => ({ id: `comment-${n}`, createdAt: new Date(n * 1000), body: `Message ${n}` } as IssueComment);
let current: ReturnType<typeof useIssueHistoryWindow>;
function Probe() { current = useIssueHistoryWindow("one", false); return <span>{current.recent.data?.comments.length}</span>; }
api.recentComments.mockResolvedValue({ comments: empty ? [] : [comment(0)], latestCommentId: empty ? null : "comment-0", hasMore: !empty });
api.listComments.mockReset();
const wait = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
try {
await act(async () => { root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>); });
for (let i = 0; i < 20 && !current!.recent.isSuccess; i++) await wait();
expect(current!.recent.isSuccess).toBe(true);
api.recentComments.mockResolvedValue({ comments: [comment(58),comment(59),comment(60)], latestCommentId: "comment-60", hasMore: true });
api.listComments.mockResolvedValueOnce(Array.from({length:50},(_,i)=>comment(i+1))).mockResolvedValueOnce(Array.from({length:10},(_,i)=>comment(i+51)));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.comments("one") }); });
for (let i = 0; i < 20 && current!.recent.data?.latestCommentId !== "comment-60"; i++) await wait();
expect(current!.recent.error).toBeNull();
expect(current!.recent.data?.comments).toHaveLength(empty ? 60 : 61);
expect(current!.recent.data?.hasMore).toBe(!empty);
expect(api.listComments).toHaveBeenCalledTimes(2);
expect(new Set(current!.recent.data?.comments.map(c=>c.id)).size).toBe(empty ? 60 : 61);
} finally { await act(async () => root.unmount()); client.clear(); }
});
it.each([true, false])("catches up after canceled input and a reply burst (earlier anchor survives: %s)", async (earlierAnchorSurvives) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const root = createRoot(document.createElement("div"));
const comment = (n: number) => ({ id: `comment-${n}`, createdAt: new Date(n * 1000), body: `Message ${n}` } as IssueComment);
let current: ReturnType<typeof useIssueHistoryWindow>;
function Probe() {
current = useIssueHistoryWindow("one", false);
return <span>{current.recent.data?.comments.length}</span>;
}
api.recentComments.mockResolvedValue({ comments: [comment(0), comment(1)], latestCommentId: "comment-1", hasMore: true });
api.listComments.mockReset();
const wait = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
try {
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
for (let i = 0; i < 20 && !current!.recent.isSuccess; i++) await wait();
expect(current!.recent.isSuccess).toBe(true);
api.recentComments.mockResolvedValue({ comments: [58, 59, 60, 61].map(comment), latestCommentId: "comment-61", hasMore: true });
api.listComments.mockImplementation(async (_issue, options) => {
if (options.after === "comment-1") return []; // Canceled queued input.
if (options.after === "comment-0" && !earlierAnchorSurvives) return [];
const start = options.after === "comment-51" ? 52 : 2;
return Array.from({ length: Math.min(50, 62 - start) }, (_, i) => comment(start + i));
});
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.comments("one") }); });
for (let i = 0; i < 20 && current!.recent.data?.latestCommentId !== "comment-61"; i++) await wait();
expect(current!.recent.error).toBeNull();
expect(current!.recent.data?.comments.filter(c => Number(c.id.split("-")[1]) >= 2).map(c => c.id))
.toEqual(Array.from({ length: 60 }, (_, i) => `comment-${i + 2}`));
expect(current!.recent.data?.latestCommentId).toBe("comment-61");
expect(api.listComments).toHaveBeenCalledTimes(earlierAnchorSurvives ? 3 : 4);
} finally {
await act(async () => root.unmount());
client.clear();
}
});
+89
View File
@@ -0,0 +1,89 @@
import { useCallback, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue, IssueComment, IssueRecentComments } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
export function mergeWindowComments(...windows: readonly IssueComment[][]): IssueComment[] {
return [...new Map(windows.flat().map((comment) => [comment.id, comment])).values()]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id.localeCompare(b.id));
}
/** Freeze the cold-open window, then retain every message observed while open. */
export function useIssueHistoryWindow(
issueRef: string | undefined,
showFullHistory: boolean,
issue?: Pick<Issue, "id" | "identifier">,
) {
const queryClient = useQueryClient();
const expansionKey = ["issues", "history-expanded", issueRef] as const;
const { data: expanded = false } = useQuery({
queryKey: expansionKey,
queryFn: () => false,
enabled: false,
initialData: false,
gcTime: Infinity,
});
const historyExpanded = showFullHistory || expanded;
const expandHistory = useCallback(() => {
for (const ref of new Set([issueRef, issue?.id, issue?.identifier])) {
if (ref) queryClient.setQueryData(["issues", "history-expanded", ref], true);
}
}, [issueRef, issue?.id, issue?.identifier, queryClient]);
useEffect(() => {
// Keep identifier and UUID navigation on the same expansion preference.
if (showFullHistory || expanded) expandHistory();
}, [showFullHistory, expanded, expandHistory]);
const recentKey = [...queryKeys.issues.comments(issueRef ?? ""), "recent"] as const;
const recent = useQuery({
queryKey: recentKey,
enabled: Boolean(issueRef) && !historyExpanded,
queryFn: async ({ signal }): Promise<IssueRecentComments> => {
const previous = queryClient.getQueryData<IssueRecentComments>(recentKey);
const snapshot = await issuesApi.recentComments(issueRef!, { signal });
if (!previous) return snapshot;
// A burst can contain more than the cold-open window. Catch up from the
// saved cursor instead of dropping messages between two invalidations.
let cursor = previous.latestCommentId;
const incoming: IssueComment[] = [];
// Canceling queued input deletes its comment, including a saved cursor.
// Retry from an earlier observed row, then from the start if that row
// was also deleted. Never advance to the preview past unread replies.
const recoveryAnchors = [
previous.comments.findLast((comment) => comment.id !== cursor)?.id ?? null,
null,
];
while (cursor !== snapshot.latestCommentId) {
const page = await issuesApi.listComments(issueRef!, {
order: "asc",
...(cursor ? { after: cursor } : {}),
limit: 50,
}, { signal });
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
if (!page.length) {
if (cursor && recoveryAnchors.length) {
cursor = recoveryAnchors.shift() ?? null;
continue;
}
break;
}
incoming.push(...page);
const next = page.at(-1)!.id;
if (next === cursor) break;
cursor = next;
if (page.length < 50 || page.some((comment) => comment.id === snapshot.latestCommentId)) break;
}
return {
...snapshot,
hasMore: previous.hasMore,
latestCommentId: incoming.at(-1)?.id ?? snapshot.latestCommentId,
comments: mergeWindowComments(previous.comments, incoming, snapshot.comments),
};
},
});
return {
historyExpanded,
expandHistory,
recent,
};
}
+3
View File
@@ -84,6 +84,9 @@ export function getCachedIssueDetail(
const cachedEntries = queryClient.getQueriesData<Issue>({ queryKey: ISSUE_DETAIL_QUERY_PREFIX });
return cachedEntries
// Core reads share invalidation/mutation updates with detail, but cannot
// stand in for its related data or suppress the enrichment request.
.filter(([key]) => key.length === ISSUE_DETAIL_QUERY_PREFIX.length + 1)
.map(([, cachedIssue]) => cachedIssue)
.find((cachedIssue): cachedIssue is Issue =>
isCompleteIssueSnapshot(cachedIssue) && matchesIssueRef(cachedIssue, refs)
+223 -1
View File
@@ -10,6 +10,7 @@ import type {
Issue,
IssueAttachment,
IssueComment,
IssueRecentComments,
IssueQueuedCommentQueue,
RequestConfirmationInteraction,
IssueTreeControlPreview,
@@ -51,6 +52,8 @@ vi.mock("../api/grokBot", () => ({ grokBotApi: mockGrokBotApi }));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
getCore: vi.fn(),
recentComments: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
@@ -78,6 +81,7 @@ const mockIssuesApi = vi.hoisted(() => ({
unarchiveFromInbox: vi.fn(),
addComment: vi.fn(),
cancelComment: vi.fn(),
deleteComment: vi.fn(),
upsertFeedbackVote: vi.fn(),
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
@@ -88,7 +92,10 @@ const mockIssuesApi = vi.hoisted(() => ({
const mockActivityApi = vi.hoisted(() => ({
forIssue: vi.fn(),
recentForIssue: vi.fn(),
inputFactsForIssue: vi.fn(),
runsForIssue: vi.fn(),
recentRunsForIssue: vi.fn(),
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
@@ -1329,6 +1336,12 @@ describe("IssueDetail", () => {
mutations: { retry: false },
},
});
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], true);
mockIssuesApi.getCore.mockImplementation((id) => mockIssuesApi.get(id));
mockIssuesApi.recentComments.mockResolvedValue({ comments: [], latestCommentId: null, hasMore: false });
mockActivityApi.recentRunsForIssue.mockResolvedValue([]);
mockActivityApi.recentForIssue.mockResolvedValue([]);
mockActivityApi.inputFactsForIssue.mockResolvedValue([]);
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
vi.spyOn(globalThis, "fetch").mockResolvedValue({
@@ -1671,6 +1684,147 @@ describe("IssueDetail", () => {
expect(windowOpen).not.toHaveBeenCalled();
});
it("reveals a cold task and recent exchange while full detail and older history are stalled", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
const stalled = createDeferred<Issue>();
mockIssuesApi.getCore.mockResolvedValue(createIssue({ description: "Original instruction" }));
mockIssuesApi.get.mockReturnValue(stalled.promise);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [createIssueComment({ body: "Latest answer" })], latestCommentId: "comment-1", hasMore: true });
await act(async () => { renderIssueView(); });
await waitForAssertion(() => {
expect(container.textContent).toContain("Issue detail smoke");
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
compactHistory: true, initialHistoryPending: false,
comments: [expect.objectContaining({ body: "Latest answer" })],
});
});
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled();
const disclosure = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].historyControl;
await act(async () => { disclosure.props.onClick(); });
await waitForAssertion(() => {
expect(mockIssuesApi.listComments).toHaveBeenCalled();
expect(mockActivityApi.runsForIssue).toHaveBeenCalled();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].compactHistory).toBe(false);
});
expect(queryClient.getQueryData(["issues", "history-expanded", "PAP-1"])).toBe(true);
});
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);
const issue = createIssue();
const old = createIssueComment({ id: "old-comment", body: "Earlier instruction" });
const recent = createIssueComment({ id: "recent-comment", body: "Latest answer" });
const sent = createIssueComment({ id: "sent-comment", body: "Follow up" });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [recent], latestCommentId: recent.id, hasMore: true });
mockIssuesApi.addComment.mockResolvedValue(sent);
mockIssuesApi.update.mockResolvedValue({ ...issue, comment: sent });
mockIssuesApi.listComments.mockResolvedValue([sent, recent, old]);
await act(async () => renderIssueView());
const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0];
await waitForAssertion(() => expect(props().comments).toEqual([expect.objectContaining({ id: recent.id })]));
await act(async () => props().onAdd(sent.body, false, reassign ? { assigneeAgentId: "agent-1", assigneeUserId: null } : undefined));
await waitForAssertion(() => expect(props().comments.some((comment: IssueComment) => comment.id === sent.id)).toBe(true));
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
await act(async () => props().historyControl.props.onClick());
await waitForAssertion(() => {
expect(mockIssuesApi.listComments).toHaveBeenCalled();
expect(props().comments.some((comment: IssueComment) => comment.id === old.id)).toBe(true);
});
});
it.each(["success", "failure"])("updates and rolls back core-only task edits on %s", async (outcome) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
const issue = createIssue();
const pendingSave = createDeferred<Issue>();
mockIssuesApi.getCore.mockResolvedValue(issue);
mockIssuesApi.get.mockReturnValue(new Promise(() => {}));
mockIssuesApi.update.mockReturnValue(pendingSave.promise);
await act(async () => renderIssueView());
let properties!: { onUpdate: (data: Record<string, unknown>) => void };
await waitForAssertion(() => {
properties = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props;
expect(properties?.onUpdate).toBeTypeOf("function");
});
await act(async () => properties.onUpdate({ title: "Edited before enrichment", status: "in_progress" }));
await waitForAssertion(() => expect(queryClient.getQueryData<Issue>([...queryKeys.issues.detail("PAP-1"), "core"])?.status).toBe("in_progress"));
if (outcome === "success") {
const saved = { ...issue, title: "Saved before enrichment", status: "in_progress" as const };
mockIssuesApi.getCore.mockResolvedValue(saved);
await act(async () => pendingSave.resolve(saved));
} else {
await act(async () => pendingSave.reject(new Error("Save failed")));
}
await waitForAssertion(() => {
expect(container.textContent).toContain(outcome === "success" ? "Saved before enrichment" : issue.title);
expect(queryClient.getQueryData<Issue>([...queryKeys.issues.detail("PAP-1"), "core"])?.status).toBe(outcome === "success" ? "in_progress" : issue.status);
});
expect(queryClient.getQueryData(queryKeys.issues.detail("PAP-1"))).toBeUndefined();
});
it.each(["settled", "deleted"])("reconciles %s watched runs outside the preview after reconnecting", async (outcome) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const run = (runId: string, status = "succeeded") => ({ runId, status, agentId: "agent-1", createdAt: new Date().toISOString() });
mockActivityApi.recentRunsForIssue.mockResolvedValue([run("watched", "running")]);
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toEqual([expect.objectContaining({ runId: "watched", status: "running" })]));
mockActivityApi.recentRunsForIssue.mockResolvedValue([run("newest"), run("middle")]);
mockActivityApi.runsForIssue.mockClear().mockResolvedValue([run("newest"), run("middle"), ...(outcome === "settled" ? [run("watched")] : [])]);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toEqual([
...(outcome === "settled" ? [expect.objectContaining({ runId: "watched", status: "succeeded" })] : []),
expect.objectContaining({ runId: "newest", status: "succeeded" }),
]));
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
expect(mockActivityApi.runsForIssue).toHaveBeenCalledTimes(1);
});
it("keeps a delivered message out of the queue when its receipt is older than recent activity", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
const comment = createQueuedCommentQueue().entries[0].comment;
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1", executionRunId: "run-active-1" }));
mockIssuesApi.recentComments.mockResolvedValue({ comments: [comment], latestCommentId: comment.id, hasMore: true });
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "paperclip_runner" })]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
id: "run-active-1", runtimeMode: "native", status: "running", agentId: "agent-1",
adapterType: "paperclip_runner", startedAt: "2026-04-21T00:00:00.000Z", createdAt: "2026-04-21T00:00:00.000Z",
});
mockActivityApi.inputFactsForIssue.mockResolvedValue([{
id: "old-receipt", action: "issue.queued_comment_steered", createdAt: "2026-04-21T00:00:06.000Z",
details: { commentId: comment.id, targetRunId: "run-active-1", duplicate: false },
}]);
await act(async () => renderIssueView());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props.queuedCommentQueue).toBeNull();
expect(props.comments).toContainEqual(expect.objectContaining({ id: comment.id, steeredIntoRunId: "run-active-1" }));
});
expect(mockActivityApi.inputFactsForIssue).toHaveBeenCalledWith("PAP-1", [comment.id]);
expect(mockActivityApi.forIssue).not.toHaveBeenCalled();
});
it("retains every recent run observed during an open task", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());
const run = (id: string) => ({ runId: id, agentId: "agent-1", status: "succeeded", runtimeMode: "native", createdAt: new Date().toISOString() });
mockActivityApi.recentRunsForIssue.mockResolvedValue([run("latest"), run("older")]);
await act(async () => { renderIssueView(); });
await waitForAssertion(() => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns.map((r: { runId: string }) => r.runId)).toEqual(["latest"]);
});
for (const id of ["next", "newest"]) {
mockActivityApi.recentRunsForIssue.mockResolvedValue([run(id)]);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); });
}
await waitForAssertion(() => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns.map((r: { runId: string }) => r.runId)).toEqual(["latest", "next", "newest"]);
});
});
it.each(["comments", "description", "empty"])("coordinates %s with the initial cards and supporting history", async (content) => {
const history = createDeferred<[]>();
mockIssuesApi.get.mockResolvedValue(createIssue({
@@ -6115,7 +6269,8 @@ describe("IssueDetail", () => {
});
});
it("keeps a discarded queued comment out of the thread when the queue becomes empty", async () => {
it.each([true, false])("keeps a discarded queued comment out of the thread (expanded=%s)", async (expanded) => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], expanded);
const queue = createQueuedCommentQueue();
const emptyQueue = createQueuedCommentQueue({
queueId: null,
@@ -6134,6 +6289,7 @@ describe("IssueDetail", () => {
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [queue.entries[0].comment], latestCommentId: "queued-comment-1", hasMore: false });
// Keep returning the pre-discard page to exercise the local projection
// across the queueId -> null transition, as can happen during refetch.
mockIssuesApi.listComments.mockResolvedValue([queue.entries[0].comment]);
@@ -6191,6 +6347,72 @@ describe("IssueDetail", () => {
});
});
it.each(["discard", "cancel", "delete"] as const)("keeps %s responses in both comment caches after reopening by either task ref", async (action) => {
const queue = createQueuedCommentQueue();
const comment = queue.entries[0].comment;
const earlier = createIssueComment({ id: "earlier-comment", createdAt: new Date("2026-04-21T00:00:01.000Z") });
const deleted = { ...comment, body: "", deletedAt: new Date() };
const emptyQueue = createQueuedCommentQueue({ queueId: null, entries: [], targetRunId: null });
const refs = ["PAP-1", "issue-1"];
for (const ref of refs) {
queryClient.setQueryData(["issues", "history-expanded", ref], false);
queryClient.setQueryData([...queryKeys.issues.comments(ref), "recent"], {
comments: [earlier, comment], latestCommentId: comment.id, hasMore: true,
});
queryClient.setQueryData(queryKeys.issues.comments(ref), { pages: [[comment, earlier]], pageParams: [null] });
}
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1" }));
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "paperclip_runner" })]);
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
mockIssuesApi.recentComments.mockResolvedValue({ comments: [earlier, comment], latestCommentId: comment.id, hasMore: true });
await act(async () => renderIssueView());
const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0];
await waitForAssertion(() => {
expect(props().comments.some((value: IssueComment) => value.id === comment.id)).toBe(true);
expect(props().queuedCommentQueue?.queueId).toBe(queue.queueId);
});
const after = action === "delete" ? [earlier, deleted] : [earlier];
mockIssuesApi.recentComments.mockResolvedValue({ comments: after, latestCommentId: after.at(-1)!.id, hasMore: true });
mockIssuesApi.listComments.mockResolvedValue(after);
mockIssuesApi.getQueuedComments.mockResolvedValue(emptyQueue);
mockIssuesApi.discardQueuedComment.mockResolvedValue(emptyQueue);
mockIssuesApi.cancelComment.mockResolvedValue(comment);
mockIssuesApi.deleteComment.mockResolvedValue(deleted);
let finishStaleRead!: (value: IssueRecentComments) => void;
const staleRead = queryClient.fetchQuery({
queryKey: [...queryKeys.issues.comments("PAP-1"), "recent"],
queryFn: () => new Promise<IssueRecentComments>((resolve) => { finishStaleRead = resolve; }),
}).catch(() => undefined);
await act(async () => {
if (action === "discard") await props().onDiscardQueuedComment(comment.id, queue.revision);
if (action === "cancel") props().onCancelQueued(comment.id);
if (action === "delete") await props().onDeleteComment(comment.id);
});
await waitForAssertion(() => expect(queryClient.isFetching({ queryKey: queryKeys.issues.comments("PAP-1") })).toBe(0));
await act(async () => {
finishStaleRead({ comments: [earlier, comment], latestCommentId: comment.id, hasMore: true });
await staleRead;
});
await waitForAssertion(() => {
for (const ref of refs) {
const recent = queryClient.getQueryData<IssueRecentComments>([...queryKeys.issues.comments(ref), "recent"]);
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());
}
});
for (const ref of refs) {
await act(async () => renderIssueView(null));
mockRouteParams.issueId = ref;
await act(async () => renderIssueView());
await waitForAssertion(() => {
const reopened = props().comments.find((value: IssueComment) => value.id === comment.id);
if (action === "delete") expect(reopened).toMatchObject({ body: "", deletedAt: deleted.deletedAt });
else expect(reopened).toBeUndefined();
});
}
});
it("uses queue identity for discard and surfaces a persistent too-late error", async () => {
const queue = createQueuedCommentQueue();
mockIssuesApi.get.mockResolvedValue(
+158 -51
View File
@@ -1,3 +1,4 @@
import { mergeWindowComments, useIssueHistoryWindow } from "../hooks/useIssueHistoryWindow";
import { useTaskRunLiveUpdates } from "../context/LiveUpdatesProvider";
import { liveRunRefetchInterval } from "../lib/live-runs-cache";
import { useTaskRetry } from "../hooks/useTaskRetry";
@@ -368,6 +369,7 @@ import {
type IssueRecoveryAction,
type IssueAttachment,
type IssueComment,
type IssueRecentComments,
type IssueDocumentSummary,
type IssueWorkProduct,
type IssueWorkMode,
@@ -1245,6 +1247,9 @@ type IssueDetailChatTabProps = {
} | null;
comments: IssueDetailComment[];
commentsInitialLoading?: boolean;
historyExpanded?: boolean;
onExpandHistory?: () => void;
hasEarlierMessages?: boolean;
initialHistoryPending?: boolean;
initialMetadataPending?: boolean;
initialHistoryError?: boolean;
@@ -1312,6 +1317,7 @@ type IssueDetailChatTabProps = {
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
onInterruptQueued: (runId: string | null) => Promise<void>;
onRemoveCommentFromCache: (commentId: string) => Promise<void>;
onDeleteComment?: (commentId: string) => Promise<void> | void;
onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise<void>;
onStopResponse?: (runId: string) => Promise<void>;
@@ -1398,6 +1404,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
legacyRecoverySourceIssue,
comments,
commentsInitialLoading = false,
historyExpanded = true,
onExpandHistory,
hasEarlierMessages = false,
initialHistoryPending = false,
initialMetadataPending = false,
initialHistoryError = false,
@@ -1441,6 +1450,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onImageUpload,
onAttachImage,
onInterruptQueued,
onRemoveCommentFromCache,
onDeleteComment,
onPauseWorkRun,
onStopResponse,
@@ -1488,10 +1498,23 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
isError: activityError,
refetch: refetchActivity,
} = useQuery({
queryKey: queryKeys.issues.activity(issueQueryRef),
queryFn: () => activityApi.forIssue(issueQueryRef),
queryKey: historyExpanded ? queryKeys.issues.activity(issueQueryRef) : [...queryKeys.issues.activity(issueQueryRef), "recent"],
queryFn: async () => {
if (historyExpanded) return activityApi.forIssue(issueQueryRef);
const recent = await activityApi.recentForIssue(issueQueryRef);
const previous = queryClient.getQueryData<ActivityEvent[]>([...queryKeys.issues.activity(issueQueryRef), "recent"]) ?? [];
return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()];
},
enabled: !!issueId,
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueQueryRef),
placeholderData: historyExpanded
? queryClient.getQueryData<ActivityEvent[]>([...queryKeys.issues.activity(issueQueryRef), "recent"])
: undefined,
});
const inputCommentIds = comments.filter((comment) => !("clientStatus" in comment) || !comment.clientStatus).map((comment) => comment.id).sort();
const inputFacts = useQuery({
queryKey: [...queryKeys.issues.activity(issueQueryRef), "comment-inputs", inputCommentIds],
queryFn: () => activityApi.inputFactsForIssue(issueQueryRef, inputCommentIds),
enabled: !!issueId && !historyExpanded && inputCommentIds.length > 0,
});
const {
data: liveRuns,
@@ -1595,20 +1618,43 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
setClassicQueuedDeliveryError(null);
}, [issueId]);
const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun;
const linkedRunsKey = historyExpanded
? queryKeys.issues.runs(issueQueryRef)
: [...queryKeys.issues.runs(issueQueryRef), "recent"];
const {
data: linkedRuns,
isPending: linkedRunsPending,
isError: linkedRunsError,
refetch: refetchLinkedRuns,
} = useQuery({
queryKey: queryKeys.issues.runs(issueQueryRef),
queryFn: () => activityApi.runsForIssue(issueQueryRef),
queryKey: linkedRunsKey,
queryFn: async () => {
if (historyExpanded) return activityApi.runsForIssue(issueQueryRef);
let recent = await activityApi.recentRunsForIssue(issueQueryRef);
let previous = queryClient.getQueryData<RunForIssue[]>(linkedRunsKey) ?? [];
if (previous.some((run) =>
(run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId),
)) {
// After a reconnect, several turns may have completed beyond the small
// window. Reconcile watched runs once against authoritative summaries.
recent = await activityApi.runsForIssue(issueQueryRef);
const existingRunIds = new Set(recent.map((run) => run.runId));
previous = previous.filter((run) => existingRunIds.has(run.runId));
}
// The second row refreshes a just-finished predecessor. Start with only
// the latest run, then keep every run observed while this task is open.
const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]);
return [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()];
},
enabled: !!issueId,
refetchInterval:
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueQueryRef),
placeholderData: historyExpanded ? queryClient.getQueryData<RunForIssue[]>([...queryKeys.issues.runs(issueQueryRef), "recent"]) : undefined,
});
const resolvedActivity = activity ?? [];
const resolvedActivity = useMemo(
() => [...new Map([...(activity ?? []), ...(inputFacts.data ?? [])].map((event) => [event.id, event])).values()],
[activity, inputFacts.data],
);
const resolvedLinkedRuns = linkedRuns ?? [];
useEffect(() => {
recordIssueDetailReadiness({ activity: !activityPending, "linked-runs": !linkedRunsPending, runtime: runtimeSelectionKnown });
@@ -2414,19 +2460,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
setDiscardedQueuedCommentIds((current) =>
new Set(current).add(commentId),
);
// Discard deletes the persisted issue comment. Remove the cached copy
// in the same commit as the queue update so it cannot briefly return as
// a normal user bubble when the now-empty queue loses its queueId.
queryClient.setQueryData<
InfiniteData<IssueComment[], string | null> | undefined
>(queryKeys.issues.comments(issueId), (current) =>
current
? {
...current,
pages: removeIssueCommentFromPages(current.pages, commentId),
}
: current,
);
// Discard deletes the persisted comment. Update both history caches
// and task aliases so the row stays removed after this view unmounts.
await onRemoveCommentFromCache(commentId);
await queryClient.invalidateQueries({
queryKey: queryKeys.issues.comments(issueId),
});
@@ -2464,6 +2500,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
[
effectiveQueuedCommentQueue?.queueId,
issueId,
onRemoveCommentFromCache,
pushToast,
queryClient,
refreshQueueAfterConflict,
@@ -2521,6 +2558,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
>
<EmailThreadProvider companyId={companyId} issueId={issueId}>
<ThreadComponent
{...(!classicTaskInterfaceEnabled ? {
compactHistory: !historyExpanded,
historyControl: !historyExpanded && (hasEarlierMessages || (linkedRuns?.length ?? 0) > 0) ? (
<Button type="button" variant="ghost" size="sm" onClick={onExpandHistory}>
More messages
</Button>
) : null,
} : {})}
executionBlocked={executionBlocked}
interruption={interruption}
retryPreparing={retryPreparing}
@@ -2530,7 +2575,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
browsers={browsers}
onOpenBrowser={onOpenBrowser}
hasOlderComments={hasOlderComments}
initialHistoryPending={!!issueId && (
initialHistoryPending={historyExpanded && !!issueId && (
initialHistoryPending ||
commentsInitialLoading ||
// These responses add rows or change the composer takeover.
@@ -2544,6 +2589,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
initialHistoryError={
initialHistoryError ||
activityError ||
(!historyExpanded && inputFacts.isError) ||
linkedRunsError ||
liveRunsError ||
(activeRunQueryEnabled && activeRunError)
@@ -2551,6 +2597,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onRetryInitialHistory={() => {
onRetryInitialHistory?.();
void refetchActivity();
if (!historyExpanded && inputCommentIds.length) void inputFacts.refetch();
void refetchLinkedRuns();
void refetchLiveRuns();
if (activeRunQueryEnabled) void refetchActiveRun();
@@ -3227,11 +3274,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
[location.state, resolvedIssueDetailState],
);
const coreIssueQuery = useQuery({
queryKey: [...queryKeys.issues.detail(issueId!), "core"],
queryFn: ({ signal }) => issuesApi.getCore(issueId!, { signal }),
enabled: !!issueId,
});
const { historyExpanded, expandHistory, recent: recentComments } = useIssueHistoryWindow(
issueId,
Boolean(conversation || classicTaskInterfaceEnabled || location.hash || detailTab !== "chat"),
coreIssueQuery.data,
);
const {
data: queriedIssue,
isLoading,
isPlaceholderData,
error,
refetch: refetchIssueDetail,
} = useQuery({
...getIssueDetailQueryOptions(queryClient, issueId!, {
placeholderIssue: issueHeaderSeed
@@ -3241,12 +3299,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
: null,
}),
enabled: !!issueId,
enabled: !!issueId && (coreIssueQuery.isFetched || !!queryClient.getQueryData(queryKeys.issues.detail(issueId))),
});
useEffect(() => {
if (issueId) void prefetchIssueThread(queryClient, issueId);
}, [issueId, queryClient]);
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
if (issueId && historyExpanded) void prefetchIssueThread(queryClient, issueId);
}, [issueId, historyExpanded, queryClient]);
const accessError = [error, coreIssueQuery.error].find((value) =>
value instanceof ApiError && [401, 403, 404].includes(value.status),
);
const issue = accessError ? undefined : queriedIssue ?? coreIssueQuery.data ?? conversation?.issue ?? draftIssue;
const taskRetry = useTaskRetry(issue ?? undefined);
const resolveWritableIssueId = async () => {
if (!conversation) return issueId!;
@@ -3262,7 +3323,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
// or upload against the previous task while the requested task is loading.
const loadedIssue =
!isPlaceholderData &&
!error &&
(!error || !!coreIssueQuery.data) &&
issue &&
issueId &&
(issue.id.toLowerCase() === issueId.toLowerCase() ||
@@ -3309,7 +3370,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
limit: ISSUE_COMMENT_PAGE_SIZE,
...(pageParam ? { after: pageParam } : {}),
}),
enabled: !!issueId,
enabled: !!issueId && historyExpanded,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE),
@@ -3318,8 +3379,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
>(issueId ?? "pending"),
});
const comments = useMemo(
() => flattenIssueCommentPages(commentPages?.pages),
[commentPages?.pages],
() => mergeWindowComments(recentComments.data?.comments ?? [], flattenIssueCommentPages(commentPages?.pages)),
[commentPages?.pages, recentComments.data?.comments],
);
useLayoutEffect(() => {
@@ -3357,7 +3418,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
const shouldPrefetchOlderComments = useMemo(
() =>
shouldAutoloadOlderIssueComments({
historyExpanded && shouldAutoloadOlderIssueComments({
activeDetailTab: detailTab,
hasOlderComments: hasOlderComments ?? false,
loadedCommentCount: comments.length,
@@ -3369,6 +3430,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
comments.length,
commentsLoading,
commentsLoadingOlder,
historyExpanded,
detailTab,
hasOlderComments,
],
@@ -4089,18 +4151,33 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}, [issueId, queryClient]);
const removeCommentFromCache = useCallback(
(commentId: string) => {
queryClient.setQueryData<
InfiniteData<IssueComment[], string | null> | undefined
>(queryKeys.issues.comments(issueId!), (current) => {
if (!current) return current;
return {
...current,
pages: removeIssueCommentFromPages(current.pages, commentId),
};
});
async (commentId: string) => {
// Cancel reads started before deletion so they cannot restore stale rows.
await Promise.all(issueCacheRefs.map((ref) =>
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(ref),
(current) => current && { ...current, pages: removeIssueCommentFromPages(current.pages, commentId) },
);
queryClient.setQueryData<IssueRecentComments>(
[...queryKeys.issues.comments(ref), "recent"],
(current) => {
if (!current) return current;
const comments = mergeWindowComments(current.comments.filter((comment) => comment.id !== commentId));
return {
...current,
comments,
latestCommentId: current.latestCommentId === commentId
? comments.at(-1)?.id ?? null
: current.latestCommentId,
};
},
);
}
},
[issueId, queryClient],
[issueCacheRefs, queryClient],
);
const clearCommentHashIfCurrent = useCallback(
@@ -4117,7 +4194,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
const upsertCommentInCache = useCallback(
(comment: IssueComment) => {
async (comment: IssueComment) => {
await Promise.all(issueCacheRefs.map((ref) =>
queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(ref) }),
));
for (const ref of issueCacheRefs) {
queryClient.setQueryData<
InfiniteData<IssueComment[], string | null> | undefined
@@ -4129,6 +4209,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
: current,
);
queryClient.setQueryData<IssueRecentComments>(
[...queryKeys.issues.comments(ref), "recent"],
(current) => current && {
...current,
comments: current.comments.map((existing) => existing.id === comment.id ? comment : existing),
},
);
}
},
[issueCacheRefs, queryClient],
@@ -4882,6 +4969,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
try {
await issuesApi.cancelComment(comment.issueId, comment.id);
await removeCommentFromCache(comment.id);
invalidateIssueDetail();
invalidateIssueThreadLazily();
invalidateIssueCollections();
@@ -4923,6 +5011,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
pages: upsertIssueCommentInPages(undefined, comment),
},
);
// A locally seeded page is not a complete history page. Expansion must
// fetch the real first page even within the normal cache freshness window.
if (!historyExpanded) {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId ?? comment.issueId), exact: true, refetchType: "none" });
}
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter(
@@ -5300,6 +5393,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
try {
await issuesApi.cancelComment(issueId!, comment.id);
await removeCommentFromCache(comment.id);
invalidateIssueDetail();
invalidateIssueThreadLazily();
invalidateIssueCollections();
@@ -5344,6 +5438,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
pages: upsertIssueCommentInPages(undefined, comment),
},
);
if (!historyExpanded) {
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!), exact: true, refetchType: "none" });
}
}
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
@@ -5408,14 +5505,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const cancelQueuedComment = useMutation({
mutationFn: async ({ commentId }: { commentId: string }) =>
issuesApi.cancelComment(issueId!, commentId),
onSuccess: (comment) => {
onSuccess: async (comment) => {
setLocallyQueuedCommentRunIds((current) => {
if (!current.has(comment.id)) return current;
const next = new Map(current);
next.delete(comment.id);
return next;
});
removeCommentFromCache(comment.id);
await removeCommentFromCache(comment.id);
restoreQueuedCommentDraft(comment.body);
invalidateIssueDetail();
invalidateIssueThreadLazily();
@@ -5441,8 +5538,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const deleteComment = useMutation({
mutationFn: async ({ commentId }: { commentId: string }) =>
issuesApi.deleteComment(issueId!, commentId),
onSuccess: (comment) => {
upsertCommentInCache(comment);
onSuccess: async (comment) => {
await upsertCommentInCache(comment);
clearCommentHashIfCurrent(comment.id);
invalidateIssueDetail();
invalidateIssueThreadLazily();
@@ -7049,9 +7146,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
}, [activePauseHold, issue]);
if (isLoading)
if (!issue && !accessError && (isLoading || coreIssueQuery.isPending))
return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue && (error || coreIssueQuery.error)) return <p className="text-sm text-destructive">{(error || coreIssueQuery.error)?.message}</p>;
if (!issue) return null;
// Do not expose a file chooser on the outgoing UUID/company/interface
// branch: its input can be detached before the chosen file is returned.
@@ -7798,6 +7895,12 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
: "max-w-3xl space-y-6"
}
>
{error && coreIssueQuery.data ? (
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
Some task details could not be loaded.
<Button type="button" variant="ghost" size="sm" onClick={() => void refetchIssueDetail()}>Retry</Button>
</div>
) : null}
{/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */}
{taskChatShellEnabled ? null : ancestorsNav}
{taskChatShellEnabled ? null : createdFromNote}
@@ -8235,7 +8338,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
comments={threadComments}
commentsInitialLoading={commentsLoading}
commentsInitialLoading={historyExpanded ? commentsLoading : recentComments.isLoading}
historyExpanded={historyExpanded}
onExpandHistory={expandHistory}
hasEarlierMessages={recentComments.data?.hasMore ?? false}
initialHistoryPending={linkedCommentPending}
initialMetadataPending={
interactionsLoading ||
@@ -8243,13 +8349,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
workProductsLoading
}
initialHistoryError={
commentsError ||
(historyExpanded ? commentsError : recentComments.isError) ||
interactionsError ||
attachmentsError ||
workProductsError
}
onRetryInitialHistory={() => {
void refetchComments();
void (historyExpanded ? refetchComments() : recentComments.refetch());
void refetchInteractions();
void refetchAttachments();
void refetchWorkProducts();
@@ -8324,6 +8430,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onImageUpload={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onInterruptQueued={handleInterruptQueuedRun}
onRemoveCommentFromCache={removeCommentFromCache}
onDeleteComment={(commentId) =>
deleteComment
.mutateAsync({ commentId })