feat(ui): task-chat redesign baseline behind enableTaskChatRedesign

New ui/src/components/task-chat/ surface (model, TASK_CHAT_STATES inventory,
fixtures, adapter, Bubble/Marker/StatusPill/ToolCard/ThinkingBlock/UsageReadout/
Scroller/PlanView/ThreadView, TweakPanel), motion tokens in index.css :root,
useTaskChatRedesignEnabled hook + TaskChatRedesignGate, one-line ThreadComponent
seam in IssueDetail.tsx, flag-scoped tabs in IssueProperties.tsx, dev harness
/dev/task-chat-lab, gate script scripts/check-task-chat-motion.mjs.

Flag wired through the 3 lockstep shared files (types/validators/feature-catalog),
preference tier, default off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:20 -07:00
1 parent 677242344c
commit d270ee47fd
24 files changed
+515 -3501

No files matched your search

+10 -10
View File
@@ -97,9 +97,9 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
selfHostedDefault: false,
},
enableTaskChatRedesign: {
title: "Chat-Style Tasks",
title: "Task Chat Redesign",
description:
"Reimagines the task detail page as a live conversation with your agents: chat bubbles for people and agents, streaming activity — thinking, tool calls, diffs — that folds into a one-line summary when a turn finishes, inline plan/question/permission cards, a three-mode composer (Agent · Plan · Ask), and a resizable Properties · Plan · Artifacts pane.",
"Render the task comment thread as a Claude Code-style session: agent lifecycle states, a plan/artifact properties pane, and a dev harness. Experimental; behind this flag the current thread is unchanged.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
@@ -135,6 +135,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableCloudSync: {
title: "Cloud Sync",
description:
"Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableExternalObjects: {
title: "External Objects",
description:
@@ -189,14 +197,6 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableSimplifiedEnglishInteractions: {
title: "Simplified English Interactions",
description:
"Instruct agents to write user interactions (confirmations, questions, suggested tasks) in ASD-STE100 Simplified Technical English with brief decision context.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableServerInfoDebugView: {
title: "Server Info Debug View",
description:
-1
View File
@@ -27,7 +27,6 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const SCAN_DIRS = ["ui/src/components/task-chat"];
const SCAN_FILES = [
"ui/src/components/TaskChatThread.tsx",
"ui/src/components/PropertiesPanel.tsx",
"ui/src/components/TaskChatRedesignGate.tsx",
"ui/src/pages/TaskChatLab.tsx",
"ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx",
+21 -32
View File
@@ -13,7 +13,6 @@ import { Cases } from "./pages/Cases";
import { CaseDetail } from "./pages/CaseDetail";
import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { PaperclipLoading } from "./components/AnimatedPaperclipIcon";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Timeline } from "./pages/Timeline";
@@ -41,17 +40,17 @@ import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
import { ApprovalDetail } from "./pages/ApprovalDetail";
import { Costs } from "./pages/Costs";
import { CompanyActivity } from "./pages/audit/CompanyActivity";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
import { TrainingInspector, TrainingLibrary } from "./pages/Training";
import { BoardChat } from "./pages/BoardChat";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CloudUpstream } from "./pages/CloudUpstream";
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab";
import { CrossIssueCollaborationUxLab } from "./pages/CrossIssueCollaborationUxLab";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute";
@@ -60,7 +59,6 @@ import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute";
import { Connections } from "./pages/apps/Connections";
import { Browse } from "./pages/apps/Browse";
import { AppsConnect } from "./pages/apps/AppsConnect";
import { canEnterAppsConnect } from "./pages/apps/app-connect-policy";
import { AppsReview } from "./pages/apps/AppsReview";
import { AppDetail } from "./pages/apps/AppDetail";
import { AppNotConnected } from "./pages/apps/AppNotConnected";
@@ -110,7 +108,7 @@ function boardRoutes() {
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
<Route path="company/settings/cloud-upstream" element={<Navigate to="/company/export" replace />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
@@ -122,15 +120,14 @@ function boardRoutes() {
<Route path="tools" element={<LegacyToolsRedirect />} />
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
<Route element={<AppsExperimentalGate />}>
<Route path="apps" element={<Browse />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Connections />} />
<Route path="apps" element={<Connections />} />
<Route path="apps/browse" element={<Browse />} />
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps/browse" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps/browse" replace />} />
<Route path="apps/review" element={<AppsReview />} />
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps/connections" replace />} />
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
@@ -263,10 +260,7 @@ function boardRoutes() {
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<CompanyActivity />} />
{/* `/audit` merged into the single Activity page (PAP-16302). Existing deep
links keep working, preset to the agent-actions scope. */}
<Route path="audit" element={<Navigate to="/activity?mode=agents" replace />} />
<Route path="activity" element={<Activity />} />
{/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay
registered but redirect to the company home while the experimental
flag is off. The board-level `artifacts` mount below is the new
@@ -276,16 +270,12 @@ function boardRoutes() {
<Route path="board-chat" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
</Route>
{/* Task Chat Redesign dev harness — dev builds only, and additionally
gated by enableTaskChatRedesign (redirects to /dashboard when the
flag is off). */}
{import.meta.env.DEV ? (
<Route element={<TaskChatRedesignGate />}>
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
</Route>
) : null}
{/* Task Chat Redesign dev harness — gated by enableTaskChatRedesign;
redirects to /dashboard when the flag is off. */}
<Route element={<TaskChatRedesignGate />}>
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
</Route>
<Route path="decisions" element={<WhatNeedsMe />} />
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />
<Route path="decisions/training" element={<TrainingLibrary />} />
<Route path="decisions/training/:id" element={<TrainingInspector />} />
<Route path="training" element={<Navigate to="/decisions/training" replace />} />
@@ -310,7 +300,7 @@ function boardRoutes() {
function AppsConnectEntryRoute() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
return canEnterAppsConnect(searchParams) ? <AppsConnect /> : <Navigate to="/apps" replace />;
return searchParams.get("byo") === "1" ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
}
function InboxRootRedirect() {
@@ -355,7 +345,7 @@ function LegacySettingsRedirect() {
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
if (loading) {
return <PaperclipLoading />;
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
const targetCompany =
@@ -404,7 +394,7 @@ function LegacyToolsRedirect() {
function legacyToolsRedirectTarget(tab?: string) {
if (!tab) return "/apps/advanced/profiles";
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections";
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps";
return `/apps/advanced/${tab}`;
}
@@ -463,7 +453,7 @@ function CompanyRootRedirect() {
const location = useLocation();
if (loading) {
return <PaperclipLoading />;
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
@@ -494,7 +484,7 @@ function UnprefixedBoardRedirect() {
const { companies, selectedCompany, loading } = useCompany();
if (loading) {
return <PaperclipLoading />;
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
@@ -550,9 +540,9 @@ export function App() {
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<Route path="ux-lab/cloud-upstream" element={<CloudUpstreamUxLab />} />
<Route path="ux-lab/bootstrap-setup" element={<BootstrapSetupUxLab />} />
<Route path="ux-lab/responsible-user-denial" element={<ResponsibleUserDenialUxLab />} />
<Route path="ux-lab/cross-issue-collaboration" element={<CrossIssueCollaborationUxLab />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
@@ -580,7 +570,6 @@ export function App() {
<Route path="pipelines/:pipelineId/items/:caseId" element={<UnprefixedBoardRedirect />} />
<Route path="pipelines/:pipelineId/cases/:caseId" element={<UnprefixedBoardRedirect />} />
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
<Route path="audit" element={<UnprefixedBoardRedirect />} />
<Route path="decisions" element={<UnprefixedBoardRedirect />} />
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
<Route path="skills/studio" element={<UnprefixedBoardRedirect />} />
+44 -424
View File
@@ -1,46 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
import { useEffect, useMemo, useState, type ComponentProps } from "react";
import { IssueChatThread } from "@/components/IssueChatThread";
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
assembleThreadItems,
attachSettledTurns,
buildTurnSummary,
coalesceSettledTurns,
deriveRunStatusLabel,
isNestableLiveChild,
isTerminalRunStatus,
prependIssueBrief,
settledRunChildren,
transcriptToTaskChatItems,
type SettledTurnMergeMeta,
} from "@/components/task-chat/transcript-adapter";
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages";
import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
import { workModeInEffectAt } from "@/lib/issue-timeline-events";
import { workModeMetaFor } from "@/lib/work-mode-meta";
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
const ms = new Date(value).getTime();
return Number.isNaN(ms) ? 0 : ms;
}
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
@@ -56,30 +25,12 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
* source the current thread uses) → the in-flight turn streams
* thinking → tool → diff → responding, capped by a live "running" status
* pill.
*
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
* ONE expandable parent row — its status line (whimsy gerund or in-flight
* tool state + elapsed + tokens) is the turn's single visible line, with the
* chronological tool activity nested behind an expand (PAP-354). Mid-run
* agent text (interstitial updates between tool calls) is ephemeral
* (PAP-361, round 9): while one streams it occupies a dedicated one-line row
* PERMANENTLY RESERVED above the status line (so the layout above never
* jumps), and when it finishes the text slides out and the slot sits empty —
* nothing persists; the run log and the classic transcript remain the
* archive. When the run terminates, its settled turn anchors after the run's
* last comment (comment.runId linkage) and — when it directly follows that
* reply bubble — attaches to it: the "✓ Worked · …" summary renders appended
* to the bubble's always-visible timestamp line (round 9), still expandable
* to the tool history. Turns without a reply bubble keep the standalone
* folded row. flag-OFF remains byte-for-byte IssueChatThread.
* pill. Only the non-terminal run streams; once it finishes its output
* lands as a comment. flag-OFF remains byte-for-byte IssueChatThread.
*/
export function TaskChatThread(props: TaskChatThreadProps) {
const {
comments,
interactions,
timelineEvents,
issueId = null,
agentMap,
userLabelMap,
currentUserId,
@@ -95,56 +46,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
linkedRuns,
liveRuns,
activeRun,
onAttachImage,
imageUploadHandler,
mentions,
enableReassign,
reassignOptions,
currentAssigneeValue,
issueStatus,
issueAssigneeAgentId = null,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
externalReferences,
threadHeader,
workModeChanges,
issueBrief,
feedbackVotes,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
} = props;
const linkedRunMetaById = useMemo(() => {
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[number]>();
for (const run of linkedRuns ?? []) map.set(run.runId, run);
return map;
}, [linkedRuns]);
// Each agent reply is tagged with the mode its request ran under: the
// issue's work mode at the reply's run start (comment.runId linkage),
// reconstructed from the activity feed's work-mode switch history — not the
// issue's current mode, which the user may have changed since.
const agentModeLabelFor = useCallback(
(comment: IssueChatComment) => {
const runMeta = comment.runId ? linkedRunMetaById.get(comment.runId) : undefined;
const atMs = toMs(runMeta?.startedAt ?? runMeta?.createdAt ?? comment.createdAt);
return workModeMetaFor(workModeInEffectAt(workModeChanges ?? [], atMs, issueWorkMode)).label;
},
[linkedRunMetaById, workModeChanges, issueWorkMode],
);
const commentItems = useMemo(
() => commentsToTaskChatItems(comments, {
agentMap,
userLabelMap,
currentUserId,
issueAssigneeAgentId,
agentModeLabelFor,
}),
[comments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId, agentModeLabelFor],
() => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId }),
[comments, agentMap, userLabelMap, currentUserId],
);
// Every run we might need a transcript for (history + live), deduped by id.
@@ -185,333 +91,55 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// The single in-flight run whose turn we stream live (non-terminal).
const liveRun = useMemo(() => {
if (isTerminalIssueStatus(issueStatus)) return null;
if (activeRun && isLiveIssueRun(activeRun, issueStatus)) return activeRun;
return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null;
}, [activeRun, issueStatus, liveRuns]);
if (activeRun && !isTerminalRunStatus(activeRun.status)) return activeRun;
return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null;
}, [activeRun, liveRuns]);
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
// they settle. Runs already terminal at mount collapse instantly.
const liveSeenRef = useRef<Set<string>>(new Set());
// Re-render on a 1s cadence while live so the status pill's elapsed advances
// even between transcript chunks.
const [tick, setTick] = useState(0);
useEffect(() => {
if (liveRun) liveSeenRef.current.add(liveRun.id);
if (!liveRun) return;
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, [liveRun]);
// Each terminal run's turn anchors immediately after the run's last comment
// (its reply bubble), via the comment.runId linkage — the summary line lands
// below the bubble, where the live "Running…" pill sat. A run with no linked
// comment (stopped run, or reply not yet fetched) instead slots into the
// backbone chronologically at its start time (PAP-367).
const lastCommentIdByRun = useMemo(() => {
const map = new Map<string, string>();
for (const comment of comments) {
if (comment.deletedAt || !comment.runId || !comment.id) continue;
map.set(comment.runId, comment.id);
}
return map;
}, [comments]);
const { data: planDocument } = useIssuePlanDocument(issueId);
// Comments, interactions, and the plan-doc marker merged into one
// chronological backbone (same sort keys and same-run handoff shift as the
// legacy buildIssueChatMessages), so plan-mode confirmation/question cards
// land where they happened in the conversation.
const orderedEntries = useMemo(() => {
const entries: { ms: number; order: number; id: string; item: TaskChatItem }[] = [];
// commentsToTaskChatItems skips deleted comments — mirror its filter so the
// two lists stay index-aligned.
const visibleComments = comments.filter((comment) => !comment.deletedAt);
visibleComments.forEach((comment, index) => {
const item = commentItems[index];
if (!item) return;
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
});
for (const interaction of interactions ?? []) {
const createdAtMs = toMs(interaction.createdAt);
const handoffAtMs =
interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({
interactionCreatedAtMs: createdAtMs,
sourceRunId: interaction.sourceRunId,
comments,
timelineEvents: timelineEvents ?? [],
linkedRuns: linkedRuns ?? [],
liveRuns: liveRuns ?? [],
})
: null;
const id = `interaction:${interaction.id}`;
entries.push({
ms: handoffAtMs ?? createdAtMs,
order: 2,
id,
item: { id, kind: "interaction", interaction },
});
}
if (planDocument) {
const revision = planDocument.latestRevisionNumber ?? 1;
const id = `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`;
entries.push({
ms: toMs(planDocument.updatedAt),
order: 0,
id,
item: {
id,
kind: "marker",
variant: "turn_boundary",
label: revision > 1 ? "Plan updated" : "Plan created",
detail: `rev ${revision} — see the Plan tab`,
},
});
}
return entries.sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
);
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]);
// Boolean gate (stable across the host's per-render brief objects) so the
// heavy assembly memo doesn't recompute on every parent render.
const hasBrief = Boolean(issueBrief);
const items = useMemo<TaskChatItem[]>(() => {
// Settled turns for every terminal run whose transcript we have. Messages
// and thinking are excluded entirely (PAP-361): the final reply already
// landed as the run's comment bubble, interstitial updates are ephemeral
// (live-line only), and thinking stays in the run log / classic
// transcript — "Worked · N tools" expands to exactly the tool rows.
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; startMs: number }[] = [];
// Raw summary inputs per turn id, so back-to-back same-agent runs can
// coalesce into one "Worked" row in the final pass (PAP-362).
const turnMergeMetaById = new Map<string, SettledTurnMergeMeta>();
for (const source of runs) {
if (!isTerminalRunStatus(source.status)) continue;
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
if (entries.length === 0) continue;
const meta = linkedRunMetaById.get(source.id);
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
const durationMs =
Number.isFinite(started) && Number.isFinite(finished)
? Math.max(0, finished - started)
: undefined;
const parsed = transcriptToTaskChatItems(entries, {
runId: source.id,
agentName: meta?.agentName,
running: false,
});
const children = settledRunChildren(parsed);
if (children.length === 0) continue;
const failed = source.status !== "succeeded";
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
turnMergeMetaById.set(`${source.id}:turn`, {
agentKey: meta?.agentId ?? "",
agentName: meta?.agentName,
parts: [{ entries, durationMs, failed }],
});
settledTurns.push({
turn: {
id: `${source.id}:turn`,
kind: "turn",
settled: true,
animateFold: liveSeenRef.current.has(source.id),
items: children,
summary: buildTurnSummary(entries, { durationMs, failed }),
},
anchorCommentId: lastCommentIdByRun.get(source.id) ?? null,
// Chronological slot for a turn with no reply comment to anchor to
// (PAP-367): the run's start time. A run with no linked meta yet
// (just-settled, list not refetched) sits at the thread tail —
// where it last rendered as the live turn — until meta arrives.
startMs: startSlotRaw ? toMs(startSlotRaw) : Number.POSITIVE_INFINITY,
});
}
settledTurns.sort((a, b) => (a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0));
const turnsByAnchor = new Map<string, TaskChatTurnItem[]>();
const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = [];
for (const { turn, anchorCommentId, startMs } of settledTurns) {
if (anchorCommentId) {
const list = turnsByAnchor.get(anchorCommentId) ?? [];
list.push(turn);
turnsByAnchor.set(anchorCommentId, list);
} else {
unanchored.push({ turn, startMs });
}
}
// Anchored turns follow their run's reply comment; comment-less turns
// (stopped runs, or a reply not yet fetched) interleave chronologically at
// their run's start time instead of piling up under the newest message
// (PAP-367).
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
if (liveRun) {
const entries = transcriptByRun.get(liveRun.id) ?? [];
const parsed = transcriptToTaskChatItems(entries, {
runId: liveRun.id,
agentName: liveRun.agentName,
running: true,
});
// Parent-row model (PAP-354): the run's status line IS the live turn —
// one expandable row owning the activity. Only tool/usage rows nest
// inside it; a streaming interstitial update renders in the reserved row
// above the status line (liveStatus.selfTalk, PAP-361/round 9) and
// vanishes when it completes. The parent row sits last; on settle its
// summary attaches to the reply bubble's timestamp line.
const children = parsed.filter(isNestableLiveChild);
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
const queued = liveRun.status === "queued";
const status = queued
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
: deriveRunStatusLabel(entries);
out.push({
id: `${liveRun.id}:turn`,
kind: "turn",
settled: false,
items: children,
summary: buildTurnSummary(entries),
liveStatus: {
id: `${liveRun.id}:status`,
kind: "status",
status: "running",
label: status.label,
detail: status.detail,
toolName: status.toolName,
selfTalk: status.selfTalk,
startedAtMs: startedAt ?? undefined,
},
});
}
// PAP-362: two runs replying back-to-back (same agent, nothing but the
// agent's own bubbles between) fold into ONE "Worked" row below the last
// reply; a user message, interaction, or the live turn keeps them apart.
// Round 9: a settled turn directly following its own agent's reply bubble
// then attaches to that bubble — the "Worked · …" summary renders on the
// bubble's always-visible timestamp line instead of as a standalone row.
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
// every assembly/merge pass, so nothing can ever sort above it.
return prependIssueBrief(
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
hasBrief,
);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
// Feedback votes keyed by the comment they target (targetType
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes ?? []) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human
// and system bubbles get nothing; copy is always available, and the feedback
// buttons render only when the host wired a vote handler.
const renderMessageActions = useCallback(
(item: TaskChatMessageItem) => {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={
onVote
? {
activeVote: feedbackVoteByTargetId.get(item.id) ?? null,
sharingPreference: feedbackDataSharingPreference,
termsUrl: feedbackTermsUrl,
onVote: (vote, options) => onVote(item.id, vote, options),
}
: null
}
/>
);
},
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
item={item}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
onUploadImage={imageUploadHandler}
externalReferences={externalReferences}
/>
),
[
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
imageUploadHandler,
externalReferences,
],
);
// Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is
// overflow-visible with auto height), so the desktop bounded h-dvh chain
// collapses the absolute-inset transcript viewport to 0px. Render the thread
// in document flow instead (the same scroll={false} path the previews use)
// and track auto-follow against window scroll. Desktop stays byte-identical.
const { isMobile } = useSidebar();
useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile);
if (!liveRun) return commentItems;
const entries = transcriptByRun.get(liveRun.id) ?? [];
const turn = transcriptToTaskChatItems(entries, {
runId: liveRun.id,
agentName: liveRun.agentName,
running: true,
});
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined;
const queued = liveRun.status === "queued";
const status = queued ? { label: "Queued", detail: "Waiting to start" } : deriveRunStatusLabel(entries);
const statusItem: TaskChatItem = {
id: `${liveRun.id}:status`,
kind: "status",
status: "running",
label: status.label,
detail: status.detail,
elapsedMs,
};
return [...commentItems, ...turn, statusItem];
// `tick` is intentionally a dependency so elapsed advances each second.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentItems, liveRun, transcriptByRun, tick]);
return (
<div
className={cn("flex flex-col", !isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1")}
data-testid="task-chat-thread"
>
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
<div className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-thread">
<div className="min-h-0 flex-1">
{items.length === 0 ? (
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
{threadHeader ? (
<div
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
data-testid="task-chat-thread-header"
>
{threadHeader}
</div>
) : null}
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
</div>
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<TaskChatThreadView
items={items}
header={threadHeader}
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
scroll={!isMobile}
/>
<TaskChatThreadView items={items} />
)}
</div>
{showComposer ? (
<div
className={cn(
"sticky",
// Mobile mirrors the flag-off thread's dock: lifted above the
// safe-area inset (and clear of the auto-hiding bottom nav), above
// page content in the document-flow stacking context.
isMobile ? "bottom-(--sz-calc-8) z-20" : "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
<div className="sticky bottom-0 z-10 flex flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
@@ -519,14 +147,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onWorkModeChange={onWorkModeChange}
disabled={Boolean(composerDisabledReason)}
disabledReason={composerDisabledReason}
onAttachImage={onAttachImage}
onImageUpload={imageUploadHandler}
mentions={mentions}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
issueStatus={issueStatus}
mobile={isMobile}
/>
{footer}
</div>
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { issueStatusText } from "@/lib/status-colors";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Link } from "@/lib/router";
import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -13,7 +10,6 @@ import { authApi } from "../../api/auth";
import { executionWorkspacesApi } from "../../api/execution-workspaces";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { issuesApi } from "../../api/issues";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { queryKeys } from "../../lib/queryKeys";
@@ -44,7 +40,6 @@ import { useRetryNowMutation } from "../../hooks/useRetryNowMutation";
import { RetryErrorBand } from "../IssueScheduledRetryCard";
import { StatusIcon } from "../StatusIcon";
import { PriorityIcon } from "../PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags";
import { Identity } from "../Identity";
import { IssueReferencePill } from "../IssueReferencePill";
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
@@ -100,7 +95,7 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback(async () => {
try {
await copyTextToClipboard(value);
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
@@ -175,41 +170,6 @@ export function IssueProperties({
// OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane
// is always task-scoped, so the flag alone is a sufficient gate.
const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true;
// When hosted by the redesigned PropertiesPanel, the tab strip portals into
// the pane's header bar (left of the window controls). The slot only exists
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
// keep the tab strip in place.
const [paneHeaderSlot, setPaneHeaderSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
if (!taskChatRedesignEnabled || inline) {
setPaneHeaderSlot(null);
return;
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [taskChatRedesignEnabled, inline]);
// Plan earns a tab as soon as an issue is in planning mode, even before the
// plan document arrives. This keeps an expected plan surface visible and
// lets its diagnostic empty state explain what is missing.
// Same query keys as the tab bodies, so these share their cached fetches.
const { data: paneTabPlanDocument } = useIssuePlanDocument(
taskChatRedesignEnabled ? issue.id : null,
);
const { data: paneTabAcceptedPlans } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
enabled: taskChatRedesignEnabled,
});
const { data: paneTabAttachments } = useQuery({
queryKey: queryKeys.issues.attachments(issue.id),
queryFn: () => issuesApi.listAttachments(issue.id),
enabled: taskChatRedesignEnabled,
});
const hasPlanTab =
Boolean(paneTabPlanDocument)
|| (paneTabAcceptedPlans?.length ?? 0) > 0
|| issue.workMode === "planning";
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
const [paneTab, setPaneTab] = useState("properties");
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -2027,16 +1987,13 @@ export function IssueProperties({
/>
</PropertyRow>
{/* PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI. Revive by flipping the flag. */}
{SHOW_TASK_PRIORITY_UI && (
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
showLabel
/>
</PropertyRow>
)}
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
showLabel
/>
</PropertyRow>
<PropertyPicker
inline={inline}
@@ -2499,83 +2456,23 @@ export function IssueProperties({
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
if (!taskChatRedesignEnabled) return propertiesBody;
// Flag ON with nothing to switch between: no tab strip — the header bar
// shows a plain title and the pane body is just the properties stack.
if (!hasPlanTab && !hasArtifactsTab) {
return (
<>
{paneHeaderSlot
? createPortal(<span className="text-sm font-medium">Properties</span>, paneHeaderSlot)
: null}
{propertiesBody}
</>
);
}
// Flag ON: wrap the same body in a Properties | Plan | Artifacts tab shell
// (v5 decision: singular "Plan", Docs merged into Artifacts). The Properties
// tab is unchanged. Panel hosts portal the strip into the pane header bar;
// portals keep React context, so the Tabs root still drives it.
// Fall back to Properties if the selected tab's content went away (or the
// selection was made on another issue).
const activePaneTab =
(paneTab === "plans" && !hasPlanTab) || (paneTab === "artifacts" && !hasArtifactsTab)
? "properties"
: paneTab;
// In the pane header the strip stretches to the bar's full height and the
// active underline drops to bottom-0, so it hugs the header's border line.
const paneTabTriggerClass = paneHeaderSlot
? "h-full group-data-[orientation=horizontal]/tabs:after:bottom-0"
: undefined;
const tabStrip = (
<TabsList
variant="line"
className={
paneHeaderSlot
? "items-stretch justify-start gap-1 p-0 group-data-[orientation=horizontal]/tabs:h-full"
: "w-full justify-start gap-1"
}
>
<TabsTrigger value="properties" className={paneTabTriggerClass}>
Properties
</TabsTrigger>
{hasPlanTab ? (
<TabsTrigger value="plans" className={paneTabTriggerClass}>
Plan
</TabsTrigger>
) : null}
{hasArtifactsTab ? (
<TabsTrigger value="artifacts" className={paneTabTriggerClass}>
Artifacts
</TabsTrigger>
) : null}
</TabsList>
);
// Flag ON: wrap the same body in a Properties | Plans | Artifacts tab shell.
// Plans/Artifacts get their real content in a later step; the shell itself is
// the deliverable here, and the Properties tab is unchanged.
return (
<Tabs value={activePaneTab} onValueChange={setPaneTab} className="flex min-h-0 flex-col gap-3">
{paneHeaderSlot
? createPortal(
// Portals keep React context but break the DOM tree the Tailwind
// group-selectors need: the active-tab underline is styled via
// `group-data-[orientation=horizontal]/tabs:*`, so restore that
// ancestor here (display: contents keeps it out of layout).
<div className="group/tabs contents" data-orientation="horizontal">
{tabStrip}
</div>,
paneHeaderSlot,
)
: tabStrip}
<Tabs defaultValue="properties" className="flex min-h-0 flex-col gap-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="properties">Properties</TabsTrigger>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
</TabsList>
<TabsContent value="properties">{propertiesBody}</TabsContent>
{hasPlanTab ? (
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} inline={inline} />
</TabsContent>
) : null}
{hasArtifactsTab ? (
<TabsContent value="artifacts">
<IssuePropertiesArtifactsTab issue={issue} />
</TabsContent>
) : null}
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} />
</TabsContent>
<TabsContent value="artifacts">
<IssuePropertiesArtifactsTab issue={issue} />
</TabsContent>
</Tabs>
);
}
@@ -1,126 +1,40 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Issue, IssueThreadInteraction } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
import { MarkdownBody } from "@/components/MarkdownBody";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useLocation } from "@/lib/router";
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
interface IssuePropertiesPlansTabProps {
issue: Issue;
/** True when hosted outside the properties panel (mobile sheet) — the plan
* confirmation bar then renders in place instead of the pane footer slot. */
inline?: boolean;
}
function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | undefined): boolean {
return (interactions ?? []).some(
(interaction) =>
interaction.kind === "request_confirmation"
&& interaction.status === "pending"
&& interaction.payload.target?.type === "issue_document"
&& interaction.payload.target.key === "plan",
);
}
/**
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
*
* Owns the plan surface with the flag ON: the `plan` document itself (formerly
* pinned above the tabs via IssueDocumentsSection, which the chat shell gates
* off) rendered above the accepted-plan decomposition history. Structured live
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
* the /dev/task-chat-lab harness).
* Migrates the accepted-plan decomposition history (formerly pinned above the
* tabs via IssuePlanDecompositionsSection) into a dedicated tab. Structured live
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in the
* /dev/task-chat-lab harness); until it lands, the tab shows accepted-plan
* revisions or an empty state.
*/
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
const location = useLocation();
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
const { data } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
});
const { data: interactions } = useQuery({
queryKey: queryKeys.issues.interactions(issue.id),
queryFn: () => issuesApi.listInteractions(issue.id),
});
const hasPlans = (data?.length ?? 0) > 0;
const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions);
if (!planDocument && !hasPlans) {
if (!hasPlans) {
return (
<>
{/* This is deliberately outside the plan-document gate: an interaction
can arrive before its plan document query resolves or persists. */}
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
<div className="px-1 py-6 text-sm text-muted-foreground">
{planDocumentLoading ? (
"Loading plan…"
) : issue.workMode === "planning" ? (
<div className="space-y-2">
<p>This task is in plan mode but no plan document has been written yet.</p>
{pendingPlanConfirmation ? (
<p className="text-amber-foreground">
A plan confirmation is pending, but the plan document it should confirm is missing.
</p>
) : null}
</div>
) : (
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
)}
</div>
</>
<div className="px-1 py-6 text-sm text-muted-foreground">
No plan yet. Accepted plans and their revisions will appear here.
</div>
);
}
return (
<div className="space-y-4 py-2">
{/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
they stay visible while the plan scrolls. */}
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
{planDocument ? (
<section data-testid="issue-plan-document" className="space-y-2">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`}
<DocumentAnnotationsCountChip
issueId={issue.id}
docKey="plan"
panelOpen={annotationPanelOpen}
onToggle={() => setAnnotationPanelOpen((open) => !open)}
/>
</div>
<IssueDocumentAnnotations
issueId={issue.id}
doc={{
key: "plan",
latestRevisionId: planDocument.latestRevisionId,
latestRevisionNumber: planDocument.latestRevisionNumber,
}}
bodyMarkdown={planDocument.body}
draftDirty={false}
draftConflicted={false}
historicalPreview={false}
locationHash={location.hash}
panelOpen={annotationPanelOpen}
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="inline"
>
<MarkdownBody>{planDocument.body}</MarkdownBody>
</IssueDocumentAnnotations>
</section>
) : null}
{hasPlans ? (
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
) : null}
<div className="py-2">
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
</div>
);
}
+18 -149
View File
@@ -1,62 +1,16 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import { CommentAttributionChip } from "@/components/CommentAttributionChip";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment";
import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments";
import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
item: TaskChatMessageItem;
/**
* The settled run turn rendered on this bubble's footer line (round 9):
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
* (the timestamp leads the summary), expandable to the nested tool history.
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
* an attached turn is present it owns these via its `leading` slot instead,
* so this bubble skips them. Human/system bubbles pass nothing.
*/
actions?: ReactNode;
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
/**
* Author-typed message row — the primary legibility signal. Human messages sit
* right in a solid accent bubble; agent messages sit left in a neutral card
* bubble with an avatar author header (the agent's assigned icon + name · mode
* chip); system notices are centered and recede.
* bubble with an author header; system notices are centered and recede.
*/
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
// once finished it renders nowhere — the run log / classic transcript
// remain the archive. Never rendered as a bubble.
return null;
}
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
if (item.author === "system") {
return (
<div className="tc-enter-bubble flex justify-center py-1">
@@ -66,116 +20,31 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
}
const isHuman = item.author === "human";
// Non-image file references ("[name](/api/attachments/…/content)") render as
// attachment chips under the bubble; link-only lines leave the body text.
const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text);
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{item.agentIcon ? (
<AvatarFallback>
<AgentIcon icon={item.agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(item.authorName)}</AvatarFallback>
)}
</Avatar>
<span className="text-sm font-semibold text-foreground">{item.authorName}</span>
{item.onBehalfOfUserName ? (
<CommentAttributionChip
agentName={item.authorName}
userName={item.onBehalfOfUserName}
/>
) : null}
{item.modeLabel ? (
<span className="rounded-full border border-border px-2 py-px text-(length:--text-micro) font-medium text-muted-foreground">
{item.modeLabel}
</span>
) : null}
</span>
) : null}
{bodyText.length > 0 ? (
<div
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>
<MarkdownBody
// The human bubble sits on the solid --liveness-blue accent, so the
// prose body text must follow the bubble's `text-white` rather than
// the default light-mode prose color (which reads as black on blue).
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
// (== inherited white) in both themes; dark mode was already correct
// only because `prose-invert` happened to lighten the text.
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
softBreaks
linkIssueReferences
>
{bodyText}
</MarkdownBody>
</div>
) : null}
{attachmentRefs.length > 0 ? (
<AttachmentGroup
className="max-w-(--pct-85)"
data-testid="task-chat-bubble-attachments"
>
{attachmentRefs.map((ref) => {
const kind = fileKindForName(ref.name);
const KindIcon = kind.icon;
return (
<Attachment key={ref.url} size="sm" className={cn(item.optimistic && "opacity-80")}>
<AttachmentMedia>
<KindIcon aria-hidden />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">{ref.name}</AttachmentTitle>
<AttachmentDescription className="max-w-48">{kind.label}</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${ref.name}`}
render={<a href={ref.url} target="_blank" rel="noreferrer" />}
/>
</Attachment>
);
})}
</AttachmentGroup>
<span className="px-1 text-xs font-medium text-muted-foreground">{item.authorName}</span>
) : null}
<div
className={cn(
"max-w-(--pct-85) whitespace-pre-wrap break-words px-3 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-primary text-primary-foreground"
: "rounded-2xl rounded-bl-sm border border-border bg-card text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>
{item.text}
{item.streaming ? (
<span className="tc-cursor ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 bg-current align-baseline" />
) : null}
</div>
{item.optimistic ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.optimistic === "queued" ? "Queued" : "Sending…"}
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"
// summary, always visible; expanding stretches beneath the bubble. The
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
// `leading` slot — not this wrapper — so they stay anchored to the
// summary line when the tool history expands beneath it.
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
{attachedTurn}
</div>
) : actions ? (
// Agent reply without run activity: the actions still lead the footer,
// with the always-visible timestamp trailing (PAP-413).
<div className="flex items-center gap-1">
{actions}
{item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
</div>
) : item.timestamp ? (
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
<span className="px-1 text-(length:--text-micro) text-muted-foreground">{item.timestamp}</span>
) : null}
</div>
);
+68 -457
View File
@@ -1,135 +1,27 @@
import {
useRef,
useState,
type ChangeEvent,
type ClipboardEvent as ReactClipboardEvent,
type CSSProperties,
} from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment";
import { fileKindForName, formatFileSize } from "./task-chat-attachments";
import { MarkdownEditor, type MarkdownEditorRef } from "@/components/MarkdownEditor";
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import type { MentionOption } from "@/components/MarkdownEditor";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
interface CommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
import { Check, ChevronDown, Send } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { IssueWorkMode } from "@paperclipai/shared";
interface TaskChatComposerProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void> | void;
onAdd: (body: string) => Promise<void> | void;
workMode: IssueWorkMode;
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
disabled?: boolean;
disabledReason?: string | null;
placeholder?: string;
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
/** Fallback upload path: returns a URL for inline image markdown. */
onImageUpload?: (file: File) => Promise<string>;
/** Mentionable entities for the editor's @-autocomplete. */
mentions?: MentionOption[];
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
issueStatus?: string;
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
mobile?: boolean;
}
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
const MODE_HUE: Partial<Record<IssueWorkMode, string>> = {
standard: "var(--tc-mode-agent)",
planning: "var(--tc-mode-plan)",
ask: "var(--tc-mode-ask)",
};
function modeHue(mode: IssueWorkMode): string {
return MODE_HUE[mode] ?? "var(--tc-mode-agent)";
}
const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
standard: "Make changes and run work",
planning: "Draft a plan before acting",
ask: "Answer questions only, no changes",
};
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
switch (mode) {
case "planning":
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
case "ask":
return `Ask ${agentName} a question — read-only, nothing runs…`;
default:
return `Message ${agentName} — describe what you want done…`;
}
}
type ComposerAttachment = {
id: string;
name: string;
size?: number;
status: "uploading" | "attached" | "error";
error?: string;
/** Set once uploaded; the submit path appends `[name](contentPath)` lines. */
contentPath?: string;
};
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked";
return resumesToTodo && assigneeValue.startsWith("agent:");
}
function parseAssigneeValue(value: string): CommentReassignment | undefined {
if (value.startsWith("agent:")) {
const id = value.slice("agent:".length);
return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined;
}
if (value.startsWith("user:")) {
const id = value.slice("user:".length);
return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined;
}
return undefined;
}
function escapeMarkdownLabel(name: string): string {
return name.replace(/[[\]]/g, "\\$&");
}
/**
* Composer for the redesigned thread (v7 spec): the shared MarkdownEditor
* (rich lists, @-mentions, /-commands, inline pasted images) over a 32px
* comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a
* status-chip rectangle carrying the pending mode's hue; the composer chrome
* itself stays neutral. Shift+Tab cycles modes (captured before Lexical);
* Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter stays a
* newline / next list item. Pasted or dropped images upload through
* `onAttachImage` (or the `onImageUpload` fallback) and land inline at the
* caret via the editor's image plugin; non-image files render as shadcn
* base/attachment chips (kind icon · name · size, remove ×, uploading/error
* states) between the editor and the comp-bar.
* Lightweight composer for the redesigned thread. Covers the baseline
* essentials: optimistic send (delegates to the existing onAdd mutation, which
* already echoes locally) and the three modes — Agent / Plan / Ask — reusing
* the app's work-mode metadata. The selected mode is applied on submit, mirroring
* the existing composer's semantics. Advanced affordances (attachments, reassign,
* mentions, drafts) are deferred; they're demonstrated on the legacy composer and
* layered on post-baseline.
*/
export function TaskChatComposer({
onAdd,
@@ -137,176 +29,27 @@ export function TaskChatComposer({
onWorkModeChange,
disabled = false,
disabledReason,
placeholder,
onAttachImage,
onImageUpload,
mentions,
enableReassign = false,
reassignOptions,
currentAssigneeValue = "",
issueStatus,
mobile = false,
placeholder = "Reply",
}: TaskChatComposerProps) {
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const bodyRef = useRef(body);
bodyRef.current = body;
const [menuOpen, setMenuOpen] = useState(false);
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0);
const assigneeValue = pendingAssignee ?? currentAssigneeValue;
const assigneeLabel =
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName);
/** Upload an image and return its URL for inline `![](src)` markdown. */
async function uploadInlineImage(file: File): Promise<string> {
if (onAttachImage) {
const attachment = await onAttachImage(file);
if (attachment?.contentPath) return attachment.contentPath;
throw new Error("Upload did not return a file URL");
}
if (onImageUpload) return onImageUpload(file);
throw new Error("This file type cannot be attached here");
}
/** Non-image files: attach to the task and track in the chip row. */
async function attachNonImageFile(file: File) {
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
setAttachments((prev) => [...prev, { id, name: file.name, size: file.size, status: "uploading" }]);
try {
if (!onAttachImage) {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: "This file type cannot be attached here" }
: item,
),
);
return;
}
const attachment = await onAttachImage(file);
const name = attachment?.originalFilename ?? file.name;
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, name, status: "attached", contentPath: attachment?.contentPath }
: item,
),
);
} catch (err) {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
: item,
),
);
}
}
/** Images picked from the + button go inline at the caret, like paste/drop. */
async function attachPickedFile(file: File) {
if (!file.type.startsWith("image/")) {
await attachNonImageFile(file);
return;
}
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
try {
const url = await uploadInlineImage(file);
editorRef.current?.insertMarkdown(`![${escapeMarkdownLabel(file.name)}](${url})`);
} catch (err) {
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
size: file.size,
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
},
]);
}
}
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
const files = evt.target.files;
if (files && files.length > 0) {
void (async () => {
for (const file of Array.from(files)) await attachPickedFile(file);
})();
}
evt.target.value = "";
}
/**
* Pasted image files fall through to the editor's image plugin (inline at
* the caret); non-image files are attached to the task here. Only swallow
* the paste when it carries no images the plugin should handle.
*/
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
if (!canAcceptFiles) return;
const files = Array.from(evt.clipboardData?.files ?? []);
if (files.length === 0) return;
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
if (nonImages.length === 0) return;
if (nonImages.length === files.length) {
evt.preventDefault();
evt.stopPropagation();
}
void (async () => {
for (const file of nonImages) await attachNonImageFile(file);
})();
}
// Uploaded file references ride along as trailing `[name](contentPath)`
// lines — the bubble renderer folds those link-only lines back into chips.
const attachedRefs = attachments.filter(
(item) => item.status === "attached" && item.contentPath,
);
// Sending mid-upload would silently drop the pending file from the comment;
// sending past a failed chip would discard the file the user selected and
// clear its error state, so both hold submission until resolved or removed.
const uploadPending = attachments.some((item) => item.status === "uploading");
const uploadFailed = attachments.some((item) => item.status === "error");
const ModeIcon = modeMeta.icon;
async function submit() {
const trimmed = bodyRef.current.trim();
if (
(!trimmed && attachedRefs.length === 0) ||
uploadPending ||
uploadFailed ||
submitting ||
disabled
)
return;
const refLines = attachedRefs
.map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`)
.join("\n");
const fullBody = [trimmed, refLines].filter(Boolean).join("\n\n");
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined;
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
const trimmed = body.trim();
if (!trimmed || submitting || disabled) return;
setSubmitting(true);
setBody("");
try {
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(fullBody, reopen, reassignment);
setAttachments([]);
setPendingAssignee(null);
await onAdd(trimmed);
} catch {
setBody(trimmed); // restore on failure (chips stay for retry)
setBody(trimmed); // restore on failure
} finally {
setSubmitting(false);
}
@@ -315,207 +58,75 @@ export function TaskChatComposer({
return (
<div
className={cn(
"rounded-xl border border-input bg-card p-2 shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
"rounded-xl border bg-card p-2 shadow-(--shadow-extract-7)",
modeMeta.classes.container || "border-border",
)}
onKeyDownCapture={(e) => {
// Shift+Tab cycles the pending mode; captured on the wrapper so it
// wins over Lexical's list-outdent binding inside the editor.
if (disabled) return;
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
setPendingMode((mode) => nextWorkMode(mode));
}
}}
onPasteCapture={handlePasteCapture}
>
<div data-testid="task-chat-composer-input">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
readOnly={disabled}
mentions={mentions}
onSubmit={() => void submit()}
imageUploadHandler={canAcceptFiles ? uploadInlineImage : undefined}
onDropFile={canAcceptFiles ? attachNonImageFile : undefined}
bordered={false}
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
/>
</div>
{attachments.length > 0 ? (
<AttachmentGroup className="mb-1 px-1" data-testid="task-chat-composer-attachments">
{attachments.map((attachment) => {
const kind = fileKindForName(attachment.name);
const KindIcon = kind.icon;
const sizeLabel = formatFileSize(attachment.size);
return (
<Attachment
key={attachment.id}
size="sm"
state={
attachment.status === "uploading"
? "uploading"
: attachment.status === "error"
? "error"
: "done"
}
>
<AttachmentMedia>
{attachment.status === "uploading" ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<KindIcon aria-hidden />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">{attachment.name}</AttachmentTitle>
<AttachmentDescription className="max-w-48">
{attachment.status === "uploading"
? "Uploading…"
: attachment.status === "error"
? (attachment.error ?? "Upload failed")
: [kind.label, sizeLabel].filter(Boolean).join(" · ")}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${attachment.name}`}
onClick={() =>
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id))
}
>
<X aria-hidden />
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
})}
</AttachmentGroup>
) : null}
}}
disabled={disabled}
placeholder={disabled ? (disabledReason ?? "Composer disabled") : placeholder}
rows={2}
className="w-full resize-none bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60"
data-testid="task-chat-composer-input"
/>
<div className="mt-1 flex items-center gap-2">
{canAcceptFiles ? (
<>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInputChange}
/>
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
title="Attach file"
aria-label="Attach file"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
data-testid="task-chat-composer-attach"
className={cn(
"flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
modeMeta.classes.chip,
)}
disabled={!onWorkModeChange}
>
<Plus className="h-4 w-4" aria-hidden />
<ModeIcon className="h-3.5 w-3.5" />
{modeMeta.shortLabel}
<ChevronDown className="h-3 w-3" />
</button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled || !onWorkModeChange}
className="status-chip flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-pending-work-mode={pendingMode}
>
{modeMeta.label}
<ChevronDown className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
</PopoverTrigger>
<PopoverContent align="start" className="w-44 p-1">
{workModeMetaList().map((m) => {
const Icon = m.icon;
const selected = m.value === pendingMode;
return (
<DropdownMenuItem
<button
key={m.value}
onSelect={() => setPendingMode(m.value)}
style={
selected
? { backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)` }
: undefined
}
type="button"
onClick={() => {
setPendingMode(m.value);
setMenuOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
m.classes.menuItem,
)}
>
<Icon className="h-4 w-4 shrink-0" style={{ color: modeHue(m.value) }} aria-hidden />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{m.label}</span>
<span className="text-xs text-muted-foreground">{MODE_DESCRIPTION[m.value] ?? ""}</span>
</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
<Icon className="h-4 w-4" />
<span className="flex-1">{m.label}</span>
{m.value === pendingMode ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{showAssignee ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="flex h-8 min-w-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
data-testid="task-chat-composer-assignee"
>
<span className="max-w-40 truncate">{assigneeLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{(reassignOptions ?? []).map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setPendingAssignee(option.id)}>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.id === assigneeValue ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</PopoverContent>
</Popover>
<button
type="button"
onClick={() => void submit()}
disabled={
disabled ||
submitting ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
}
title={
uploadPending
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
}
aria-label="Send"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
disabled={disabled || submitting || body.trim().length === 0}
className="ml-auto flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
data-testid="task-chat-composer-send"
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
<Send className="h-3.5 w-3.5" />
{submitting ? "Sending…" : "Send"}
</button>
</div>
</div>
@@ -1,10 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
import type { TaskChatStatusItem } from "./task-chat-model";
import { statusLabelIcon, toolTaxonomy } from "./tool-taxonomy";
import { isGenericStatusLabel, whimsyWord } from "./status-whimsy";
import { parseCssTimeMs } from "./motion-tokens";
function elapsedLabel(ms?: number): string | null {
if (ms == null) return null;
@@ -13,242 +9,6 @@ function elapsedLabel(ms?: number): string | null {
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
/** Tenths-precision elapsed ("24.3s", "1m 24.3s") so the readout visibly moves. */
function liveElapsedLabel(ms?: number): string | null {
if (ms == null) return null;
const s = Math.max(0, ms) / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m ${(s % 60).toFixed(1)}s`;
}
/** Elapsed ms since `startedAtMs`, ticking ten times a second while `live`. */
function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): number | undefined {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (startedAtMs == null || !live) return;
const id = window.setInterval(() => setNow(Date.now()), 100);
return () => window.clearInterval(id);
}, [startedAtMs, live]);
return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs);
}
/**
* How many line-heights the streaming interstitial block is shifted up so only
* its LAST line shows in the 1lh viewport: lines − 1 (never negative).
*/
export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): number {
if (!Number.isFinite(scrollHeight) || !Number.isFinite(lineHeightPx) || lineHeightPx <= 0) {
return 0;
}
return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1);
}
/** Read a `--motion-*` time token off :root in ms (0 when unset, e.g. jsdom). */
function motionTokenMs(name: string): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
const ms = parseCssTimeMs(raw);
return Number.isFinite(ms) ? ms : 0;
}
/**
* The interstitial update inside a one-line-height clipped viewport.
*
* The `.tc-line-scroll` slide plays ONLY at swap moments (PAP-368): on mount —
* i.e. when a new update replaces the previous one, the parent keys this
* component by swap — the text slides up from below the 1lh clip, and once
* that enter window (--motion-line-scroll) elapses the transition is disabled
* inline. While `streaming`, the block is shifted up by whole line-heights so
* only the tail line shows, but with the transition off the shift SNAPS — no
* per-wrap glide keeping the row in constant motion. Once the update finishes
* (`streaming` false) the held text renders single-line ellipsized, static.
* When `leaving` (held past the dwell with no successor, PAP-376), the
* transition re-enables and the line continues up out of the clip. Line count
* is measured from scrollHeight / line-height, re-checked on resize.
*/
function LiveSelfTalkLine({
text,
streaming,
leaving,
}: {
text: string;
streaming: boolean;
leaving?: boolean;
}) {
const innerRef = useRef<HTMLSpanElement | null>(null);
const [offset, setOffset] = useState(0);
const [entered, setEntered] = useState(false);
const [settled, setSettled] = useState(false);
useLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => {
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
setOffset(lineScrollOffset(el.scrollHeight, lineHeight));
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [text, streaming]);
useEffect(() => {
const id = requestAnimationFrame(() => setEntered(true));
const timer = window.setTimeout(() => setSettled(true), motionTokenMs("--motion-line-scroll"));
return () => {
cancelAnimationFrame(id);
window.clearTimeout(timer);
};
}, []);
const shift = !entered ? 1 : leaving ? -1 : streaming ? -offset : 0;
return (
<span
className="tc-line-scroll min-w-0 flex-1"
data-testid="task-chat-live-self-talk"
data-streaming={streaming || undefined}
data-leaving={leaving || undefined}
>
<span
ref={innerRef}
className={cn("tc-line-scroll-inner block", !streaming && "truncate")}
style={{
transform: shift !== 0 ? `translateY(calc(${shift} * 1lh))` : undefined,
transition: settled && !leaving ? "none" : undefined,
}}
>
{text}
</span>
</span>
);
}
interface HeldSelfTalk {
text: string;
/** The displayed update is the currently-streaming tail message. */
streaming: boolean;
/** Increments at each swap — keys LiveSelfTalkLine so the slide replays. */
swapKey: number;
/** Sliding out after sitting held for the full dwell (PAP-376). */
leaving?: boolean;
}
/**
* Held interstitial state (PAP-368, amended by PAP-376): a finished update
* stays visible, static, for --motion-interstitial-dwell (~4s) — then, if
* nothing superseded it, slides away rather than sitting until the next
* update happens to push it out. The same dwell paces swaps (~4s minimum
* between them) so visibility tracks reading time, not streaming speed.
* Updates that arrive inside the dwell window collapse latest-wins (a queue
* of one: only the newest pending update swaps in when the dwell expires).
*
* Message boundaries are the `selfTalk` gaps: the transcript tail clears it
* between assistant messages (tool calls / thinking in between), so a
* defined→undefined→defined sequence marks a new update.
*/
function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfTalk | null {
const [line, setLine] = useState<HeldSelfTalk | null>(null);
const lineRef = useRef<HeldSelfTalk | null>(line);
const pendingRef = useRef<string | null>(null);
const tailRef = useRef<string | undefined>(selfTalk);
const lastSwapAtRef = useRef(0);
const timerRef = useRef<number | null>(null);
const leaveTimerRef = useRef<number | null>(null);
const exitTimerRef = useRef<number | null>(null);
tailRef.current = selfTalk;
useEffect(() => {
const clear = (ref: { current: number | null }) => {
if (ref.current != null) {
window.clearTimeout(ref.current);
ref.current = null;
}
};
const show = (next: HeldSelfTalk | null) => {
lineRef.current = next;
setLine(next);
};
// Held past the dwell with nothing queued: slide the line out of the 1lh
// viewport (--motion-line-scroll) and unmount it back to the empty slot.
const scheduleLeave = () => {
clear(leaveTimerRef);
clear(exitTimerRef);
leaveTimerRef.current = window.setTimeout(() => {
leaveTimerRef.current = null;
// A queued update's swap timer fires no later than this — let the
// swap replace the line instead of playing the exit first.
if (pendingRef.current != null) return;
const cur = lineRef.current;
if (cur == null || cur.streaming) return;
show({ ...cur, leaving: true });
exitTimerRef.current = window.setTimeout(() => {
exitTimerRef.current = null;
if (lineRef.current?.leaving) show(null);
}, motionTokenMs("--motion-line-scroll"));
}, motionTokenMs("--motion-interstitial-dwell"));
};
const swapIn = (text: string, streaming: boolean) => {
clear(leaveTimerRef);
clear(exitTimerRef);
lastSwapAtRef.current = Date.now();
show({ text, streaming, swapKey: (lineRef.current?.swapKey ?? 0) + 1 });
// Swapped in with its stream already over: its idle clock starts now.
if (!streaming) scheduleLeave();
};
if (!live) {
// Turn end clears the row (the reserved slot itself unmounts with it).
clear(timerRef);
clear(leaveTimerRef);
clear(exitTimerRef);
pendingRef.current = null;
if (lineRef.current) show(null);
return;
}
const cur = lineRef.current;
if (selfTalk == null) {
// Stream ended: the update becomes held and its idle clock starts.
if (cur?.streaming) {
show({ ...cur, streaming: false });
scheduleLeave();
}
return;
}
if (cur == null) {
swapIn(selfTalk, true);
return;
}
if (cur.streaming) {
// Same message growing — update in place, no swap.
if (cur.text !== selfTalk) show({ ...cur, text: selfTalk });
return;
}
// A new update while one is held (or leaving): queue it latest-wins and
// swap when the dwell since the last swap has elapsed.
pendingRef.current = selfTalk;
if (timerRef.current == null) {
const dwellMs = motionTokenMs("--motion-interstitial-dwell");
const delay = Math.max(0, lastSwapAtRef.current + dwellMs - Date.now());
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
const text = pendingRef.current;
pendingRef.current = null;
// Still streaming iff the tail message is live at swap time; if so the
// next effect pass keeps the text tracking the tail.
if (text != null) swapIn(text, tailRef.current != null);
}, delay);
}
}, [selfTalk, live]);
useEffect(
() => () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
if (leaveTimerRef.current != null) window.clearTimeout(leaveTimerRef.current);
if (exitTimerRef.current != null) window.clearTimeout(exitTimerRef.current);
},
[],
);
return line;
}
const CONFIG = {
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
@@ -261,145 +21,18 @@ const CONFIG = {
interface TaskChatStatusPillProps {
item: TaskChatStatusItem;
onApprovalDecision?: (optionId: string) => void;
/**
* When set, the live line is the header of an expandable parent row
* (TaskChatTurn): render a trailing chevron reflecting the open state —
* the same expand grammar as tool rows and the settled "Worked ·" line.
*/
chevronOpen?: boolean;
/**
* Expand toggle for the parent row. The button (hover + click target) wraps
* ONLY the gerund status line — the interstitial row above it stays outside
* (PAP-376): its text vanishes on expand, so hovering it as part of the
* header read as covering two unrelated lines.
*/
onToggle?: () => void;
}
/**
* Lifecycle state as a status affordance (never a bubble), with a defined
* enter transition. Live running/working states render the v7 flat statusline
* (pulse dot + shimmering label + mono meta); Tier-B attention states keep
* card chrome — awaiting-approval elevates with an attention pulse and
* enter transition. Awaiting-approval elevates with an attention pulse and
* exposes the ACP permission options as actions.
*/
export function TaskChatStatusPill({
item,
onApprovalDecision,
chevronOpen,
onToggle,
}: TaskChatStatusPillProps) {
export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusPillProps) {
const { Icon, spin, tone } = CONFIG[item.status];
const awaiting = item.status === "awaiting_approval";
const live = item.status === "running" || item.status === "working";
const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live);
const heldSelfTalk = useHeldSelfTalk(live ? item.selfTalk : undefined, live);
const elapsed = elapsedLabel(item.elapsedMs);
if (live) {
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
// While an interstitial streams, its text lives on its OWN row above; the
// status line below keeps the uninterrupted gerund rotation ("Responding"
// never takes the line, and no Responding icon appears here).
const streamingInterstitial = item.selfTalk != null;
const ToolIcon = item.toolName
? toolTaxonomy(item.toolName).icon
: streamingInterstitial
? null
: statusLabelIcon(item.label);
// Whimsy fills gaps, never replaces signal: only the generic
// "Running"/"Working" labels — and the interstitial-streaming state, whose
// text renders on the row above — swap for a deterministic whimsical
// gerund (seeded by the run-scoped item id, rotating ~10s via the live
// tick).
const label = streamingInterstitial || isGenericStatusLabel(item.label)
? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0)
: item.label;
const statusLine = (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
{/* Fixed-size lead slot keeps the label from moving as tool icons
come and go; the pulse dot renders unconditionally. */}
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<span
aria-hidden
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
/>
</span>
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
{/* Text cells share a baseline row so the smaller mono readouts don't
float above the label's baseline. */}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">
{label}…
</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
</span>
) : null}
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
{item.tokens ? (
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
{chevronOpen !== undefined ? (
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
aria-hidden
/>
) : null}
</div>
);
// The interstitial row is PERMANENTLY RESERVED while the turn is live
// (round 9): the layout above never jumps to make room. A streaming
// interstitial gets this bubble-less, icon-less single-line row DIRECTLY
// ABOVE the status line — the two never share a line. The latest update is
// held after its stream ends (PAP-368) and slides away once it has sat
// for the full --motion-interstitial-dwell without a successor (PAP-376);
// the .tc-line-scroll slide otherwise plays only when a new update swaps
// in (keyed remount). Before the first update the row is an empty one-line
// slot of identical height; the empty lead slot keeps the text
// column-aligned with the status label. The expand button wraps ONLY the
// status line, so the interstitial row never picks up its hover.
return (
<div className="flex flex-col">
<div
className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground"
data-testid="task-chat-interstitial-row"
>
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
{heldSelfTalk ? (
<LiveSelfTalkLine
key={heldSelfTalk.swapKey}
text={heldSelfTalk.text}
streaming={heldSelfTalk.streaming}
leaving={heldSelfTalk.leaving}
/>
) : (
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
)}
</div>
{onToggle ? (
<button
type="button"
onClick={onToggle}
aria-expanded={chevronOpen}
className="-mx-1.5 block w-full rounded-sm px-1.5 text-left transition-colors hover:bg-muted/60"
data-testid="task-chat-live-turn"
>
{statusLine}
</button>
) : (
statusLine
)}
</div>
);
}
return (
<div
className={cn(
@@ -0,0 +1,47 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Brain, ChevronDown, ChevronRight } from "lucide-react";
import type { TaskChatThinkingItem } from "./task-chat-model";
/**
* Chain-of-thought (ACP agent_thought_chunk). While streaming it renders the
* live lines with a staggered enter; when the item reports `collapsed` it folds
* to a "Worked for N" header that can be re-expanded. Recessed styling keeps
* thinking visually subordinate to agent output.
*/
export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) {
const [open, setOpen] = useState(!item.collapsed);
const collapsedByDefault = item.collapsed === true;
return (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground"
>
<Brain className="h-3.5 w-3.5" />
<span className="font-medium">
{collapsedByDefault ? (item.summaryLabel ?? "Thought process") : item.streaming ? "Thinking…" : "Thought process"}
</span>
{open ? <ChevronDown className="ml-auto h-3.5 w-3.5" /> : <ChevronRight className="ml-auto h-3.5 w-3.5" />}
</button>
{open ? (
<div className="space-y-1 px-3 pb-2">
{item.lines.map((line, i) => (
<p
key={i}
className={cn("tc-enter-cot-line text-xs italic text-muted-foreground")}
style={{ animationDelay: `calc(var(--motion-cot-line-stagger) * ${i})` }}
>
{line}
</p>
))}
{item.streaming ? (
<span className="tc-cursor inline-block h-3 w-1.5 bg-muted-foreground align-baseline" />
) : null}
</div>
) : null}
</div>
);
}
@@ -1,45 +1,16 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
} from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import type { TaskChatItem } from "./task-chat-model";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskMessageScroller } from "./TaskMessageScroller";
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;
/**
* 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;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@@ -48,43 +19,14 @@ interface TaskChatThreadViewProps {
function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
) {
switch (item.kind) {
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);
return (
<TaskChatBubble
item={item}
actions={actions}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
leading={actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
/>
);
}
case "message":
return <TaskChatBubble item={item} />;
case "marker":
return <TaskChatMarker item={item} />;
case "thinking":
// Thinking never renders as a row (PAP-361): its live signal is the
// pill's "Thinking…" state, and the text stays in the run log / classic
// transcript. The kind survives in the model because the transcript
// parser still emits it (both nesting rules filter it out).
return null;
return <TaskChatThinkingBlock item={item} />;
case "tool":
return <TaskChatToolCard item={item} />;
case "status":
@@ -96,17 +38,6 @@ function renderItem(
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "brief":
return renderBrief ? renderBrief() : null;
case "turn":
return (
<TaskChatTurn
item={item}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
);
default: {
// Exhaustiveness guard: a new item kind must add a branch above.
const _never: never = item;
@@ -123,56 +54,28 @@ function renderItem(
*/
export function TaskChatThreadView({
items,
header,
onApprovalDecision,
renderInteraction,
renderBrief,
renderMessageActions,
className,
scroll = true,
}: TaskChatThreadViewProps) {
const body = (
<div className={cn("mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-3 px-4 py-4", className)}>
{header ? (
<div className="flex flex-col gap-6 pb-2" data-testid="task-chat-thread-header">
{header}
</div>
) : null}
<div className={cn("flex flex-col gap-3 px-3 py-4", className)}>
{items.map((item) => (
<div key={item.id}>
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
</div>
<div key={item.id}>{renderItem(item, onApprovalDecision)}</div>
))}
</div>
);
if (!scroll) return body;
return <TaskMessageScroller contentKey={taskChatContentKey(items)}>{body}</TaskMessageScroller>;
}
// Include a cheap content signature so streaming growth (text lengthening
// without the item count changing) still advances the auto-follow key.
const contentKey = items.reduce((acc, it) => {
if (it.kind === "message") return acc + it.text.length;
if (it.kind === "thinking") return acc + it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool") return acc + (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
return acc + 1;
}, items.length);
// Cheap content signature so streaming growth (text lengthening without the
// item count changing) still advances the auto-follow key. Shared by the
// desktop scroller above and the mobile window-scroll follow (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 === "turn") {
if (it.settled) return 1;
// 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);
}
return 1;
}
export function taskChatContentKey(items: TaskChatItem[]): number {
return items.reduce((acc, it) => acc + signatureOf(it), items.length);
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
}
@@ -1,90 +1,50 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Check,
ChevronRight,
Loader2,
ShieldCheck,
ShieldX,
X,
} from "lucide-react";
import { Check, Loader2, X, Wrench, ShieldCheck, ShieldX } from "lucide-react";
import type { TaskChatToolItem } from "./task-chat-model";
import { toolTaxonomy } from "./tool-taxonomy";
const STATUS_ICON = {
pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" },
in_progress: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
completed: { Icon: Check, spin: false, tone: "text-muted-foreground" },
completed: { Icon: Check, spin: false, tone: "text-(--status-task-icon-done)" },
failed: { Icon: X, spin: false, tone: "text-destructive" },
} as const;
/**
* Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] …
* [hover chevron] [status]. No card chrome — activity is metadata, not
* content. Clicking toggles the mono result/detail block (left-rail inset);
* diffs render as an inset panel below the row.
* Tool invocation card. Renders the tool name + live status glyph, an optional
* resolved permission-decision badge, and an inline diff when present.
*/
export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
const { Icon, spin, tone } = STATUS_ICON[item.status];
const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon;
const [showDetail, setShowDetail] = useState(false);
const expandable = Boolean(item.detail);
return (
<div className="tc-enter-tool flex flex-col text-xs">
<button
type="button"
onClick={expandable ? () => setShowDetail((v) => !v) : undefined}
aria-expanded={expandable ? showDetail : undefined}
className={cn(
"group/tool -mx-1.5 flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-0.5 text-left text-muted-foreground",
expandable ? "cursor-pointer transition-colors hover:bg-muted/60 hover:text-foreground" : "cursor-default",
)}
>
<RowIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="shrink-0 font-medium">{item.name}</span>
{item.target ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.target}</span>
<div className="tc-enter-tool rounded-lg border border-border bg-card/60 text-sm">
<div className="flex min-w-0 items-center gap-2 px-3 py-2">
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate font-mono text-xs">{item.name}</span>
{item.decision ? (
<span
className={cn(
"flex shrink-0 items-center gap-1 text-(length:--text-micro)",
item.decision === "allowed" ? "text-(--status-task-icon-done)" : "text-destructive",
)}
>
{item.decision === "allowed" ? <ShieldCheck className="h-3 w-3" /> : <ShieldX className="h-3 w-3" />}
{item.decision}
</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-2">
{item.decision ? (
<span
className={cn(
"flex items-center gap-1 text-(length:--text-micro)",
item.decision === "allowed" ? "text-(--status-task-icon-done)" : "text-destructive",
)}
>
{item.decision === "allowed" ? <ShieldCheck className="h-3 w-3" /> : <ShieldX className="h-3 w-3" />}
{item.decision}
</span>
) : null}
{expandable ? (
<ChevronRight
className={cn(
"h-3 w-3 opacity-0 transition-[opacity,transform] group-hover/tool:opacity-80",
showDetail ? "rotate-90" : null,
)}
aria-hidden
/>
) : null}
<Icon className={cn("h-3.5 w-3.5", tone, spin && "animate-spin")} aria-hidden />
</span>
</button>
{expandable && showDetail ? (
<pre className="ml-2.5 mt-0.5 overflow-x-auto whitespace-pre-wrap border-l-2 border-border py-1 pl-2.5 font-mono text-(length:--text-micro) leading-relaxed text-muted-foreground">
{item.detail}
</pre>
) : null}
<Icon className={cn("ml-auto h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
</div>
{item.detail ? <p className="px-3 pb-2 text-xs text-muted-foreground">{item.detail}</p> : null}
{item.diff ? (
<div className="tc-reveal-diff ml-2.5 mt-1 border-l-2 border-border pl-2.5">
<div className="tc-reveal-diff border-t border-border px-3 py-2">
<div className="mb-1 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
{item.diff.path ? <span className="truncate font-mono">{item.diff.path}</span> : null}
<span className="ml-auto shrink-0 font-mono font-semibold">
+{item.diff.added} −{item.diff.removed}
<span className="ml-auto shrink-0">
<span className="text-(--status-task-icon-done)">+{item.diff.added}</span>{" "}
<span className="text-destructive">−{item.diff.removed}</span>
</span>
</div>
{item.diff.lines?.length ? (
<pre className="overflow-x-auto rounded-sm bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
<pre className="overflow-x-auto rounded bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
{item.diff.lines.map((l, i) => (
<div
key={i}
@@ -4,19 +4,6 @@ import { ArrowDown } from "lucide-react";
const PIN_THRESHOLD_PX = 48;
/** Visibility lifecycle of the scroll-to-latest pill. */
type PillPhase = "hidden" | "in" | "out";
/**
* True when animations are disabled (prefers-reduced-motion, or environments
* without matchMedia such as jsdom). In that case the pill's exit animation
* never fires animationend, so we must unmount immediately.
*/
function motionDisabled(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
interface TaskMessageScrollerProps {
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
@@ -28,33 +15,16 @@ interface TaskMessageScrollerProps {
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
* threshold) new content auto-follows with INSTANT scroll; the moment the user
* scrolls up we hold their position and surface a scroll-to-latest pill
* instead of yanking them down.
*
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
* the smooth scroll fires intermediate scroll events that would otherwise
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
* within the pin threshold re-pins and hides the pill; a wheel/touch during
* the glide cancels it and treats the user as unpinned. Content-driven follow
* while pinned stays instant, so no reflow/jump happens during streaming.
* threshold) new content auto-follows; the moment the user scrolls up we hold
* their position and surface a "Jump to latest" affordance instead of yanking
* them down. Scrolling is INSTANT (not smooth) so a scroll listener cannot
* unpin mid-animation — a known stick-to-bottom failure mode. No reflow/jump is
* introduced during streaming because we only ever scroll when already pinned.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const ref = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const easingRef = useRef(false);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
const hidePill = useCallback(() => {
// Keep rendering through the exit animation; unmount on animationend.
// Without animations (reduced motion / no matchMedia) unmount immediately.
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
}, []);
const [showJump, setShowJump] = useState(false);
const isPinned = useCallback(() => {
const el = ref.current;
@@ -70,60 +40,9 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const handleScroll = useCallback(() => {
const pinned = isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
// unpin/re-show the pill. Only act once we arrive within the threshold.
if (pinned) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
return;
}
pinnedRef.current = pinned;
if (pinned) hidePill();
else showPill();
}, [isPinned, hidePill, showPill]);
const handleJumpToLatest = useCallback(() => {
const el = ref.current;
if (!el) return;
if (isPinned()) {
// Already at (or within threshold of) the bottom: no glide needed.
pinnedRef.current = true;
hidePill();
return;
}
easingRef.current = true;
if (typeof el.scrollTo === "function") {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} else {
// Environments without scrollTo (older jsdom): fall back to instant.
el.scrollTop = el.scrollHeight;
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
}, [isPinned, hidePill]);
// A user gesture during the smooth glide cancels the re-follow: stop
// treating scroll events as easing and consider the user unpinned.
useEffect(() => {
const el = ref.current;
if (!el) return;
const cancelEasing = () => {
if (!easingRef.current) return;
easingRef.current = false;
pinnedRef.current = false;
showPill();
};
el.addEventListener("wheel", cancelEasing, { passive: true });
el.addEventListener("touchstart", cancelEasing, { passive: true });
return () => {
el.removeEventListener("wheel", cancelEasing);
el.removeEventListener("touchstart", cancelEasing);
};
}, [showPill]);
setShowJump(!pinned);
}, [isPinned]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
@@ -140,30 +59,23 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
<div
ref={ref}
onScroll={handleScroll}
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
// wrapper exactly — percentage heights don't reliably resolve against
// flex-determined block heights, which let the thread overflow the page.
className={cn("absolute inset-0 overflow-y-auto", className)}
className={cn("h-full overflow-y-auto", className)}
data-testid="task-chat-scroller"
>
{children}
</div>
{pillPhase !== "hidden" ? (
{showJump ? (
<button
type="button"
aria-label="Scroll to latest"
onClick={handleJumpToLatest}
onAnimationEnd={() => {
if (pillPhase === "out") setPillPhase("hidden");
onClick={() => {
scrollToBottom();
pinnedRef.current = true;
setShowJump(false);
}}
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
className={cn(
"absolute bottom-3 left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
)}
className="tc-enter-status absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs shadow-sm hover:bg-accent/50"
>
<ArrowDown className="h-4 w-4" />
<ArrowDown className="h-3.5 w-3.5" />
Jump to latest
</button>
) : null}
</div>
+1 -2
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
import {
EASING_PRESETS,
@@ -221,7 +220,7 @@ export function TweakPanel() {
value={exportText}
onFocus={(e) => {
e.currentTarget.select();
void copyTextToClipboard(exportText).catch(() => {});
void navigator.clipboard?.writeText(exportText).catch(() => {});
}}
/>
) : null}
@@ -27,9 +27,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-ease-out-expo", group: "Easing", kind: "easing" },
{ name: "--motion-ease-standard", group: "Easing", kind: "easing" },
{ name: "--motion-ease-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-in", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
@@ -37,11 +34,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-duration-base", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-slow", group: "Durations", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-duration-deliberate", group: "Durations", kind: "time", min: 0, max: 2000, step: 10 },
// Shared enter/exit/swap primitives owned by the decision/quicklook motion
// block in index.css.
{ name: "--motion-duration-enter", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-exit", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-swap", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
// State/component-scoped
{ name: "--motion-marker-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
@@ -57,12 +49,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 },
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
];
/** Common easing presets offered by the tweak panel's easing picker. */
@@ -9,24 +9,12 @@
*/
import type { Agent } from "@paperclipai/shared";
import type { IssueChatComment } from "@/lib/issue-chat-messages";
import { resolveCommentAttribution } from "@/lib/comment-attribution";
import type { TaskChatAuthorKind, TaskChatItem } from "./task-chat-model";
export interface TaskChatAdapterContext {
agentMap?: Map<string, Agent>;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
/**
* Task's current assignee. Agent comments from anyone else are cross-issue
* writes and get a "for {user}" attribution chip (the open cross-task write design (attribution)).
*/
issueAssigneeAgentId?: string | null;
/**
* Capitalized mode chip for agent-authored bubbles ("Agent mode" / "Plan
* mode" / "Ask mode") — resolved per comment, so each reply is tagged with
* the mode its request actually ran under (not the issue's current mode).
*/
agentModeLabelFor?: (comment: IssueChatComment) => string | undefined;
}
function effectiveAgentId(comment: IssueChatComment): string | null {
@@ -40,8 +28,7 @@ function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
return "system";
}
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
export function formatTaskChatTimestamp(value: unknown): string | undefined {
function formatTimestamp(value: unknown): string | undefined {
if (!value) return undefined;
const d = value instanceof Date ? value : new Date(value as string);
if (Number.isNaN(d.getTime())) return undefined;
@@ -57,18 +44,9 @@ export function commentsToTaskChatItems(
if (comment.deletedAt) continue;
const kind = authorKind(comment);
let authorName: string | undefined;
let agentIcon: string | null | undefined;
let onBehalfOfUserName: string | undefined;
if (kind === "agent") {
const agentId = effectiveAgentId(comment);
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined;
onBehalfOfUserName = resolveCommentAttribution({
authorAgentId: agentId,
onBehalfOfUserId: comment.onBehalfOfUserId ?? null,
issueAssigneeAgentId: ctx.issueAssigneeAgentId,
resolveUserLabel: (userId) => ctx.userLabelMap?.get(userId),
})?.userName;
} else if (kind === "human") {
authorName =
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
@@ -85,11 +63,8 @@ export function commentsToTaskChatItems(
author: kind,
authorName,
text: comment.body,
timestamp: formatTaskChatTimestamp(comment.createdAt),
timestamp: formatTimestamp(comment.createdAt),
optimistic,
agentIcon,
onBehalfOfUserName,
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
});
}
return items;
@@ -50,90 +50,30 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
],
};
case "thinking":
// The surviving thinking signal (PAP-361): the live line's "Thinking…"
// state — Brain icon + shimmer on the pill. Thinking rows no longer
// render in the thread or nest under turns.
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-thinking",
kind: "turn",
settled: false,
summary: { toolCount: 1, added: 0, removed: 0 },
liveStatus: { id: "st-thinking", kind: "status", status: "running", label: "Thinking", startedAtMs: Date.now() - 6100, tokens: { used: 18240, size: 200000 } },
items: [
{ id: "th-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
],
},
{ id: "th-1", kind: "thinking", streaming: true, lines: ["The login route is in server/src/routes/auth.ts.", "There's already an ipRateLimit helper I can reuse.", "I'll add a per-account bucket keyed on the email."] },
],
};
case "responding":
// A streaming interstitial update gets its own row directly above the
// status line (PAP-361, amended): it wraps into the 1lh viewport
// line-scroll while the gerund rotation below runs uninterrupted.
// Ephemeral — when it finishes, the row slides out and the text renders
// nowhere.
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-responding",
kind: "turn",
settled: false,
summary: { toolCount: 1, added: 0, removed: 0 },
liveStatus: {
id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 },
selfTalk:
"I found an existing ipRateLimit helper, so I'll extend it with a per-account token bucket keyed on the email address instead of adding a second limiter. The bucket refills at six requests a minute, matching the lockout policy the auth spec documents, and failed attempts drain it twice as fast so brute-force runs hit the ceiling quickly while a fat-fingered password barely registers.",
},
items: [
{ id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
],
},
],
};
case "responding-burst":
// A run emitting several interstitial updates in quick succession
// (PAP-368): the lab replay streams each blank-line-separated segment as
// its own update with a short gap between. The pill HOLDS each finished
// update until the next swaps in, paced by --motion-interstitial-dwell
// (latest-wins when updates outpace the dwell).
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-responding-burst",
kind: "turn",
settled: false,
summary: { toolCount: 2, added: 0, removed: 0 },
liveStatus: {
id: "st-responding-burst", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 21400, tokens: { used: 18240, size: 200000 },
selfTalk:
"Found the existing ipRateLimit helper — extending it beats adding a second limiter.\n\n" +
"Wiring a per-account token bucket keyed on the email address, refilling at six requests a minute per the auth spec.\n\n" +
"Failed attempts drain the bucket twice as fast, so brute-force runs hit the ceiling while a fat-fingered password barely registers.\n\n" +
"Now updating the login route to consume from the bucket before the password check and adding tests for the lockout path.",
},
items: [
{ id: "burst-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
{ id: "burst-grep", kind: "tool", name: "Grep", target: "ipRateLimit", toolKind: "search", status: "completed" },
],
},
{ id: "m-agent-stream", kind: "message", author: "agent", authorName: AGENT, streaming: true, text: "I found an existing ipRateLimit helper, so I'll extend it with a per-account bucket and" },
],
};
case "tool-call":
return {
surface: "thread",
items: [
{ id: "tool-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
{ id: "tool-1", kind: "tool", name: "read server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
],
};
case "diff":
@@ -141,7 +81,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
surface: "thread",
items: [
{
id: "tool-diff", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
id: "tool-diff", kind: "tool", name: "edit server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
diff: {
path: "server/src/routes/auth.ts", added: 3, removed: 1,
lines: [
@@ -156,73 +96,25 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
],
};
case "working":
// Parent-row live turn (PAP-354): the tool-state line owns the activity;
// expanding nests the chronological history underneath.
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-working",
kind: "turn",
settled: false,
summary: { toolCount: 2, added: 0, removed: 0 },
liveStatus: { id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
items: [
{ id: "w-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
{ id: "w-edit", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "in_progress" },
],
},
{ id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", elapsedMs: 4200 },
],
};
case "running":
// Generic label → the parent row header rotates whimsical gerunds.
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-running",
kind: "turn",
settled: false,
summary: { toolCount: 1, added: 0, removed: 0 },
liveStatus: { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
items: [
{ id: "r-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
],
},
{ id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", elapsedMs: 12000, tokens: { used: 18240, size: 200000 } },
],
};
case "completed":
return {
surface: "thread",
items: [
...exchangePrefix(),
// Round 9: the settled turn attaches to the final reply bubble — the
// "✓ Worked · …" summary renders on the bubble's always-visible
// timestamp line ("2:34 PM · ✓ Worked · 38s · 2 tools").
{
id: "m-done",
kind: "message",
author: "agent",
authorName: AGENT,
agentIcon: "bot",
modeLabel: "Agent mode",
text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.",
timestamp: "2:34 PM",
attachedTurn: {
id: "turn-done",
kind: "turn",
settled: true,
// "Worked · N tools" expands to exactly the tool rows (PAP-361):
// toolCount matches the nested rows, no thinking row.
summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
items: [
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
{ id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
],
},
},
{ id: "th-done", kind: "thinking", collapsed: true, summaryLabel: "Worked for 38s · 3 tools · +34 −3", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
{ id: "m-done", kind: "message", author: "agent", authorName: AGENT, text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" },
],
};
case "awaiting-approval":
+2 -127
View File
@@ -12,7 +12,6 @@
* inventory for the mapping. No timing/motion values live here — those are
* CSS motion tokens in ui/src/index.css.
*/
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
/** Who authored a thread row — the primary legibility signal. */
export type TaskChatAuthorKind = "human" | "agent" | "system";
@@ -69,37 +68,6 @@ export interface TaskChatMessageItem {
streaming?: boolean;
/** Optimistic local echo state (matches IssueChatComment.clientStatus). */
optimistic?: "pending" | "queued";
/**
* Per-message mode tag ("Agent mode" / "Plan mode" / "Ask mode"). Shown as a
* chip in the agent header and under a sent human bubble (v6 decision).
*/
modeLabel?: string;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
/**
* Responsible user's display name, set only when this agent comment is a
* cross-issue write (the author is not the assignee). Renders as a
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
*/
onBehalfOfUserName?: string;
/**
* Agent text streamed inside a run turn (interstitial updates between tool
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
* finished it renders nowhere — the run log / classic transcript remain the
* archive. Tagged for live and settled transcripts alike.
*/
interstitial?: boolean;
/** Epoch ms of the message's first streamed chunk. */
atMs?: number;
/**
* The settled run turn "attached" to this bubble (round 9): when the run's
* final reply lands, the live parent row transforms into the "Worked · …"
* summary rendered on this bubble's always-visible timestamp line —
* "2:34 PM · ✓ Worked · 38s · 3 tools" — instead of a standalone row.
* Expanding still nests the tool history beneath the bubble.
*/
attachedTurn?: TaskChatTurnItem;
}
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
@@ -114,21 +82,14 @@ export interface TaskChatThinkingItem {
summaryLabel?: string;
}
/** A tool invocation row (ACP tool_call / tool_call_update). */
/** A tool invocation card (ACP tool_call / tool_call_update). */
export interface TaskChatToolItem {
id: string;
kind: "tool";
name: string;
/** Raw ACP tool name pre-collapse ("mcp__server__tool") for taxonomy lookup. */
rawName?: string;
/** ACP ToolKind. */
toolKind?: string;
status: TaskChatToolStatus;
/**
* Summarized primary argument (file path, command, pattern…) rendered mono
* next to the tool name — the v7 toolrow "target".
*/
target?: string;
detail?: string;
diff?: TaskChatDiff;
/** Resolved permission decision badge, when one applied. */
@@ -152,22 +113,7 @@ export interface TaskChatStatusItem {
| "truncated";
label: string;
detail?: string;
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
toolName?: string;
/**
* Flattened plain text of the interstitial update currently streaming
* (PAP-361, round 9): it renders in a dedicated single-line row directly
* above the status line — a slot PERMANENTLY RESERVED while the turn is
* live, so the layout above never jumps — inside a one-line viewport
* showing the streaming tail line; the gerund rotation below runs
* uninterrupted. When the message finishes the pill HOLDS the text as a
* static ellipsized line until the next update supersedes it or the turn
* ends (PAP-368) — nothing persists in the settled thread.
*/
selfTalk?: string;
elapsedMs?: number;
/** Run start epoch ms; live states tick their own elapsed from this. */
startedAtMs?: number;
tokens?: TaskChatTokenUsage;
/** Present for awaiting_approval (ACP RequestPermissionRequest). */
approval?: { toolName: string; options: TaskChatApprovalOption[] };
@@ -189,84 +135,13 @@ export interface TaskChatUsageItem {
usage: TaskChatTokenUsage;
}
/**
* The task description rendered as the requester's first chat bubble
* (PAP-375). A placeholder kind only — the host supplies the render
* (TaskChatThread binds TaskChatDescriptionBubble to the live issue), mirroring
* how interaction items defer to renderInteraction. Always prepended AFTER
* thread assembly, so no backbone entry or settled turn can sort above it.
*/
export interface TaskChatBriefItem {
id: string;
kind: "brief";
}
/**
* An issue-thread interaction (plan confirmation, question card, suggested
* tasks…) interleaved chronologically into the thread. The payload is the
* existing control-plane IssueThreadInteraction — the render layer wraps the
* shared IssueThreadInteractionCard rather than re-modeling the five kinds.
*/
export interface TaskChatInteractionItem {
id: string;
kind: "interaction";
interaction: IssueThreadInteraction;
}
/** Items a turn can group — everything except another turn. */
export type TaskChatTurnChildItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem;
/**
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
* can fold into a one-line expandable summary ("✓ Worked · 38s · 3 tools ·
* +34 −3"). A live turn carrying `liveStatus` renders as a single expandable
* parent row headed by that status line (gerund/tool-state + elapsed +
* tokens); expanding nests the chronological activity underneath, and on
* settle the header morphs in place into the summary. The live → settled
* fold animates (~ --motion-turn-fold), while turns that load already-settled
* collapse instantly.
*/
export interface TaskChatTurnItem {
id: string;
kind: "turn";
items: TaskChatTurnChildItem[];
settled: boolean;
/**
* The in-flight run's status line, hoisted to be THE turn's single visible
* row while collapsed (PAP-354 parent-row model). Absent once settled.
*/
liveStatus?: TaskChatStatusItem;
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
animateFold?: boolean;
summary: {
/** e.g. "38s" — omitted when unknown. */
durationLabel?: string;
toolCount: number;
added: number;
removed: number;
/** e.g. "12.3k tokens" — omitted when unknown. */
tokensLabel?: string;
/** Failed/interrupted turns get a ✗ affordance instead of ✓. */
failed?: boolean;
};
}
export type TaskChatItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatInteractionItem
| TaskChatTurnItem
| TaskChatBriefItem;
| TaskChatUsageItem;
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
export interface TaskChatPlanEntry {
@@ -7,7 +7,6 @@ import { TASK_CHAT_STATES } from "./task-chat-states";
import { buildScenario } from "./task-chat-fixtures";
import { TaskChatThreadView } from "./TaskChatThreadView";
import { TaskChatPlanView } from "./TaskChatPlanView";
import { ThemeProvider } from "@/context/ThemeContext";
/**
* Finish-line clause C: every state id in the inventory renders without error.
@@ -33,15 +32,11 @@ describe("Task chat state inventory renders", () => {
expect(() => {
flushSync(() => {
root.render(
// ThemeProvider: bubbles render markdown via MarkdownBody, which
// reads the theme — mirror the real app's provider tree.
<ThemeProvider>
{scenario.surface === "plan" && scenario.plan ? (
<TaskChatPlanView plan={scenario.plan} />
) : (
<TaskChatThreadView items={scenario.items} scroll={false} />
)}
</ThemeProvider>,
scenario.surface === "plan" && scenario.plan ? (
<TaskChatPlanView plan={scenario.plan} />
) : (
<TaskChatThreadView items={scenario.items} scroll={false} />
),
);
});
}).not.toThrow();
@@ -18,7 +18,6 @@ export const TASK_CHAT_STATES = [
"agent-message",
"thinking",
"responding",
"responding-burst",
"tool-call",
"diff",
"working",
@@ -82,13 +81,6 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
surface: "thread",
protocol: "text_delta stream:output, streaming",
},
"responding-burst": {
id: "responding-burst",
label: "Responding (update burst)",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
},
"tool-call": {
id: "tool-call",
label: "Tool call",
+18 -501
View File
@@ -10,10 +10,7 @@ import type {
TaskChatDiff,
TaskChatItem,
TaskChatToolItem,
TaskChatTurnChildItem,
TaskChatTurnItem,
} from "./task-chat-model";
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
const TERMINAL_STATUSES = new Set([
"failed",
@@ -27,127 +24,12 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
return status != null && TERMINAL_STATUSES.has(status);
}
/**
* The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool
* calls and usage readouts nest inside the expandable live turn. Messages
* never nest — an interstitial update streams on the parent row's own line
* (TaskChatStatusItem.selfTalk) and vanishes when it completes, and the run's
* final reply lands as its posted comment bubble. Thinking never nests either:
* its live signal is the pill's "Thinking…" state, and the text stays in the
* run log / classic transcript. Markers, statuses and interaction cards stay
* in the thread outside.
*/
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
return item.kind === "tool" || item.kind === "usage";
}
/**
* Flatten markdown-ish interstitial text to one plain line for the live parent
* row. Stream-safe: markers are stripped without requiring pairs.
*/
export function flattenSelfTalk(text: string): string {
return text
.replace(/```[a-z]*\n?/gi, " ")
.replace(/`/g, "")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1")
.replace(/\*+/g, "")
.replace(/__/g, "")
.replace(/\s+/g, " ")
.trim();
}
function diffKind(changeType: string): "add" | "remove" | "context" {
if (changeType === "add") return "add";
if (changeType === "remove") return "remove";
return "context";
}
/** Param keys probed (in order) for a tool call's one-line mono "target". */
const TARGET_KEYS = [
"file_path",
"path",
"notebook_path",
"command",
"pattern",
"query",
"url",
"prompt",
"description",
"skill",
"subject",
] as const;
const TARGET_MAX = 96;
function clip(text: string, max: number): string {
const oneLine = text.replace(/\s+/g, " ").trim();
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
}
/**
* Summarize a tool call's input into the v7 toolrow target: the most
* identifying parameter when the input is a keyed object, else a clipped
* rendering of the raw input. Returns undefined when there is nothing useful.
*/
export function summarizeToolInput(input: unknown): string | undefined {
if (input == null) return undefined;
if (typeof input === "string") return input.trim() ? clip(input, TARGET_MAX) : undefined;
if (typeof input !== "object") return clip(String(input), TARGET_MAX);
const record = input as Record<string, unknown>;
for (const key of TARGET_KEYS) {
const value = record[key];
if (typeof value === "string" && value.trim()) return clip(value, TARGET_MAX);
}
// The acpx log parser synthesizes { text, status } onto tool inputs from the
// event's summary line — presentation noise, not call parameters.
const entries = Object.entries(record).filter(
([k, v]) =>
(typeof v === "string" || typeof v === "number" || typeof v === "boolean") &&
k !== "text" &&
k !== "status",
);
if (entries.length === 0) return undefined;
return clip(entries.map(([k, v]) => `${k}: ${String(v)}`).join(", "), TARGET_MAX);
}
/**
* Display name for a tool call. Live acpx `tool_call` events carry real names
* ("Read", "Bash", mcp__server__tool); legacy stored logs may not — those fall
* back to a generic "Tool" row. MCP names collapse to their tool segment.
*/
export function toolDisplayName(name: string | undefined | null): string {
const raw = (name ?? "").trim();
if (isGenericToolName(raw)) return "Tool";
const mcp = mcpToolSegment(raw);
if (mcp) return mcp;
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** "Thought for Ns" once a coalesced thinking group spans ≥1s. */
function thoughtDurationLabel(startTs: string | undefined, endTs: string): string | undefined {
if (!startTs) return undefined;
const start = Date.parse(startTs);
const end = Date.parse(endTs);
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
const secs = Math.round((end - start) / 1000);
if (secs < 1) return undefined;
return secs < 60 ? `Thought for ${secs}s` : `Thought for ${Math.floor(secs / 60)}m ${secs % 60}s`;
}
const DETAIL_MAX = 600;
/** Result content → the expandable mono detail block (clipped, trimmed). */
function formatToolResultDetail(content: unknown): string | undefined {
if (content == null) return undefined;
const text = typeof content === "string" ? content : String(content);
const trimmed = text.trim();
if (!trimmed) return undefined;
return trimmed.length > DETAIL_MAX ? `${trimmed.slice(0, DETAIL_MAX)}\n…` : trimmed;
}
interface TranscriptAdapterOptions {
runId: string;
agentName?: string;
@@ -166,7 +48,6 @@ export function transcriptToTaskChatItems(
): TaskChatItem[] {
const items: TaskChatItem[] = [];
const toolIndexById = new Map<string, number>();
const thinkingStartTs = new Map<number, string>();
let lastToolIndex = -1;
let thinkingIndex = -1;
let messageIndex = -1;
@@ -182,24 +63,16 @@ export function transcriptToTaskChatItems(
if (!entry.text) break;
if (thinkingIndex >= 0) {
const it = items[thinkingIndex];
if (it.kind === "thinking") {
it.lines.push(...entry.text.split("\n"));
const startTs = thinkingStartTs.get(thinkingIndex);
const label = thoughtDurationLabel(startTs, entry.ts);
if (label) it.summaryLabel = label;
}
if (it.kind === "thinking") it.lines.push(...entry.text.split("\n"));
} else {
items.push({
id: `${runId}:think:${i}`,
kind: "thinking",
lines: entry.text.split("\n"),
streaming: running,
// Settled history folds its thinking behind the header (v7);
// the in-flight run streams it expanded.
collapsed: !running,
collapsed: false,
});
thinkingIndex = items.length - 1;
thinkingStartTs.set(thinkingIndex, entry.ts);
messageIndex = -1;
}
break;
@@ -210,7 +83,6 @@ export function transcriptToTaskChatItems(
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
const atMs = Date.parse(entry.ts);
items.push({
id: `${runId}:msg:${i}`,
kind: "message",
@@ -218,10 +90,6 @@ export function transcriptToTaskChatItems(
authorName: agentName,
text: entry.text,
streaming: running,
// Everything the agent says inside a run turn is self-talk until it
// lands as the posted comment — live and history tag it alike.
interstitial: true,
atMs: Number.isFinite(atMs) ? atMs : undefined,
});
messageIndex = items.length - 1;
thinkingIndex = -1;
@@ -230,32 +98,17 @@ export function transcriptToTaskChatItems(
}
case "tool_call": {
const toolCallId = entry.toolUseId || `tool-${i}`;
const existingIndex = toolIndexById.get(toolCallId);
const existing = existingIndex != null ? items[existingIndex] : undefined;
if (existing?.kind === "tool") {
// tool_call_update for a call already in the list: merge. Updates
// often omit the title (acpx fills in a literal "tool call"), so a
// generic name must never displace the initial call's real one. ACP
// also retitles a call to its invocation once known ("Terminal" →
// "ls -la"); that is detail for the target slot, not a new identity.
if (!isGenericToolName(entry.name)) {
if (isGenericToolName(existing.rawName)) {
existing.name = toolDisplayName(entry.name);
existing.rawName = entry.name ?? undefined;
} else if (!existing.target && entry.name !== existing.rawName) {
existing.target = clip(entry.name!, TARGET_MAX);
}
}
lastToolIndex = existingIndex!;
const item: TaskChatToolItem = {
id: `${runId}:tool:${toolCallId}`,
kind: "tool",
name: entry.name || "tool",
status: "in_progress",
};
if (toolIndexById.has(toolCallId)) {
items[toolIndexById.get(toolCallId)!] = item;
lastToolIndex = toolIndexById.get(toolCallId)!;
} else {
items.push({
id: `${runId}:tool:${toolCallId}`,
kind: "tool",
name: toolDisplayName(entry.name),
rawName: entry.name ?? undefined,
target: summarizeToolInput(entry.input),
status: "in_progress",
});
items.push(item);
toolIndexById.set(toolCallId, items.length - 1);
lastToolIndex = items.length - 1;
}
@@ -269,13 +122,8 @@ export function transcriptToTaskChatItems(
const existing = items[idx];
if (existing.kind === "tool") {
existing.status = entry.isError ? "failed" : "completed";
if (isGenericToolName(existing.rawName) && !isGenericToolName(entry.toolName)) {
existing.name = toolDisplayName(entry.toolName);
existing.rawName = entry.toolName;
}
if (!existing.detail) {
const detail = formatToolResultDetail(entry.content);
if (detail) existing.detail = detail;
if (entry.content && !existing.detail) {
existing.detail = String(entry.content).slice(0, 400);
}
}
}
@@ -296,7 +144,7 @@ export function transcriptToTaskChatItems(
items.push({
id: `${runId}:diff:${i}`,
kind: "tool",
name: "Edit",
name: "edit",
status: "completed",
diff: {
added: line.kind === "add" ? 1 : 0,
@@ -316,352 +164,21 @@ export function transcriptToTaskChatItems(
}
}
// Only the message still open at the transcript tail is streaming; earlier
// self-talk is finished and nests as a settled row even mid-run.
if (running) {
for (const [idx, it] of items.entries()) {
if (it.kind === "message" && idx !== messageIndex) it.streaming = false;
}
}
return items;
}
/**
* A settled run's nested children (PAP-361): exactly the tool rows (plus usage
* readouts) in transcript order, under the "✓ Worked ·" summary — parity with
* the summary's tool count. Messages are excluded: the final reply already
* landed as the run's posted comment bubble, and interstitial updates are
* ephemeral (they take the live line while streaming, then vanish). Thinking
* is excluded too — the run log / classic transcript remain its archive.
*/
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
return parsed.filter(
(it): it is TaskChatTurnChildItem =>
it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking",
);
}
function formatDurationLabel(ms: number): string | undefined {
if (!Number.isFinite(ms) || ms <= 0) return undefined;
const totalSec = Math.round(ms / 1000);
if (totalSec < 1) return "1s";
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return sec === 0 ? `${min}m` : `${min}m ${sec}s`;
}
function formatTokensLabel(tokens: number): string | undefined {
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
const label = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
return `${label} tokens`;
}
/** First→last ts span of a transcript, or undefined when unknowable. */
function transcriptSpanMs(entries: readonly TranscriptEntry[]): number | undefined {
if (entries.length < 2) return undefined;
const first = Date.parse(entries[0].ts);
const last = Date.parse(entries[entries.length - 1].ts);
if (!Number.isFinite(first) || !Number.isFinite(last)) return undefined;
return Math.max(0, last - first);
}
/**
* Aggregate a turn's transcript into the folded one-line summary
* ("✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens"). Duration prefers the
* caller-supplied run duration and falls back to the transcript's ts span.
*/
export function buildTurnSummary(
entries: readonly TranscriptEntry[],
opts: { durationMs?: number; failed?: boolean } = {},
): TaskChatTurnItem["summary"] {
const toolIds = new Set<string>();
let added = 0;
let removed = 0;
let tokens = 0;
for (const [i, entry] of entries.entries()) {
// Each status change of a call logs its own tool_call entry sharing the
// toolUseId; count unique calls so the folded summary matches the rows the
// expanded list renders (same `tool-${i}` fallback as the parser above).
if (entry.kind === "tool_call") toolIds.add(entry.toolUseId || `tool-${i}`);
else if (entry.kind === "diff") {
if (entry.changeType === "add") added += 1;
else if (entry.changeType === "remove") removed += 1;
} else if (entry.kind === "result") {
tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0);
}
}
const durationMs = opts.durationMs ?? transcriptSpanMs(entries);
return {
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
toolCount: toolIds.size,
added,
removed,
tokensLabel: formatTokensLabel(tokens),
failed: opts.failed || undefined,
};
}
/** One settled run's raw summary inputs, kept so back-to-back runs can coalesce (PAP-362). */
export interface TurnSummaryPart {
entries: readonly TranscriptEntry[];
durationMs?: number;
failed?: boolean;
}
/**
* Summary for a turn coalesced from several back-to-back runs (PAP-362).
* Tool/diff/token counts re-derive from the concatenated transcripts; duration
* is the SUM of the per-run durations (each with its own ts-span fallback) —
* never the wall span across the idle gap between runs.
*/
export function buildMergedTurnSummary(
parts: readonly TurnSummaryPart[],
): TaskChatTurnItem["summary"] {
const all: TranscriptEntry[] = [];
let durationMs: number | undefined;
let failed = false;
for (const part of parts) {
all.push(...part.entries);
if (part.failed) failed = true;
const d = part.durationMs ?? transcriptSpanMs(part.entries);
if (d != null && Number.isFinite(d)) durationMs = (durationMs ?? 0) + Math.max(0, d);
}
// durationMs: 0 suppresses the concatenated-span fallback (which would count
// the gap between runs); the summed label is applied over it below.
const counts = buildTurnSummary(all, { durationMs: 0, failed });
return {
...counts,
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
};
}
/** One chronological backbone entry (comment / interaction / marker). */
export interface ThreadBackboneEntry {
/** Chronological sort key in ms (backbone is already sorted by it). */
ms: number;
/** Stable entry id — the anchor key settled turns attach after. */
id: string;
item: TaskChatItem;
}
/**
* Assemble the thread body (PAP-367): backbone entries in order, each followed
* by the settled turns anchored to it (run → reply-comment linkage), with
* comment-less settled turns — stopped runs, or a reply not yet fetched —
* interleaved chronologically at their run's start time instead of
* bottom-appended under the newest message. Ties go after the backbone entry
* (trigger comment first); turns with no known start (startMs = Infinity) keep
* the tail slot. `unanchored` must be sorted ascending by startMs.
*/
export function assembleThreadItems(
entries: readonly ThreadBackboneEntry[],
turnsByAnchor: ReadonlyMap<string, readonly TaskChatTurnItem[]>,
unanchored: readonly { turn: TaskChatTurnItem; startMs: number }[],
): TaskChatItem[] {
const out: TaskChatItem[] = [];
let next = 0;
for (const entry of entries) {
while (next < unanchored.length && unanchored[next].startMs < entry.ms) {
out.push(unanchored[next++].turn);
}
out.push(entry.item);
const following = turnsByAnchor.get(entry.id);
if (following) out.push(...following);
}
while (next < unanchored.length) out.push(unanchored[next++].turn);
return out;
}
/** Stable id of the description-as-first-bubble item (PAP-375). */
export const ISSUE_BRIEF_ITEM_ID = "issue-brief";
/**
* Prepend the issue-brief placeholder (PAP-375) to the fully assembled thread.
* Running AFTER assembleThreadItems/coalesce/attach makes the ordering
* guarantee structural rather than data-dependent: even an unanchored settled
* turn whose startMs predates every backbone entry (F15) lands below the
* description bubble.
*/
export function prependIssueBrief(items: TaskChatItem[], hasBrief: boolean): TaskChatItem[] {
if (!hasBrief) return items;
return [{ id: ISSUE_BRIEF_ITEM_ID, kind: "brief" }, ...items];
}
/** Per-turn identity + raw summary inputs for coalescing (keyed by turn item id). */
export interface SettledTurnMergeMeta {
/** Stable agent identity (agentId); empty/unknown turns never merge. */
agentKey: string;
/** Display name, used to keep another agent's bubble from bridging a merge. */
agentName?: string;
parts: TurnSummaryPart[];
}
/**
* Final assembly pass (PAP-362): merge runs of settled turns from the SAME
* agent that arrive back-to-back — two runs replying consecutively — into one
* "Worked" row. The agent's own reply bubbles do not break the run (the merged
* row lands below the last bubble, in the last run's slot); a human/system
* message, an interaction, a marker, or the live in-flight turn does. Child
* items concatenate in order, the summary re-derives via buildMergedTurnSummary,
* the merged turn keeps the FIRST run's id (stable across re-renders), and
* animateFold survives if any merged run was seen live.
*/
export function coalesceSettledTurns(
items: readonly TaskChatItem[],
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
): TaskChatItem[] {
const out: TaskChatItem[] = [];
// Overlay for merged turns' accumulated parts (metaById stays untouched).
const mergedMeta = new Map<string, SettledTurnMergeMeta>();
const metaFor = (id: string) => mergedMeta.get(id) ?? metaById.get(id);
let heldIdx = -1; // index in `out` of the last mergeable settled turn
for (const item of items) {
if (item.kind === "turn" && item.settled) {
const meta = metaFor(item.id);
const held = heldIdx >= 0 ? (out[heldIdx] as TaskChatTurnItem) : null;
const heldMeta = held ? metaFor(held.id) : undefined;
if (held && meta && heldMeta && meta.agentKey && meta.agentKey === heldMeta.agentKey) {
out.splice(heldIdx, 1);
const parts = [...heldMeta.parts, ...meta.parts];
out.push({
...held,
items: [...held.items, ...item.items],
animateFold: held.animateFold || item.animateFold || undefined,
summary: buildMergedTurnSummary(parts),
});
mergedMeta.set(held.id, { ...heldMeta, parts });
} else {
out.push(item);
}
heldIdx = meta?.agentKey ? out.length - 1 : -1;
continue;
}
out.push(item);
if (item.kind === "message" && item.author === "agent") {
// The same agent's reply bubble sits between its runs — keep merging
// across it. A different agent's bubble ends the run of turns.
const heldMeta = heldIdx >= 0 ? metaFor((out[heldIdx] as TaskChatTurnItem).id) : undefined;
if (heldMeta?.agentName && item.authorName && item.authorName !== heldMeta.agentName) {
heldIdx = -1;
}
} else {
heldIdx = -1;
}
}
return out;
}
/**
* Final assembly pass (round 9, after coalesceSettledTurns): a settled turn
* that directly follows its own agent's reply bubble folds INTO that bubble as
* `attachedTurn` — the "Worked · …" summary renders appended to the bubble's
* always-visible timestamp line instead of as a standalone row. The turn must
* belong to the same agent as the bubble (metaById identity); turns without
* meta, or preceded by anything other than that agent's non-interstitial
* bubble, keep the standalone-row fallback.
*/
export function attachSettledTurns(
items: readonly TaskChatItem[],
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
): TaskChatItem[] {
const out: TaskChatItem[] = [];
for (const item of items) {
const prev = out[out.length - 1];
if (
item.kind === "turn" &&
item.settled &&
prev?.kind === "message" &&
prev.author === "agent" &&
!prev.interstitial &&
!prev.streaming &&
prev.attachedTurn == null
) {
const meta = metaById.get(item.id);
const sameAgent =
meta != null && (meta.agentName == null || prev.authorName == null || meta.agentName === prev.authorName);
if (sameAgent) {
out[out.length - 1] = { ...prev, attachedTurn: item };
continue;
}
}
out.push(item);
}
return out;
}
/**
* Human-readable label for the live status pill from the tail of a transcript.
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
* with tool + target as detail; `toolName` lets the pill show the family icon.
* A tail assistant message yields "Responding" plus `selfTalk` — the flattened
* text of the interstitial update streamed so far, which takes over the parent
* row's line while it streams (PAP-361).
*/
/** Human-readable label for the live status pill from the tail of a transcript. */
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
label: string;
detail?: string;
toolName?: string;
selfTalk?: string;
} {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.kind === "tool_call") {
// A tail update may carry the generic placeholder name or a retitle to
// the invocation ("Terminal" → "ls -la"): the call's FIRST real name is
// its identity, and a differing later title is the invocation detail.
let name = entry.name;
let invocation: string | undefined;
if (entry.toolUseId) {
for (const prev of entries) {
if (prev === entry) break;
if (
prev.kind === "tool_call" &&
prev.toolUseId === entry.toolUseId &&
!isGenericToolName(prev.name)
) {
if (!isGenericToolName(entry.name) && entry.name !== prev.name) {
invocation = entry.name;
}
name = prev.name;
break;
}
}
}
const target =
summarizeToolInput(entry.input) ?? (invocation ? clip(invocation, TARGET_MAX) : undefined);
const display = toolDisplayName(name);
return {
label: toolTaxonomy(name).verbLabel,
detail: target ? `${display} · ${target}` : display,
toolName: name ?? undefined,
};
return { label: "Working", detail: `Using ${entry.name || "tool"}` };
}
if (entry.kind === "tool_result") break;
if (entry.kind === "assistant") {
// Accumulate the trailing message's deltas (the same coalescing run the
// parser groups into one item: broken by tool/thinking/diff, not by
// status-only entries like stdout).
const parts: string[] = [];
for (let j = i; j >= 0; j--) {
const prev = entries[j];
if (prev.kind === "assistant") {
if (prev.text) parts.unshift(prev.text);
continue;
}
if (
prev.kind === "tool_call" ||
prev.kind === "tool_result" ||
prev.kind === "thinking" ||
prev.kind === "diff"
) {
break;
}
}
const selfTalk = flattenSelfTalk(parts.join(""));
return { label: "Responding", selfTalk: selfTalk || undefined };
}
if (entry.kind === "assistant") return { label: "Responding" };
if (entry.kind === "thinking") return { label: "Thinking" };
}
return { label: "Running" };
+7 -297
View File
@@ -209,10 +209,10 @@
hardcoded ms / cubic-bezier in components (enforced by a check script).
──────────────────────────────────────────────────────────────────────── */
/* Easing primitives (tunable). --motion-ease-out itself is declared by the
decision/quicklook motion block further down; the thread shares it. */
/* Easing primitives (tunable) */
--motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* house signature curve */
--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* house material curve */
--motion-ease-out: cubic-bezier(0, 0, 0.2, 1);
/* Duration primitives (tunable) */
--motion-duration-instant: 80ms;
@@ -235,35 +235,6 @@
--motion-plan-check: var(--motion-duration-fast);
--motion-count-tween: var(--motion-duration-slow);
--motion-streaming-cursor-blink: 1.1s;
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
--motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */
--motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */
--motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */
--motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
--motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
/* Composer mode-chip hues (v5–v7 decisions): agent + plan reuse the task
status hues; ask is the v5 blue. Consumed as `--sc` seeds through the
.status-chip color-mix recipe. */
--tc-mode-agent: var(--status-task-backlog);
--tc-mode-plan: var(--status-task-todo);
--tc-mode-ask: #3b82f6;
/* Agent-comment bubble fill (v7 grammar): quiet gray card, no border. */
--bubble-agent: oklch(0.955 0 0);
/* Redesigned thread viewport bound: lets the thread own its scroll (auto-
follow + pinned composer) inside the page flow. The issue header lives
INSIDE the scroll viewport (it scrolls away with the messages), so the
only chrome outside the thread is the top bar plus page padding ≈ 8rem —
this is what keeps the composer pinned near the viewport bottom. */
--tc-thread-max-h: calc(100dvh - 8rem);
/* Redesigned chat-shell column cap (phase 2c): one token caps the page
wrapper, thread body, and pinned composer so they widen in lockstep. */
--tc-shell-max-w: 60rem;
}
.dark {
@@ -275,7 +246,6 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--bubble-agent: oklch(0.24 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -536,14 +506,6 @@
--motion-approval-pulse: 0ms;
--motion-plan-entry-stagger: 0ms;
--motion-streaming-cursor-blink: 0ms;
--motion-turn-fold: 0ms;
--motion-line-scroll: 0ms;
/* --motion-interstitial-dwell is deliberately NOT zeroed: it is a pacing
hold, not a movement — zeroing it would make the interstitial line churn
faster under reduced motion. Swaps themselves snap (line-scroll: 0). */
--motion-scroll-pill-enter: 0ms;
--motion-scroll-pill-exit: 0ms;
--motion-pane-glide: 0ms;
}
}
@@ -576,6 +538,7 @@
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0; }
}
.tc-enter-bubble { animation: tc-fade-rise var(--motion-bubble-enter) var(--motion-ease-out-expo) both; }
.tc-enter-marker { animation: tc-fade-in var(--motion-marker-enter) var(--motion-ease-out) both; }
.tc-enter-tool { animation: tc-fade-rise var(--motion-tool-enter) var(--motion-ease-out-expo) both; }
@@ -585,42 +548,6 @@
.tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; }
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
.tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; }
/* Live-line viewport (PAP-361): exactly one line-height tall, clipping the
wrapped interstitial block. On wrap, the inner block translates up whole
line-heights (set inline as translateY(-(n−1)·1lh)) so the completed line
slides out the top — transform-only, same motion grammar as tool rows
animating in. */
.tc-line-scroll { display: block; height: 1lh; overflow: hidden; }
.tc-line-scroll-inner { transition: transform var(--motion-line-scroll) var(--motion-ease-standard); will-change: transform; }
/* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized).
The pill is centered with translateX(-50%), so the keyframes carry the X
offset through the whole transform. */
@keyframes tc-pill-in {
from { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
@keyframes tc-pill-out {
from { opacity: 1; transform: translate(-50%, 0) scale(1); }
to { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
}
.tc-scroll-pill-in { animation: tc-pill-in var(--motion-scroll-pill-enter) var(--motion-ease-scroll-pill-in) both; }
.tc-scroll-pill-out { animation: tc-pill-out var(--motion-scroll-pill-exit) var(--motion-ease-scroll-pill-out) both; }
/* Finished-turn fold: grid-rows 1fr→0fr animates height without measuring;
the single grid child must be overflow-hidden for 0fr to clip. */
.tc-turn-fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows var(--motion-turn-fold) var(--motion-ease-standard),
opacity var(--motion-turn-fold) var(--motion-ease-standard);
}
.tc-turn-fold[data-folded="true"] { grid-template-rows: 0fr; opacity: 0; }
.tc-turn-fold > * { overflow: hidden; min-height: 0; }
/* Properties-pane maximize/restore glide (left animates between docked and
sidebar-flush while the pane is position:fixed). */
.tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); }
@media (prefers-reduced-motion: reduce) {
.tc-enter-bubble,
@@ -629,15 +556,7 @@
.tc-enter-status,
.tc-enter-cot-line,
.tc-reveal-diff,
.tc-scroll-pill-in,
.tc-scroll-pill-out,
.tc-enter-plan-entry { animation: none; }
.tc-turn-fold,
.tc-line-scroll-inner,
.tc-pane-glide { transition: none; }
/* The pill keyframes carry the X-centering; with animation:none restore it. */
.tc-scroll-pill-in,
.tc-scroll-pill-out { transform: translate(-50%, 0); }
.tc-approval { animation: none; box-shadow: 0 0 0 2px hsl(var(--primary) / 0.18); }
.tc-cursor { animation: none; opacity: 1; }
}
@@ -647,129 +566,6 @@
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* ---------------------------------------------------------------------------
Motion tokens
---------------------------------------------------------------------------
The first named motion values in the system. Durations and easings were
previously written inline at each call site; these give the "snappy, eases
in and out" behaviour one definition so surfaces stay in step.
Exit is deliberately shorter than enter: a thing appearing wants to be
followed, a thing leaving just needs to get out of the way.
The enter curve is the same cubic-bezier the dialog max-width transition
already used — decelerating hard at the end, which is what reads as "snappy"
rather than "slow start". Consumers: the decision-card disclosure and the
task quicklook, both below.
--------------------------------------------------------------------------- */
:root {
--motion-duration-enter: 160ms;
--motion-duration-exit: 110ms;
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
/* A crossfading pair must share one duration and one curve. Two elements
swapping places both change height, and the container shows the SUM. Give
them different timing — the outgoing on a 110ms ease-in against the
incoming on a 160ms ease-out — and that sum stops being monotonic: the
container bulges or dips mid-swap, which reads as a lurch even though each
half is individually smooth. Run both off the same clock and the same
curve and the sum collapses to a clean interpolation between the two
states, because at every moment progress is identical on both sides. */
--motion-duration-swap: 150ms;
}
/* ---------------------------------------------------------------------------
Decision-card disclosure (See more / See less)
---------------------------------------------------------------------------
Radix Collapsible measures the panel and publishes its height, so the card
can grow and shrink to a real number instead of snapping. The content
unmounts once the exit finishes, so an expanded row's resolver is not left
running behind a collapsed card.
--------------------------------------------------------------------------- */
@keyframes decision-disclosure-open {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes decision-disclosure-close {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
[data-slot="collapsible-content"][data-decision-disclosure] {
overflow: hidden;
}
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"] {
animation: decision-disclosure-open var(--motion-duration-swap) var(--motion-ease-out);
}
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"] {
/* Same duration and same curve as the open side — see --motion-duration-swap.
The two run against each other, so matched timing is what keeps the card's
height a straight interpolation instead of a bulge. */
animation: decision-disclosure-close var(--motion-duration-swap) var(--motion-ease-out);
}
/* ---------------------------------------------------------------------------
Task quicklook: a small scale-up out of the trigger, not a fade in place.
---------------------------------------------------------------------------
The shadcn PopoverContent class string carries `animate-in`, `zoom-in-95`
and friends, but those utilities come from the tailwindcss-animate plugin,
which this project does not install — there is no `enter`/`exit` keyframe in
the build, so they are inert. Rather than take on that dependency for one
surface (it would newly animate every dialog, dropdown and tooltip in the
app), the quicklook defines its own motion here.
The scale is deliberately shallow. `--radix-popover-content-transform-origin`
pins the origin to the corner nearest the trigger, so the card reads as
growing out of the task key that opened it rather than swelling in place.
--------------------------------------------------------------------------- */
@keyframes quicklook-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes quicklook-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
[data-slot="popover-content"][data-quicklook][data-state="open"] {
animation: quicklook-open var(--motion-duration-enter) var(--motion-ease-out);
}
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
animation: quicklook-close var(--motion-duration-exit) var(--motion-ease-in);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"],
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"],
[data-slot="popover-content"][data-quicklook][data-state="open"],
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
animation: none;
}
}
/* Dashboard activity row entry motion */
@keyframes dashboard-activity-enter {
0% {
@@ -865,101 +661,12 @@
animation: shimmer-text-slide 2.5s linear infinite;
}
.paperclip-thinking-icon {
transform-origin: 50% 50%;
}
@keyframes paperclip-thinking-draw {
0% {
stroke-dasharray: 0 85.717;
stroke-dashoffset: -85.717;
opacity: 1;
animation-timing-function: cubic-bezier(0.455, 0.03, 0.515, 0.955);
}
39.0625% {
stroke-dasharray: 85.717 85.717;
stroke-dashoffset: 0;
opacity: 1;
animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19);
}
78.125% {
stroke-dasharray: 0 85.717;
stroke-dashoffset: 0;
opacity: 1;
}
78.225% {
opacity: 0;
}
100% {
stroke-dasharray: 0 85.717;
stroke-dashoffset: 0;
opacity: 0;
}
}
.paperclip-thinking-icon-path {
animation: paperclip-thinking-draw 1s linear infinite;
}
/* Muted variant (task-chat v7 activity rows): the sweep runs over the
muted-foreground tier so live "Thinking…"/"Working…" labels stay subordinate
to real content. */
.shimmer-text-muted {
--shimmer-base: var(--muted-foreground);
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
}
/* Utilities expected by shadcn base components (ui/attachment.tsx). Declared
with @utility so variant forms (e.g. group-data-[state=uploading]:shimmer)
compile under Tailwind v4. */
@utility shimmer {
--shimmer-base: var(--foreground);
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
background: linear-gradient(
90deg,
var(--shimmer-base) 0%,
var(--shimmer-base) 40%,
var(--shimmer-highlight) 50%,
var(--shimmer-base) 60%,
var(--shimmer-base) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer-text-slide 2.5s linear infinite;
}
@utility scrollbar-none {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility scroll-fade-x {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
@media (prefers-reduced-motion: reduce) {
.shimmer-text {
animation: none;
-webkit-text-fill-color: unset;
background: none;
}
.paperclip-thinking-icon-path {
animation: none;
opacity: 1;
stroke-dasharray: 85.717 85.717;
stroke-dashoffset: 0;
}
}
/* Agent heartbeat capsule motion (PAP-75).
@@ -2195,7 +1902,10 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--gtc-17: 1fr auto; /* Extracted from ui/src/components/ui/card.tsx (grid-cols-[1fr_auto]). */
--gtc-18: auto minmax(0,1fr) minmax(12rem,0.65fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[auto_minmax(0,1fr)_minmax(12rem,0.65fr)]). */
--gtc-19: 260px minmax(0,1fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[260px_minmax(0,1fr)]). */
/* --gtc-20 through --gtc-23 retired with the removed Cloud Upstream page. */
--gtc-20: 7rem 8rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[7rem_8rem_1fr]). */
--gtc-21: 1.25rem 12rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[1.25rem_12rem_1fr]). */
--gtc-22: 8rem 1fr 1fr 8rem; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[8rem_1fr_1fr_8rem]). */
--gtc-23: 8rem 1fr auto; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[8rem_1fr_auto]). */
--gtc-24: minmax(0,1.5fr) 120px 120px 180px; /* Extracted from ui/src/pages/CompanyAccess.tsx (grid-cols-[minmax(0,1.5fr)_120px_120px_180px]). */
--gtc-25: 19rem minmax(0,1fr); /* Extracted from ui/src/pages/CompanyExport.tsx (grid-cols-[19rem_minmax(0,1fr)]). */
--gtc-26: 7rem minmax(0,1fr); /* Extracted from ui/src/pages/CompanySkills.tsx (grid-cols-[7rem_minmax(0,1fr)]). */
+155 -361
View File
@@ -26,7 +26,7 @@ import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, formatAssigneeUserLabel, formatUserLabel, suggestedCommentAssigneeValue } from "../lib/assignees";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
import { extractIssueTimelineEvents, extractIssueWorkModeChanges } from "../lib/issue-timeline-events";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
@@ -66,7 +66,6 @@ import {
createOptimisticIssueComment,
flattenIssueCommentPages,
getNextIssueCommentPageParam,
ISSUE_COMMENT_PAGE_SIZE,
isQueuedIssueComment,
loadRemainingIssueCommentPages,
matchesIssueRef,
@@ -83,7 +82,6 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { liveBlueBadge } from "../lib/status-colors";
import { ApprovalCard } from "../components/ApprovalCard";
import { ProjectTile } from "../components/ProjectTile";
import { InlineEditor } from "../components/InlineEditor";
import {
IssueChatThread,
@@ -91,7 +89,6 @@ import {
type IssueChatRunFinalizationAction,
} from "../components/IssueChatThread";
import { TaskChatThread } from "../components/TaskChatThread";
import type { TaskChatIssueBrief } from "../components/task-chat/TaskChatDescriptionBubble";
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
import { workModeMetaFor } from "../lib/work-mode-meta";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
@@ -111,9 +108,6 @@ import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueFieldChangeReceipt } from "../components/IssueFieldChangeReceipt";
import { IssueWriteDenialNotice } from "../components/IssueWriteDenialNotice";
import { issueWriteDenialForActivity } from "../lib/issue-write-denial-activity";
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
import {
IssueMonitorBanner,
@@ -135,7 +129,6 @@ import { ArtifactFileChip } from "../components/ArtifactFileChip";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
@@ -183,6 +176,7 @@ import {
ScanEye,
Flag,
FileCode2,
Hexagon,
ListTree,
MessageSquare,
MoreHorizontal,
@@ -257,6 +251,7 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ISSUE_COMMENT_PAGE_SIZE = 50;
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
@@ -690,16 +685,9 @@ function IssueDetailLoadingState({
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
}) {
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
return (
<div
className={
taskChatShellEnabled
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
: "max-w-3xl space-y-6"
}
>
<div className="max-w-3xl space-y-6">
<div className="space-y-3">
<Skeleton className="h-3 w-40" />
@@ -707,8 +695,7 @@ function IssueDetailLoadingState({
{headerSeed ? (
<>
<StatusIcon status={headerSeed.status} blockerAttention={headerSeed.blockerAttention} />
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
{SHOW_TASK_PRIORITY_UI && <PriorityIcon priority={headerSeed.priority} />}
<PriorityIcon priority={headerSeed.priority} />
{identifier ? (
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
) : null}
@@ -721,18 +708,16 @@ function IssueDetailLoadingState({
Routine
</Badge>
) : null}
{/* Seeded header — same anatomy as the resolved one below, so the
eyebrow does not change shape when the real issue arrives. */}
{headerSeed.projectId ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
<ProjectTile size="xs" />
<Hexagon className="h-3 w-3 shrink-0" />
<span className="truncate">
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
<ProjectTile size="xs" />
<Hexagon className="h-3 w-3 shrink-0" />
No project
</span>
)}
@@ -877,8 +862,6 @@ type IssueDetailChatTabProps = {
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
/** Marks cross-issue agent comments in the thread (the open cross-task write design (attribution)). */
issueAssigneeAgentId: Issue["assigneeAgentId"];
issueWorkMode: IssueWorkMode;
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
@@ -903,7 +886,6 @@ type IssueDetailChatTabProps = {
title?: string | null;
} | null;
comments: IssueDetailComment[];
commentsInitialLoading?: boolean;
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
hasOlderComments: boolean;
@@ -914,17 +896,6 @@ type IssueDetailChatTabProps = {
composerRef: Ref<IssueChatComposerHandle>;
/** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */
composerAccessory?: ReactNode;
/**
* Issue header (title row, badges, plugin toolbars) that the redesigned
* thread renders inside its scroll viewport so it scrolls away with the
* messages (flag: enableTaskChatRedesign). Ignored by the legacy thread.
*/
threadHeader?: ReactNode;
/**
* The task description rendered as the requester's first chat bubble in the
* redesigned thread (PAP-375). Ignored by the legacy thread.
*/
issueBrief?: TaskChatIssueBrief;
footer?: ReactNode;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
@@ -985,7 +956,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
projectId,
issueWorkMode,
issueStatus,
issueAssigneeAgentId,
executionRunId,
blockedBy,
liveIssueIds,
@@ -1005,7 +975,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
canFalsePositiveRecoveryAction,
legacyRecoverySourceIssue,
comments,
commentsInitialLoading = false,
locallyQueuedCommentRunIds,
interactions,
hasOlderComments,
@@ -1015,8 +984,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onWorkModeChange,
composerRef,
composerAccessory,
threadHeader,
issueBrief,
footer,
feedbackVotes,
feedbackDataSharingPreference,
@@ -1195,45 +1162,25 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
() => extractIssueTimelineEvents(resolvedActivity),
[resolvedActivity],
);
const workModeChanges = useMemo(
() => extractIssueWorkModeChanges(resolvedActivity),
[resolvedActivity],
);
const loadOlderButton = hasOlderComments ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={commentsLoadingOlder}
onClick={onLoadOlderComments}
>
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
</Button>
</div>
) : null;
return (
<div className={taskChatRedesignEnabled ? "flex min-h-0 flex-1 flex-col" : "space-y-3"}>
{/* Redesign: the button rides inside the thread's scroll viewport with the
header so nothing sits above the thread in the page flow. */}
{taskChatRedesignEnabled ? null : loadOlderButton}
{commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? (
<IssueChatSkeleton />
) : (
<div className="space-y-3">
{hasOlderComments ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={commentsLoadingOlder}
onClick={onLoadOlderComments}
>
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
</Button>
</div>
) : null}
<ThreadComponent
composerRef={composerRef}
composerAccessory={composerAccessory}
threadHeader={
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
<>
{threadHeader}
{loadOlderButton}
</>
) : undefined
}
issueBrief={issueBrief}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
@@ -1241,7 +1188,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
feedbackTermsUrl={feedbackTermsUrl}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
workModeChanges={workModeChanges}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
issueId={issueId}
@@ -1265,7 +1211,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
issueAssigneeAgentId={issueAssigneeAgentId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
@@ -1315,7 +1260,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
externalReferences={externalReferences}
linkCaseReferences={linkCaseReferences}
/>
)}
</div>
);
});
@@ -1547,24 +1491,6 @@ function IssueDetailActivityTab({
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
{/* Field-level who/what/why receipt for agent and board edits alike. */}
<IssueFieldChangeReceipt
event={evt}
resolveAgentLabel={(agentId) => agentMap.get(agentId)?.name ?? null}
resolveUserLabel={(userId) => userProfileMap.get(userId)?.label ?? null}
/>
{/* A refused write explains itself here, not just in the API error. */}
{(() => {
const denial = issueWriteDenialForActivity(evt.action, evt.details, {
actorLabel: evt.agentId ? agentMap.get(evt.agentId)?.name ?? null : null,
responsibleUserName: evt.responsibleUserId
? userProfileMap.get(evt.responsibleUserId)?.label ?? null
: null,
});
return denial ? (
<IssueWriteDenialNotice code={denial.code} context={denial.context} />
) : null;
})()}
</div>
);
}}
@@ -1604,18 +1530,6 @@ function IssueDetailActivityTab({
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
// Task Chat Redesign (flag: enableTaskChatRedesign): with the flag ON the
// thread owns the center column — the legacy title/description block,
// sub-tasks table, plan decompositions and Documents section are gated off
// (plan lives in the properties-pane Plan tab). Flag OFF renders the legacy
// page byte-identically.
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
// Flag ON the page wrapper spans the full center pane so the thread's scroll
// viewport (and its scrollbar) reaches the properties-pane border; every
// non-thread section re-centers itself at the 60rem shell cap instead.
const shellSectionClass = taskChatShellEnabled
? "mx-auto w-full max-w-(--tc-shell-max-w)"
: undefined;
const { openNewIssue } = useDialogActions();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
@@ -1630,9 +1544,6 @@ export function IssueDetail() {
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
const [detailTab, setDetailTab] = useState("chat");
// Redesign: the center tab strip is hidden, so chat is the only surface —
// deep links that would switch tabs (e.g. #document- hashes) stay on chat.
const resolvedDetailTab = taskChatShellEnabled ? "chat" : detailTab;
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
approvalId: string;
@@ -1980,10 +1891,7 @@ export function IssueDetail() {
},
[issue?.id, rawChildIssues],
);
const liveIssueIds = useMemo(
() => collectLiveIssueIds(companyLiveRuns, issue ? [issue, ...childIssues] : childIssues),
[childIssues, companyLiveRuns, issue],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
const issuePanelKey = useMemo(
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
[childIssues, issue],
@@ -2047,7 +1955,6 @@ export function IssueDetail() {
[comments, optimisticComments],
);
const breadcrumbTitle = issue?.title ?? issueId ?? "Task";
const breadcrumbIdentifier = issue?.identifier ?? issueHeaderSeed?.identifier ?? undefined;
const breadcrumbStatus = issue?.status;
const breadcrumbBlockerAttention = issue?.blockerAttention;
// Stable identity for the breadcrumb status glyph. The glyph's shape/colour
@@ -2225,7 +2132,7 @@ export function IssueDetail() {
return { previousDetailQueries, previousList, selectedCompanyId };
},
onSuccess: ({ comment: _comment, changes: _changes, blockedByIssueIds: _blockedByIssueIds, ...nextIssue }) => {
onSuccess: ({ comment: _comment, ...nextIssue }) => {
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
@@ -3215,14 +3122,12 @@ export function IssueDetail() {
// The status glyph (leading) already conveys in-progress/live state;
// no redundant 🔵 emoji prefix on the title.
label: breadcrumbTitle,
identifier: breadcrumbIdentifier,
leading: breadcrumbStatusLeading,
leadingKey: breadcrumbStatusKey,
},
]);
}, [
breadcrumbTitle,
breadcrumbIdentifier,
hasLiveRuns,
setBreadcrumbs,
sourceBreadcrumb.href,
@@ -4160,13 +4065,12 @@ export function IssueDetail() {
</>
);
// Task Chat Redesign ("not sticky" header): the parent breadcrumb, the
// title/badge block, and the plugin toolbars render INSIDE the thread's
// scroll viewport, so they scroll away with the messages and the composer
// stays near the viewport bottom. Flag OFF renders the same nodes in the
// page flow, in their original order relative to the alert banners.
const ancestorsNav = ancestors.length > 0 && (
<nav className={cn("flex items-center gap-1 text-xs text-muted-foreground flex-wrap", shellSectionClass)}>
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div className="max-w-3xl space-y-6">
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
{[...ancestors].reverse().map((ancestor, i) => (
<span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
@@ -4189,10 +4093,91 @@ export function IssueDetail() {
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="text-foreground/60 truncate max-w-(--sz-200px)">{issue.title}</span>
</nav>
);
)}
const issueHeaderBlock = (
<div className={cn("space-y-3", shellSectionClass)}>
{issue.hiddenAt && (
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<EyeOff className="h-4 w-4 shrink-0" />
This task is hidden
</div>
)}
{activePauseHold && (
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200">
{activePauseHold.isRoot ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
</span>
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
{childIssues.length === 0
? "Task execution is held until resume. Human comments can still wake the assignee for triage."
: "Root and descendant execution is held until resume. Human comments can still wake assignee agents for triage."}
</span>
</div>
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
{childIssues.length === 0
? "1 task held"
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
</div>
{canShowSubtreeControls || canResumeLeafWork ? (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
</Button>
{canShowSubtreeControls ? (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
setTreeControlMode("cancel");
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
}}
>
Cancel subtree...
</Button>
) : null}
</div>
) : null}
</div>
) : (
<div className="text-xs">
This task is paused by ancestor{" "}
{activePauseHoldRoot?.identifier ? (
<Link to={createIssueDetailPath(activePauseHoldRoot.identifier)} className="underline">
{activePauseHoldRoot.identifier}
</Link>
) : (
activePauseHold.rootIssueId.slice(0, 8)
)}
. Resume from the root task to deliver deferred work.
</div>
)}
</div>
)}
<div className="space-y-3">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
@@ -4200,13 +4185,10 @@ export function IssueDetail() {
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
/>
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
{SHOW_TASK_PRIORITY_UI && (
<PriorityIcon
priority={issue.priority}
onChange={(priority) => updateIssue.mutate({ priority })}
/>
)}
<PriorityIcon
priority={issue.priority}
onChange={(priority) => updateIssue.mutate({ priority })}
/>
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
{hasLiveRuns && (
@@ -4254,11 +4236,7 @@ export function IssueDetail() {
</Badge>
) : null}
{/* Task Chat Redesign: no mode chip in the header — mode is a
per-request choice made in the composer, and each agent reply
carries its own mode chip; a header chip would misread as a
task-global setting. Flag OFF keeps the legacy badge. */}
{!taskChatShellEnabled && (issue.workMode === "ask" || issue.workMode === "planning") ? (() => {
{issue.workMode === "ask" || issue.workMode === "planning" ? (() => {
const workModeMeta = workModeMetaFor(issue.workMode);
const WorkModeIcon = workModeMeta.icon;
return (
@@ -4283,24 +4261,17 @@ export function IssueDetail() {
</Badge>
) : null}
{/* Project reads as a tile plus a name, matching the project rows in
the sidebar and the Projects list rather than a bare outline
glyph. The tile stays neutral here on purpose: the eyebrow already
carries the status glyph's colour, and a second tinted swatch
beside it competes with the one signal that means something.
Project colour still identifies the project on project-native
surfaces. */}
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
>
<ProjectTile size="xs" icon={resolvedProject?.icon ?? issue.project?.icon} />
<Hexagon className="h-3 w-3 shrink-0" />
<span className="truncate">{resolvedProject?.name ?? issue.project?.name ?? issue.projectId.slice(0, 8)}</span>
</Link>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
<ProjectTile size="xs" />
<Hexagon className="h-3 w-3 shrink-0" />
No project
</span>
)}
@@ -4527,7 +4498,7 @@ export function IssueDetail() {
value={issue.title}
onSave={(title) => updateIssue.mutateAsync({ title })}
as="h2"
className={taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold"}
className="text-xl font-bold"
/>
<IssueMonitorBanner
@@ -4536,31 +4507,26 @@ export function IssueDetail() {
checkingNow={checkIssueMonitorNow.isPending}
/>
{taskChatShellEnabled ? null : (
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/>
)}
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/>
</div>
);
const pluginOutletsBlock = (
<>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
@@ -4570,7 +4536,7 @@ export function IssueDetail() {
entityId: issue.id,
entityType: "issue",
}}
className={cn("flex flex-wrap gap-2", shellSectionClass)}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
@@ -4584,7 +4550,7 @@ export function IssueDetail() {
entityId: issue.id,
entityType: "issue",
}}
className={cn("flex flex-wrap gap-2", shellSectionClass)}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
@@ -4597,126 +4563,12 @@ export function IssueDetail() {
entityId: issue.id,
entityType: "issue",
}}
className={cn("space-y-3", shellSectionClass)}
className="space-y-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</>
);
const taskChatThreadHeader = taskChatShellEnabled ? (
<>
{ancestorsNav}
{issueHeaderBlock}
{pluginOutletsBlock}
</>
) : undefined;
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div
className={
taskChatShellEnabled
? isMobile
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
// auto height) — the thread renders in normal flow (PAP-360).
"flex w-full flex-col gap-6"
: // Fill main exactly so the outer page never scrolls — the
// thread's own viewport is the only scroll surface.
"flex h-full min-h-0 w-full flex-col gap-6"
: "max-w-3xl space-y-6"
}
>
{/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */}
{taskChatShellEnabled ? null : ancestorsNav}
{issue.hiddenAt && (
<div className={cn("flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive", shellSectionClass)}>
<EyeOff className="h-4 w-4 shrink-0" />
This task is hidden
</div>
)}
{activePauseHold && (
<div className={cn("rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200", shellSectionClass)}>
{activePauseHold.isRoot ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
</span>
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
{childIssues.length === 0
? "Task execution is held until resume. Human comments can still wake the assignee for triage."
: "Root and descendant execution is held until resume. Human comments can still wake assignee agents for triage."}
</span>
</div>
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
{childIssues.length === 0
? "1 task held"
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
</div>
{canShowSubtreeControls || canResumeLeafWork ? (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
</Button>
{canShowSubtreeControls ? (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
setTreeControlMode("cancel");
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
}}
>
Cancel subtree...
</Button>
) : null}
</div>
) : null}
</div>
) : (
<div className="text-xs">
This task is paused by ancestor{" "}
{activePauseHoldRoot?.identifier ? (
<Link to={createIssueDetailPath(activePauseHoldRoot.identifier)} className="underline">
{activePauseHoldRoot.identifier}
</Link>
) : (
activePauseHold.rootIssueId.slice(0, 8)
)}
. Resume from the root task to deliver deferred work.
</div>
)}
</div>
)}
{taskChatShellEnabled ? null : issueHeaderBlock}
{taskChatShellEnabled ? null : pluginOutletsBlock}
{taskChatShellEnabled ? null : showRichSubIssuesSection ? (
{showRichSubIssuesSection ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-tasks</h3>
@@ -4751,7 +4603,7 @@ export function IssueDetail() {
</div>
)}
{!taskChatShellEnabled && showPlanDecompositionsSection ? (
{showPlanDecompositionsSection ? (
<IssuePlanDecompositionsSection
issueId={issue.id}
issueIdentifier={issue.identifier}
@@ -4759,9 +4611,6 @@ export function IssueDetail() {
/>
) : null}
{/* Flag ON: attachments/work products/workspace live in the properties
pane (Artifacts tab) — the center column belongs to the thread. */}
{taskChatShellEnabled ? null : (
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
@@ -4789,9 +4638,7 @@ export function IssueDetail() {
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
)}
{taskChatShellEnabled ? null : (
<IssueOutputSection
workProducts={workProducts}
onMediaClick={(item) => {
@@ -4806,9 +4653,8 @@ export function IssueDetail() {
setGalleryOpen(true);
}}
/>
)}
{taskChatShellEnabled ? null : attachmentsInitialLoading ? (
{attachmentsInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
) : hasAttachments ? (
<IssueAttachmentsSection
@@ -4846,7 +4692,6 @@ export function IssueDetail() {
onOpenChange={setGalleryOpen}
/>
{taskChatShellEnabled ? null : (
<IssueWorkspaceCard
issue={issue}
project={resolvedProject}
@@ -4854,9 +4699,8 @@ export function IssueDetail() {
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
/>
)}
{!taskChatShellEnabled && fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
const workProductsWithFileRefs = issue.workProducts
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
.filter(({ fileRef }) => fileRef !== null);
@@ -4881,17 +4725,10 @@ export function IssueDetail() {
);
})()}
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
<Separator />
<Tabs
value={resolvedDetailTab}
onValueChange={setDetailTab}
className={taskChatShellEnabled ? (isMobile ? undefined : "min-h-0 flex-1") : "space-y-3"}
>
{/* Redesign: the chat IS the page — the Chat/Activity/Related-work tab
strip is hidden and the thread renders as the only surface. */}
{taskChatShellEnabled ? null : (
<TabsList variant="line" className={cn("w-full justify-start gap-1", shellSectionClass)}>
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="chat" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Chat
@@ -4910,56 +4747,14 @@ export function IssueDetail() {
</TabsTrigger>
))}
</TabsList>
)}
{/* Flag ON the thread viewport extends under main's horizontal padding
(symmetric, so the centered column keeps the same axis) and the
scrollbar sits flush against the properties-pane border. */}
<TabsContent
value="chat"
className={
taskChatShellEnabled
? isMobile
? "-mx-4"
: "-mx-4 md:-mx-6 flex min-h-0 flex-col"
: undefined
}
>
{resolvedDetailTab === "chat" ? (
<TabsContent value="chat">
{detailTab === "chat" ? (
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}
issueBrief={
taskChatShellEnabled
? {
description: issue.description ?? "",
author: issue.createdByAgentId ? "agent" : "human",
authorName: issue.createdByAgentId
? agentMap.get(issue.createdByAgentId)?.name ?? "Agent"
: undefined,
agentIcon: issue.createdByAgentId
? agentMap.get(issue.createdByAgentId)?.icon
: undefined,
createdAt: issue.createdAt,
onSave: (description) => updateIssue.mutateAsync({ description }),
mentions: mentionOptions,
externalReferences: externalObjectsState.isEnabled
? externalObjectsState.markdownReferences
: undefined,
imageUploadHandler: async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
},
onDropFile: async (file) => {
await uploadAttachment.mutateAsync(file);
},
}
: undefined
}
issueId={issue.id}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
issueAssigneeAgentId={issue.assigneeAgentId}
issueWorkMode={issue.workMode ?? "standard"}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
@@ -4980,7 +4775,6 @@ export function IssueDetail() {
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
comments={threadComments}
commentsInitialLoading={commentsLoading}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
hasOlderComments={hasOlderComments}
@@ -4998,7 +4792,7 @@ export function IssueDetail() {
) : null
}
footer={
!taskChatShellEnabled && siblingNavigation ? (
siblingNavigation ? (
<IssueSiblingNavigation
navigation={siblingNavigation}
linkState={resolvedIssueDetailState ?? location.state}
@@ -5055,7 +4849,7 @@ export function IssueDetail() {
) : null}
</TabsContent>
<TabsContent value="activity" className={shellSectionClass}>
<TabsContent value="activity">
{detailTab === "activity" ? (
<IssueDetailActivityTab
issue={issue}
@@ -5077,7 +4871,7 @@ export function IssueDetail() {
) : null}
</TabsContent>
<TabsContent value="related-work" className={shellSectionClass}>
<TabsContent value="related-work">
<IssueRelatedWorkPanel
relatedWork={issue.relatedWork}
externalObjectsEnabled={externalObjectsState.isEnabled}
@@ -5089,7 +4883,7 @@ export function IssueDetail() {
</TabsContent>
{activePluginTab && (
<TabsContent value={activePluginTab.value} className={shellSectionClass}>
<TabsContent value={activePluginTab.value}>
<PluginSlotMount
slot={activePluginTab.slot}
context={{
+10 -85
View File
@@ -8,65 +8,10 @@ import {
import { buildScenario } from "@/components/task-chat/task-chat-fixtures";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import { TweakPanel } from "@/components/task-chat/TweakPanel";
import type {
TaskChatItem,
TaskChatMessageItem,
} from "@/components/task-chat/task-chat-model";
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
/**
* Demo binding for the agent-bubble copy · 👍 · 👎 cluster (PAP-413). The live
* thread wires these to the feedback-vote API; here the votes no-op so the
* harness can show the footer without a control plane.
*/
function labMessageActions(item: TaskChatMessageItem) {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={{
activeVote: null,
sharingPreference: "allowed",
termsUrl: null,
onVote: async () => {},
}}
/>
);
}
/** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */
const SELF_TALK_GAP_TICKS = 30;
/** A fixture's scripted interstitial updates: blank-line-separated segments. */
function selfTalkSegments(script: string): string[] {
return script
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* The selfTalk visible at replay tick `tick`: each segment streams one char
* per tick as its own update, with a gap (selfTalk undefined — the real
* adapter clears it between assistant messages) before the next; after the
* last segment ends, selfTalk stays undefined so the pill's hold-until-
* superseded behavior (PAP-368) is visible in the lab.
*/
function selfTalkAtTick(segments: string[], tick: number): string | undefined {
let t = tick;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (t <= seg.length) return seg.slice(0, t) || undefined;
t -= seg.length;
if (i === segments.length - 1) return undefined;
if (t <= SELF_TALK_GAP_TICKS) return undefined;
t -= SELF_TALK_GAP_TICKS;
}
return undefined;
}
/** Progressively reveal any streaming message / live-line interstitial text at `speed`. */
/** Progressively reveal any streaming message/thinking text at `speed`. */
function useStreamingReplay(
baseItems: TaskChatItem[],
speed: number,
@@ -74,23 +19,15 @@ function useStreamingReplay(
): TaskChatItem[] {
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
const streamingIndex = baseItems.findIndex(
(i) =>
(i.kind === "message" && i.streaming) ||
(i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null),
(i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming),
);
const streamingItem = baseItems[streamingIndex];
const isInterstitial = streamingItem?.kind === "turn";
const fullText = useMemo(() => {
const it = baseItems[streamingIndex];
if (!it) return "";
if (it.kind === "message") return it.text;
if (it.kind === "turn") return it.liveStatus?.selfTalk ?? "";
if (it.kind === "thinking") return it.lines.join("\n");
return "";
}, [baseItems, streamingIndex]);
const segments = useMemo(
() => (isInterstitial ? selfTalkSegments(fullText) : []),
[isInterstitial, fullText],
);
useEffect(() => {
if (streamingIndex < 0) {
@@ -99,21 +36,14 @@ function useStreamingReplay(
}
setChars(0);
let n = 0;
// One extra tick past the last segment lands on the trailing undefined
// (message ended) state.
const stopAt = isInterstitial
? segments.reduce((a, s) => a + s.length, 0) +
SELF_TALK_GAP_TICKS * Math.max(0, segments.length - 1) +
1
: fullText.length;
const baseIntervalMs = 24;
const interval = window.setInterval(() => {
n += 1;
setChars(n);
if (n >= stopAt) window.clearInterval(interval);
if (n >= fullText.length) window.clearInterval(interval);
}, Math.max(4, baseIntervalMs / speed));
return () => window.clearInterval(interval);
}, [streamingIndex, fullText, isInterstitial, segments, speed, playToken]);
}, [streamingIndex, fullText, speed, playToken]);
if (streamingIndex < 0) return baseItems;
return baseItems.map((it, idx) => {
@@ -121,12 +51,7 @@ function useStreamingReplay(
const shown = fullText.slice(0, chars);
const done = chars >= fullText.length;
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
// Parent-row interstitial (PAP-361/368): reveal the scripted updates
// segment by segment; the pill holds each finished one until the next
// swaps in.
if (it.kind === "turn" && it.liveStatus) {
return { ...it, liveStatus: { ...it.liveStatus, selfTalk: selfTalkAtTick(segments, chars) } };
}
if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done };
return it;
});
}
@@ -227,14 +152,14 @@ export function TaskChatLab() {
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
</div>
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-stage">
<div ref={targetRef} className="min-h-0 flex-1" data-testid="task-chat-stage">
{scenario.surface === "plan" && scenario.plan ? (
<div className="mx-auto max-w-2xl px-4">
<TaskChatPlanView plan={scenario.plan} />
</div>
) : (
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<TaskChatThreadView items={items} renderMessageActions={labMessageActions} />
<div className="mx-auto h-full max-w-2xl">
<TaskChatThreadView items={items} />
</div>
)}
</div>