diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index b32a7faae74..0510e65ad2e 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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; /** @@ -44,6 +54,9 @@ export type TaskChatThreadProps = ComponentProps; 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(() => { // 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) => ( + + ), + [ + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + onCancelInteraction, + onSubmitInteractionVerdicts, + imageUploadHandler, + externalReferences, + ], + ); return (
{emptyMessage}
) : ( - + )} {showComposer ? ( -
+
{composerAccessory} issuesApi.listAcceptedPlanDecompositions(issue.id), }); const hasPlans = (data?.length ?? 0) > 0; - if (!hasPlans) { + if (!planDocument && !hasPlans) { return (
- 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."}
); } return ( -
- +
+ {planDocument ? ( +
+
+ {`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`} +
+ {planDocument.body} +
+ ) : null} + {hasPlans ? ( + + ) : null}
); } diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 3a8788c31b3..c233be31196 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -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( - - - - - , - ); - }); - 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( diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.tsx index b2533026970..870cbb98b08 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.tsx @@ -38,7 +38,7 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract className="tc-enter-bubble w-full" data-testid="task-chat-interaction" > - +
); } diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index b989a2051f8..d8f9f4847a3 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -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 ; + case "interaction": + return renderInteraction ? renderInteraction(item) : null; case "turn": return ( +
{items.map((item) => ( -
{renderItem(item, onApprovalDecision)}
+
{renderItem(item, onApprovalDecision, renderInteraction)}
))}
); diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 740ecb15960..c5008515009 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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. */ diff --git a/ui/src/index.css b/ui/src/index.css index fe35598f69c..b0319b44f80 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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 { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 119c1a6fe22..4f019ef7bb7 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -685,9 +685,16 @@ function IssueDetailLoadingState({ headerSeed: ReturnType; }) { const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; + const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled(); return ( -
+
@@ -4073,7 +4080,13 @@ export function IssueDetail() { return ( -
+
{/* Parent chain breadcrumb */} {ancestors.length > 0 && (