feat(ui): task-chat phase 2e — pane tabs in header bar, chat-only center, non-sticky header, plan CTA action bar

Flag-gated (enableTaskChatRedesign), flag OFF byte-identical:

- Properties | Plan | Artifacts tab strip portals from IssueProperties into
  the pane's header bar (new slot left of the maximize/close controls).
- Center Chat/Activity/Related-work tab strip hidden — the thread is the page;
  deep links that switched tabs stay on chat.
- Issue header (parent breadcrumb, title/badge block, plugin toolbars, load-
  earlier button) renders INSIDE the thread scroll viewport, so it scrolls
  away with the messages instead of staying pinned.
- --tc-thread-max-h reserve 21rem → 8rem: composer now sits ~26px above the
  viewport bottom.
- New IssuePlanConfirmationActionBar: while a plan confirmation is pending,
  its Confirm/Decline CTAs (with decline-reason flow) pin to a footer slot
  below the pane's scroll area on the Plan tab.

Verified: ui tsc clean; motion gate (25 files) + token gates clean; 218 tests
across task-chat/pane/IssueDetail/IssueChatThread (1 pre-existing TZ flake in
IssueProperties monitor row, fails on base); Playwright on :3093 light+dark —
tabs in pane header, header scrolls away/returns, thread bottom 923/950, plan
bar pinned at pane bottom in footer slot, zero page errors; flag-off run shows
legacy tabs/pane title, 0 redesign testids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:49 -07:00
1 parent ad018a9224
commit 9a5f7e26e4
7 files changed
+249 -126

No files matched your search

+21 -3
View File
@@ -47,6 +47,19 @@ export function PropertiesPanel() {
* variant. Everything below renders only when the flag is ON.
* ------------------------------------------------------------------------- */
/**
* Portal target in the redesigned pane's header bar: hosted content (the
* Properties | Plan | Artifacts tab strip) renders here, left of the window
* controls. See IssueProperties' flag-ON shell.
*/
export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
/**
* Portal target pinned below the pane's scroll area: hosted content (the plan
* confirmation action bar) renders here so it stays visible while the pane
* body scrolls.
*/
export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
const DEFAULT_PANE_WIDTH = 322;
const MIN_PANE_WIDTH = 260;
@@ -340,9 +353,13 @@ function ResizablePropertiesPanel({
className={cn("flex-1 flex flex-col min-h-0", isFixed && "w-full")}
style={isFixed ? undefined : { width, minWidth: width }}
>
{/* No pane title: the tab strip below (Properties/…) already names the
surface — this row is just the window controls. */}
<div className="flex items-center justify-end px-4 py-2 border-b border-border">
{/* No pane title: the tab strip (portaled into the slot by
IssueProperties) names the surface; window controls sit right. */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-border">
<div
id={PROPERTIES_PANE_HEADER_SLOT_ID}
className="flex min-w-0 flex-1 items-center self-stretch"
/>
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -371,6 +388,7 @@ function ResizablePropertiesPanel({
{panelContent}
</div>
</ScrollArea>
<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} className="shrink-0" />
</div>
</aside>
</>
+13 -2
View File
@@ -84,6 +84,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onCancelInteraction,
onSubmitInteractionVerdicts,
externalReferences,
threadHeader,
} = props;
const agentModeLabel = workModeMetaFor(issueWorkMode).label;
@@ -359,9 +360,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
>
<div className="flex min-h-0 flex-1 flex-col">
{items.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
<div className="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>
) : (
<TaskChatThreadView items={items} renderInteraction={renderInteraction} />
<TaskChatThreadView items={items} header={threadHeader} renderInteraction={renderInteraction} />
)}
</div>
{showComposer ? (
@@ -1,4 +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 { Link } from "@/lib/router";
@@ -170,6 +172,18 @@ 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]);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -2458,17 +2472,24 @@ export function IssueProperties({
// 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.
// tab is unchanged. Panel hosts portal the strip into the pane header bar;
// portals keep React context, so the Tabs root still drives it.
const tabStrip = (
<TabsList
variant="line"
className={paneHeaderSlot ? "justify-start gap-1" : "w-full justify-start gap-1"}
>
<TabsTrigger value="properties">Properties</TabsTrigger>
<TabsTrigger value="plans">Plan</TabsTrigger>
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
</TabsList>
);
return (
<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">Plan</TabsTrigger>
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
</TabsList>
{paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : tabStrip}
<TabsContent value="properties">{propertiesBody}</TabsContent>
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} />
<IssuePropertiesPlansTab issue={issue} inline={inline} />
</TabsContent>
<TabsContent value="artifacts">
<IssuePropertiesArtifactsTab issue={issue} />
@@ -5,9 +5,13 @@ import { queryKeys } from "@/lib/queryKeys";
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
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;
}
/**
@@ -19,7 +23,7 @@ interface IssuePropertiesPlansTabProps {
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
* the /dev/task-chat-lab harness).
*/
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
const { data } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
@@ -39,6 +43,9 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
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. */}
{planDocument ? <IssuePlanConfirmationActionBar issue={issue} inline={inline} /> : null}
{planDocument ? (
<section data-testid="issue-plan-document" className="space-y-2">
<div className="text-xs text-muted-foreground">
@@ -12,6 +12,12 @@ 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
@@ -72,6 +78,7 @@ function renderItem(
*/
export function TaskChatThreadView({
items,
header,
onApprovalDecision,
renderInteraction,
className,
@@ -79,6 +86,11 @@ export function TaskChatThreadView({
}: 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}
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
))}
+5 -3
View File
@@ -253,9 +253,11 @@
--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. 21rem ≈ chrome above the
thread (topbar + title row + tabs) plus page padding. */
--tc-thread-max-h: calc(100dvh - 21rem);
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. */
+162 -110
View File
@@ -903,6 +903,12 @@ 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;
footer?: ReactNode;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
@@ -991,6 +997,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onWorkModeChange,
composerRef,
composerAccessory,
threadHeader,
footer,
feedbackVotes,
feedbackDataSharingPreference,
@@ -1170,24 +1177,36 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
[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="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}
{/* 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}
<ThreadComponent
composerRef={composerRef}
composerAccessory={composerAccessory}
threadHeader={
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
<>
{threadHeader}
{loadOlderButton}
</>
) : undefined
}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
@@ -1563,6 +1582,9 @@ 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;
@@ -4084,17 +4106,12 @@ export function IssueDetail() {
</>
);
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div
className={
taskChatShellEnabled
? "w-full space-y-6"
: "max-w-3xl space-y-6"
}
>
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
// 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)}>
{[...ancestors].reverse().map((ancestor, i) => (
<span key={ancestor.id} className="flex items-center gap-1">
@@ -4118,90 +4135,9 @@ 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>
)}
{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>
)}
);
const issueHeaderBlock = (
<div className={cn("space-y-3", shellSectionClass)}>
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
@@ -4553,7 +4489,10 @@ export function IssueDetail() {
/>
)}
</div>
);
const pluginOutletsBlock = (
<>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
@@ -4594,6 +4533,114 @@ export function IssueDetail() {
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
? "w-full space-y-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 ? (
<div className="space-y-3">
@@ -4760,9 +4807,12 @@ export function IssueDetail() {
);
})()}
<Separator className={shellSectionClass} />
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
<Tabs value={resolvedDetailTab} onValueChange={setDetailTab} className="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)}>
<TabsTrigger value="chat" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
@@ -4782,13 +4832,15 @@ 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 ? "-mx-4 md:-mx-6" : undefined}>
{detailTab === "chat" ? (
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}
issueId={issue.id}
companyId={issue.companyId}
projectId={issue.projectId ?? null}