Skip to content

Commit 4e94b55

Browse files
Quickbeardclaude
andcommitted
feat(ui): close dialogs from a corner button, not an outside click
A click anywhere in the dimmed area around a dialog dismissed it, taking whatever it held with it: a half-filled sandbox form, a provider mid-connect, a folder being picked. That came from two independent mechanisms, so both go — the overlay's own onClick in the dialog context, and Kobalte's dismissable layer, which closes on outside pointerdown regardless of the overlay and needs onInteractOutside prevented on the content. In exchange every dialog now has a close button. Both Dialog primitives render the Kobalte.CloseButton themselves, as the last child of their content and pinned to its top-right corner at the same coordinates the header button used to land on — so headed dialogs look unchanged, while header-less ones (settings, release notes, the command palettes) get one at all. Keeping it inside Kobalte.Content keeps it inside the focus trap and the modal's pointer-events layer. Headers reserve the corner in padding-right, and the containers take a min-height so a short fit dialog cannot clip the button. That let the hideClose opt-outs go from every call site that had one, and in v1 the button no longer competes with the `action` slot, whose branch used to replace it outright — manage-models had a "Connect provider" button and no way out. Two dialogs keep an exception: the configure gate, which the dialog stack also marks persistent so a button there would be inert, and the two search-first palettes, which reserve the corner rather than let the button sit on the input. Escape still closes everything it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e493f2d commit 4e94b55

24 files changed

Lines changed: 216 additions & 95 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,15 @@ already in use` and no way forward short of finding the file in a temporary
276276

277277
### Desktop / Web UI
278278

279+
- **Dialogs no longer close when you click beside them, and every one has a
280+
close button** — a stray click anywhere in the dimmed area around a dialog
281+
dismissed it instantly, discarding whatever it held: a half-filled sandbox
282+
form, a provider being connected, a folder being picked. Clicking outside now
283+
does nothing. In exchange every dialog carries an × in its top-right corner —
284+
the several that had been hiding theirs included — so there is always a
285+
visible way out; Escape still works everywhere it did. The one exception is
286+
the provider setup screen the app shows when no provider is configured, which
287+
has never been dismissable.
279288
- **The Work tabs stay out of the way until you use sandboxes** — every desktop
280289
install showed a **Code** / **Work** switch above the projects list, even
281290
though Work was empty for anyone who had never made a sandbox: switching to it

‎DIVERGENCES.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,9 @@ from the squash message first, or use a plain merge.
284284
| Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `<Show when={newSessionDesign()}>` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. |
285285

286286
| Work side is opt-in: `packages/app/src/context/settings.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es}.ts` | The Work side and the Code/Work tab strip render only when `workSideAvailable()` (fork-only `packages/app/src/workspace/side.ts`) says so: sandbox-capable platform AND (a sandbox exists OR the new `general.showWork` preference is on). settings.tsx gains `showWork` (default `false`) with its accessor/setter beside `showCustomAgents`; settings-v2's Advanced section gains a "Show Work" switch, wrapped in `<Show when={platform.sandboxes}>` so non-sandbox builds never offer it; i18n gains `settings.general.row.showWork.{title,description}`. The fork-only wiring is in `workspace/tabs.tsx`: a `useWorkAvailable()` hook feeds both `useWorkspaceSide()`'s `effectiveSide` call and `WorkspaceTabs`'s `<Show>` (which previously gated on `platform.sandboxes`), so the side and its strip can never disagree — a hidden strip with Work selected would strand the user on sandbox servers with no way back. An undefined sandbox list means "not loaded", not "none", and keeps Work (the same race the Code/Work split row calls out); the main process answers `sandbox-get-state` synchronously, so that window is sub-frame. Tests: `packages/app/test-browser/workspace-side.test.ts` covers the four `workSideAvailable` branches. | Sandboxes are a heavyweight opt-in feature (a VM per sandbox), but every desktop profile paid for them in the sidebar: a Code/Work strip with a Work side that had nothing in it and offered to build one. Hiding both until a sandbox exists makes the strip appear exactly when there are two sides worth picking between; the preference is the discoverable way in for someone who wants Work before creating anything, and Settings → Servers → + → Add sandbox is the other. Owning a sandbox shows Work whatever the preference says. | Keep the `workSideAvailable` gate on both `useWorkspaceSide` and `WorkspaceTabs` — gating only one reintroduces the stranded-side bug. Take upstream's surrounding settings/i18n changes. If upstream adds its own sandbox-visibility preference, prefer theirs and drop `showWork`. Never collapse on an unloaded sandbox list. |
287+
| `packages/ui/src/context/dialog.tsx` (again) | The `Kobalte.Overlay` lost its `onClick`, so a backdrop click no longer closes the top dialog. Escape (the window `keydown` listener) and `onOpenChange` (the close button) are untouched, as is the `persistent` guard on both. | A stray click in the dimmed area around a dialog dismissed it and discarded whatever it held — a half-filled sandbox form, a provider mid-connect. Dismissal is now always deliberate: the corner close button or Escape. | Keep the overlay handler-less. If upstream adds behaviour to the overlay, take it minus the close. Pairs with the `onInteractOutside` guards on the three dialog contents below — removing the handler alone is not enough, Kobalte's own dismissable layer closes on outside pointerdown too. |
288+
| Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. |
289+
| Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. |
287290

288291
## Known rebrand gaps
289292

‎packages/app/src/components/dialog-command-palette-v2.css‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030

3131
.command-palette-v2-search {
3232
flex-shrink: 0;
33-
padding: 6px;
33+
/* Right padding keeps the search field clear of the dialog's corner close
34+
button, which floats over the content at 14px from the top and right. */
35+
padding: 6px 40px 6px 6px;
3436
}
3537

3638
.command-palette-v2-search [data-component="text-input-v2"] {

‎packages/app/src/components/dialog-connect-provider.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export const DialogConnectProvider: Component<{
121121
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
122122
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
123123
>
124-
<DialogHeader closeLabel={language.t("common.close")}>
124+
<DialogHeader>
125125
<Show
126126
when={controller.selected()}
127127
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}

‎packages/app/src/components/dialog-manage-models.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const DialogManageModelsV2: Component = () => {
155155

156156
return (
157157
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
158-
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
158+
<DialogHeader>
159159
<DialogTitleGroup
160160
title={language.t("dialog.model.manage")}
161161
description={language.t("dialog.model.manage.description")}

‎packages/app/src/components/dialog-select-file.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
104104
}
105105

106106
return (
107-
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
107+
<Dialog class="pt-3 pb-0 !max-h-[480px] [&_[data-slot=list-search-wrapper]]:pr-10" transition>
108108
<List
109109
class="px-3"
110110
search={{

‎packages/app/src/components/dialog-select-model-unpaid-v2.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
7171
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
7272
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
7373
>
74-
<DialogHeader closeLabel={language.t("common.close")}>
74+
<DialogHeader>
7575
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
7676
</DialogHeader>
7777
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">

‎packages/app/src/components/gateway/dialog-configure.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function DialogConfigure() {
173173
}
174174

175175
return (
176-
<Dialog class="h-full" transition>
176+
<Dialog class="h-full" transition hideClose>
177177
<div class="flex flex-col gap-6 px-5 py-4 w-[440px] max-w-full">
178178
<Switch>
179179
<Match when={step().kind === "menu"}>

‎packages/app/src/components/session-actions-menu.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function DialogDeleteSession(props: { title: string; onConfirm: () => unk
182182
if (settings.general.newLayoutDesigns())
183183
return (
184184
<DialogV2 fit>
185-
<DialogHeader hideClose>
185+
<DialogHeader>
186186
<DialogTitleGroup
187187
title={language.t("session.delete.title")}
188188
description={language.t("session.delete.confirm", { name: props.title })}

‎packages/app/src/components/settings-v2/dialog-server-v2.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const DialogServerV2: Component<{
5454

5555
return (
5656
<Dialog fit class="settings-v2-server-dialog">
57-
<DialogHeader hideClose={true}>
57+
<DialogHeader>
5858
<DialogTitle>{title()}</DialogTitle>
5959
</DialogHeader>
6060
<DividerV2 />

0 commit comments

Comments
 (0)