Files
PaperClipAI/ui/src/components/task-chat/TaskChatThreadView.tsx
T
Clément df5a78cf91 fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
## Thinking Path

> - Paperclip keeps human-agent conversations attached to tasks.
> - People need to read and reply comfortably from a phone.
> - The mobile task page scrolled the document and positioned its
composer with a sticky navigation offset.
> - Safari's keyboard resize and later focus pan could move that
document again, causing overlap, gaps, and jumps while editing.
> - This change gives mobile and desktop the same message scroller and a
normal flex footer.
> - CSS owns the layout. A small VisualViewport adapter handles the
visible height and Safari's stale document focus scroll.
> - Streaming no longer measures every historical row while following
the bottom.

## Linked Issues or Issue Description

Fixes https://github.com/paperclipai/paperclip/issues/16497

Related: https://github.com/paperclipai/paperclip/pull/15245 also
addresses keyboard positioning. This change additionally removes mobile
document auto-follow and reduces historical-row measurement work.

## What Changed

### One scroll owner and a natural-flow composer

Before:

```text
Document scroll + mobile auto-follow
  └─ Conversation + sticky composer + animated navigation offset
```

After:

```text
Height-constrained conversation shell
  ├─ Header
  ├─ Message scroller — owns follow/reading position
  └─ Composer — normal flex footer
Navigation stays at the layout viewport bottom, behind the keyboard
```

- Reuse TaskMessageScroller on mobile. Delete the separate 136-line
useWindowAutoFollow implementation and its superseded tests.
- Keep the composer in normal flex flow. Remove its sticky bottom
positioning and navigation-offset animation.
- Constrain the mobile conversation with CSS. Classic tasks and other
pages retain their existing layout.
- Add a 47-line useMobileVisualViewport hook. It changes shell height on
resize, not on typing or incoming messages. It never translates the
shell or hides navigation.
- Reject Safari's stale document focus pan only for an unzoomed,
keyboard-open conversation. Inner message and editor scrolling stay
native.
- Keep a saved reading anchor when reading older messages. Skip
historical-row measurements while following the bottom.
- Update tests and the existing mobile streaming fixture. Keep only two
layout-contract sentences in DESIGN.md.

**Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall.
Production UI/CSS alone is +158 / -190 lines. No API, database, or
dependency changes.

## Verification

Bottom-follow is also saved explicitly for Back navigation. A new
regression covers returning after rotation changes message wrapping,
without restoring historical-row scans.

### Physical iPhone acceptance

Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed:

- Opening the keyboard positions the composer correctly immediately,
before any typing.
- The installed web app works.
- Long drafts, insertion, deletion, and sending are acceptable. The
reporter described long drafts as not perfect but good enough.
- Reading older messages during incoming output preserves position;
returning to the bottom resumes following.
- Rotation, keyboard close/reopen, and attachment/picker transitions
work.

Remote inspection corroborated the corrected open-keyboard state:
visible height 358px, composer bottom 358px, document scrollY 0.
Navigation remained display:block at y=631px, behind the keyboard.

Earlier candidates failed device testing. The decisive trace showed a
correct viewport resize followed about one frame later by a stale 337px
native document focus pan. Typing caused Safari to correct it. Tests now
cover that event ordering, the reverse ordering, and zoom panning.

### Performance evidence

The existing TaskChatPerfHarness supplied 200 historical responses,
4,000 collapsed tools, and 30 updates at 100ms intervals.

| Measurement | Before | After |
| --- | ---: | ---: |
| Historical row geometry reads | 6,000 | 0 |
| Renderer main-thread work | 331.2ms | 273.5ms |
| Largest task | 27.1ms | 12.3ms |

This is one instrumented Chromium development run on the Mac, not a
phone benchmark or a statistical speedup claim. The zero-row-measurement
result also has a regression test that failed with 6,000 reads before
the change.

To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite
server. Record 30 streaming updates at the bottom. Inspect
TaskMessageScroller and row getBoundingClientRect calls. Existing
historical-render tests also confirm that unchanged Markdown does not
reparse on live-tail updates.

### Automated and full-app checks

- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031)
is green: 54 successful checks and 2 skipped checks. Greptile rates the
current head 5/5, confirms the Back-navigation fix, and has no
unresolved findings.

- **601 focused tests pass** across the task page, layout, composer,
scroller, viewport adapter, and historical-render coverage.
- UI typecheck and production build pass.
- Token gates, Storybook catalog checks, and catalog/map structural
tests pass.
- Independent review of the final source tree passed with no findings.
- The full source app ran on macOS with an isolated database and saved
conversation history. A reply sent through the real UI persisted after
reload. Provider execution was not part of this layout test.
- **Full local repository checks did not pass.** Build/typecheck stop at
missing Rust cargo. The full test run reported 142 failed server tests
and 3 failed suites; missing cargo is confirmed in those three suites.
The other server failures were not diagnosed in this UI task.
Current-head CI now supplies the repository-wide evidence; these local
failures remain disclosed.

## Risks

- Mobile conversations now share the desktop scroll owner. Navigation,
loading, history restoration, and reading-position tests cover that
change.
- The keyboard-state threshold is a 120px viewport reduction.
Accessory-only keyboards retain the normal navigation spacing.
- The document-scroll fallback is scoped to rendered conversations with
an open keyboard at scale 1. It does not handle inner scrolling or
prevent zoom panning.
- Long-draft behavior is accepted, with minor polish still possible.
- The UI change is reversible and requires no data migration.

## Model Used

OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode.
Capabilities used: reasoning, code editing, tests, Chromium performance
tracing, and Safari remote inspection. The harness does not expose the
context-window size. An independent general-purpose subagent reviewed
the final tree; its serving model ID was not exposed.

## 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; repository-wide
failures are disclosed above
- [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
2026-10-26 08:44:39 -07:00

474 lines
18 KiB
TypeScript

import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity";
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard";
import { useMemo, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
} from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskMessageScroller } from "./TaskMessageScroller";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { compactAdjacentErrors } from "./adjacent-errors";
const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
interface TaskChatThreadViewProps {
items: TaskChatItem[];
/**
* Content rendered above the first message INSIDE the scroll viewport (the
* issue header when hosted by the live thread) — it scrolls away with the
* messages instead of staying pinned above the thread.
*/
header?: ReactNode;
onApprovalDecision?: (statusItemId: string, optionId: string) => void;
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>;
/**
* Renders an interleaved issue-thread interaction (the live thread supplies
* TaskChatInteractionCard bound to its accept/reject handlers). Interaction
* items render nothing without it — the harness has no control plane.
*/
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
/**
* Renders the description-as-first-bubble placeholder (PAP-375). The live
* thread binds TaskChatDescriptionBubble to the issue; brief items render
* nothing without it.
*/
renderBrief?: () => ReactNode;
/**
* Renders the copy/👍/👎 action cluster prepended to an agent bubble's footer
* line (PAP-413). The live thread binds it to the feedback-vote API; harness
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
/** Renders an interrupt action beside a queued human message. */
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode;
/** Requeues a blocked task from its no-live-execution-path failure surfaces. */
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
tryAgainNoLiveExecutionPathPending?: boolean;
onRetryFailedRun?: (runId: string) => Promise<void> | void;
retryFailedRunId?: string | null;
/** Content appended inside the transcript scroller after the settled thread. */
tail?: ReactNode;
/** Optional streaming-aware key when `tail` changes without changing `items`. */
contentKey?: unknown;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
attachments?: IssueAttachment[];
onOpenSkill?: (skillId: string, name: string) => void;
onOpenBrowser?: (browserId: string) => void;
}
function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode,
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>,
activityAppearance: "classic" | "runner" = "classic",
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void,
tryAgainNoLiveExecutionPathPending = false,
retryableMarkerId?: string,
onRetryFailedRun?: (runId: string) => Promise<void> | void,
retryFailedRunId?: string | null,
attachments: IssueAttachment[] = [],
onOpenSkill?: (skillId: string, name: string) => void,
onOpenBrowser?: (browserId: string) => void,
) {
switch (item.kind) {
case "browser": return <TaskBrowserActivity browser={item.browser} label={item.label} onOpen={onOpenBrowser} />;
case "project_created": return <TaskChatProjectCreatedCard item={item} />;
case "skill_created": return <TaskChatSkillCreatedCard item={item} onOpen={onOpenSkill} />;
case "message": {
// Compute the actions once: the bubble renders them for a runless reply
// (footer = actions + timestamp), while an attached turn hands them to
// TaskChatTurn's `leading` slot so they ride the summary line and stay
// put when the tool history expands (PAP-413). The two paths are mutually
// exclusive at runtime, so only one host ever mounts the node.
const actions = renderMessageActions?.(item);
const attachedTurnItem = item.attachedTurn?.standaloneHeader
? {
...item.attachedTurn,
agentName: item.attachedTurn.agentName ?? item.authorName,
agentIcon: item.attachedTurn.agentIcon ?? item.agentIcon,
agent: item.attachedTurn.agent ?? item.agent,
}
: item.attachedTurn;
const turn = attachedTurnItem ? (
<TaskChatTurn
item={attachedTurnItem}
timestampPrefix={
attachedTurnItem.standaloneHeader ? undefined : item.timestamp
}
leading={attachedTurnItem.standaloneHeader ? undefined : actions}
renderChild={(child) =>
renderItem(
child,
onApprovalDecision,
undefined,
undefined,
undefined,
undefined,
onRuntimeRequestDecision,
item.attachedTurn?.standaloneHeader ? "runner" : "classic",
undefined,
false,
undefined,
undefined,
undefined,
attachments,
onOpenSkill,
onOpenBrowser,
)
}
/>
) : undefined;
return (
<TaskChatBubble
item={item}
// Hydration and live-to-durable reconciliation may move a logical
// message between parents. Mounting must never replay a fade.
animateEntry={false}
actions={
item.attachedTurn?.standaloneHeader
? actions
: item.attachedTurn
? undefined
: actions
}
queuedAction={renderQueuedAction?.(item)}
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
hideAgentIdentity={Boolean(item.attachedTurn?.standaloneHeader)}
onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath}
tryAgainNoLiveExecutionPathPending={
tryAgainNoLiveExecutionPathPending
}
attachments={attachments}
/>
);
}
case "marker":
return (
<TaskChatMarker
item={item}
onTryAgain={
item.id === retryableMarkerId && item.retryable !== false
? item.runId && onRetryFailedRun
? () => onRetryFailedRun(item.runId!)
: onTryAgainNoLiveExecutionPath
: undefined
}
tryAgainPending={
item.runId
? retryFailedRunId === item.runId
: tryAgainNoLiveExecutionPathPending
}
/>
);
case "thinking":
return <TaskChatThinking item={item} />;
case "tool":
return <TaskChatToolCard item={item} />;
case "status":
return (
<TaskChatStatusPill
item={item}
onApprovalDecision={(optionId) =>
onApprovalDecision?.(item.id, optionId)
}
/>
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
// Legacy adapter transcripts and native runner transcripts now share the
// same compact activity treatment. Keeping this decision at the common
// renderer boundary also gives old persisted runs the current taxonomy,
// alignment, one-line targets, and collapsed-by-default behavior.
return <TaskChatRunnerActivityGroup item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "plan_document":
return (
<TaskChatPlanPreviewCard
source={{ kind: "saved", document: item.document }}
testId={
item.placement === "fallback"
? "task-chat-plan-preview-fallback"
: "task-chat-plan-preview"
}
/>
);
case "brief":
return renderBrief ? renderBrief() : null;
case "turn":
return (
<TaskChatTurn
item={item}
renderChild={(child) =>
renderItem(
child,
onApprovalDecision,
undefined,
undefined,
undefined,
undefined,
onRuntimeRequestDecision,
item.standaloneHeader ? "runner" : "classic",
undefined,
false,
undefined,
undefined,
undefined,
attachments,
onOpenSkill,
onOpenBrowser,
)
}
/>
);
case "protocol":
return (
<TaskChatProtocolCard
item={item}
onRuntimeRequestDecision={onRuntimeRequestDecision}
/>
);
default: {
// Exhaustiveness guard: a new item kind must add a branch above.
const _never: never = item;
return _never;
}
}
}
function isSystemLikeItem(item: TaskChatItem): boolean {
return (
item.kind === "marker" ||
(item.kind === "message" && item.author === "system")
);
}
export function taskChatItemSpacingClass(
item: TaskChatItem,
previousItem: TaskChatItem | null,
): string | undefined {
if (!previousItem) return undefined;
const currentIsSystemLike = isSystemLikeItem(item);
const previousIsSystemLike = isSystemLikeItem(previousItem);
if (currentIsSystemLike && previousIsSystemLike) return "mt-2";
if (currentIsSystemLike || previousIsSystemLike) return "mt-3";
if (item.kind === "turn" || previousItem.kind === "turn") return "mt-3";
if (item.kind === "interaction" || previousItem.kind === "interaction")
return "mt-4";
return "mt-6";
}
/**
* Presentational render layer for the redesigned task thread. Consumed by both
* the live thread (adapter over comment/run props) and the dev harness
* (synthetic fixtures). Owns no data fetching — it maps a normalized
* TaskChatItem[] onto the primitives inside the auto-follow scroller.
*/
export function TaskChatThreadView({
items,
header,
onApprovalDecision,
onRuntimeRequestDecision,
renderInteraction,
renderBrief,
renderMessageActions,
renderQueuedAction,
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending = false,
onRetryFailedRun,
retryFailedRunId = null,
tail,
contentKey,
className,
scroll = true,
attachments = EMPTY_ATTACHMENTS,
onOpenSkill,
onOpenBrowser,
}: TaskChatThreadViewProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const { items: visibleItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]);
const retryableMarkerId =
onRetryFailedRun || onTryAgainNoLiveExecutionPath
? [...visibleItems]
.reverse()
.find(
(item) => {
const marker = item.kind === "marker" ? item : retryMarkers.get(item.id);
return marker?.variant === "interrupted" &&
(marker.label === "Run failed" || marker.label === "Usage limit reached");
},
)?.id
: undefined;
// Streaming tail and header updates must not rebuild settled markdown/tool trees.
const history = useMemo(() => {
const renderVisibleItem = (item: TaskChatItem) => {
const marker = item.id === retryableMarkerId ? retryMarkers.get(item.id) : undefined;
const runId = marker?.retryable !== false ? marker?.runId : undefined;
const runRetry = !onTryAgainNoLiveExecutionPath && onRetryFailedRun && runId
? () => onRetryFailedRun(runId)
: undefined;
return renderItem(
item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions,
renderQueuedAction, onRuntimeRequestDecision, "classic",
runRetry ?? onTryAgainNoLiveExecutionPath,
runRetry ? retryFailedRunId === runId : tryAgainNoLiveExecutionPathPending,
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
);
};
const renderedItems = streamlined
? visibleItems.map((item) => ({ item, content: renderVisibleItem(item) }))
.filter((entry) => entry.content !== null)
: [];
return (
<>
{streamlined
? renderedItems.map(({ item, content }, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-conversation-typography=""
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={taskChatItemSpacingClass(
item,
renderedItems[index - 1]?.item ?? null,
)}
data-thread-item-kind={
item.kind === "message" ? item.author : item.kind
}
>
{content}
</div>
))
: visibleItems.map((item, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-conversation-typography=""
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={cn(
index > 0 &&
item.kind === "interaction" &&
item.interaction.status !== "pending" &&
"-mt-3",
)}
>
{renderVisibleItem(item)}
</div>
))}
</>
);
}, [
visibleItems, retryMarkers, streamlined, onApprovalDecision, onRuntimeRequestDecision,
renderInteraction, renderBrief, renderMessageActions, renderQueuedAction,
onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending,
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
]);
const body = (
<div
className={cn(
"paperclip-mobile-thread mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-1 py-3 md:px-4 md:py-4",
streamlined ? "md:px-0" : "gap-5",
className,
)}
>
{header ? (
<div
className={cn("flex flex-col gap-6", streamlined ? "pb-4" : "pb-2")}
data-testid="task-chat-thread-header"
>
{header}
</div>
) : null}
{history}
{tail ? <div data-conversation-typography="" className={streamlined ? "mt-4" : undefined}>{tail}</div> : null}
</div>
);
if (!scroll) return body;
return (
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)}>
{body}
</TaskMessageScroller>
);
}
// Cheap content signature so streaming growth (text lengthening without the
// item count changing) still advances the auto-follow key. Shared by the
// shared message scroller on desktop and mobile (TaskChatThread).
function signatureOf(it: TaskChatItem): number {
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool")
return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
if (it.kind === "plan_document") {
return it.document.body.length + it.document.latestRevisionNumber;
}
if (it.kind === "turn") {
if (it.settled) return 1 + (it.finalResponse?.text.length ?? 0);
// The live parent row's header changes (gerund ↔ tool-state flashes,
// streaming interstitial text growing) count too, so the collapsed
// single-line turn still advances the key.
const headerSig = it.liveStatus
? it.liveStatus.label.length +
(it.liveStatus.detail?.length ?? 0) +
(it.liveStatus.selfTalk?.length ?? 0)
: 0;
return it.items.reduce(
(n, child) => n + signatureOf(child),
it.items.length + headerSig + (it.finalResponse?.text.length ?? 0),
);
}
if (it.kind === "activity_phase") {
return it.items.reduce(
(n, child) => n + signatureOf(child),
it.summary.length + (it.interstitial?.text.length ?? 0),
);
}
return 1;
}
export function taskChatContentKey(items: TaskChatItem[]): number {
return items.reduce((acc, it) => acc + signatureOf(it), items.length);
}