fix(ui): task-chat phase 2e follow-ups — kill outer main scrollbar, restore pane-tab underline, conditional Plan/Artifacts tabs

Flag-gated (enableTaskChatRedesign), flag OFF unchanged:

- Extra scrollbar: with the flag ON the page content used a fixed
  calc(100dvh - 8rem) thread height inside the padded outer <main>
  (overflow-auto), so main itself also scrolled. The IssueDetail root now
  fills main exactly (h-full flex column) and the chain down to the thread
  is flexed with min-h-0, so the thread's internal viewport is the only
  scroll surface. TaskChatThread's dvh height token still applies on
  mobile, where main has no fixed height (flex-basis falls back to it).
- Pane-tab underline: the portal into the pane header bar kept React
  context but broke the DOM ancestry that Tailwind's
  group-data-[orientation]/tabs selectors need, so the active-tab
  underline never applied. A display:contents group/tabs wrapper inside
  the portal restores it.
- Conditional tabs: Plan/Artifacts triggers render only when the issue
  has a plan document/accepted decompositions or attachments (same query
  keys as the tab bodies — shared cache). With neither, the header bar
  shows a plain "Properties" title and the pane body is the plain
  properties stack. Tabs are controlled with a fallback to Properties if
  the selected tab's content disappears.

Verified on :3093 light+dark via Playwright: main scrollHeight ==
clientHeight (923/923) on //; underline opacity 1 on
active portaled tab; Properties|Plan on , all three on ,
title-only on ; portaled tab clicks switch content; flag-off run
scrolls the legacy page (4454px) with no redesign testids. ui tsc clean;
issue-properties/task-chat/IssueDetail/IssueChatThread tests pass (1
pre-existing TZ flake in IssueProperties monitor row, fails on base).

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 9a5f7e26e4
commit 111f7bac6a
3 files changed
+81 -16

No files matched your search

+3 -2
View File
@@ -353,8 +353,9 @@ 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 (portaled into the slot by
IssueProperties) names the surface; window controls sit right. */}
{/* The slot hosts whatever IssueProperties portals in — the tab strip
when Plan/Artifacts have content, else a plain "Properties" title;
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}
@@ -12,6 +12,7 @@ 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";
@@ -184,6 +185,25 @@ export function IssueProperties({
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [taskChatRedesignEnabled, inline]);
// Plan/Artifacts only earn a tab when they have content; with neither, the
// header bar shows a plain "Properties" title instead of a one-tab strip.
// 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;
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
@@ -2470,30 +2490,64 @@ 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;
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>
{hasPlanTab ? <TabsTrigger value="plans">Plan</TabsTrigger> : null}
{hasArtifactsTab ? <TabsTrigger value="artifacts">Artifacts</TabsTrigger> : null}
</TabsList>
);
return (
<Tabs defaultValue="properties" className="flex min-h-0 flex-col gap-3">
{paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : tabStrip}
<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}
<TabsContent value="properties">{propertiesBody}</TabsContent>
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} inline={inline} />
</TabsContent>
<TabsContent value="artifacts">
<IssuePropertiesArtifactsTab issue={issue} />
</TabsContent>
{hasPlanTab ? (
<TabsContent value="plans">
<IssuePropertiesPlansTab issue={issue} inline={inline} />
</TabsContent>
) : null}
{hasArtifactsTab ? (
<TabsContent value="artifacts">
<IssuePropertiesArtifactsTab issue={issue} />
</TabsContent>
) : null}
</Tabs>
);
}
+14 -4
View File
@@ -1192,7 +1192,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
) : null;
return (
<div className="space-y-3">
<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}
@@ -4549,7 +4549,10 @@ export function IssueDetail() {
<div
className={
taskChatShellEnabled
? "w-full space-y-6"
? // Fill main exactly so the outer page never scrolls — the thread's
// own viewport is the only scroll surface (h-full is inert on
// mobile, where main has no fixed height and the page scrolls).
"flex h-full min-h-0 w-full flex-col gap-6"
: "max-w-3xl space-y-6"
}
>
@@ -4809,7 +4812,11 @@ export function IssueDetail() {
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
<Tabs value={resolvedDetailTab} onValueChange={setDetailTab} className="space-y-3">
<Tabs
value={resolvedDetailTab}
onValueChange={setDetailTab}
className={taskChatShellEnabled ? "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 : (
@@ -4837,7 +4844,10 @@ 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. */}
<TabsContent value="chat" className={taskChatShellEnabled ? "-mx-4 md:-mx-6" : undefined}>
<TabsContent
value="chat"
className={taskChatShellEnabled ? "-mx-4 md:-mx-6 flex min-h-0 flex-col" : undefined}
>
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}