Files
PaperClipAI/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
T
DottaandPaperclip dff8bed007 fix(ui): show chat activity immediately during admission (#20559)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Chat uses a shared runner turn to show activity after Send.
> - The turn currently shows Queued while the server admits the request.
> - People need immediate activity feedback during this short wait.
> - This change uses the existing Working header and Thinking activity
during ordinary admission.
> - Specific server holds stay visible so people can see setup or
maintenance delays.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The activity indicator in task chat and agent chat after Send.

**Current behavior**

Ordinary admission shows Queued in the header and activity row. It
changes to Working and Thinking when execution starts.

**Proposed behavior**

Show Working and Thinking from message submission through ordinary
queued admission. Keep the same avatar, name, and activity row in place.
Preserve specific server waits and the existing queue controls.

**Subsystem affected**

ui/ — React board UI.

## What Changed

- Use the default activity presentation when a queued turn has no
specific server hold.
- Retain maintenance, agent setup, server restart, previous-run,
provider, and preparation labels.
- Omit the elapsed-time suffix until a start time is known. This
prevents a frozen Working for 0s header before a run exists.
- Update component tests for submission, acknowledgement, reassignment,
and runtime discovery on desktop and mobile.
- Update the activity guidance in DESIGN.md.
- Keep this behavior in the shared runner turn. No API, queue, or
execution contract changes are needed.

## Verification

- Chat component suites: 582 passed, 1 existing skip.
- Command: `pnpm exec vitest run
ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
ui/src/components/TaskChatThread.test.tsx
ui/src/components/IssueChatThread.test.tsx`.
- `pnpm check:token-gates`: passed.
- `pnpm verify`: exit 2. DESIGN.md is a global CI input, so the selector
requires broader CI coverage. The three focused chat suites passed again
on final commit `e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Required CI
passed on this revision. See the CI result below.
- The earlier CI attempt on `796fac80a06769d67af5b899fb186a875bfb228d`
failed the unchanged Rust test
`process_lineage::journal::tests::reaping_stops_a_dead_runners_provider_and_its_escaped_tool_job`
with `unattributed_orphan`. This UI change does not alter its compiled
inputs. The cause is not yet established. The current revision passed
the Rust check. The earlier failure cause remains unknown. [Failed
job](https://github.com/paperclipai/paperclip/actions/runs/42843806417/job/129926132490).
- The workspace preflight installed dependencies and built shared and
plugin SDK outputs. Its unrelated Runner build could not start because
cargo is unavailable. The current revision passed CI typecheck and
build.

- Both current-head reviews are 5/5 with no remaining findings. There
are no unresolved review threads.
- Current-head typecheck, build, general tests, serialized server tests,
all eight E2E shards, and Rust checks passed. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/42845383745).
- The Runner support suite reported one 30-second timeout in
`private-reporter-artifacts.test.ts`; 11,291 other tests passed. Both
tests in that file passed locally in isolation (1.64 seconds for the
full command). The one rerun passed in CI attempt 2. The timeout cause
remains unknown. [Failed
job](https://github.com/paperclipai/paperclip/actions/runs/42845383745/job/129932351496).

- Final CI result: attempt 2 passed on
`e85b8e43b007288f3ef41e0a42e801d5d2c41e54`. Typecheck, build, tests,
Rust checks, and all eight E2E shards passed. [CI attempt
2](https://github.com/paperclipai/paperclip/actions/runs/42845383745/attempts/2).

## Risks

- Low risk. This changes presentation only. A queued turn can show
Thinking before provider execution starts.
- Specific server holds remain visible. Scheduling, permissions, and
saved message controls do not change.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The runtime does not expose a more specific deployment model
ID or context window size.

## 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>
2026-11-10 10:04:52 -06:00

327 lines
12 KiB
TypeScript

import { groupImageResources } from "./image-resources";
import { useRef, type ReactNode } from "react";
import type { ExecutionProjection } from "@paperclipai/shared";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
TaskChatMessageItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
} from "./task-chat-model";
import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatSubagentTree } from "./TaskChatSubagentCard";
import { TaskChatRunHealthBanner, partitionRunHealthItems } from "./TaskChatRunHealthBanner";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatShimmerScope, useTaskChatShimmer } from "./TaskChatShimmer";
import {
buildTurnTimelineRows,
isTerminalRunStatus,
omitProgressRepeatedByResponse,
paperclipRunnerFinalResponse,
paperclipRunnerTimelineItems,
} from "./transcript-adapter";
function currentActivityStatusItems(
items: readonly TaskChatItem[],
): readonly TaskChatItem[] {
let boundaryIndex = -1;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (
item.kind === "message" ||
item.kind === "plan_document" ||
(item.kind === "protocol" &&
(item.surface === "runtime_request" ||
item.surface === "run_result" ||
item.surface === "run_terminal"))
) {
boundaryIndex = index;
break;
}
}
return items.slice(boundaryIndex + 1);
}
function formatCompactDuration(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
function terminalStatusFailed(status: string): boolean {
return (
status === "failed" ||
status === "cancelled" ||
status === "timed_out" ||
status === "interrupted"
);
}
function RunnerTurnStatus({
status,
startedAtMs,
finishedAtMs,
continuedAfterSteering = false,
waitingLabel,
}: {
status: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
continuedAfterSteering?: boolean;
waitingLabel?: string | null;
}) {
const terminal = isTerminalRunStatus(status);
useSecondTick(!terminal && startedAtMs != null);
const elapsedMs =
startedAtMs == null
? null
: Math.max(
0,
(terminal ? (finishedAtMs ?? Date.now()) : Date.now()) - startedAtMs,
);
const elapsed = formatCompactDuration(elapsedMs);
const failed = terminalStatusFailed(status);
const label = terminal ? (failed ? "Stopped" : "Worked") : waitingLabel ?? "Working";
const suffix = terminal
? elapsed ? ` ${failed ? "after" : "for"} ${elapsed}` : ""
: elapsed ? `${waitingLabel ? " · " : " for "}${elapsed}` : "";
return (
<span
className="min-w-0 truncate text-sm font-normal text-muted-foreground"
data-testid="task-chat-turn-status-header"
data-turn-position="identity"
aria-live="polite"
aria-atomic="true"
>
{continuedAfterSteering ? <span data-conversation-meta="">Continued after steering · </span> : null}
{label}<span data-conversation-meta="">{suffix}</span>
</span>
);
}
function RunnerCurrentActivityTail({ status, waitingLabel }: { status: string; waitingLabel?: string | null }) {
const shimmer = useTaskChatShimmer("working");
if (isTerminalRunStatus(status)) return null;
return <div className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground" data-testid="task-chat-current-activity" data-turn-position="tail">
<span className={cn(!waitingLabel && shimmer && "shimmer-text shimmer-text-muted")} aria-live="polite" data-testid="task-chat-current-activity-label">{waitingLabel ?? "Thinking"}</span>
</div>;
}
interface RunnerHeaderProps {
runId?: string | null;
agentName?: string | null;
agentIcon?: string | null;
agent?: import("../AgentAvatar").AvatarAgent;
status: string;
execution?: ExecutionProjection | null;
preparationLabel?: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
continuedAfterSteering?: boolean;
}
function runnerWaitingLabel({ runId, status, execution, preparationLabel }: RunnerHeaderProps) {
if (isTerminalRunStatus(status)) return null;
if (execution?.phase === "waiting_for_provider") return execution.label;
if (!runId && preparationLabel) return preparationLabel;
return status === "queued" && execution?.cause ? execution.label : null;
}
/** One identity stays mounted while admission discovers the run's renderer. */
export function TaskChatRunnerHeader({ statusContent, ...props }: RunnerHeaderProps & { statusContent?: ReactNode }) {
return <div className="flex min-h-8 min-w-0 items-center gap-2 pb-1 pt-2" data-testid="task-chat-runner-identity-row">
{props.agentName ? <TaskChatAgentIdentity agentName={props.agentName} agentIcon={props.agentIcon} agent={props.agent} /> : null}
{statusContent ?? <RunnerTurnStatus status={props.status} startedAtMs={props.startedAtMs} finishedAtMs={props.finishedAtMs}
continuedAfterSteering={props.continuedAfterSteering} waitingLabel={runnerWaitingLabel(props)} />}
</div>;
}
export function TaskChatRunnerTurn({
runId,
agentName,
agentIcon,
agent,
items,
status,
execution,
preparationLabel,
startedAtMs,
finishedAtMs,
activityUnavailable = false,
allowSummaryFallback = true,
awaitingContinuation = false,
waitSummary = null,
continuedAfterSteering = false,
showHeader = true,
onRuntimeRequestDecision,
}: {
/** Stable identity used to clear replay-latched final text for the next turn. */
runId?: string | null;
agentName?: string | null;
agentIcon?: string | null;
agent?: import("../AgentAvatar").AvatarAgent;
items: readonly TaskChatItem[];
status: string;
execution?: ExecutionProjection | null;
/** Presentation only while Send/Retry admission has no run yet. */
preparationLabel?: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
activityUnavailable?: boolean;
allowSummaryFallback?: boolean;
/**
* Accepted wait/interaction authority: the turn is waiting, so an early
* provider final that only restates the wait is not presented as a reply.
* Prose the agent wrote for the user stays visible.
*/
awaitingContinuation?: boolean;
/** The accepted yielded result's wait summary, when known. */
waitSummary?: string | null;
/** The visible tail resumes the same native run after an accepted steer. */
continuedAfterSteering?: boolean;
/** The task thread keeps a shared header across native and legacy discovery. */
showHeader?: boolean;
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>;
}) {
const { failures: mcpStartupFailures, rest: turnItems } = partitionRunHealthItems(items);
const terminal = isTerminalRunStatus(status);
const waitingLabel = runnerWaitingLabel({ runId, status, execution, preparationLabel, startedAtMs });
const yielded = turnItems.some(
(item) =>
item.kind === "protocol" &&
item.surface === "run_result" &&
item.disposition === "yielded",
);
const observedFinal = paperclipRunnerFinalResponse(turnItems, awaitingContinuation
? { allowFallback: false, yielded: true, waitSummary }
: { allowFallback: terminal && allowSummaryFallback });
const observedProviderText = Boolean(
observedFinal &&
turnItems.some(
(item) =>
item.kind === "message" &&
item.id === observedFinal.id &&
item.channel !== "progress",
),
);
// A reconnect/replay can briefly rebuild the transcript without the final
// item (or with an earlier, shorter prefix). Provider-authored final text
// always replaces a structured summary fallback, even when it is shorter;
// within either class, displayed answer text remains monotonic.
const finalRef = useRef<{
runId?: string | null;
item?: TaskChatMessageItem;
providerText?: boolean;
}>({ runId });
if (finalRef.current.runId !== runId) finalRef.current = { runId };
// A provider final can arrive before the accepted yielded result. Clear any
// replay latch once the control plane establishes that this turn is waiting
// for continuation; only an authored (non-wait) final is re-latched below.
if (yielded || awaitingContinuation) finalRef.current = { runId };
if (
observedFinal &&
(!finalRef.current.item ||
(observedProviderText && !finalRef.current.providerText) ||
(observedProviderText === Boolean(finalRef.current.providerText) &&
observedFinal.text.length >= finalRef.current.item.text.length))
) {
finalRef.current.item = observedFinal;
finalRef.current.providerText = observedProviderText;
}
const final = finalRef.current.item;
const timelineItems = paperclipRunnerTimelineItems(turnItems);
const currentActivityItems = currentActivityStatusItems(timelineItems);
const timelineRows = groupImageResources(buildTurnTimelineRows(
omitProgressRepeatedByResponse(timelineItems, final?.text),
!terminal,
));
return (
<TaskChatShimmerScope items={items}><div
className="flex min-w-0 flex-col"
data-testid="task-chat-runner-turn"
data-phase={status === "queued" ? "startup" : undefined}
>
{showHeader ? <TaskChatRunnerHeader runId={runId} agentName={agentName} agentIcon={agentIcon} agent={agent}
status={status} execution={execution} preparationLabel={preparationLabel} startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs} continuedAfterSteering={continuedAfterSteering} /> : null}
<TaskChatRunHealthBanner failures={mcpStartupFailures} />
{activityUnavailable ? (
<div
className="px-1 py-1 text-xs text-muted-foreground"
role="status"
data-testid="task-chat-activity-unavailable"
>
Live runner activity is temporarily unavailable. Retrying…
</div>
) : null}
{timelineRows.length > 0 ? (
<div
className="flex min-w-0 flex-col gap-2 py-1"
data-testid="task-chat-turn-timeline"
>
{timelineRows.map((row) => (
<div
className="min-w-0"
key={`${runId ?? "run"}:${row.id}`}
data-testid="task-chat-turn-timeline-row"
data-timeline-row-id={row.id}
data-thread-anchor={row.id}
>
{row.kind === "activity_phase" ? (
<TaskChatRunnerActivityGroup item={row} />
) : row.kind === "plan_document" ? (
<TaskChatPlanPreviewCard
source={{ kind: "saved", document: row.document }}
testId={
row.placement === "fallback"
? "task-chat-plan-preview-fallback"
: "task-chat-plan-preview"
}
/>
) : row.kind === "marker" ? (
<TaskChatMarker item={row} />
) : row.kind === "subagent_group" ? (
<TaskChatSubagentTree subagents={row.subagents} />
) : row.kind === "protocol" ? (
<TaskChatProtocolCard
item={row}
onRuntimeRequestDecision={onRuntimeRequestDecision}
/>
) : null}
</div>
))}
</div>
) : null}
{final ? (
<div
className="w-full"
data-testid="task-chat-final-response"
>
<TaskChatBubble
item={{ ...final, authorName: agentName ?? undefined, agentIcon, agent, timestamp: final.timestamp ?? formatTaskChatTimestamp(final.atMs) }}
animateEntry={false}
hideAgentIdentity={!continuedAfterSteering}
actions={<TaskChatBubbleActions copyText={final.text} />}
/>
</div>
) : null}
{!final && currentActivityItems.length === 0 ? <RunnerCurrentActivityTail status={status} waitingLabel={waitingLabel} /> : null}
</div></TaskChatShimmerScope>
);
}