Tree panel: animated width toggle, no reflow flash; steady chat header

- The three panel modes were three separate fixed-width asides, so a
  mode switch jumped the whole transcript column width in one frame:
  scrollbar flash, overshoot, re-correct. Now ONE aside animates its
  width exactly like the left sidebar (reusing the sidebar-motion
  hook, so it inherits the provider's mount-gated transition), with
  a FIXED-width inner layer clipped by overflow-hidden — content
  never reflows mid-transition.
- The chat header was auto-height: the sidebar toggle (a size-9 icon
  button) renders only while the sidebar is closed, so opening it
  collapsed the row and slid the title + phase chip up. The header is
  now a constant h-10.
This commit is contained in:
amosblomqvist
2026-09-02 02:39:49 +03:00
parent 4a5bc40243
commit 8dda21ce1b
3 changed files with 50 additions and 29 deletions
+4 -1
View File
@@ -210,7 +210,10 @@ export function Chat({
dock stays sticky inside the scroller, untouched. */} dock stays sticky inside the scroller, untouched. */}
<div className="flex h-dvh"> <div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 px-3 py-2"> {/* h-10, not py-2: the toggle (a size-9 icon button) renders only
while the sidebar is CLOSED — an auto-height row would collapse
when it disappears and slide the title + chip up. */}
<header className="flex h-10 items-center gap-3 px-3">
<SidebarToggle /> <SidebarToggle />
<div className="min-w-0 flex-1 truncate font-medium">{title}</div> <div className="min-w-0 flex-1 truncate font-medium">{title}</div>
<PhaseChip phase={phases.at(-1) ?? "probe"} /> <PhaseChip phase={phases.at(-1) ?? "probe"} />
+37 -19
View File
@@ -41,6 +41,14 @@ import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } fro
/** localStorage: the mode persists as "lh:tree-panel-mode" /** localStorage: the mode persists as "lh:tree-panel-mode"
* ("rail" | "list" | "graph"); the pre-graph key * ("rail" | "list" | "graph"); the pre-graph key
* ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */ * ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */
/** The panel width per mode — the aside AND its fixed inner layer (see
* TreePanel: the inner layer must not squish while the aside animates). */
const PANEL_WIDTH: Record<PanelMode, string> = {
rail: "w-10",
list: "w-64",
graph: "w-96",
};
const MODE_KEY = "lh:tree-panel-mode"; const MODE_KEY = "lh:tree-panel-mode";
const LEGACY_KEY = "lh:tree-panel-collapsed"; const LEGACY_KEY = "lh:tree-panel-collapsed";
@@ -198,20 +206,36 @@ export function TreePanel({
// the moment plan_init's output lands and updates live from there. // the moment plan_init's output lands and updates live from there.
if (!tree || tree.nodes.length === 0) return null; if (!tree || tree.nodes.length === 0) return null;
if (mode === "rail") { // ONE animated container, exactly like the left sidebar: the aside
// animates its width (sidebar-motion transition cascades from the
// provider and is mount-gated there) while the inner layer keeps a
// FIXED width and is clipped by overflow-hidden — content never
// reflows mid-transition, so no scrollbar flash or overshoot. The
// mode's content just swaps instantly inside the steady frame.
return ( return (
<aside
data-testid="tree-panel"
className={cn(
"sidebar-motion hidden shrink-0 overflow-hidden border-l border-border/60 bg-card/30 duration-200 xl:flex",
PANEL_WIDTH[mode],
)}
>
<div className={cn("flex h-full flex-col", PANEL_WIDTH[mode])}>
{mode === "rail" ? (
<TreePanelRail <TreePanelRail
doneCount={tree.doneNodeIds.length} doneCount={tree.doneNodeIds.length}
totalCount={tree.nodes.length} totalCount={tree.nodes.length}
onExpand={() => go("list")} onExpand={() => go("list")}
/> />
) : mode === "graph" ? (
<TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />
) : (
<TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />
)}
</div>
</aside>
); );
} }
if (mode === "graph") {
return <TreeGraphPanel tree={tree} onShowList={() => go("list")} onCollapse={() => go("rail")} />;
}
return <TreePanelBody tree={tree} onShowGraph={() => go("graph")} onCollapse={() => go("rail")} />;
}
/** The collapsed panel: a thin strip with the map icon + quiet progress. */ /** The collapsed panel: a thin strip with the map icon + quiet progress. */
export function TreePanelRail({ export function TreePanelRail({
@@ -224,9 +248,9 @@ export function TreePanelRail({
onExpand: () => void; onExpand: () => void;
}) { }) {
return ( return (
<aside <div
data-testid="tree-panel-rail" data-testid="tree-panel-rail"
className="hidden w-10 shrink-0 flex-col items-center gap-3 border-l border-border/60 py-3 xl:flex" className="flex h-full flex-col items-center gap-3 py-3"
> >
<button <button
type="button" type="button"
@@ -243,7 +267,7 @@ export function TreePanelRail({
> >
{doneCount}/{totalCount} {doneCount}/{totalCount}
</span> </span>
</aside> </div>
); );
} }
@@ -264,10 +288,7 @@ export function TreePanelBody({
const totalCount = tree.nodes.length; const totalCount = tree.nodes.length;
return ( return (
<aside <div className="flex h-full flex-col">
data-testid="tree-panel"
className="hidden w-64 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
>
<header className="flex items-center gap-2 px-3 py-2.5"> <header className="flex items-center gap-2 px-3 py-2.5">
<ListTreeIcon className="size-3.5 text-muted-foreground" /> <ListTreeIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
@@ -354,7 +375,7 @@ export function TreePanelBody({
})} })}
</ol> </ol>
</nav> </nav>
</aside> </div>
); );
} }
@@ -777,10 +798,7 @@ export function TreeGraphPanel({
const doneCount = tree.doneNodeIds.length; const doneCount = tree.doneNodeIds.length;
const totalCount = tree.nodes.length; const totalCount = tree.nodes.length;
return ( return (
<aside <div data-testid="tree-graph-panel" className="flex h-full flex-col">
data-testid="tree-graph-panel"
className="hidden w-96 shrink-0 flex-col border-l border-border/60 bg-card/30 xl:flex"
>
<header className="flex items-center gap-2 px-3 py-2.5"> <header className="flex items-center gap-2 px-3 py-2.5">
<NetworkIcon className="size-3.5 text-muted-foreground" /> <NetworkIcon className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span> <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Plan</span>
@@ -810,6 +828,6 @@ export function TreeGraphPanel({
</button> </button>
</header> </header>
<TreeGraph tree={tree} /> <TreeGraph tree={tree} />
</aside> </div>
); );
} }
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.