feat(ui): task-chat phase 2c — center/widen shell, inline interaction cards, plan doc in Plan tab

All flag-gated behind enableTaskChatRedesign; flag OFF stays byte-identical.

- --tc-shell-max-w (60rem) token; IssueDetail wrapper + skeleton become
  mx-auto w-full max-w-(--tc-shell-max-w) when the flag is on, and the
  thread body + pinned composer caps switch to the same token
- interaction kind added to the task-chat model; TaskChatThread merges
  interactions chronologically (same sort keys + same-run handoff shift
  as buildIssueChatMessages) and renders them via TaskChatInteractionCard,
  a v7-grammar wrapper over the shared IssueThreadInteractionCard
  (expired confirmations demote to a marker row)
- useIssuePlanDocument hook (404 → null) shared by the Plan tab, which
  now renders the plan document via MarkdownBody with a revision line
  above the decompositions, and by the thread's Plan updated/created
  marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:31 -07:00
1 parent e76584b95b
commit a5bdb851ea
8 files changed
+191 -45

No files matched your search

+114 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { useCallback, useEffect, useMemo, useRef, 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";
@@ -9,14 +9,24 @@ import {
transcriptToTaskChatItems,
} from "@/components/task-chat/transcript-adapter";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatTurnChildItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
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>;
/**
@@ -44,6 +54,9 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
export function TaskChatThread(props: TaskChatThreadProps) {
const {
comments,
interactions,
timelineEvents,
issueId = null,
agentMap,
userLabelMap,
currentUserId,
@@ -65,6 +78,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
reassignOptions,
currentAssigneeValue,
issueStatus,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
externalReferences,
} = props;
const agentModeLabel = workModeMetaFor(issueWorkMode).label;
@@ -148,6 +167,64 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return map;
}, [linkedRuns]);
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]);
const items = useMemo<TaskChatItem[]>(() => {
// Settled turns for every terminal run whose transcript we have. The
// transcript's assistant text is excluded — it already landed as the run's
@@ -202,10 +279,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
const out: TaskChatItem[] = [];
for (const item of commentItems) {
const preceding = turnsByAnchor.get(item.id);
for (const entry of orderedEntries) {
const preceding = turnsByAnchor.get(entry.id);
if (preceding) out.push(...preceding);
out.push(item);
out.push(entry.item);
}
out.push(...unanchored);
@@ -243,7 +320,37 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return out;
// `tick` is intentionally a dependency so elapsed advances each second.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentItems, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
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,
],
);
return (
<div
@@ -254,11 +361,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{items.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<TaskChatThreadView items={items} />
<TaskChatThreadView items={items} renderInteraction={renderInteraction} />
)}
</div>
{showComposer ? (
<div className="sticky bottom-0 z-10 mx-auto flex w-full max-w-3xl 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 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">
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
@@ -3,6 +3,8 @@ 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 { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
interface IssuePropertiesPlansTabProps {
issue: Issue;
@@ -11,30 +13,48 @@ interface IssuePropertiesPlansTabProps {
/**
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
*
* 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.
* 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).
*/
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
const { data } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
});
const hasPlans = (data?.length ?? 0) > 0;
if (!hasPlans) {
if (!planDocument && !hasPlans) {
return (
<div className="px-1 py-6 text-sm text-muted-foreground">
No plan yet. Accepted plans and their revisions will appear here.
{planDocumentLoading
? "Loading plan…"
: "No plan yet. The plan document, accepted plans, and their revisions will appear here."}
</div>
);
}
return (
<div className="py-2">
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
<div className="space-y-4 py-2">
{planDocument ? (
<section data-testid="issue-plan-document" className="space-y-2">
<div className="text-xs text-muted-foreground">
{`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`}
</div>
<MarkdownBody>{planDocument.body}</MarkdownBody>
</section>
) : null}
{hasPlans ? (
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
) : null}
</div>
);
}
@@ -22,9 +22,6 @@ function createRequestConfirmation(
summary: "Review and approve the latest plan.",
status: "pending",
continuationPolicy: "wake_assignee",
resolverPolicy: "board_only",
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
@@ -78,26 +75,6 @@ describe("TaskChatInteractionCard", () => {
expect(container.textContent).toContain("Approve the plan");
});
it("puts the primary CTA on the right via a reversed action row", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
</ThemeProvider>
</TooltipProvider>,
);
});
const buttons = Array.from(container.querySelectorAll("button"));
const confirm = buttons.find((button) => button.textContent === "Confirm");
const decline = buttons.find((button) => button.textContent === "Decline");
expect(confirm).not.toBeUndefined();
expect(decline).not.toBeUndefined();
const row = confirm?.parentElement;
expect(row).toBe(decline?.parentElement);
expect(row?.className).toContain("flex-row-reverse");
});
it("demotes an expired confirmation to a marker row", () => {
flushSync(() => {
root.render(
@@ -38,7 +38,7 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract
className="tc-enter-bubble w-full"
data-testid="task-chat-interaction"
>
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
<IssueThreadInteractionCard interaction={interaction} {...cardProps} />
</div>
);
}
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TaskChatItem } from "./task-chat-model";
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
@@ -12,6 +13,12 @@ import { TaskMessageScroller } from "./TaskMessageScroller";
interface TaskChatThreadViewProps {
items: TaskChatItem[];
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;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@@ -20,6 +27,7 @@ interface TaskChatThreadViewProps {
function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
) {
switch (item.kind) {
case "message":
@@ -39,6 +47,8 @@ function renderItem(
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "turn":
return (
<TaskChatTurn
@@ -63,13 +73,14 @@ function renderItem(
export function TaskChatThreadView({
items,
onApprovalDecision,
renderInteraction,
className,
scroll = true,
}: TaskChatThreadViewProps) {
const body = (
<div className={cn("mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-4", className)}>
<div className={cn("mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-3 px-4 py-4", className)}>
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision)}</div>
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
))}
</div>
);
@@ -12,6 +12,7 @@
* 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";
@@ -147,6 +148,18 @@ export interface TaskChatUsageItem {
usage: TaskChatTokenUsage;
}
/**
* 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
@@ -190,6 +203,7 @@ export type TaskChatItem =
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatInteractionItem
| TaskChatTurnItem;
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
+4
View File
@@ -256,6 +256,10 @@
follow + pinned composer) inside the page flow. 21rem ≈ chrome above the
thread (topbar + title row + tabs) plus page padding. */
--tc-thread-max-h: calc(100dvh - 21rem);
/* 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 {
+15 -2
View File
@@ -685,9 +685,16 @@ function IssueDetailLoadingState({
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
}) {
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
return (
<div className="max-w-3xl space-y-6">
<div
className={
taskChatShellEnabled
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
: "max-w-3xl space-y-6"
}
>
<div className="space-y-3">
<Skeleton className="h-3 w-40" />
@@ -4073,7 +4080,13 @@ export function IssueDetail() {
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div className="max-w-3xl space-y-6">
<div
className={
taskChatShellEnabled
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
: "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">