mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
perf(ui): defer hidden task rendering until history is ready (#16878)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People open a task to read saved work and reply. > - The conversation waits for its initial data so messages do not move after reveal. > - The hidden conversation and editor still render while those reads are pending. > - Profiling showed a useful cold-load gain when that hidden rendering was delayed. > - Prepare ordinary task content after its initial data is ready. Keep the existing preparation path for comment links. > - Keep the skeleton, measured reveal, timeout, and mounted conversation during refresh. ## Linked Issues or Issue Description Follow-up to #16862 and #16849. Related task-layout work: #14758. Searches found no overlapping open rendering change. **What existing behavior does this improve?** Opening a task mounts hidden message rows and the rich editor while supporting reads are still pending. These trees can render again as data arrives before the user can see or use them. **Subsystem affected** UI task conversation startup and its browser acceptance suite. **Proposed behavior** Keep the existing skeleton and outer layout containers. Prepare the hidden conversation and composer when initial history is ready, then use the existing measured reveal. Keep content mounted after reveal. Hash navigation retains eager preparation so older comment targets can be positioned before reveal. The existing 15-second timeout still reveals available history and the composer. ## What Changed - Gate concealed history and composer children with the existing history readiness, revealed-task latch, timeout, and scroll-navigation hash. - Keep data queries, shell geometry, scroll ownership, and the reveal frame unchanged. - Update unit coverage for deferred content, comment-link preparation, task changes, history timeout, and mounted editor identity during refresh. - Add a real browser journey that holds a background task read, retains the same editor and draft, reloads the saved draft, sends one reply, and checks the persisted comment independently. - Document the loading and draft acceptance paths. ## Verification - Repository-wide `pnpm -r typecheck` and `pnpm build` passed locally. Token gates and all 231 task-thread unit cases passed. - Seven regression cases failed against the old rendering behavior before the source change. - All 14 loading and layout browser cases passed again against the final production UI and API build at `15c360aa63e01ad9318aaf7865c07d1ee0d36968`. Coverage includes delayed initial data, reading anchors, media growth, composer sizing, older history, cold comment links, desktop/mobile same-task links and Back, rapid switching, reduced motion, failed reads and retry, stalled history, plan reuse, and cached-task profiling. - The new draft journey retained the same editor during a blocked background task refresh, restored the draft after reload, and persisted exactly one reply. - Final performance comparison used current master `249ab47d41834f174ddd5e9523dc07fa8ebd8e4c` and implementation `15c360aa63e01ad9318aaf7865c07d1ee0d36968` against the same local 160-message Markdown conversation and API process. Conditions: 50 ms network latency, 9 Mbps down/1.5 Mbps up, 4x CPU slowdown, fresh browsers, five alternating pairs per scenario, no CPU profiler, and no concurrent local build/test work. Warm task navigation improved in all five pairs: median paired saving **405 ms**; variant medians **1,787 → 1,456 ms**. Cold results were mixed: paired saving **−89 ms** (slower), despite variant medians **3,188 → 3,021 ms**. There is no consistent cold-load gain established by this comparison. - Earlier prototype comparisons showed paired cold savings of 259–283 ms. Those gains did not consistently reproduce in the final comparison. These are bounded local measurements and do not establish deployed staging latency. - The first prototype delayed comment-link preparation too and failed the older-comment anchor check by 240.5 px. The narrowed implementation keeps hash navigation on the existing eager path. That case and the full suite now pass. The original failure trace is retained locally. - A visual browser walkthrough read the saved conversation and plan, restored a draft after reload, sent it once, reloaded again, and independently checked that exactly one reply was saved. - Greptile reviewed this exact commit at 5/5, with no actionable findings or inline comments. - The duplicate local `pnpm test:run` attempt was interrupted while the same suites were completing in sharded CI so local performance could be measured on an idle host. It is not counted as a local full-suite pass. - All current-head [CI jobs passed](https://github.com/paperclipai/paperclip/actions/runs/39296740548), including the server, workspace, Runner, serialized, browser, typecheck, build, canary, and final verification gates. The first run failed one native-process supervision test (`preserves a real listening process and its marker when a health RPC times out`). Its unchanged shard and dependent gate passed on retry. The specific test also passed locally. No source change was made for that transient failure. ## Risks Delaying preparation can change initial scroll geometry. Hash navigation keeps the existing preparation path because it needs the target present before positioning. The revealed-task latch keeps refreshes from removing history or the editor. The existing timeout still makes saved content and the composer accessible when reads stall. No API, database, permission, or provider changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository tools, code execution, and browser verification. A more precise runtime model identifier and context-window size are unavailable. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
45b67f1d93
commit
4d92589799
4 files changed
+341
-248
No files matched your search
@@ -69,6 +69,13 @@ Videos and per-frame `layout.json` measurements are written under each test's
|
||||
output directory. The two-pixel assertion measures a logical row's viewport
|
||||
offset; total scroll offset legitimately changes when history is prepended.
|
||||
|
||||
Ordinary task opens defer the concealed history and composer until their initial
|
||||
data is ready. The skeleton and scroll/composer containers retain their geometry.
|
||||
Comment-link navigation prepares content eagerly so the target can be positioned
|
||||
before reveal. Revealed content stays mounted during refresh. The draft scenario
|
||||
holds a background task read, checks the same editor remains mounted, reloads the
|
||||
saved draft, sends one reply, and independently checks its persisted comment.
|
||||
|
||||
To run against an existing **disposable local test-drive instance**:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -8,6 +8,7 @@ test.use({ video: "on", trace: "retain-on-failure", viewport: { width: 1440, hei
|
||||
let prefix: string;
|
||||
let issue: { id: string; identifier: string };
|
||||
let other: { id: string; identifier: string };
|
||||
let draftIssue: { id: string; identifier: string };
|
||||
let oldestCommentId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
@@ -22,6 +23,7 @@ test.beforeAll(async ({ request }) => {
|
||||
};
|
||||
issue = await createIssue("Stable conversation acceptance");
|
||||
other = await createIssue("Rapid navigation destination");
|
||||
draftIssue = await createIssue("Draft stability acceptance");
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const response = await request.post(`/api/issues/${issue.id}/comments`, {
|
||||
data: { body: `## Historical message ${i}\n\n${"Long Markdown with **emphasis**, detail, and enough text to wrap across several lines. ".repeat(8)}\n\n\`\`\`js\nconsole.log(${i});\n\`\`\`\n\n| State | Result |\n|---|---|\n| Complete | Stable |${i === 159 ? `\n\n[Read the oldest message](#comment-${oldestCommentId})` : ""}` },
|
||||
@@ -151,6 +153,52 @@ test("explicit comment links resolve older pages before revealing at the target"
|
||||
}).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("a draft survives background task refresh, reload, and one persisted reply", async ({ page, request }) => {
|
||||
await page.goto(`/${prefix}/issues/${draftIssue.identifier}`);
|
||||
await ready(page);
|
||||
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
|
||||
const draft = "This draft survives task refresh and is submitted exactly once.";
|
||||
await editor.fill(draft);
|
||||
await editor.evaluate((el) => { el.setAttribute("data-draft-mount-proof", "retained"); });
|
||||
await expect.poll(() => page.evaluate((id) => localStorage.getItem(`paperclip:issue-comment-draft:${id}`), draftIssue.id)).toBe(draft);
|
||||
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let refreshing = false;
|
||||
await page.route(/\/api\/issues\/[^/?]+$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
refreshing = true;
|
||||
await held;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
const title = "Draft stability after background update";
|
||||
const updated = await request.patch(`/api/issues/${draftIssue.id}`, { data: { title } });
|
||||
expect(updated.ok(), await updated.text()).toBeTruthy();
|
||||
try {
|
||||
await expect.poll(() => refreshing).toBe(true);
|
||||
await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0);
|
||||
await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained");
|
||||
await expect(editor).toHaveText(draft);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await expect(page.getByText(title, { exact: true }).first()).toBeVisible();
|
||||
await expect(editor).toHaveAttribute("data-draft-mount-proof", "retained");
|
||||
await page.reload();
|
||||
await ready(page);
|
||||
await expect(editor).toHaveText(draft);
|
||||
await page.getByTestId("task-chat-composer-send").click();
|
||||
await expect.poll(async () => {
|
||||
const comments = await (await request.get(`/api/issues/${draftIssue.id}/comments`)).json();
|
||||
return comments.filter((comment: { body: string }) => comment.body === draft).length;
|
||||
}).toBe(1);
|
||||
await page.reload();
|
||||
await ready(page);
|
||||
await expect(editor).toHaveText("");
|
||||
await expect(page.getByText(draft, { exact: true })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("failed initial comments expose retry, then recover without an indefinite skeleton", async ({ page }) => {
|
||||
await page.route("**/api/issues/*/comments?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: '{"error":"Temporarily unavailable"}' }));
|
||||
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { heartbeatsApi } from "@/api/heartbeats";
|
||||
import { nativeRunEventsToTranscript } from "./transcript/native-run-events";
|
||||
import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { TaskChatScrollNavigation } from "./task-chat/scroll-navigation";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
|
||||
const transcriptState = vi.hoisted(() => ({
|
||||
@@ -312,20 +313,21 @@ it.each([true, false])("interleaves browser sessions with their requests and pre
|
||||
expect(anchors()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("coordinates first reveal while keeping the composer and visible history mounted through refresh", async () => {
|
||||
sidebarState.isMobile = true;
|
||||
it.each([true, false])("defers hidden content until history is ready and keeps it mounted through refresh (mobile=%s)", async (mobile) => {
|
||||
sidebarState.isMobile = mobile;
|
||||
const props = {
|
||||
issueId: "coordinated-issue",
|
||||
comments: [],
|
||||
onAdd: async () => {},
|
||||
};
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(composer).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(container.querySelector('.task-chat-history-pending')).not.toBeNull();
|
||||
expect(Boolean(container.querySelector('.task-chat-history-pending'))).toBe(mobile);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(true);
|
||||
render(<TaskChatThread {...props} initialHistoryPending={false} />);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -341,6 +343,30 @@ it("coordinates first reveal while keeping the composer and visible history moun
|
||||
expect(container.querySelector('.task-chat-history-pending')).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares comment-link history and its composer before reveal", async () => {
|
||||
const props = {
|
||||
issueId: "linked-issue",
|
||||
comments: createLongThreadComments(),
|
||||
onAdd: async () => {},
|
||||
};
|
||||
const show = (pending: boolean) => render(
|
||||
<TaskChatScrollNavigation.Provider value={{ key: "linked-entry", hash: "#comment-comment-1", restore: false }}>
|
||||
<TaskChatThread {...props} initialHistoryPending={pending} />
|
||||
</TaskChatScrollNavigation.Provider>,
|
||||
);
|
||||
show(true);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
const row = container.querySelector('[data-thread-anchor="comment-1"]');
|
||||
expect(composer).not.toBeNull();
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.closest('[inert]')).not.toBeNull();
|
||||
show(false);
|
||||
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); });
|
||||
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
|
||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBe(row);
|
||||
});
|
||||
|
||||
it.each([true, false])("reveals saved conversation when initial history stalls (mobile=%s)", async (mobile) => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -356,10 +382,12 @@ it.each([true, false])("reveals saved conversation when initial history stalls (
|
||||
onAdd: async () => {},
|
||||
};
|
||||
render(<TaskChatThread {...props} initialHistoryPending />);
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(14_999); });
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).not.toBeNull();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
const composer = container.querySelector('[data-testid="mock-editor"]');
|
||||
expect(composer).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-history-loading"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-history-content"]')?.hasAttribute("inert")).toBe(false);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(false);
|
||||
@@ -461,8 +489,8 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')?.closest('[inert]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Thread message 1");
|
||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')).toBeNull();
|
||||
transcriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
transcriptState.transcriptByRun.set("started-run", [{
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
|
||||
import { skillCreatedItems } from "@/components/task-chat/skill-created-items";
|
||||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||||
import { TaskChatScrollReady } from "@/components/task-chat/scroll-navigation";
|
||||
import { TaskChatScrollReady, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -2911,6 +2911,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// 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;
|
||||
const scrollNavigation = useTaskChatScrollNavigation();
|
||||
useEffect(() => {
|
||||
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
|
||||
}, [issueId, planLoading, transcriptHistoryPending, historyPending]);
|
||||
@@ -2929,6 +2930,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const historyRevealed = revealedIssue === issueId;
|
||||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ issueId: typeof issueId } | null>(null);
|
||||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.issueId === issueId;
|
||||
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
|
||||
// Supporting requests can stall without rejecting. Bound the first reveal
|
||||
// independently of their pending states so saved conversation and the
|
||||
// composer stay accessible, with an explicit incomplete-history notice.
|
||||
@@ -3021,145 +3023,149 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
)}
|
||||
inert={!historyRevealed}
|
||||
>
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div
|
||||
className={
|
||||
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
{prepareInitialContent ? (
|
||||
<>
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
|
||||
isMobile ? "pt-4" : "pt-3",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
data-testid="task-chat-thread-header"
|
||||
className={
|
||||
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
|
||||
isMobile ? "pt-4" : "pt-3",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{bottomBlockerLinks ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
>
|
||||
{bottomBlockerLinks}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{bottomBlockerLinks ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
>
|
||||
{bottomBlockerLinks}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
attachments={attachments}
|
||||
header={threadHeaderWithBlockers}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={renderBrief}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
onTryAgainNoLiveExecutionPath={
|
||||
issueStatus === "blocked" &&
|
||||
!requiresExecutionReconciliation(
|
||||
props.recoveryAction?.cause,
|
||||
) &&
|
||||
!linkedRuns?.some(
|
||||
(run) => run.execution?.phase === "recovery_needed",
|
||||
)
|
||||
? onTryAgainNoLiveExecutionPath
|
||||
: undefined
|
||||
}
|
||||
tryAgainNoLiveExecutionPathPending={
|
||||
tryAgainNoLiveExecutionPathPending
|
||||
}
|
||||
onRetryFailedRun={retryFailedRunHandler}
|
||||
retryFailedRunId={retryFailedRunId}
|
||||
onOpenSkill={onOpenSkill}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
tail={
|
||||
tailRunId ||
|
||||
optimisticRunnerStartup ||
|
||||
bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail ||
|
||||
optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
? liveRun.execution
|
||||
: null
|
||||
}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||||
items={tailItems}
|
||||
status={
|
||||
optimisticRunnerStartup
|
||||
? "queued"
|
||||
: tailStatus
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
activityUnavailable={tailActivityUnavailable}
|
||||
suppressFinal={
|
||||
suppressPaperclipRunnerTailFinal
|
||||
}
|
||||
continuedAfterSteering={
|
||||
paperclipRunnerTail &&
|
||||
tailTimelineAnchors.length > 0
|
||||
}
|
||||
onRuntimeRequestDecision={
|
||||
handleRuntimeRequestDecision
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
? liveRun.execution
|
||||
: null
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: (liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) ||
|
||||
(tailStatus === "failed"
|
||||
? linkedRunMetaById.get(tailRunId ?? "")?.errorCode?.startsWith("workspace_git_scan_")
|
||||
? "Workspace setup failed before the agent started."
|
||||
: "This run stopped before a response was available. Review the task’s connection or recovery action below."
|
||||
: "Waiting for transcript...")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
attachments={attachments}
|
||||
header={threadHeaderWithBlockers}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={renderBrief}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
onTryAgainNoLiveExecutionPath={
|
||||
issueStatus === "blocked" &&
|
||||
!requiresExecutionReconciliation(
|
||||
props.recoveryAction?.cause,
|
||||
) &&
|
||||
!linkedRuns?.some(
|
||||
(run) => run.execution?.phase === "recovery_needed",
|
||||
)
|
||||
? onTryAgainNoLiveExecutionPath
|
||||
: undefined
|
||||
}
|
||||
tryAgainNoLiveExecutionPathPending={
|
||||
tryAgainNoLiveExecutionPathPending
|
||||
}
|
||||
onRetryFailedRun={retryFailedRunHandler}
|
||||
retryFailedRunId={retryFailedRunId}
|
||||
onOpenSkill={onOpenSkill}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
tail={
|
||||
tailRunId ||
|
||||
optimisticRunnerStartup ||
|
||||
bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail ||
|
||||
optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
? liveRun.execution
|
||||
: null
|
||||
}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||||
items={tailItems}
|
||||
status={
|
||||
optimisticRunnerStartup
|
||||
? "queued"
|
||||
: tailStatus
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
activityUnavailable={tailActivityUnavailable}
|
||||
suppressFinal={
|
||||
suppressPaperclipRunnerTailFinal
|
||||
}
|
||||
continuedAfterSteering={
|
||||
paperclipRunnerTail &&
|
||||
tailTimelineAnchors.length > 0
|
||||
}
|
||||
onRuntimeRequestDecision={
|
||||
handleRuntimeRequestDecision
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
execution={
|
||||
liveRun?.id === tailRunId
|
||||
? liveRun.execution
|
||||
: null
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: (liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) ||
|
||||
(tailStatus === "failed"
|
||||
? linkedRunMetaById.get(tailRunId ?? "")?.errorCode?.startsWith("workspace_git_scan_")
|
||||
? "Workspace setup failed before the agent started."
|
||||
: "This run stopped before a response was available. Review the task’s connection or recovery action below."
|
||||
: "Waiting for transcript...")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{assignedAgentForNotice?.status === "paused" ? (
|
||||
@@ -3173,110 +3179,114 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
) : null}
|
||||
{showComposer ? (
|
||||
<TaskChatComposerDock mobile={isMobile} streamlined={streamlinedUiEnabled} concealed={!historyRevealed}>
|
||||
{composerAccessory}
|
||||
{tailTurnStatus ? (
|
||||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||||
{prepareInitialContent ? (
|
||||
<>
|
||||
{composerAccessory}
|
||||
{tailTurnStatus ? (
|
||||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||||
) : null}
|
||||
<RunnerGoalWidget control={runnerGoal} />
|
||||
<div
|
||||
className="relative isolate flex flex-col"
|
||||
data-testid="task-chat-composer-stack"
|
||||
>
|
||||
{!composerPause ? (
|
||||
<TaskChatQueuedMessages
|
||||
key={issueId}
|
||||
queue={queuedMessageQueue ?? emptyQueuedCommentQueue}
|
||||
onEdit={beginQueuedEdit}
|
||||
onReorder={async (orderedCommentIds, revision) => {
|
||||
if (!onReorderQueuedComments)
|
||||
throw new Error("Queue reordering is unavailable.");
|
||||
await onReorderQueuedComments(
|
||||
orderedCommentIds,
|
||||
revision,
|
||||
);
|
||||
}}
|
||||
onSteer={async (commentId, revision) => {
|
||||
if (!onSteerQueuedComment)
|
||||
throw new Error("Steering is unavailable.");
|
||||
await onSteerQueuedComment(commentId, revision);
|
||||
}}
|
||||
onInterrupt={
|
||||
onInterruptQueued && queuedMessageQueue?.queueId
|
||||
? async () => {
|
||||
await onInterruptQueued(
|
||||
queuedMessageQueue.targetRunId,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDiscard={async (commentId, revision) => {
|
||||
if (commentId.startsWith("optimistic-")) {
|
||||
if (!onCancelQueued)
|
||||
throw new Error("Discard is unavailable.");
|
||||
onCancelQueued(commentId);
|
||||
return;
|
||||
}
|
||||
if (!onDiscardQueuedComment)
|
||||
throw new Error("Discard is unavailable.");
|
||||
await onDiscardQueuedComment(commentId, revision);
|
||||
if (queuedEdit?.commentId === commentId)
|
||||
setQueuedEdit(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10">
|
||||
<TaskChatComposer
|
||||
onAdd={handleThreadAdd}
|
||||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||||
!("clientStatus" in comment && comment.clientStatus)
|
||||
).map((comment) => comment.clientRequestId!))}
|
||||
onReviewConversation={onReviewConversation}
|
||||
onStop={liveRun ? onCancelRun : undefined}
|
||||
stopPending={stopPending}
|
||||
stopScope={stopScope}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(runtimeComposerDisabledReason)}
|
||||
disabledReason={runtimeComposerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
conversationMode={conversationMode}
|
||||
reassignOptions={reassignOptions}
|
||||
agentMap={agentMap}
|
||||
modelAgents={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={composerSuggestedAssignee}
|
||||
companyId={companyId}
|
||||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||||
onPendingAssigneeChange={setPendingComposerAssignee}
|
||||
issueStatus={issueStatus}
|
||||
mobile={isMobile}
|
||||
draftKey={draftKey}
|
||||
queuedEdit={queuedEdit}
|
||||
onSaveQueuedEdit={saveQueuedEdit}
|
||||
onCancelQueuedEdit={() => setQueuedEdit(null)}
|
||||
pause={composerPause}
|
||||
takeover={composerTakeover}
|
||||
runnerGoalCapability={runnerGoal.data?.capability ?? null}
|
||||
onRunnerGoalCommand={runnerGoal.executeComposerCommand}
|
||||
onRunnerGoalReassign={reassignForRunnerGoal}
|
||||
pendingTakeover={
|
||||
pendingReminderInputs.length > 0
|
||||
? {
|
||||
count: pendingReminderInputs.length,
|
||||
label: `${pendingReminderInputs.length} pending input${pendingReminderInputs.length === 1 ? "" : "s"}`,
|
||||
onOpen: openPendingTakeover,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
) : null}
|
||||
<RunnerGoalWidget control={runnerGoal} />
|
||||
<div
|
||||
className="relative isolate flex flex-col"
|
||||
data-testid="task-chat-composer-stack"
|
||||
>
|
||||
{!composerPause ? (
|
||||
<TaskChatQueuedMessages
|
||||
key={issueId}
|
||||
queue={queuedMessageQueue ?? emptyQueuedCommentQueue}
|
||||
onEdit={beginQueuedEdit}
|
||||
onReorder={async (orderedCommentIds, revision) => {
|
||||
if (!onReorderQueuedComments)
|
||||
throw new Error("Queue reordering is unavailable.");
|
||||
await onReorderQueuedComments(
|
||||
orderedCommentIds,
|
||||
revision,
|
||||
);
|
||||
}}
|
||||
onSteer={async (commentId, revision) => {
|
||||
if (!onSteerQueuedComment)
|
||||
throw new Error("Steering is unavailable.");
|
||||
await onSteerQueuedComment(commentId, revision);
|
||||
}}
|
||||
onInterrupt={
|
||||
onInterruptQueued && queuedMessageQueue?.queueId
|
||||
? async () => {
|
||||
await onInterruptQueued(
|
||||
queuedMessageQueue.targetRunId,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDiscard={async (commentId, revision) => {
|
||||
if (commentId.startsWith("optimistic-")) {
|
||||
if (!onCancelQueued)
|
||||
throw new Error("Discard is unavailable.");
|
||||
onCancelQueued(commentId);
|
||||
return;
|
||||
}
|
||||
if (!onDiscardQueuedComment)
|
||||
throw new Error("Discard is unavailable.");
|
||||
await onDiscardQueuedComment(commentId, revision);
|
||||
if (queuedEdit?.commentId === commentId)
|
||||
setQueuedEdit(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10">
|
||||
<TaskChatComposer
|
||||
onAdd={handleThreadAdd}
|
||||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||||
!("clientStatus" in comment && comment.clientStatus)
|
||||
).map((comment) => comment.clientRequestId!))}
|
||||
onReviewConversation={onReviewConversation}
|
||||
onStop={liveRun ? onCancelRun : undefined}
|
||||
stopPending={stopPending}
|
||||
stopScope={stopScope}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(runtimeComposerDisabledReason)}
|
||||
disabledReason={runtimeComposerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
conversationMode={conversationMode}
|
||||
reassignOptions={reassignOptions}
|
||||
agentMap={agentMap}
|
||||
modelAgents={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={composerSuggestedAssignee}
|
||||
companyId={companyId}
|
||||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||||
onPendingAssigneeChange={setPendingComposerAssignee}
|
||||
issueStatus={issueStatus}
|
||||
mobile={isMobile}
|
||||
draftKey={draftKey}
|
||||
queuedEdit={queuedEdit}
|
||||
onSaveQueuedEdit={saveQueuedEdit}
|
||||
onCancelQueuedEdit={() => setQueuedEdit(null)}
|
||||
pause={composerPause}
|
||||
takeover={composerTakeover}
|
||||
runnerGoalCapability={runnerGoal.data?.capability ?? null}
|
||||
onRunnerGoalCommand={runnerGoal.executeComposerCommand}
|
||||
onRunnerGoalReassign={reassignForRunnerGoal}
|
||||
pendingTakeover={
|
||||
pendingReminderInputs.length > 0
|
||||
? {
|
||||
count: pendingReminderInputs.length,
|
||||
label: `${pendingReminderInputs.length} pending input${pendingReminderInputs.length === 1 ? "" : "s"}`,
|
||||
onOpen: openPendingTakeover,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</TaskChatComposerDock>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user