Files
Danny White 6cff728742 feat(studio): polish Connect sheet mode selector and steps (#48266)
## What kind of change does this PR introduce?

UI polish for the Connect sheet: clearer mode selection, wider sheet
layout, and step/content chrome across Direct, Server, MCP, and shadcn
flows.

## What is the current behavior?

- Connect modes use a weak selected state and an awkward grid layout.
- The sheet can jump width below the `lg` breakpoint when switching
modes.
- Direct connection chrome is noisy (reset in a footer, Title Case /
mono pooler labels, mismatched copy-button sizes).
- Several steps use admonitions or extra tips that repeat footer
guidance.
- Case-sensitive import of `InlineLink` breaks Linux/Vercel builds.

## What is the new behavior?

### Mode selector and sheet
- Stronger selected/hover treatment; comfortable single row that wraps
via `@container`.
- Empty odd slots use a sunk placeholder cell.
- Sheet uses `size="lg"` with `max-w-4xl` and `w-full min-w-0` so width
stays stable when switching modes.

### Steps chrome
- “Follow these steps” header with a copy-prompt action for coding
agents.
- Optional steps labelled `(optional)`.
- Shared `CodeBlock` for install snippets; MCP feature groups preselect
all except Storage.
- Server / shadcn tips folded into footers; IPv4 add-on admonition is
responsive with an inline Learn more link and a single Enable action.

### Direct connection
- Connection string and connection parameters stay one step (same
credentials, two formats).
- Reset database password lives in the string card title row beside
Shared/Dedicated pooler.
- Card titles use sans + sentence case (`Shared pooler`, `Connection
parameters`); `.env` stays mono.
- Icon-only copy buttons match CodeBlock square sizing; row actions sit
slightly closer to the right edge (`pr-2`).
- Shared pooler toggle copy clarified.

| Before | After |
| --- | --- |
| <img width="390" height="763" alt="API Keys Settings Chisel Toolshed
Supabase"
src="https://github.com/user-attachments/assets/adca3cc5-94f8-47e5-a4a2-2831790f430a"
/> | <img width="390" height="763" alt="API Keys Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/f03afe58-e654-435e-a821-835f6243ca95"
/> |
| <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed
Supabase"
src="https://github.com/user-attachments/assets/79f08620-7e1e-4246-a70f-801606c0f499"
/> | <img width="1718" height="1323" alt="API Keys Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/fb45e851-955e-46c2-90f1-afecb93d6ac4"
/> |
| <img width="1718" height="1323" alt="API Keys Settings Chisel Toolshed
Supabase"
src="https://github.com/user-attachments/assets/eda36d21-bba7-46ab-ad48-134acf93b471"
/> | <img width="1718" height="1323" alt="API Keys Settings Chisel
Toolshed Supabase"
src="https://github.com/user-attachments/assets/b7b728c6-fc92-46a7-8e3f-2f182c56ece7"
/> |

### Test plan

- [ ] Open **Connect** and confirm mode cells select/hover clearly;
narrow the sheet and confirm wrap + stable width.
- [ ] Direct: switch Direct / Transaction / Session; confirm pooler
title, reset in title row, parameters table, and percent-encode note.
- [ ] Toggle IPv4 shared pooler on Transaction; confirm string updates
and admonition/Learn more behaviour when on IPv4-only paths.
- [ ] Server: `.env` Copy all / row copy sizing; install command copy.
- [ ] MCP / shadcn / Framework: steps still resolve and copy prompt
still builds a useful agent prompt.
- [ ] Spot-check light/dark and a Linux/Vercel build (InlineLink import
casing).
2026-07-25 00:13:54 +10:00

157 lines
5.1 KiB
TypeScript

import { Check, Copy } from 'lucide-react'
import { useEffect, useState, type RefObject } from 'react'
import { copyToClipboard } from 'ui'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface CopyPromptButtonProps {
stepsContainerRef: RefObject<HTMLDivElement | null>
/** When set, the Copy prompt button uses this verbatim instead of scraping the steps. */
customPrompt?: string
}
const normalizeTextLines = (value: string) => {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n')
}
const getStepTextContent = (contentElement: HTMLElement) => {
const clone = contentElement.cloneNode(true) as HTMLElement
clone
.querySelectorAll('pre, button, svg, input, textarea, select, [aria-hidden="true"]')
.forEach((element) => {
element.remove()
})
clone.querySelectorAll('p, div').forEach((el) => {
el.appendChild(document.createTextNode('\n'))
})
const text = clone.textContent ?? ''
return normalizeTextLines(text)
}
const getStepCodeSnippets = (contentElement: HTMLElement) => {
const snippets: Array<{ label: string; snippet: string }> = []
const seen = new Set<string>()
const addSnippet = (label: string, snippet: string) => {
if (!snippet || seen.has(snippet)) return
seen.add(snippet)
snippets.push({ label, snippet })
}
const getSnippet = (element: Element) => {
const copyValueElement = element.closest('[data-connect-copy-value]') as HTMLElement | null
return copyValueElement?.dataset.connectCopyValue?.trim() || element.textContent?.trim()
}
const tabContents = Array.from(
contentElement.querySelectorAll('[data-connect-tab-content]')
) as HTMLElement[]
tabContents.forEach((tabContent) => {
const label = tabContent.getAttribute('data-tab-label') || 'Code'
const tabSnippets = Array.from(tabContent.querySelectorAll('pre'))
.map(getSnippet)
.filter((snippet): snippet is string => Boolean(snippet))
if (tabSnippets.length === 0) {
const inlineSnippets = Array.from(tabContent.querySelectorAll('code'))
.filter((code) => !code.closest('pre') && code.closest('.font-mono'))
.map((code) => code.textContent?.trim())
.filter((snippet): snippet is string => Boolean(snippet))
inlineSnippets.forEach((snippet, index) => {
const inlineLabel = inlineSnippets.length > 1 ? `${label} (part ${index + 1})` : label
addSnippet(inlineLabel, snippet)
})
return
}
tabSnippets.forEach((snippet, index) => {
const tabLabel = tabSnippets.length > 1 ? `${label} (part ${index + 1})` : label
addSnippet(tabLabel, snippet)
})
})
contentElement.querySelectorAll('pre').forEach((pre) => {
if (pre.closest('[data-connect-tab-content]')) return
const snippet = getSnippet(pre)
if (snippet) addSnippet('Code', snippet)
})
contentElement.querySelectorAll('code').forEach((code) => {
if (code.closest('pre')) return
if (code.closest('[data-connect-tab-content]')) return
if (!code.closest('.font-mono')) return
const snippet = code.textContent?.trim()
if (snippet) addSnippet('Code', snippet)
})
return snippets
}
export const buildConnectPrompt = (stepsContainer: HTMLElement | null) => {
const stepElements = stepsContainer?.querySelectorAll('[data-connect-step]')
if (!stepElements?.length) return ''
const promptContent = Array.from(stepElements)
.map((stepElement, index) => {
const title = stepElement.getAttribute('data-step-title') ?? `Step ${index + 1}`
const description = stepElement.getAttribute('data-step-description') ?? ''
const contentElement = stepElement.querySelector('[data-step-content]') as HTMLElement | null
const details = contentElement ? getStepTextContent(contentElement) : ''
const codeSnippets = contentElement ? getStepCodeSnippets(contentElement) : []
const sections = [
`${index + 1}. ${title}`,
description,
details ? `Details:\n${details}` : null,
codeSnippets.length
? `Code:\n${codeSnippets
.map(({ label, snippet }) => `File: ${label}\n\`\`\`\n${snippet}\n\`\`\``)
.join('\n\n')}`
: null,
].filter(Boolean)
return sections.join('\n')
})
.join('\n\n')
return promptContent
}
export function CopyPromptButton({ stepsContainerRef, customPrompt }: CopyPromptButtonProps) {
const [showCopied, setShowCopied] = useState(false)
useEffect(() => {
if (!showCopied) return
const timer = setTimeout(() => setShowCopied(false), 2000)
return () => clearTimeout(timer)
}, [showCopied])
return (
<ButtonTooltip
variant="default"
icon={showCopied ? <Check strokeWidth={2} className="text-brand" /> : <Copy />}
onClick={() => {
const textToCopy = customPrompt ?? buildConnectPrompt(stepsContainerRef.current)
setShowCopied(true)
copyToClipboard(textToCopy)
}}
tooltip={{
content: {
side: 'left',
text: 'Copy these steps for your coding agent',
},
}}
>
{showCopied ? 'Copied' : 'Copy prompt'}
</ButtonTooltip>
)
}