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:
DottaandPaperclip authored and GitHub committed 2026-10-19 14:01:37 -05:00
1 parent 45b67f1d93
commit 4d92589799
4 files changed
+341 -248

No files matched your search

+7
View File
@@ -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}`);
+36 -8
View File
@@ -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", [{
+250 -240
View File
@@ -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>