mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## 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
474 lines
18 KiB
TypeScript
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);
|
|
}
|