mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task feed shows agent messages, documents, and artifacts. > - An agent can create a subtask or a separate follow-up task during that work. > - The reader must open the Tasks tab to find the new task. > - This pull request adds a linked task card to the source feed at creation time. > - The card shows the current title, task number, and status. > - The reader can inspect delegated work without searching a second panel. ## Linked Issues or Issue Description **What existing behavior does this improve?** Task creation visibility in the source task feed. Related work: #13823 added ancestor navigation in the Tasks panel. **Current behavior** Agent-created child tasks appear in the Tasks tab. The main feed has no persistent link card for those tasks. **Proposed behavior** Show each agent-created direct child or source-linked follow-up task once in the feed. Keep the card at creation time when status changes. Keep existing chat handoff and accepted suggestion cards without duplicates. **Reason and benefit** Readers can see which work an agent created and open that task from the conversation. **Breaking changes** None. This change uses existing authorized task reads. It does not add API or database fields. ## What Changed - Pass the existing child and source-task reads from task detail to the feed. - Add a task card with a link, wrapping title, task number, and status badge. Use existing design tokens. - Suppress repeated cards and tasks already shown by accepted suggestions or chat handoffs. - Show task-read failures with Retry. Keep the conversation readable during those failures. - Add feed and page regression cases plus desktop, multiple-task, dark, and mobile stories. Update the product description and delegation recipe. - Keep task selection and rendering in the task feed module. Existing issue APIs continue to own access checks and company scope. ## Verification - Passed: 224 focused tests with `pnpm exec vitest run ui/src/components/TaskChatThread.task-creation-order.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx --maxWorkers=1`. - Passed: `pnpm --filter @paperclipai/ui typecheck`. - Passed: `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs`. - Cases cover creation order, current status, links, company filtering, direct children versus grandchildren, duplicate suppression, and read failure with retry. - Passed: browser render and link assertions for desktop, multiple tasks, dark theme, and mobile at 390px. All four story play phases finished. No page errors or horizontal overflow occurred. Screenshots use fixture task data and the production task feed. They do not prove server persistence or agent execution. - Passed: `pnpm --filter @paperclipai/ui build` after the concurrent memory failure. - The mobile preview frame needed spacing to contain the feed scrollbar gutter. Fixed the frame and repeated the browser checks. - Addressed the first Greptile finding: source-linked follow-ups now load with Streamlined UI off. A regression test covers that setting. - Full local build and typecheck cannot complete because Cargo is absent. The first concurrent UI build and UI typecheck were killed by the workspace memory limit. Both UI commands passed when run separately. The full local test run was stopped to fit that limit. PR CI must supply the full-suite verdict. ## Risks - Low risk. Delayed task reads can insert an older card into a loaded feed. Stable task IDs preserve the card during status updates. - New cards use the same access-checked task data as the Tasks panel. No task mutation or permission change is added. ## Model Used - OpenAI Codex, GPT-6. The runtime does not expose a more specific model ID or context size. Used reasoning, tool calls, code edits, tests, and browser automation. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity";
|
|
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
|
|
import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard";
|
|
import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard";
|
|
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, type ScrollingHistory } from "./TaskMessageScroller";
|
|
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
|
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
|
|
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
|
import { compactAdjacentErrors } from "./adjacent-errors";
|
|
import { CoordinatorChatTaskHandoff } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
|
|
import { CoordinatorChatDecisionReceipt } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
|
|
import { CoordinatorChatPendingQuestion } from "@/components/coordinator/CoordinatorChatPendingQuestions";
|
|
|
|
const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
|
|
|
|
interface TaskChatThreadViewProps {
|
|
scrollingHistory?: ScrollingHistory;
|
|
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 "pending_question": return <CoordinatorChatPendingQuestion item={item.question} />;
|
|
case "task_handoff": return <CoordinatorChatTaskHandoff handoff={item.handoff} animateEntry={item.animateEntry} />;
|
|
case "decision_receipt": return <CoordinatorChatDecisionReceipt receipt={item.receipt} />;
|
|
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 "task_created": return <TaskChatTaskCreatedCard item={item} />;
|
|
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({
|
|
scrollingHistory,
|
|
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)} scrollingHistory={scrollingHistory}>
|
|
{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);
|
|
}
|