The size-4 sweep stripped four icons the sweep had no right to touch, and they
rendered at lucide's default 24px: the avatar's UserIcon and the theme
switcher's Monitor/Sun/Moon in the sidebar user menu.
The sweep assumed every listed container sizes its icons at any depth. Only
Button/Toggle/CommandItem do, via [&_svg]. SidebarMenuButton and DropdownMenuItem
use [&>svg], which reaches a DIRECT child only — and these four sit deeper, inside
an AvatarFallback and inside TabsTrigger, so nothing sized them.
Found by measuring rendered icon width across every route with the menus open,
not by re-reading the selectors: the avatar icon was the only one a closed-menu
pass could see. Verified after: no icon inside a button or menu row renders over
16px except EmptyMedia's, which is meant to be large.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remaining 20 rendered Loader2 spinners carried a deliberate size or colour, so
the earlier pass left them. They convert cleanly after all: Loader2 is a lucide
alias of LoaderCircle (`LoaderCircle as Loader2` in lucide-react's d.ts) and that
is exactly what Spinner variant="circle" renders, while Circle merges an incoming
className over its built-in animate-spin. So each site keeps its size/colour class
and only drops the now-redundant animate-spin. Every rendered spinner in the app is
the Spinner primitive now.
flow-status-icon and flow-task-status-icon keep Loader2: there it is a value in a
Record<StatusType, { icon: LucideIcon }> map, not a rendered spinner, and Spinner
is a variant wrapper rather than a LucideIcon — it does not fit that slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two mechanical cleanups over the same files.
<Loader2 className="size-4 animate-spin"> and <Loader2 className="animate-spin">
appeared 28 times across 20 files while Spinner variant="circle" — the same
LoaderCircleIcon with animate-spin already baked in — was the idiom elsewhere.
Loader2 spinners with a deliberate size (size-3/5/6/10/16) or a colour are left
alone: Spinner sets no size of its own and would fall back to lucide's 24px.
Separately, 51 icons carried className="size-4" inside a Button, DropdownMenuItem
or AppHeaderAction, all of which already force [&_svg]:size-4 on descendants, so
the class was a no-op. Only those 51 are stripped — the sites picked by walking
each icon's real JSX ancestors through the TypeScript AST, because an indentation
heuristic mistakes multi-line opening tags for the parent. The 40 icons with no
forcing ancestor (Alert, TabsTrigger, AppHeaderTitle, a resize handle) keep their
size-4: without it they would render at lucide's 24px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The remove X was a raw button. It is a ghost Button now, sized to the chip's own
26px so the whole chip height is a hit target instead of the bare 14px glyph.
Negative margins keep the geometry identical: -my-[5px] and -ml-1 -mr-1.5 shrink
the margin box back to the 14px footprint the raw icon had, and [&_svg]:size-3.5
holds the X at 14px against Button's [&_svg]:size-4 descendant rule, which would
otherwise win on specificity. Measured against the old markup on the real
stylesheet: chip 26px and 107.3px wide, X 14px, X 21px from the chip's right edge
— all unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Test affordance in each agent accordion header was a raw span reimplementing
outline-button chrome by hand (rounded border px-2 py-1 + hover:bg-accent + a
manual disabled style). It now composes Button asChild variant="outline" size="xs"
over that span, so it inherits the same outline chrome as every other action
button.
It stays a span, not a button: AccordionTrigger already renders a button and a
button-in-button is invalid HTML — verified live, zero nested buttons in the DOM.
The disabled styling stays hand-rolled because a span can't be :disabled, and the
onClick keeps its stopPropagation so clicking Test still doesn't toggle the row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The four detail-page saves rendered two ways: knowledge as AppHeaderAction (a
primary CTA that collapses to an icon on mobile), the other three as a secondary
FormSubmitButton. FormSubmitButton buys nothing here — all three sit in the
header, outside the form's FormProvider, so its useFormContext read returns null
and they already submit via form= + a manual loading prop. Its real value is the
in-<Form> subscription, which its 8 dialog/auth consumers keep using untouched.
AppHeaderAction gains an optional loading (icon->spinner + disable); the three
header saves become AppHeaderAction like knowledge, so all four are now the same
primary CTA that collapses to an icon-only button (aria-label preserved) below
md. type="submit" is explicit because Button defaults type to "button". Verified
at 480px: label hidden, 32px icon button, aria-label "Create"; at 1440: primary
fill, label shown.
Researched the alternatives first: React 19 useFormStatus can't read a
form=-associated button (react.dev; facebook/react#27980) and targets native
form actions not RHF; and a Radix Slot asChild compose throws on FormSubmitButton's
two-child array and can't inject the spinner past child-wins prop merging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Template title was the only single-line field with neither a FormLabel nor a
FormMessage, so "Title is required" could never surface and the field was
unlabeled while knowledge/provider label theirs. It now has both — verified live:
submitting empty shows "Title is required".
Also: descriptive placeholders on the template title and editor (matching the
sentence-style placeholders elsewhere), the Rename dropdown icon to size-4 (its
siblings are size-4), and the new-flow intro to the shared block (h2 + gap-2
instead of h1 + mt-2) so every create/edit intro is identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The provider Type combobox rendered as a Button outline — opaque bg-background,
a ChevronsUpDown icon and font-medium text — while every select on the knowledge
form is a transparent-bg SelectTrigger with a single ChevronDown. Side by side
the Type field read as a different control.
The combobox trigger now carries bg-transparent, px-3, font-normal and a
ChevronDown size-4, matching SelectTrigger; the model combobox's addon chevron
follows suit. Measured against the canon at 1440: transparent bg, chevron-down
16px opacity .5, h-9, px-3, weight 400 — identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The variables and presets popovers scrolled the page once their own scroll hit
the end — overscroll-behavior defaulted to auto and a non-modal popover doesn't
lock the body. overscroll-contain keeps the momentum inside. Reproduced at
375x640: wheeling past the popover's end moved the body 0 -> 204px; with contain
it stays at 0.
The prompt variables card now shows the variable count on desktop too, matching
the preset-count badge the template card already carries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Below the split the presets card was a 599px block that sat under the fold, past
the editor — on the new-template form the editor is empty and the presets are how
you fill it, so the primary tool was hidden behind a scroll.
The panel now mirrors the prompt variables one: on desktop it stays the card in
the split; below 1280 its header becomes a full-width secondary trigger with the
preset count, opening a non-modal popover that holds the same preset list. The
list is shared by both wrappers; only the wrapper differs. In the stack the
trigger takes the presets' desktop slot — after the title, before the editor.
Applying a preset from the popover closes it (the popover is controlled), then
either fills the form or raises the existing replace-confirm dialog when the form
already has content — verified live at 390: fill, replace-confirm, and expanding a
preset preview inside the popover all work, and the desktop split is unchanged.
presetsList drops its useMemo to take an onApplied callback; typing does not
re-render the form (FormField isolates it), so the 11 collapsibles are not on the
keystroke path — confirmed by zero slow input events while typing on desktop with
all of them mounted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Below the split, preventing the popover's open-autofocus left focus on the
trigger while the content sat in a body portal, so the first Tab landed in the
editor and the second dismissed the popover: the chips could not be reached by
keyboard at all. Before the popover existed they were inline tabbable badges, so
this was a WCAG 2.1.1 regression.
Both autofocus handlers are gone. They were written to protect the editor caret,
but ProseMirror keeps its selection across DOM blur, so they were never needed —
verified live at 390: opening now focuses the first chip, Escape returns focus to
the trigger, and clicking a variable still inserts at the caret the user left,
with the editor focused and the popover closed.
Also stop the shared chip cloud painting bg-background over the popover: dark
--background matches the page behind it, which flattened the popover to a
borderline. The two-tone well is the desktop card's business, so the fill moved
onto its wrapper, where it still contrasts bg-card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`pnpm run prettier` is a required CI step and it exits 1 on these five files at
HEAD while passing on them at 001c6c0, so the branch is currently red. Formatting
only — no logic touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The badge default height is 22px — text-xs' 16px line box, py-0.5 and a 1px
border — which crowds the 32px trigger. h-5 pins it to 20px; border-box leaves
18px of content for the 16px line box, so nothing clips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The count badge sat on a bg-secondary button, where every neutral surface token
is within 1.08–1.23 contrast of it: secondary painted the pill invisible and
outline drew its border in --border, at 1.18. Only --primary separates (2.76), so
the badge is default.
The tab overrides exist because the desktop panel sits on its own surface. Below
the split they fought the shadcn defaults, so they are xl:-scoped now — the same
1280 the layout branches on. Stacked widths get bg-muted with a bg-background
active tab again; 1280+ keeps bg-background with a bg-card active tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trigger is a compact secondary button carrying just its label and, on the
right, the variable count — same count-badge shape the sheet and template
headers already use.
The badge is outline, not secondary: a secondary badge paints bg-secondary onto
a bg-secondary button, which renders the count as bare text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The header and its hint were rendered twice below the split breakpoint: once as
the trigger label and again at the top of the popover it opened.
The header belongs to the wrapper, not to the shared part. On desktop it stays
the card header; on narrow widths it becomes the trigger, and the popover now
carries only the chips. Title and hint move to constants so the two wrappers
cannot drift apart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three follow-ups to the variables palette.
The chips composed badgeVariants onto a raw <button>, going around the primitive,
because Badge renders a div. Badge now accepts asChild through the same Slot
switch button.tsx already uses, so a chip is <Badge asChild><button>: the badge
keeps owning the styling, the button keeps native semantics, and the hand-rolled
role/tabIndex/Enter-Space handler stays gone. className has to stay on Badge —
Slot concatenates classes, so only cn() resolves font-normal against
badgeVariants' font-semibold.
Variables reads useBreakpoint itself rather than taking isDesktop from the page.
Every other consumer here does that, sidebar.tsx included, and it keeps the panel
droppable into the next page that needs one.
The narrow-width trigger is a full-width labelled button now. It carries no
chevron and no count badge: those, not the width, are what make a control read as
a value select.
Also size the popover by --radix-popover-trigger-width, the way autocomplete.tsx
does. It was 100vw wide and spilled across the sidebar between 768 and 1280.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the stacked layout the "Available variables" cloud sat between the tabs and
the editor, pushing ~530px of chips ahead of the content it annotates. The panel
now keeps its slot and its desktop rendering, and below the split breakpoint the
same cloud moves behind a { } trigger, leaving the editor at the top.
The cloud itself is shared by both wrappers; only the wrapper differs (card vs
non-modal popover). The popover must not trap focus: insert and cycle act on the
editor's stored selection, and cycling only helps if its highlight stays visible.
Chips are real buttons now — they were divs with role=button and a hand-rolled
Enter/Space handler that the native element provides for free.
Verified live at 500px: opening keeps the caret, clicking a used variable selects
the next occurrence in view, the popover closes as the editor takes focus back,
and the desktop split is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On narrow viewports the editor was rendered between the prompt meta/tabs
and the "Available variables" panel, splitting one logical block in two and
forcing a different composition per breakpoint. Merge them into a single
panel block that the desktop split and the mobile stack both render as a
unit, so the variables stay attached to the tabs they belong to.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate every empty / loading / error / not-found / error-boundary
state off the bespoke StatusCard onto the canonical shadcn Empty (already
used by DataTable) and delete components/ui/status-card.tsx. Empty renders
borderless and centered, so the wide-viewport "letterbox" look is fixed at
the root instead of capping a solid card.
Also in this pass:
- DataTable: truncate column headers so plain-string headers in narrow
fixed-size columns (e.g. "System Prompt") stop wrapping to two lines.
- Settings > Providers / API Tokens: move the Create action into the page
header (icon-collapses on mobile) and the API-token Playground/Swagger
links into a header overflow menu; drop the cramped description row that
needed the earlier truncate/flex-wrap workarounds. Those links use a
relative href since the API base path is root-relative.
- Settings > Prompts: drop the fixed size on the Name column of both tables
so it flexes to fill (matching Providers) and truncates.
- Settings > Account: keep "Member since" on one line at mobile widths
(min-w-0 + truncate, badge shrink-0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Off option to the per-agent Reasoning Mode control, gated on the model's
cannotDisable capability so it appears only where a disable actually takes effect;
relax the adaptive-only lock for disablable models; disable effort/max-tokens when
Off. Reconcile a stale Off when the selected model can't disable (e.g. after typing
a custom model name) so it cannot silently persist. Map the off mode through the
form->GraphQL conversion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Container-listing polish on top of the partial-success work:
- A directory whose entries all fail to stat no longer 500s. The find exec
already proved the container alive, so ListContainerDir returns the readable
entries (possibly none) plus the per-entry failures, and the handler serves a
200 partial listing; only a cancelled request or a dir that can't be listed at
all still errors.
- Over the entry cap, list the first page and set a Truncated flag rather than
erroring with end-user copy from the docker layer. The flag flows through to
the Pull dialog, which now warns the user the directory was truncated instead
of silently showing a subset.
- The Pull dialog no longer flashes a skeleton on an in-place refetch (Refresh,
or after a Pull): the listing hook clears its rows only when the path actually
changes, so a same-path reload keeps the current rows under the loading guard.
Tests: an all-entries-failed listing returns 200 with the failures; the
Truncated flag surfaces in the response. Swagger regenerated for the new field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigating to a new directory in the Pull dialog kept showing the old files
and old "N entries could not be read" banner until the response landed (the
hook never cleared its data at fetch start), and when every entry failed the
per-entry messages were shown nowhere. Clear files/failures when a fetch
starts, and render the first few failure messages in the all-unreadable
empty state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The container file endpoint now returns HTTP 200 with the readable entries
plus a Failures list when some entries can't be stat'd, instead of failing
the whole request. Plumb those failures through useFlowContainerFiles and
show a non-blocking warning banner above the readable files ("N entries
could not be read"), reserving the full error empty-state for a true
listing failure. When nothing is readable, the empty-state says so rather
than "Directory is empty".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Export tokenNameSchema and pin the 100-character boundary (100 accepted, 101
rejected) plus its trim/default behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The create/edit form allowed 255 characters, but the backend caps token
names at 100 — a longer name cleared client validation and was rejected on
save. Lower the zod cap to 100 to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop comments that restate the code they sit on (public-route auth block,
app.tsx catch-all-route label, image-handle overlay description) per the
project's default-zero comment policy. Kept the load-bearing inline note that
password_change_required is local-users-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multi-drag "N items" drag image set `hsl(var(--primary))`, but the theme
tokens are OKLCH values — `hsl(oklch(...))` is invalid CSS and dropped, leaving
a transparent badge with default text. Reference the tokens directly via
`var(--primary)` / `var(--primary-foreground)` (theme-aware, valid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`updateMessage` only mutated a ref, so the visible status ("Authentication in
progress...") never changed in production — it appeared to work only in dev via
StrictMode's double-mounted useLayoutEffect. Call setStatusMessage directly and
drop the dead ref + layout-effect machinery. The OAuth flow itself was already
correct (close/redirect use locals); only the visible text was frozen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- main-sidebar.tsx: the three icon-only "New" quick-action links wrapped a bare
<Plus/> with no accessible name (WCAG 2.4.4/4.1.2) — add aria-label
"New flow"/"New template"/"New knowledge".
- editor toolbar heading/list/table-align menus: single-select options marked
the active one with only a visual <Check>. Add role="menuitemradio" +
aria-checked (mirroring the shipped Header-row menuitemcheckbox) so screen
readers announce which option is active. Visual affordance unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both provider components built their context `value` as a fresh object literal
each render (providers-provider also re-sorted into a new array and recreated
its setter), forcing every consumer to re-render on each provider render.
Memoize the sorted array, the setter, and both value objects — the two
non-memoized outliers among the app's context providers. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Subscribing to `formState.isValid` via useFormState flips RHF's internal
`_proxyFormState.isValid`, which makes the zod resolver re-run over the WHOLE
form (13 agent accordions × ~19 fields + refines) on every keystroke — even
in onSubmit mode. `isValid` is only needed to gate the unsaved-changes dialog,
so validate lazily when that dialog opens instead of subscribing. Live INP on
the Name field: steady-state per-keystroke processing 152ms worst-case → ~5ms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vite.config.ts: drop the manualChunks 'monaco' branch — monaco was removed
in the monaco→tiptap migration (82b82c5), so the regex never matches.
- use-file-manager-selection.ts: remove `setSelection` — no caller destructures
it (file-manager.tsx never did); all writes go through rawSelectedPaths.
- settings-prompt.tsx: remove the unreachable `isNew` (promptId === 'new')
branch — nothing routes to /settings/prompts/new, and it would render a
"Create Prompt" header over a "Prompt not found" card. The real create path
is `isUpdate === false`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rich editor is a contenteditable div with role="textbox", which is
NOT a labelable element — a sibling `<FormLabel htmlFor>` can't name it,
and two of the three consumers (settings-prompt, template) render no
visible label at all, so the field had no accessible name (Lighthouse
A5). Thread `aria-label` through MarkdownEditorField to the contenteditable
(via the same view.dom passthrough as aria-describedby/aria-invalid) and
to the raw textarea, with each consumer supplying its name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline "Create Token" row is a data row prepended at index 0, so an active
table filter (globalFilterFn) or a non-first page hid it — clicking Create Token
appeared to do nothing. handleCreateNew now calls setFilter(''), which clears the
filter and resets pageIndex to 0 (clearPageOnFilterChange default), so the create
row is always visible.
Live-verified (docker/HEAD backend): with a non-matching filter, Create Token now
shows the create row and clears the filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to a55cac3 / 2a3a553. A percentage minSize scales with the screen:
30% is ~700px on a 2560px display and ~1000px on an ultra-wide — far too large
a floor. Give both resizable splits (DetailSplitLayout + flow.tsx) a fixed 390px
per-panel minimum (numeric = px in react-resizable-panels v4). defaultSize stays
a percentage: flow 50%, DetailSplitLayout 45%/55%.
Verified live (chrome-devtools, dev server, real flow + settings-prompt):
- 1280px (narrowest desktop): group 1024px, both mins 390px — no collision
(2x390 < 1024), drag range 390..633px.
- 2560px: min stays 390px (16.9%), not 691px (30%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same react-resizable-panels v4 pixels-not-percent pitfall as detail-split-layout
(a55cac3): flow.tsx's desktop two-panel split passed minSize={30}/defaultSize={50}
as numbers, so the 30 was a 30px floor — a user could drag either the central-tabs
panel or the detail panel down to a ~30px sliver instead of the intended 30%.
Use string percentages: minSize "30%", defaultSize "50%".
Verified live (chrome-devtools @1440px, dev server on a real flow): separator
aria-valuemin=30, dragging a panel to its minimum floors at 29.97% (355px).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
react-resizable-panels v4 reads numeric size props as PIXELS, not percent
("Numeric values are assumed to be pixels"). So `minSize={30}` was a 30px
floor — a user could drag either panel down to a ~30px sliver (~2.5% on a
1184px group) instead of the intended 30%. `defaultSize={45/55}` were px too
(they only rendered ~45/55% because default sizes normalize to a ratio;
minSize is an absolute per-panel constraint and is not normalized).
Use string percentages so the constraints mean what they read: minSize "30%",
defaultSize "45%"/"55%".
Verified live (chrome-devtools @1440px): separator aria-valuemin 2.536 -> 30;
dragging a panel to its minimum now floors at 29.97% (355px) instead of 30px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-reviewed every frontend comment against the "names a concrete wrong action"
rubric. Cut pure restatements, change-narration, self-defense and bug-history;
trimmed mixed comments down to their load-bearing gotcha/contract; kept genuine
framework/API/security notes. Relocated a misplaced JSDoc in resources-provider
that sat on `error` but described `resources` (fields are alphabetically sorted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The JS "desktop" threshold was 1200px, which is no Tailwind breakpoint, so anything
mixing useBreakpoint with a CSS `md:`/`xl:` variant for the same decision could drift
in the 1024–1279 zone. Move the threshold to Tailwind `xl` (1280) — mobile already
sat on `md` (768) — so JS layout switches and CSS variants now share the same values.
With the breakpoint a real token, MarkdownEditorField drops useBreakpoint and expresses
its height in plain CSS: fixed `h-[calc(100dvh-5rem)]` below xl (stacked forms, internal
scroll), `xl:min-h-0 xl:flex-1` to fill its pane on the desktop split. No JS hook, no
comment. Note: the split view now appears at ≥1280 instead of ≥1200.
Verified <xl live (fixed 1194px, internal scroll); the ≥1280 branch resolves to the same
`min-h-0 flex-1` fill already measured at 1440px — this browser window caps at ~1141px so
the wide layout could not be re-observed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile/tablet branch used `min-h-[calc(100dvh-5rem)]`, only a floor — the
stacked forms have no flex parent to cap it, so a long document (a big agent
prompt is ~10k px of text) stretched the editor to its full content height and
the whole page grew with it. Make it a FIXED `h-[calc(100dvh-5rem)]` so the box
stays one viewport tall and the content scrolls inside it. Desktop (fills its
pane) is unchanged. Live-verified: the Adviser prompt editor is now 1194px with
an internal scrollbar instead of 10.6k px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile fixed height was knowledge-only; make it the field's job for all. The
field now derives its own height from useBreakpoint — the SAME hook the layouts
switch on — so it fills its flex parent on desktop and takes a near-viewport fixed
height on mobile/tablet, everywhere. Knowledge drops its last height className.
This also fixes the settings-prompt and template editors, which had no mobile
height and were cramped in their stacked mobile layouts. Live-verified all three
on desktop (fills) and mobile (min-h calc(100dvh-5rem)).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every consumer embedded MarkdownEditorField in a `flex min-h-0 flex-col` column and
repeated `min-h-0 flex-1` to make it fill. Bake that default into the field (applied
first so a consumer can still override the height), and drop the boilerplate:
settings-prompt and template pass no className now, and the knowledge field only
keeps its one deviation — a fixed `min-h-[calc(100dvh-5rem)]` for the mobile stack
where it isn't inside a flex box. Live-verified all four surfaces: rich editor fills
(flex-1, min-h 0) on prompt/template/knowledge-desktop, and knowledge-mobile keeps
the fixed 100dvh-5rem height.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prompt-validation dialog (max-w-2xl) and the provider-test-results dialog
(max-w-3xl) had the same latent bug as the Diff dialog: a bare max-w-* cannot
override DialogContent's default sm:max-w-lg at ≥sm, so both rendered at 512px
instead of 672/768px. Prefix with sm: so they reach their intended width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Diff dialog passed a bare `max-w-7xl`, but DialogContent's default already
sets `sm:max-w-lg`. tailwind-merge keeps both (different variants), and at ≥sm the
`sm:` rule wins the cascade — so the dialog was pinned at 512px and the split diff
overflowed with a horizontal scrollbar. Use `sm:max-w-7xl` so it overrides the
default at the same breakpoint, matching the app's other wide dialogs. Verified
live: the real Diff dialog now renders at 1280px with no horizontal scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames the shared two-pane detail layout to DetailSplitLayout (file
detail-split-layout.tsx) and its slots left/right/rightClassName to
panel/content/contentClassName, which name the actual roles — a form/meta panel
beside the main editor content — instead of bare position. Updates all four
consumers (knowledge form, template, settings prompt, settings provider).
Also drops a dead GripVertical child passed to <ResizableHandle withHandle /> (the
handle renders its own grip and ignores children) and gives the left panel a
bg-card fill so the gutters around the centered card match the card colour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds markdown-editor-heading-autoformat.test.ts (57 cases): levels, marker stripping,
negatives, hardBreak protection, every trigger path (delete/split/merge/insertText/
setContent/insertContent), the canReplaceWith container gate, multi-target ordering,
mark/variable/tag preservation, byte-fidelity interaction, and re-entrancy. The 6
ad-hoc Case B tests move here from the extensions suite.
Writing the coverage surfaced a soundness bug: promoting a MULTI-LINE paragraph whose
first line starts with `# ` (e.g. `# a`+Shift+Enter+`# b`, then deleting the lead-in)
emitted a heading containing a hardBreak, which re-parsed as TWO headings on the next
load. HeadingAutoformat now skips any paragraph that contains a hardBreak — a heading
is single-line — so such a block stays body text and escapeLineLeadingBlockMarkers
round-trips it. Single-line promotion is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>