{showComposer ? (
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 3e7209e8120..d2920cbabed 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -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(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 = (
+
+ Properties
+ Plan
+ Artifacts
+
+ );
return (
-
- Properties
- Plan
- Artifacts
-
+ {paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : tabStrip}
{propertiesBody}
-
+
diff --git a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
index 0e9d34500b2..0e40995f689 100644
--- a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
@@ -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 (
+ {/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
+ they stay visible while the plan scrolls. */}
+ {planDocument ? : null}
{planDocument ? (
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx
index d8f9f4847a3..87c27f1f228 100644
--- a/ui/src/components/task-chat/TaskChatThreadView.tsx
+++ b/ui/src/components/task-chat/TaskChatThreadView.tsx
@@ -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 = (
))}
diff --git a/ui/src/index.css b/ui/src/index.css
index b0319b44f80..480fb05f886 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -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. */
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 132acb9e6ed..f778878b0b7 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -903,6 +903,12 @@ type IssueDetailChatTabProps = {
composerRef: Ref;
/** 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 ? (
+
+
+
+ ) : null;
+
return (
- {hasOlderComments ? (
-
-
-
- ) : 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}
+ {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 (
-
-
- {/* 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 && (
- )}
-
- {issue.hiddenAt && (
-
-
- This task is hidden
-
- )}
- {activePauseHold && (
-
- {activePauseHold.isRoot ? (
-
-
-
- {childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
-
-
- {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."}
-
-
+
+ {childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
+
+
+ {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."}
+
+
@@ -4760,9 +4807,12 @@ export function IssueDetail() {
);
})()}
-
+ {taskChatShellEnabled ? null : }
-
+
+ {/* 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 : (
@@ -4782,13 +4832,15 @@ export function IssueDetail() {
))}
+ )}
{/* 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. */}
- {detailTab === "chat" ? (
+ {resolvedDetailTab === "chat" ? (