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
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 })