From eb3c9bc16634cb03a84e98040b96463320988cba Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 20 Jul 2026 20:03:41 +0000 Subject: [PATCH 1/8] feat(site/src/pages/TemplateBuilder): scroll to module when clicking sidebar row Make each selected module row in the SelectionSummary sidebar a jump target. Clicking a module switches to the module settings step and scrolls that module's card into view via a module-config- anchor. When the module settings step is skipped (no configurable variables), the click is a no-op. --- .../TemplateBuilder/ModuleSettingsStep.tsx | 8 ++- .../SelectionSummary.stories.tsx | 38 ++++++++++-- .../TemplateBuilder/SelectionSummary.tsx | 61 ++++++++++++++----- .../TemplateBuilderPageView.tsx | 50 +++++++++++++++ 4 files changed, 137 insertions(+), 20 deletions(-) diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx index eb89be0810ae2..bf420ec7634a0 100644 --- a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -151,7 +151,13 @@ export const ModuleSettingsStep: FC = ({ const optionalFields = optionalVars.map(toField); return ( -
+ // `scroll-mt-24` offsets the sticky top nav so the module + // title is not hidden when scrolled into view via the sidebar. +
= { title: "pages/TemplateBuilder/SelectionSummary", component: SelectionSummary, + args: { + onNavigateModule: fn(), + }, }; export default meta; @@ -112,11 +115,36 @@ export const WithLongNameModule: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const deselectModuleButton = await canvas.findByRole("button", { - name: "Deselect module", + // Long module names must not be truncated and must stay visible. The + // row is a single navigation button. + const moduleButton = await canvas.findByRole("button", { + name: /^Configure A module with a name/i, + }); + moduleButton.focus(); + await expect(moduleButton).toBeVisible(); + }, +}; + +export const NavigateModuleClick: Story = { + args: { + currentStep: 2, + selectedTemplate: { + name: "Docker Containers", + iconUrl: "/icon/docker.svg", + }, + selectedModules: [ + { id: "claude-code", name: "Claude Code", iconUrl: "/icon/claude.svg" }, + { id: "cursor", name: "Cursor IDE", iconUrl: "/icon/cursor.svg" }, + ], + onNavigateModule: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const moduleButton = await canvas.findByRole("button", { + name: "Configure Claude Code", }); - deselectModuleButton.focus(); - await expect(deselectModuleButton).toBeVisible(); + await userEvent.click(moduleButton); + await expect(args.onNavigateModule).toHaveBeenCalledWith("claude-code"); }, }; diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index f66914304860e..33a88f5fa24f7 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -22,12 +22,19 @@ type SelectionSummaryProps = { currentStep: number; selectedTemplate?: SelectedTemplate; selectedModules?: SelectedModule[]; + /** + * Jump to a specific module's configuration section. The consumer + * switches to the module settings step and scrolls the module into view. + * When omitted, module rows render as non-interactive text. + */ + onNavigateModule?: (moduleId: string) => void; }; export const SelectionSummary: React.FC = ({ currentStep, selectedTemplate, selectedModules, + onNavigateModule, }) => { const variant = (step: number) => { if (currentStep === step) return "current"; @@ -49,7 +56,10 @@ export const SelectionSummary: React.FC = ({ Modules {selectedModules ? ( - + ) : ( )} @@ -154,24 +164,47 @@ const BaseTemplateSelection: React.FC = ({ type ModuleSelectionProps = { modules: SelectedModule[]; + onSelectModule?: (moduleId: string) => void; }; -const ModuleSelection: React.FC = ({ modules }) => { +const ModuleSelection: React.FC = ({ + modules, + onSelectModule, +}) => { return ( - {modules.map((module) => ( -
-
- + {modules.map((module) => + onSelectModule ? ( + + ) : ( +
+
+ +
+ + {module.name} +
- - {module.name} - -
- ))} + ), + )} ); }; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 12e776ed2019b..47cd6670a3d86 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, + useRef, } from "react"; import { useQuery } from "react-query"; @@ -177,6 +178,54 @@ export const TemplateBuilderPageView: FC = ({ }); }; + // Holds the module a sidebar click wants to scroll to, so the scroll can + // happen after the module-settings step has rendered. + const pendingModuleScrollRef = useRef(null); + + const scrollModuleIntoView = (moduleId: string) => { + const el = document.getElementById(`module-config-${moduleId}`); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + // Sidebar module rows call this to jump to a module's configuration. + const navigateToModule = (moduleId: string) => { + const settingsIndex = WIZARD_STEPS.findIndex( + (s) => s.id === "module-settings", + ); + const settingsVisible = + settingsIndex >= 0 && !WIZARD_STEPS[settingsIndex].shouldSkip(state); + + // If module-settings is skipped (no configurable vars) there is no + // card to scroll to, so the click is a no-op. + if (!settingsVisible) { + return; + } + + if (currentStep.id === "module-settings") { + scrollModuleIntoView(moduleId); + return; + } + // Remember the target and scroll once the step has rendered. + pendingModuleScrollRef.current = moduleId; + navigateToStep(settingsIndex); + }; + + // Runs after the scroll-reset effect above (declared earlier, so it fires + // first). Scrolls the requested module into view once module-settings + // has rendered. + // biome-ignore lint/correctness/useExhaustiveDependencies: run on step change + useEffect(() => { + if (currentStep.id !== "module-settings") { + return; + } + const moduleId = pendingModuleScrollRef.current; + if (!moduleId) { + return; + } + pendingModuleScrollRef.current = null; + requestAnimationFrame(() => scrollModuleIntoView(moduleId)); + }, [currentStep.id]); + if (isCreating) { return ; } @@ -235,6 +284,7 @@ export const TemplateBuilderPageView: FC = ({
Date: Mon, 20 Jul 2026 20:21:52 +0000 Subject: [PATCH 2/8] chore: rm unnecessary play function --- .../TemplateBuilder/SelectionSummary.stories.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx index 176902148648d..0b91ff315ec31 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.stories.tsx @@ -112,17 +112,6 @@ export const WithLongNameModule: Story = { }, ], }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Long module names must not be truncated and must stay visible. The - // row is a single navigation button. - const moduleButton = await canvas.findByRole("button", { - name: /^Configure A module with a name/i, - }); - moduleButton.focus(); - await expect(moduleButton).toBeVisible(); - }, }; export const NavigateModuleClick: Story = { From 68c94e47895c2eb38c9c984e2cd0568f2835c3b3 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 20 Jul 2026 20:23:02 +0000 Subject: [PATCH 3/8] chore: rm unnecessary comment --- site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx index bf420ec7634a0..bc98c7119e1f8 100644 --- a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -151,8 +151,6 @@ export const ModuleSettingsStep: FC = ({ const optionalFields = optionalVars.map(toField); return ( - // `scroll-mt-24` offsets the sticky top nav so the module - // title is not hidden when scrolled into view via the sidebar.
Date: Mon, 20 Jul 2026 20:57:40 +0000 Subject: [PATCH 4/8] fix: increase font size for module navigation buttons --- site/src/pages/TemplateBuilder/SelectionSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index 33a88f5fa24f7..5e816e5ffc456 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -182,7 +182,7 @@ const ModuleSelection: React.FC = ({ aria-label={`Configure ${module.name}`} className={cn( "flex items-start w-full text-left p-1 mb-1 rounded-sm bg-transparent border-0 cursor-pointer", - "text-content-secondary hover:text-content-primary hover:bg-surface-secondary", + "text-sm text-content-secondary hover:text-content-primary hover:bg-surface-secondary", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-primary", )} > From 561aa3b7ef1e17da1932f329065d4780851a4538 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 20 Jul 2026 21:07:18 +0000 Subject: [PATCH 5/8] fix: prevent overscroll when clicking module --- site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 47cd6670a3d86..2758cb3759a74 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -184,7 +184,7 @@ export const TemplateBuilderPageView: FC = ({ const scrollModuleIntoView = (moduleId: string) => { const el = document.getElementById(`module-config-${moduleId}`); - el?.scrollIntoView({ behavior: "smooth", block: "start" }); + el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }; // Sidebar module rows call this to jump to a module's configuration. From 81f52a6caf5fedbb31a7804eae74daa22d84f28c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 20 Jul 2026 21:12:43 +0000 Subject: [PATCH 6/8] refactor(site/src/pages/TemplateBuilder): make onNavigateModule required The sidebar always receives a navigation handler in practice, so make onNavigateModule required and drop the untested non-interactive module row branch. --- .../TemplateBuilder/SelectionSummary.tsx | 53 +++++++------------ 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/site/src/pages/TemplateBuilder/SelectionSummary.tsx b/site/src/pages/TemplateBuilder/SelectionSummary.tsx index 5e816e5ffc456..b29e4b395d1a7 100644 --- a/site/src/pages/TemplateBuilder/SelectionSummary.tsx +++ b/site/src/pages/TemplateBuilder/SelectionSummary.tsx @@ -25,9 +25,8 @@ type SelectionSummaryProps = { /** * Jump to a specific module's configuration section. The consumer * switches to the module settings step and scrolls the module into view. - * When omitted, module rows render as non-interactive text. */ - onNavigateModule?: (moduleId: string) => void; + onNavigateModule: (moduleId: string) => void; }; export const SelectionSummary: React.FC = ({ @@ -164,7 +163,7 @@ const BaseTemplateSelection: React.FC = ({ type ModuleSelectionProps = { modules: SelectedModule[]; - onSelectModule?: (moduleId: string) => void; + onSelectModule: (moduleId: string) => void; }; const ModuleSelection: React.FC = ({ @@ -173,38 +172,24 @@ const ModuleSelection: React.FC = ({ }) => { return ( - {modules.map((module) => - onSelectModule ? ( - - ) : ( -
-
- -
- - {module.name} - + {modules.map((module) => ( + + ))} ); }; From 7aff4b7cd1ab3d26ba67ec7362d8ca6e02bf51a9 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Mon, 20 Jul 2026 21:27:17 +0000 Subject: [PATCH 7/8] fix: prevent document from overscrolling after scrollModuleIntoView --- site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx | 3 +-- site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx index bc98c7119e1f8..dfe2354b5d700 100644 --- a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -129,8 +129,7 @@ export const ModuleSettingsStep: FC = ({ Set values for module variables. - {/* 340px accounts for navbar, page header, card padding, and nav controls */} -
+
{selectedModules.map((mod) => { const configurableVars = mod.variables.filter((v) => !v.sensitive); const sensitiveVars = mod.variables.filter((v) => v.sensitive); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 2758cb3759a74..3a6ed78c83e71 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -184,7 +184,7 @@ export const TemplateBuilderPageView: FC = ({ const scrollModuleIntoView = (moduleId: string) => { const el = document.getElementById(`module-config-${moduleId}`); - el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + el?.scrollIntoView({ behavior: "smooth" }); }; // Sidebar module rows call this to jump to a module's configuration. From d67d39d14bcbf81b61b159cec093c3414b80fa2f Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 5 Aug 2026 21:16:27 +0000 Subject: [PATCH 8/8] refactor(site/src/pages/TemplateBuilder): use ref map to scroll to module Replace the module-config DOM id lookup with a ref registry. The parent owns a useRef(new Map()) and passes a registerModuleRef callback down to ModuleSettingsStep, which attaches it via a callback ref on each module wrapper. Addresses PR review feedback. --- .../TemplateBuilder/ModuleSettingsStep.tsx | 4 +++- .../TemplateBuilderPageView.tsx | 22 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx index 5ae9859b8a3e9..61549e044b771 100644 --- a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -27,6 +27,7 @@ interface ModuleSettingsStepProps { variables: Record, ) => void; onRemoveModule: (moduleId: string) => void; + registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void; } function variableToField( @@ -109,6 +110,7 @@ export const ModuleSettingsStep: FC = ({ moduleVariables, onChangeModuleVariables, onRemoveModule, + registerModuleRef, }) => { const { data } = useQuery(templateBuilderModules(baseId)); const modules = data?.modules ?? []; @@ -152,7 +154,7 @@ export const ModuleSettingsStep: FC = ({ return (
registerModuleRef(mod.id, node)} className="scroll-mt-24" > = ({ }); }; + // Maps module id -> its config section node, populated by + // ModuleSettingsStep via callback refs. Used to scroll a module into + // view without relying on DOM ids. + const moduleRefs = useRef(new Map()); + + const registerModuleRef = useCallback( + (moduleId: string, node: HTMLDivElement | null) => { + if (node) { + moduleRefs.current.set(moduleId, node); + } else { + moduleRefs.current.delete(moduleId); + } + }, + [], + ); + // Holds the module a sidebar click wants to scroll to, so the scroll can // happen after the module-settings step has rendered. const pendingModuleScrollRef = useRef(null); const scrollModuleIntoView = (moduleId: string) => { - const el = document.getElementById(`module-config-${moduleId}`); - el?.scrollIntoView({ behavior: "smooth" }); + moduleRefs.current.get(moduleId)?.scrollIntoView({ behavior: "smooth" }); }; // Sidebar module rows call this to jump to a module's configuration. @@ -260,6 +275,7 @@ export const TemplateBuilderPageView: FC = ({ createError, handleProvisionerStatusChange, handleDeselectModule, + registerModuleRef, )}
@@ -313,6 +329,7 @@ function renderStepContent( createError: Error | null, onProvisionerStatusChange: (value: boolean | undefined) => void, onRemoveModule: (moduleId: string) => void, + registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void, ): ReactNode { switch (stepId) { case "base-infra": @@ -359,6 +376,7 @@ function renderStepContent( }) } onRemoveModule={onRemoveModule} + registerModuleRef={registerModuleRef} /> ); case "customizations":