mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
perf(task-chat): stop a slow run transcript from hiding the chat (#20277)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task chat shows saved messages, run transcripts and the composer > - The chat hides the messages and the composer until every run transcript loads. This prevents layout movement > - On staging, one task's run-log read took 11–12 s. Saved messages and the composer stayed hidden for 13.9 s after a click, and for 14.6 s again on revisit > - This pull request limits how long a transcript can hold the first reveal after saved history is ready > - The benefit is that a slow run log no longer hides the conversation, and the layout contract stays in place ## Linked Issues or Issue Description **Describe the bug** When a task has a run whose transcript loads slowly, the whole chat stays behind the loading skeleton. The composer stays hidden too. On staging, a failed run with no saved reply needed an 11–12 s object-store log read. The chat appeared after 13.9 s. Saved messages that loaded in about 2 s were not visible during that time. **Steps to reproduce** 1. Open a task whose latest run failed before it saved a reply, and whose run log is large or slow to read. 2. Watch the chat area after the click. **Expected behavior** Saved messages and the composer appear when saved history is ready. Run output must not move the messages that are already visible. **Actual behavior** The chat stays hidden until the run log read finishes, or until the 15 s reveal timeout. Refs #20276 (the measurement harness and baseline plan). ## What Changed - `TaskChatThread` now waits at most `INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS` (600 ms) for run transcripts. The time starts when saved history, the plan and chat metadata are ready. The budget is one deadline. A transcript that loads early does not restart it. - When the budget expires, the slow runs are latched for that task, so the decision does not change later: - **Live runs** stop holding the reveal. Their output continues to stream into the live tail at the bottom of the chat. - **Settled runs in compact history** stop loading. They use the existing fixed **More run output** control, so late output cannot insert rows above the reader. Selecting the control loads the full history, as it does today. - **Settled runs in other modes** (expanded history, conversation mode, a deep link to a comment) still wait, as before. In those modes, output would insert rows around saved replies. - **Live runs with a steering message or other timeline anchors** still wait, as before. Their output before the anchor goes into the main conversation above the anchored message. - A live run that finishes after the reveal keeps its visible transcript. Only runs that were already settled when the budget expired are deferred. ## Verification - `cd ui && npx vitest run src/components/TaskChatThread.test.tsx`. New tests for legacy and native runs: - A slow failed run in compact history: the chat reveals at the budget, the transcript is no longer requested, **More run output** appears, and expanding loads the run. - A slow live run: the chat reveals at the budget, the transcript keeps streaming, and the transcript stays after the run finishes. - A slow settled run without the compact control: the chat still waits. - The budget does not start while saved history is pending. - When one of two transcripts loads before the budget ends, the chat still reveals at the first deadline. - A slow live run with a steering message: the chat still waits. - A compact task with 300 slow failed runs: the chat reveals at the budget and defers every run. - `cd ui && npx vitest run src/components/task-chat/transcript-reveal-budget.test.ts`. The budget selection is one pass. A 5,000-run test counts anchor lookups and throws above one lookup for each live run. - Red check: with the budget timer disabled, the 6 new positive tests fail. With the change, they pass. - `npx vitest run src/components/TaskChatThread.test.tsx src/components/task-chat src/pages/IssueDetail`: 64 files and 1,609 tests pass. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/task-loading-frame.spec.ts`: 7 of 7 pass. This covers a stable loading frame on desktop and mobile, composer geometry, the reading position, a stalled web font, and the Classic and mobile preference paths. - After deploy, staging is measured with the harness from #20276. The finish condition is that a slow run-log task opens in the same time as other tasks. ## Risks - After a slow transcript is deferred, a settled failed run in compact history shows its failure message and **More run output**, not its last transcript lines. The output is one click away. The server-side log read fix is a separate follow-up. - A live run's output can appear in the tail after the reveal. This is the normal streaming behaviour of the tail. - A 600 ms budget is a trade-off. Fast transcripts still arrive in the first frame, and slow ones no longer hide the chat. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code with tool use (shell, browser automation, file editing). ## 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 - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac0d0374f2
commit
3e8744fa91
4 files changed
+304
-5
No files matched your search
@@ -13,7 +13,7 @@ import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConnectivityProvider, createConnectivityStore } from "@/lib/connectivity";
|
||||
import { TaskChatThread } from "./TaskChatThread";
|
||||
import { INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS, TaskChatThread } from "./TaskChatThread";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -881,6 +881,162 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
expect((runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy).at(-1)).toMatchObject([{ id: "saved-run" }]);
|
||||
});
|
||||
|
||||
const transcriptHookCalls = () => runtimeMode === "native" ? transcriptHookRuns.native : transcriptHookRuns.legacy;
|
||||
const historyLoading = () => container.querySelector('[data-testid="task-chat-history-loading"]') !== null;
|
||||
// React commits the budget's state update when act() exits; the reveal then
|
||||
// needs a few more (fake) animation frames to confirm stable geometry.
|
||||
const passTranscriptBudget = async () => {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
for (let frame = 0; frame < 3; frame++) await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
};
|
||||
|
||||
it("reveals saved messages after the transcript budget and keeps a slow settled run behind a fixed control", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const props = {
|
||||
issueId: "slow-failed-run", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(),
|
||||
linkedRuns: [{ ...retryRun, runId: "failed-run", status: "failed", startedAt: retryRun.createdAt }],
|
||||
comments: createLongThreadComments(),
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(false);
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Some task history is still loading.");
|
||||
// The late transcript is no longer requested, so it cannot insert rows
|
||||
// above the reader; the output stays one click away.
|
||||
expect(transcriptHookCalls().at(-1)).toEqual([]);
|
||||
const output = [...container.querySelectorAll("button")].find(button => button.textContent === "More run output");
|
||||
expect(output).toBeDefined();
|
||||
act(() => output!.click());
|
||||
expect(props.onExpandHistory).toHaveBeenCalledOnce();
|
||||
render(<TaskChatThread {...props} compactHistory={false} />);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("reveals saved messages after the transcript budget while a live run keeps streaming", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const props = {
|
||||
issueId: "slow-live-run", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(),
|
||||
linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }],
|
||||
comments: createLongThreadComments(),
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(false);
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]);
|
||||
// Finishing after the reveal keeps the visible transcript in place.
|
||||
render(<TaskChatThread {...props} linkedRuns={[{ ...props.linkedRuns[0], status: "succeeded" }]} />);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]);
|
||||
expect(container.textContent).not.toContain("More run output");
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("keeps waiting for a settled transcript that would insert output when no compact control exists", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<TaskChatThread issueId="settled-full-history" onAdd={async () => {}} comments={createLongThreadComments()}
|
||||
linkedRuns={[{ ...retryRun, runId: "failed-run", status: "failed", startedAt: retryRun.createdAt }]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(true);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "failed-run" }]);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("defers every slow settled run of a large compact task at the budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const linkedRuns = Array.from({ length: 300 }, (_, index) => ({
|
||||
...retryRun, runId: `failed-run-${index}`, status: "failed",
|
||||
createdAt: new Date(Date.parse(retryRun.createdAt) + index * 60_000).toISOString(),
|
||||
startedAt: new Date(Date.parse(retryRun.createdAt) + index * 60_000).toISOString(),
|
||||
}));
|
||||
render(<TaskChatThread issueId="large-slow-task" compactHistory onAdd={async () => {}} onExpandHistory={vi.fn()}
|
||||
comments={createLongThreadComments()} linkedRuns={linkedRuns} />);
|
||||
expect(transcriptHookCalls().at(-1)?.length).toBeGreaterThan(0);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(false);
|
||||
expect(transcriptHookCalls().at(-1)).toEqual([]);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("keeps the transcript budget when another transcript hydrates first", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const props = {
|
||||
issueId: "two-live-runs", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(),
|
||||
linkedRuns: [
|
||||
{ ...retryRun, runId: "fast-run", status: "running", startedAt: retryRun.createdAt },
|
||||
{ ...retryRun, runId: "slow-run", status: "running", startedAt: retryRun.createdAt },
|
||||
],
|
||||
comments: createLongThreadComments(),
|
||||
};
|
||||
render(<TaskChatThread {...props} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 100); });
|
||||
transcriptState.hydratedRunIds = new Set(["fast-run"]);
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["fast-run"]);
|
||||
render(<TaskChatThread {...props} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(99); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
// The budget still ends at its first deadline. It does not restart
|
||||
// because the fast transcript finished.
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(false);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "fast-run" }, { id: "slow-run" }]);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("keeps waiting for a live transcript that splits around a steering message", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const steering = {
|
||||
...createLongThreadComments()[0], id: "steering-comment", body: "Change course now.",
|
||||
steeredIntoRunId: "live-run", conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-25T18:00:02.000Z"), updatedAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
};
|
||||
render(<TaskChatThread issueId="steered-live-run" compactHistory onAdd={async () => {}} onExpandHistory={vi.fn()}
|
||||
comments={[...createLongThreadComments(), steering]}
|
||||
linkedRuns={[{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
await passTranscriptBudget();
|
||||
// Its pre-steer output enters the main chronology above the steering
|
||||
// message, so revealing before it loads would move visible messages.
|
||||
expect(historyLoading()).toBe(true);
|
||||
expect(transcriptHookCalls().at(-1)).toMatchObject([{ id: "live-run" }]);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("does not start the transcript budget while saved history is still loading", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const props = {
|
||||
issueId: "saved-history-first", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(),
|
||||
linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }],
|
||||
comments: createLongThreadComments(),
|
||||
};
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS * 3); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
render(<TaskChatThread {...props} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(INITIAL_TRANSCRIPT_REVEAL_BUDGET_MS - 1); });
|
||||
expect(historyLoading()).toBe(true);
|
||||
await passTranscriptBudget();
|
||||
expect(historyLoading()).toBe(false);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("retains a live transcript after the saved reply arrives", () => {
|
||||
const props = { issueId: "live-to-saved", compactHistory: true, onAdd: async () => {}, onExpandHistory: vi.fn(),
|
||||
linkedRuns: [{ ...retryRun, runId: "live-run", status: "running", startedAt: retryRun.createdAt }], comments: createLongThreadComments() };
|
||||
|
||||
Reference in new issue
Block a user