diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index 973d35c0e7..50cf963fa4 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -815,7 +815,7 @@ Every continuation carries the triggering request, ordered user direction, inter
Legacy adapters without a verified resume capability use the same automatic no-replay disposition after provider failure. An availability error family (including quota or upstream overload) is not proof that earlier actions did not happen. The compatible adapter result field `executionRecovery: { kind: "bootstrap", providerWorkStarted: false }` can establish a pre-provider retry; the server records the same evidence for failures before adapter dispatch. Bootstrap retries and process-loss bootstrap retries use the same durable counter and delay. Productive max-turn continuation remains a separate execution boundary rather than a failed provider incident. A pre-dispatch wait for a confirmed live workspace holder is also a resource wait, not a provider failure: explicit `workspace_wait` evidence preserves that wait path without consuming the failure incident budget.
-The server projection remains available for execution diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. A retry may briefly change the existing transcript header to Reconnecting; attempts, causes, and recovery decisions belong in the run log. There is no reconciliation dialog. Safe recovery remains automatic. If it cannot continue safely, the source-scoped recovery record resolves with a blocked no-replay disposition and the ordinary task status becomes blocked, preserving its owner. Resolving this record does not grant replay authority: dispatch continues enforcing the durable hold. Replacement history remains inspectable and the composer stays usable.
+The server projection remains available for execution diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. There is no reconciliation dialog. Safe recovery remains automatic. If it cannot continue safely, the source-scoped recovery record resolves with a blocked no-replay disposition and the ordinary task status becomes blocked, preserving its owner. Resolving this record does not grant replay authority: dispatch continues enforcing the durable hold. Replacement history remains inspectable and the composer stays usable.
An operator Stop reaches embedded ACP execution through its run-owned cancellation signal. The response waits for adapter settlement; acknowledgment requires the local provider to have exited. A deadline or failed cleanup never grants continuation permission. A persistent local ACP session can record an interrupted checkpoint only after acknowledged cancellation, complete tool reporting with settled reads (or no tools), and successful cleanup. Writes, shell commands, incomplete client-operation receipts, forced cancellation, and lost transports retain the ordinary no-replay hold. Continuation must restore the same compatible session; an unavailable checkpoint cannot fall back to a new session. A restored provider receives the current run identity, API credential, and scratch environment. Run-owned scratch paths rotate without changing session identity, while user configuration changes still invalidate compatibility.
diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx
index 4ae9b2263a..b68d80f49a 100644
--- a/ui/src/components/ActiveAgentsPanel.tsx
+++ b/ui/src/components/ActiveAgentsPanel.tsx
@@ -203,7 +203,7 @@ const AgentRunCard = memo(function AgentRunCard({
- {(run.execution?.phase === "reconnecting" || run.execution?.phase === "retry_scheduled") ? "Reconnecting…" : (isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`)}
+ {isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index 8d3ebbba96..5c60e88650 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -1261,12 +1261,7 @@ function IssueChatChainOfThought({
let headerVerb: string;
let headerSuffix: string | null = null;
if (isActive) {
- const execution = custom.execution as { phase?: string } | undefined;
- headerVerb =
- execution?.phase === "reconnecting" ||
- execution?.phase === "retry_scheduled"
- ? "Reconnecting…"
- : "Working";
+ headerVerb = "Working";
if (liveElapsed) headerSuffix = `for ${liveElapsed}`;
} else if (segmentTiming) {
const durationMs = segmentTiming.endMs - segmentTiming.startMs;
diff --git a/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx b/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
index 4f43289f8d..363636431e 100644
--- a/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
+++ b/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
@@ -2,7 +2,8 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ExecutionProjection } from "@paperclipai/shared";
import type { TranscriptEntry } from "../../adapters";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
@@ -50,6 +51,7 @@ describe("TaskChatLiveRunPill", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
+ vi.useRealTimers();
});
it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
@@ -71,6 +73,34 @@ describe("TaskChatLiveRunPill", () => {
expect(pill?.textContent).toContain("called 3 tools");
});
+ it.each(["reconnecting", "retry_scheduled"] as const)(
+ "keeps Working animated and the timer advancing with a %s projection",
+ (phase) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
+ const startedAtMs = Date.now() - 6_000;
+ const execution = { phase } as ExecutionProjection;
+ const render = (status: string) => act(() => root.render(
+ ,
+ ));
+ render("running");
+ expect(container.querySelector(".shimmer-text")?.textContent).toBe("Working");
+ expect(container.querySelector(".animate-spin")).not.toBeNull();
+ expect(container.textContent).toContain("for 6 seconds");
+ expect(container.textContent).toContain("called 2 tools");
+ act(() => vi.advanceTimersByTime(2_000));
+ expect(container.textContent).toContain("for 8 seconds");
+ render("succeeded");
+ expect(container.textContent).toContain("Worked");
+ expect(container.querySelector(".animate-spin")).toBeNull();
+ render("failed");
+ expect(container.textContent).toContain("Stopped");
+ expect(container.textContent).not.toContain("Reconnecting");
+ },
+ );
+
it("settles to a static 'Worked' summary once the run is terminal", () => {
const startedAtMs = 1_000;
act(() => {
diff --git a/ui/src/components/task-chat/TaskChatLiveRunPill.tsx b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx
index 68e0fc5271..8c58ff6b7a 100644
--- a/ui/src/components/task-chat/TaskChatLiveRunPill.tsx
+++ b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx
@@ -45,7 +45,6 @@ export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[])
*/
export function TaskChatLiveRunPill({
status,
- execution,
startedAtMs,
finishedAtMs,
toolSummary,
@@ -58,7 +57,7 @@ export function TaskChatLiveRunPill({
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
- const active = !isTerminalRunStatus(status) && (!execution || execution.phase === "working");
+ const active = !isTerminalRunStatus(status);
// One shared page-wide ticker drives the live elapsed readout, matching the
// default view's `useLiveElapsed`.
useSecondTick(active && startedAtMs != null);
@@ -68,9 +67,8 @@ export function TaskChatLiveRunPill({
const elapsed = elapsedMs != null
? formatDurationWords(elapsedMs)
: null;
- const reconnecting = execution?.phase === "reconnecting" || execution?.phase === "retry_scheduled";
const failed = ["failed", "timed_out", "cancelled", "interrupted"].includes(status);
- const verb = reconnecting ? "Reconnecting…" : (!isTerminalRunStatus(status) ? "Working" : failed ? "Stopped" : "Worked");
+ const verb = active ? "Working" : failed ? "Stopped" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
return (
@@ -83,7 +81,7 @@ export function TaskChatLiveRunPill({
) : (
-
+
)}
{active ? {verb} : verb}
diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
index 997a0d5570..21b2b1aaa8 100644
--- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
+++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
+import type { ExecutionProjection } from "@paperclipai/shared";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +41,7 @@ describe("TaskChatRunnerTurn", () => {
) => void,
suppressFinal = false,
continuedAfterSteering = false,
+ execution?: ExecutionProjection,
) =>
act(() =>
root.render(
@@ -50,6 +52,7 @@ describe("TaskChatRunnerTurn", () => {
agentName="Runner"
items={items}
status={status}
+ execution={execution}
startedAtMs={Date.now() - 2_000}
suppressFinal={suppressFinal}
continuedAfterSteering={continuedAfterSteering}
@@ -141,6 +144,26 @@ describe("TaskChatRunnerTurn", () => {
);
});
+ it.each(["reconnecting", "retry_scheduled"] as const)(
+ "keeps the active turn and Thinking tail visible with a %s projection",
+ (phase) => {
+ const execution = { phase } as ExecutionProjection;
+ render([], "running", "run-1", undefined, false, false, execution);
+ expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
+ .toContain("Working for");
+ expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
+ .toBe("Thinking");
+ render([], "succeeded", "run-1", undefined, false, false, execution);
+ expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
+ .toContain("Worked");
+ expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
+ render([], "failed", "run-1", undefined, false, false, execution);
+ expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
+ .toContain("Stopped");
+ expect(container.textContent).not.toContain("Reconnecting");
+ },
+ );
+
it("labels the streaming tail as a continuation after steering", () => {
render([], "running", "run-1", undefined, false, true);
diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
index 9eab3a83ba..7f944e6c1b 100644
--- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
+++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
@@ -292,13 +292,11 @@ function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) {
function RunnerTurnStatus({
status,
- execution,
startedAtMs,
finishedAtMs,
continuedAfterSteering = false,
}: {
status: string;
- execution?: ExecutionProjection | null;
startedAtMs: number | null;
finishedAtMs?: number | null;
continuedAfterSteering?: boolean;
@@ -315,9 +313,8 @@ function RunnerTurnStatus({
const elapsed = formatCompactDuration(elapsedMs);
const failed = terminalStatusFailed(status);
- const reconnecting = execution?.phase === "reconnecting" || execution?.phase === "retry_scheduled";
- const label = reconnecting ? "Reconnecting…" : (terminal ? (failed ? "Stopped" : "Worked") : "Working");
- const semanticLabel = reconnecting ? label : terminal
+ const label = terminal ? (failed ? "Stopped" : "Worked") : "Working";
+ const semanticLabel = terminal
? elapsed
? `${label} ${failed ? "after" : "for"} ${elapsed}`
: label
@@ -431,7 +428,6 @@ export function TaskChatRunnerTurn({
agentIcon,
items,
status,
- execution,
startedAtMs,
finishedAtMs,
activityUnavailable = false,
@@ -530,7 +526,6 @@ export function TaskChatRunnerTurn({
) : null}
) : null}
- {!final && (!execution || execution.phase === "working") ? : null}
+ {!final ? : null}
);
}
diff --git a/ui/src/lib/issueActiveRun.test.ts b/ui/src/lib/issueActiveRun.test.ts
index 78b961185b..2da4aae000 100644
--- a/ui/src/lib/issueActiveRun.test.ts
+++ b/ui/src/lib/issueActiveRun.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
-import type { ActiveRunForIssue } from "../api/heartbeats";
+import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun";
describe("issueActiveRun", () => {
@@ -12,6 +12,39 @@ describe("issueActiveRun", () => {
...overrides,
});
+ it("refreshes the selected run from the polled list after startup confirmation", () => {
+ const issue = makeIssue({ status: "in_progress", executionRunId: "run-1" });
+ const initialRun = {
+ id: "run-1",
+ status: "running",
+ execution: { phase: "reconnecting", label: "Confirming execution" },
+ } as ActiveRunForIssue;
+ const refreshedRun = {
+ ...initialRun,
+ execution: { phase: "working", label: "Working" },
+ currentToolName: "Read file",
+ } as LiveRunForIssue;
+ const otherRun = { ...refreshedRun, id: "run-2" };
+
+ expect(resolveIssueActiveRun(issue, initialRun)).toBe(initialRun);
+ expect(resolveIssueActiveRun(issue, initialRun, [otherRun, refreshedRun])).toBe(refreshedRun);
+ expect(resolveIssueActiveRun(issue, initialRun, [otherRun])).toBe(initialRun);
+ expect(resolveIssueActiveRun(issue, null, [refreshedRun])).toBe(refreshedRun);
+ expect(resolveIssueActiveRun(makeIssue({ status: "done" }), initialRun, [refreshedRun])).toBeNull();
+ });
+
+ it("selects the task's replacement run instead of the cached predecessor", () => {
+ const issue = makeIssue({ status: "in_progress", executionRunId: "run-new" });
+ const oldRun = { id: "run-old", status: "running" } as LiveRunForIssue;
+ const newRun = { id: "run-new", status: "running" } as LiveRunForIssue;
+
+ expect(resolveIssueActiveRun(issue, oldRun, [oldRun, newRun])).toBe(newRun);
+ expect(resolveIssueActiveRun(issue, oldRun, [oldRun])).toBeNull();
+ expect(resolveIssueActiveRun(issue, oldRun)).toBeNull();
+ expect(resolveIssueActiveRun(issue, null, [newRun])).toBe(newRun);
+ expect(resolveIssueActiveRun(makeIssue({ status: "in_progress" }), oldRun, [oldRun, newRun])).toBe(oldRun);
+ });
+
it("tracks active runs while an issue is still in progress", () => {
expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true);
});
diff --git a/ui/src/lib/issueActiveRun.ts b/ui/src/lib/issueActiveRun.ts
index 48b8d5c3be..af289c30f7 100644
--- a/ui/src/lib/issueActiveRun.ts
+++ b/ui/src/lib/issueActiveRun.ts
@@ -1,5 +1,5 @@
import type { Issue } from "@paperclipai/shared";
-import type { ActiveRunForIssue } from "../api/heartbeats";
+import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
export function shouldTrackIssueActiveRun(
issue: Pick | null | undefined,
@@ -10,6 +10,13 @@ export function shouldTrackIssueActiveRun(
export function resolveIssueActiveRun(
issue: Pick | null | undefined,
activeRun: ActiveRunForIssue | null | undefined,
+ liveRuns?: readonly LiveRunForIssue[],
): ActiveRunForIssue | null {
- return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null;
+ if (!shouldTrackIssueActiveRun(issue)) return null;
+ // The active-run query stops polling while the live-run list is populated.
+ // Prefer the task's current execution identity when its cached run is stale.
+ const runId = issue?.executionRunId ?? activeRun?.id;
+ if (!runId) return null;
+ return liveRuns?.find((run) => run.id === runId)
+ ?? (activeRun?.id === runId ? activeRun : null);
}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index cfefcb1f17..f7c0fafdaa 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -2173,8 +2173,8 @@ export function DesignGuide() {
Recovery runs in the background. Task lists keep their ordinary status without
- execution badges. The transcript may briefly say Reconnecting, then resumes its
- normal presentation. Recovery decisions and attempts belong in the run log;
+ execution badges. Active transcript headers keep saying Working during automatic
+ recovery. Recovery decisions and attempts belong in the run log;
there is no execution status card or reconciliation form.
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 9e37998b8d..83bf61661c 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -1462,8 +1462,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
});
const resolvedActiveRun = useMemo(
() =>
- resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun),
- [activeRun, executionRunId, issueStatus],
+ resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun, liveRuns),
+ [activeRun, executionRunId, issueStatus, liveRuns],
);
const assigneeUsesPaperclipRunner = Boolean(
issueAssigneeAgentId &&
diff --git a/ui/storybook/stories/execution-recovery.stories.tsx b/ui/storybook/stories/execution-recovery.stories.tsx
index 25482cb548..609f5456c6 100644
--- a/ui/storybook/stories/execution-recovery.stories.tsx
+++ b/ui/storybook/stories/execution-recovery.stories.tsx
@@ -81,11 +81,15 @@ const state = (overrides: Partial) => ({
args: { execution: { ...base, ...overrides } },
});
export const Working: Story = state({});
-export const Reconnecting: Story = state({
- phase: "reconnecting",
- label: "Reconnecting",
- attempt: 2,
-});
+export const Reconnecting: Story = {
+ ...state({ phase: "reconnecting", label: "Reconnecting", attempt: 2 }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("task-chat-turn-status-header")).toHaveTextContent("Working");
+ await expect(canvas.getByTestId("task-chat-current-activity-label")).toHaveTextContent("Thinking");
+ await expect(canvas.queryByText(/Reconnecting/)).not.toBeInTheDocument();
+ },
+};
export const RetryScheduled: Story = state({
phase: "retry_scheduled",
label: "Retry scheduled",
@@ -262,8 +266,8 @@ export const NativeChatStatusLabels: Story = {
Native-runner chat status labels
- The existing transcript header stays quiet. Only an intermediate
- reconnection briefly changes its text.
+ Active transcript headers keep saying Working during automatic recovery.
+ Current activity remains visible while the run continues.
{labelExamples.map((execution) => (
@@ -286,8 +290,8 @@ export const LegacyChatStatusLabels: Story = {
Legacy chat status labels
- Normal transcript presentation, with a brief neutral reconnection
- indicator and no recovery panel.
+ Active transcripts keep the Working indicator and elapsed timer during
+ automatic recovery, with no recovery panel.
{labelExamples.map((execution) => (
@@ -350,8 +354,8 @@ function DashboardLabelExamples() {
Dashboard agent-card labels
- The existing dashboard layout stays unchanged. Reconnection is a brief
- update to the existing line, with no additional card or controls.
+ Active agents say Working during automatic recovery, with no additional
+ card or controls.