From 605c1f7641d8a4328f7e4252f10c98d96d877fce Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 10:59:16 -0700 Subject: [PATCH 1/2] fix(console-ui): repair plans workspace layout Fix resizable handle sizing, constrain long plan content inside panels, and make the plan metadata disclosure preserve body scroll space. --- .../preview/plan-body-highlight.tsx | 2 +- .../src/components/ui/resizable.tsx | 2 +- packages/console-ui/src/globals.css | 1 + .../views/plans/__tests__/plans-view.test.tsx | 26 +- .../console-ui/src/views/plans/plans-view.tsx | 16 +- .../src/views/plans/session-plan-detail.tsx | 426 ++++++++++-------- .../src/views/plans/session-plan-list.tsx | 8 +- .../plans/session-plan-markdown-preview.tsx | 16 +- 8 files changed, 296 insertions(+), 201 deletions(-) diff --git a/packages/console-ui/src/components/preview/plan-body-highlight.tsx b/packages/console-ui/src/components/preview/plan-body-highlight.tsx index 25b7375b7..76cbd6319 100644 --- a/packages/console-ui/src/components/preview/plan-body-highlight.tsx +++ b/packages/console-ui/src/components/preview/plan-body-highlight.tsx @@ -97,7 +97,7 @@ export function PlanBodyHighlight({ content }: PlanBodyHighlightProps) { return (
); diff --git a/packages/console-ui/src/components/ui/resizable.tsx b/packages/console-ui/src/components/ui/resizable.tsx index a900a74e3..d4dfef2ed 100644 --- a/packages/console-ui/src/components/ui/resizable.tsx +++ b/packages/console-ui/src/components/ui/resizable.tsx @@ -32,7 +32,7 @@ const ResizableHandle = ({ }) => ( div]:rotate-90', + 'relative flex h-full w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[aria-orientation=horizontal]]:h-px [&[aria-orientation=horizontal]]:w-full [&[aria-orientation=horizontal]]:after:left-0 [&[aria-orientation=horizontal]]:after:h-1 [&[aria-orientation=horizontal]]:after:w-full [&[aria-orientation=horizontal]]:after:-translate-y-1/2 [&[aria-orientation=horizontal]]:after:translate-x-0 [&[aria-orientation=horizontal]>div]:rotate-90', className, )} {...props} diff --git a/packages/console-ui/src/globals.css b/packages/console-ui/src/globals.css index b2c1a5985..7259f5b70 100644 --- a/packages/console-ui/src/globals.css +++ b/packages/console-ui/src/globals.css @@ -108,6 +108,7 @@ .plan-prose { color: var(--color-foreground); line-height: 1.6; + overflow-wrap: anywhere; } .plan-prose > * + * { diff --git a/packages/console-ui/src/views/plans/__tests__/plans-view.test.tsx b/packages/console-ui/src/views/plans/__tests__/plans-view.test.tsx index eb336c965..caa869921 100644 --- a/packages/console-ui/src/views/plans/__tests__/plans-view.test.tsx +++ b/packages/console-ui/src/views/plans/__tests__/plans-view.test.tsx @@ -416,13 +416,27 @@ describe('PlansView', () => { }; globalThis.fetch = makeFetchMock({ plans: [plan] }, richShowResponse); render(); + + // Profile lives in the always-visible summary; should render without + // expanding the disclosure. Tightened to getByText (no duplication). await waitFor(() => { - // Profile expect(screen.getByText('analytics-heavy')).toBeDefined(); - // Required dimensions + }); + + // Disclosure content is hidden until the trigger is clicked. + expect(screen.queryByText('performance')).toBeNull(); + expect(screen.queryByText('What is the rollout plan?')).toBeNull(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /show details/i })); + }); + + await waitFor(() => { + // Required dimensions. `scope` is required but not in covered. expect(screen.getByText('scope')).toBeDefined(); - // acceptance_criteria appears in required_dimensions and missing_dimensions badges - expect(screen.getAllByText('acceptance_criteria').length).toBeGreaterThan(0); + // `acceptance_criteria` is both a required and a missing dimension — + // appears twice (once per section). + expect(screen.getAllByText('acceptance_criteria').length).toBe(2); // Optional dimensions expect(screen.getByText('performance')).toBeDefined(); // Skipped dimensions (plan-level) @@ -434,11 +448,11 @@ describe('PlansView', () => { expect(screen.getByText('problem_statement_covered')).toBeDefined(); // Readiness - missing dimensions section expect(screen.getByText('Missing dimensions')).toBeDefined(); - const allMissing = screen.getAllByText('acceptance_criteria'); - expect(allMissing.length).toBeGreaterThan(0); // Readiness - skipped dimensions section expect(screen.getByText('Skipped dimensions (readiness)')).toBeDefined(); expect(screen.getByText('low_priority_skipped')).toBeDefined(); + // Profile no longer duplicated in the disclosure body. + expect(screen.getAllByText('analytics-heavy').length).toBe(1); }); }); diff --git a/packages/console-ui/src/views/plans/plans-view.tsx b/packages/console-ui/src/views/plans/plans-view.tsx index 554fe8d2c..5084ad18c 100644 --- a/packages/console-ui/src/views/plans/plans-view.tsx +++ b/packages/console-ui/src/views/plans/plans-view.tsx @@ -70,10 +70,18 @@ export function PlansView({ onNavigate }: PlansViewProps) {
)} - {/* Main list + detail layout */} + {/* + Main list + detail layout. + Note: `min-w-0` is set on every panel and propagates down through + SessionPlanList / SessionPlanDetail / SessionPlanMarkdownPreview. + Long unbroken tokens (URLs, file paths) in the markdown preview + would otherwise expand the flex children past the panel width. + Do not remove `min-w-0` from these ancestors without testing with + a plan body containing a long unbroken string. + */} {hasPlans && ( - - + + - + void; } +type BadgeVariant = React.ComponentProps['variant']; + +function DimensionBadges({ + dimensions, + variant = 'outline', + coveredDimensions, + coveredVariant = 'secondary', +}: { + dimensions: string[]; + variant?: BadgeVariant; + /** When provided, dimensions in this set render with `coveredVariant`. */ + coveredDimensions?: string[]; + coveredVariant?: BadgeVariant; +}) { + const coveredSet = React.useMemo( + () => new Set(coveredDimensions ?? []), + [coveredDimensions], + ); + return ( +
+ {dimensions.map((dimension) => ( + + {dimension} + + ))} +
+ ); +} + +function BuildLink({ + session, + onNavigate, + className, +}: { + session: string; + onNavigate?: (href: string) => void; + className?: string; +}) { + const href = `/console/runs/${session}`; + const handleClick = (event: React.MouseEvent) => { + if (onNavigate) { + event.preventDefault(); + onNavigate(href); + } + }; + return ( + + {session} + + ); +} + +function MetadataSummary({ + plan, + onNavigate, +}: { + plan: SessionPlanShowResponse['plan']; + onNavigate?: (href: string) => void; +}) { + return ( +
+ + Type + {plan.planning_type} + + + Depth + {plan.planning_depth} + + {plan.profile && ( + + Profile + {plan.profile} + + )} + {plan.agent_profile && ( + + Agent profile + {plan.agent_profile} + + )} + {plan.eforge_session && ( + + Build + + + )} +
+ ); +} + +/** + * Disclosure body. Renders only the deeper breakdown (dimensions, open + * questions, readiness, path). Type/Depth/Profile/Agent profile/Build live in + * `MetadataSummary` above and are intentionally omitted here. + */ +function DetailsContent({ detail }: { detail: SessionPlanShowResponse }) { + const { plan, readiness, path } = detail; + + return ( +
+ {plan.required_dimensions.length > 0 && ( +
+

Required dimensions

+ +
+ )} + + {plan.optional_dimensions.length > 0 && ( +
+

Optional dimensions

+ +
+ )} + + {plan.skipped_dimensions.length > 0 && ( +
+

Skipped dimensions

+ dimension.name)} + /> +
+ )} + + {plan.open_questions.length > 0 && ( +
+

Open questions

+
    + {plan.open_questions.map((question, index) => ( +
  • {question}
  • + ))} +
+
+ )} + + {readiness.coveredDimensions.length > 0 && ( +
+

Covered dimensions

+ +
+ )} + + {!readiness.ready && readiness.missingDimensions.length > 0 && ( +
+

Missing dimensions

+ +
+ )} + + {readiness.skippedDimensions.length > 0 && ( +
+

Skipped dimensions (readiness)

+ +
+ )} + +
+

Path

+

{path}

+
+
+ ); +} + export function SessionPlanDetail({ detail, status, @@ -50,194 +233,77 @@ export function SessionPlanDetail({ if (!detail) return null; - const { plan, readiness, path } = detail; - - const handleBuildLink = ( - e: React.MouseEvent, - href: string, - ) => { - if (onNavigate) { - e.preventDefault(); - onNavigate(href); - } - }; + const { plan, readiness } = detail; return ( - -
- {/* Session ID, lifecycle status, readiness */} -
-
- {plan.session} - - {plan.status} - - - {readiness.ready ? 'ready' : 'not ready'} - -
- {plan.topic && ( -

{plan.topic}

- )} -
- - {/* Planning type / depth / profile */} -
-
-

Type

-

{plan.planning_type}

-
-
-

Depth

-

{plan.planning_depth}

-
- {plan.profile && ( -
-

Profile

-

{plan.profile}

-
- )} - {plan.agent_profile && ( -
-

Agent profile

-

{plan.agent_profile}

-
- )} -
- - {/* Required dimensions */} - {plan.required_dimensions.length > 0 && ( -
-

Required dimensions

-
- {plan.required_dimensions.map((d) => ( - - {d} - - ))} -
-
- )} - - {/* Optional dimensions */} - {plan.optional_dimensions.length > 0 && ( -
-

Optional dimensions

-
- {plan.optional_dimensions.map((d) => ( - - {d} - - ))} -
-
- )} - - {/* Skipped dimensions */} - {plan.skipped_dimensions.length > 0 && ( -
-

Skipped dimensions

-
- {plan.skipped_dimensions.map((d) => ( - - {d.name} - - ))} -
-
- )} - - {/* Open questions */} - {plan.open_questions.length > 0 && ( -
-

Open questions

-
    - {plan.open_questions.map((q, i) => ( -
  • {q}
  • - ))} -
-
- )} - - {/* Readiness detail: covered dimensions */} - {readiness.coveredDimensions.length > 0 && ( -
-

Covered dimensions

-
- {readiness.coveredDimensions.map((d) => ( - - {d} - - ))} -
-
- )} - - {/* Readiness detail: missing dimensions */} - {!readiness.ready && readiness.missingDimensions.length > 0 && ( -
-

Missing dimensions

-
- {readiness.missingDimensions.map((d) => ( - - {d} - - ))} -
-
- )} - - {/* Readiness detail: skipped dimensions */} - {readiness.skippedDimensions.length > 0 && ( -
-

Skipped dimensions (readiness)

-
- {readiness.skippedDimensions.map((d) => ( - - {d} - - ))} +
+ {/* + `key` resets the Collapsible's internal open state when the selected + plan changes — preferred over a useEffect for clarity. + */} + +
+
+
+ {plan.session} + + {plan.status} + + + {readiness.ready ? 'ready' : 'not ready'} +
+ {plan.topic && ( +

{plan.topic}

+ )} +
- )} - - {/* Path */} -
-

Path

-

{path}

-
- {/* Build session link */} - {plan.eforge_session && ( - - )} + + Show details + Hide details + + +
+ {/* + Disclosure body is capped relative to viewport so a dense plan + can't push the markdown preview off-screen. Native scroll matches + the markdown preview below; both swap Radix ScrollArea for native + overflow because Radix's viewport breaks `flex-1` height behavior. + */} + + + +
- {/* Markdown preview */} - {plan.body && ( -
-

Plan body

- +
+

Plan body

+ {plan.body ? ( + + ) : ( +
+ No plan body.
)}
- +
); } diff --git a/packages/console-ui/src/views/plans/session-plan-list.tsx b/packages/console-ui/src/views/plans/session-plan-list.tsx index 7d4337e1d..3e222ba84 100644 --- a/packages/console-ui/src/views/plans/session-plan-list.tsx +++ b/packages/console-ui/src/views/plans/session-plan-list.tsx @@ -15,8 +15,8 @@ interface SessionPlanListProps { export function SessionPlanList({ plans, selectedSession, onSelect }: SessionPlanListProps) { return ( - -
    + +
      {plans.map((plan) => (