From 28aab325fbb0ebc55eb43fbbf3106498bd22d13e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 24 Sep 2026 11:30:56 +1000 Subject: [PATCH 1/4] feat(dialogs): drag the right edge to resize note and session dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoteModal, WriteNoteModal, SessionModal and NewSessionModal each get a draggable right edge. Today's fixed width becomes the minimum, the maximum follows the window (32px of gutter per side), and the chosen width persists per dialog kind in `~/.staged/preferences.json` — so a user who never drags sees no change. Two shared pieces under the dialog primitive do the work: - `dialogWidth.svelte.ts` holds the width per preference key. Instances are cached at module level because every mount site wraps its dialog in `{#if …}`, and the note viewer and note editor deliberately share one key. `set()` clamps against the live window size and the inline style also carries a viewport-relative `max-width`, so shrinking the window only caps rendering and the dialog returns to its saved width when the window grows back. - `dialog-resize-handle.svelte` is an 8px right-edge button using pointer capture. The dialog stays centred, so it applies twice the pointer delta to keep the right edge under the cursor. Arrow keys nudge by 16px, Home/End jump to the bounds, double-click resets, and it renders nothing on mobile, where dialogs are full-screen. NoteModal's chat split changes shape to suit: instead of swapping 700px for 1080px, the chat pane becomes a fixed 380px column added on top of the persisted note width. Toggling chat no longer changes how wide the note reads, and the grid's `1fr` note column can grow past the pane's old 390px cap without leaving a gap. Startup hydration runs from `initPreferences` after the UI unblock so the first dialog of an app run opens at the saved width rather than jumping to it. Verified with `pnpm run check`, `pnpm test` (1014 passing, including 13 new cases for the width store) and `prettier --check`. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../ui/dialog/dialog-resize-handle.svelte | 164 +++++++++++++++ .../ui/dialog/dialogWidth.svelte.ts | 140 +++++++++++++ .../components/ui/dialog/dialogWidth.test.ts | 192 ++++++++++++++++++ .../src/lib/components/ui/dialog/index.ts | 3 + .../src/lib/features/notes/NoteModal.svelte | 57 +++++- .../lib/features/notes/WriteNoteModal.svelte | 22 +- .../features/sessions/NewSessionModal.svelte | 20 +- .../lib/features/sessions/SessionModal.svelte | 20 +- .../features/settings/preferences.svelte.ts | 6 + 9 files changed, 611 insertions(+), 13 deletions(-) create mode 100644 apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte create mode 100644 apps/staged/src/lib/components/ui/dialog/dialogWidth.svelte.ts create mode 100644 apps/staged/src/lib/components/ui/dialog/dialogWidth.test.ts diff --git a/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte b/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte new file mode 100644 index 000000000..a1ee6a39b --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte @@ -0,0 +1,164 @@ + + + +{#if !viewport.isMobile} + +{/if} + + diff --git a/apps/staged/src/lib/components/ui/dialog/dialogWidth.svelte.ts b/apps/staged/src/lib/components/ui/dialog/dialogWidth.svelte.ts new file mode 100644 index 000000000..88a178f84 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogWidth.svelte.ts @@ -0,0 +1,140 @@ +/** + * Persisted, resizable widths for the dialogs that carry a drag handle. + * + * Every mount site wraps its dialog in `{#if …}`, so the chosen width cannot + * live in component scope — instances are cached per preference key at module + * level and survive remounts. The default equals the minimum, which is the + * width the dialog had before it became resizable, so a user who never drags + * sees no change. + * + * The maximum follows the window: `set()` clamps against the live window size, + * and the inline style also carries a viewport-relative `max-width` so a later + * window shrink only caps rendering. The saved preference is untouched, and the + * dialog returns to it when the window grows again. + */ + +import { getStoreValue, setStoreValue } from '../../../shared/persistentStore'; + +/** Breathing room kept between the dialog and each window edge. */ +export const DIALOG_VIEWPORT_GUTTER = 32; + +export const NOTE_DIALOG_WIDTH_KEY = 'note-dialog-width'; +export const SESSION_DIALOG_WIDTH_KEY = 'session-dialog-width'; +export const NEW_SESSION_DIALOG_WIDTH_KEY = 'new-session-dialog-width'; + +export const NOTE_DIALOG_MIN_WIDTH = 700; +export const SESSION_DIALOG_MIN_WIDTH = 700; +export const NEW_SESSION_DIALOG_MIN_WIDTH = 580; + +/** + * Widest the dialog may be drawn right now. Never below `minWidth`: when the + * window is narrower than the minimum, the CSS `max-width` caps rendering and + * the stored width stays put, matching the pre-resize behaviour. + */ +export function dialogMaxWidth(minWidth: number): number { + if (typeof window === 'undefined') return minWidth; + return Math.max(minWidth, window.innerWidth - DIALOG_VIEWPORT_GUTTER * 2); +} + +/** Inline style for a dialog of `width` px, capped to the viewport. */ +export function dialogWidthStyle(width: number): string { + return `width:${width}px;max-width:calc(100vw - ${DIALOG_VIEWPORT_GUTTER * 2}px);`; +} + +export interface DialogWidth { + readonly key: string; + readonly minWidth: number; + /** Stored width in px, clamped to `[minWidth, dialogMaxWidth(minWidth)]`. */ + readonly width: number; + readonly hydrated: boolean; + readonly maxWidth: number; + /** `width` as an inline style, ready for `Dialog.Content`'s `style` prop. */ + readonly style: string; + /** Clamp and apply a new width, persisting it unless `persist` is false. */ + set(width: number, persist?: boolean): void; + /** Return to the default (the minimum) and persist that. */ + reset(): void; + /** Read the saved width once per app run; safe to call on every mount. */ + ensureHydrated(): Promise; +} + +const instances = new Map(); + +/** + * Reactive width for the dialog stored under `key`. Repeat calls with the same + * key return the same instance, so a dialog that remounts (or two dialogs that + * deliberately share a width, like the note viewer and note editor) stay in + * sync. + */ +export function createDialogWidth(options: { key: string; minWidth: number }): DialogWidth { + const { key, minWidth } = options; + + const cached = instances.get(key); + if (cached) return cached; + + const inner = $state({ width: minWidth, hydrated: false }); + let hydration: Promise | null = null; + + function clamp(width: number): number { + if (!Number.isFinite(width)) return inner.width; + return Math.max(minWidth, Math.min(dialogMaxWidth(minWidth), Math.round(width))); + } + + async function hydrate(): Promise { + const saved = await getStoreValue(key); + if (typeof saved === 'number' && Number.isFinite(saved)) { + inner.width = clamp(saved); + } + inner.hydrated = true; + } + + const instance: DialogWidth = { + key, + minWidth, + get width() { + return inner.width; + }, + get hydrated() { + return inner.hydrated; + }, + get maxWidth() { + return dialogMaxWidth(minWidth); + }, + get style() { + return dialogWidthStyle(inner.width); + }, + set(width: number, persist = true) { + const clamped = clamp(width); + if (inner.width !== clamped) { + inner.width = clamped; + } + if (persist) { + void setStoreValue(key, clamped); + } + }, + reset() { + instance.set(minWidth); + }, + ensureHydrated() { + hydration ??= hydrate(); + return hydration; + }, + }; + + instances.set(key, instance); + return instance; +} + +/** + * Read every saved dialog width at startup so the first open of an app run + * renders at the user's width instead of jumping there from the minimum. + */ +export async function hydrateDialogWidths(): Promise { + await Promise.all( + [ + { key: NOTE_DIALOG_WIDTH_KEY, minWidth: NOTE_DIALOG_MIN_WIDTH }, + { key: SESSION_DIALOG_WIDTH_KEY, minWidth: SESSION_DIALOG_MIN_WIDTH }, + { key: NEW_SESSION_DIALOG_WIDTH_KEY, minWidth: NEW_SESSION_DIALOG_MIN_WIDTH }, + ].map((options) => createDialogWidth(options).ensureHydrated()) + ); +} diff --git a/apps/staged/src/lib/components/ui/dialog/dialogWidth.test.ts b/apps/staged/src/lib/components/ui/dialog/dialogWidth.test.ts new file mode 100644 index 000000000..b76721d35 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogWidth.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mock plumbing ── + +let getStoreValue: ReturnType; +let setStoreValue: ReturnType; +let savedWidth: unknown; + +async function importDialogWidth() { + return await import('./dialogWidth.svelte'); +} + +beforeEach(() => { + vi.resetModules(); + // Runes compile away in the app build; under vitest they stay plain global + // calls, so stub $state as identity (navigation.test.ts precedent). + vi.stubGlobal('$state', (initial: unknown) => initial); + // The module reads `window.innerWidth` for its maximum; tests run in node. + vi.stubGlobal('window', { innerWidth: 1200 }); + + savedWidth = undefined; + getStoreValue = vi.fn().mockImplementation(() => Promise.resolve(savedWidth)); + setStoreValue = vi.fn().mockResolvedValue(undefined); + + vi.doMock('../../../shared/persistentStore', () => ({ getStoreValue, setStoreValue })); +}); + +afterEach(() => { + vi.doUnmock('../../../shared/persistentStore'); + vi.unstubAllGlobals(); +}); + +describe('createDialogWidth', () => { + it('starts at the minimum so an undragged dialog keeps its old width', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + + expect(width.width).toBe(700); + expect(width.hydrated).toBe(false); + expect(width.style).toBe('width:700px;max-width:calc(100vw - 64px);'); + }); + + it('clamps below the minimum up to the minimum', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(200); + + expect(width.width).toBe(700); + }); + + it('clamps above the window maximum down to a gutter on each side', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(5000); + + // 1200 window − 32px gutter per side. + expect(width.width).toBe(1136); + expect(width.maxWidth).toBe(1136); + }); + + it('keeps the minimum when the window is narrower than it', async () => { + vi.stubGlobal('window', { innerWidth: 500 }); + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(5000); + + expect(width.maxWidth).toBe(700); + expect(width.width).toBe(700); + }); + + it('hydrates from the store, clamping the saved value', async () => { + savedWidth = 5000; + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await width.ensureHydrated(); + + expect(getStoreValue).toHaveBeenCalledWith('test-width'); + expect(width.width).toBe(1136); + expect(width.hydrated).toBe(true); + }); + + it('leaves the default in place when nothing is saved, and hydrates once', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await Promise.all([width.ensureHydrated(), width.ensureHydrated()]); + await width.ensureHydrated(); + + expect(width.width).toBe(700); + expect(width.hydrated).toBe(true); + expect(getStoreValue).toHaveBeenCalledTimes(1); + }); + + it('persists only when asked, so a drag writes once on release', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(800, false); + width.set(900, false); + + expect(width.width).toBe(900); + expect(setStoreValue).not.toHaveBeenCalled(); + + width.set(900); + + expect(setStoreValue).toHaveBeenCalledExactlyOnceWith('test-width', 900); + }); + + it('persists the clamped width, not the requested one', async () => { + const { createDialogWidth } = await importDialogWidth(); + + createDialogWidth({ key: 'test-width', minWidth: 700 }).set(100); + + expect(setStoreValue).toHaveBeenCalledWith('test-width', 700); + }); + + it('resets to the default and persists that', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(900); + width.reset(); + + expect(width.width).toBe(700); + expect(setStoreValue).toHaveBeenLastCalledWith('test-width', 700); + }); + + it('shares one instance per key so remounts keep the width', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const first = createDialogWidth({ key: 'test-width', minWidth: 700 }); + first.set(900); + const second = createDialogWidth({ key: 'test-width', minWidth: 700 }); + + expect(second).toBe(first); + expect(second.width).toBe(900); + }); + + it('keeps separate keys independent', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const note = createDialogWidth({ key: 'note-width', minWidth: 700 }); + const newSession = createDialogWidth({ key: 'new-session-width', minWidth: 580 }); + note.set(900); + + expect(newSession.width).toBe(580); + }); + + it('ignores a non-finite width', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(900, false); + width.set(Number.NaN); + + expect(width.width).toBe(900); + }); +}); + +describe('hydrateDialogWidths', () => { + it('hydrates every known dialog key', async () => { + savedWidth = 900; + const { + hydrateDialogWidths, + createDialogWidth, + NOTE_DIALOG_WIDTH_KEY, + NOTE_DIALOG_MIN_WIDTH, + SESSION_DIALOG_WIDTH_KEY, + NEW_SESSION_DIALOG_WIDTH_KEY, + } = await importDialogWidth(); + + await hydrateDialogWidths(); + + expect(getStoreValue.mock.calls.map(([key]) => key)).toEqual([ + NOTE_DIALOG_WIDTH_KEY, + SESSION_DIALOG_WIDTH_KEY, + NEW_SESSION_DIALOG_WIDTH_KEY, + ]); + // The instance a dialog creates on mount is the one already hydrated. + const note = createDialogWidth({ + key: NOTE_DIALOG_WIDTH_KEY, + minWidth: NOTE_DIALOG_MIN_WIDTH, + }); + expect(note.width).toBe(900); + expect(note.hydrated).toBe(true); + }); +}); diff --git a/apps/staged/src/lib/components/ui/dialog/index.ts b/apps/staged/src/lib/components/ui/dialog/index.ts index d2dd4a373..aab112cb1 100644 --- a/apps/staged/src/lib/components/ui/dialog/index.ts +++ b/apps/staged/src/lib/components/ui/dialog/index.ts @@ -8,6 +8,7 @@ import Content from './dialog-content.svelte'; import Description from './dialog-description.svelte'; import Trigger from './dialog-trigger.svelte'; import Close from './dialog-close.svelte'; +import ResizeHandle from './dialog-resize-handle.svelte'; export { Root, @@ -20,6 +21,7 @@ export { Content, Description, Close, + ResizeHandle, // Root as Dialog, Title as DialogTitle, @@ -31,4 +33,5 @@ export { Content as DialogContent, Description as DialogDescription, Close as DialogClose, + ResizeHandle as DialogResizeHandle, }; diff --git a/apps/staged/src/lib/features/notes/NoteModal.svelte b/apps/staged/src/lib/features/notes/NoteModal.svelte index 6be0e4ff9..0e3b00e58 100644 --- a/apps/staged/src/lib/features/notes/NoteModal.svelte +++ b/apps/staged/src/lib/features/notes/NoteModal.svelte @@ -20,6 +20,12 @@ import PanelRightClose from '@lucide/svelte/icons/panel-right-close'; import PanelRightOpen from '@lucide/svelte/icons/panel-right-open'; import * as Dialog from '$lib/components/ui/dialog'; + import { + createDialogWidth, + dialogWidthStyle, + NOTE_DIALOG_MIN_WIDTH, + NOTE_DIALOG_WIDTH_KEY, + } from '$lib/components/ui/dialog/dialogWidth.svelte'; import { Button } from '$lib/components/ui/button'; import { countAssistantMessagesAfter, @@ -52,6 +58,13 @@ import ReferenceNavControls from '../references/ReferenceNavControls.svelte'; import type { HashtagClickInfo, ReferenceNavState } from '../references/referenceHistory.svelte'; + /** + * Width the chat column takes when the split is open, added on top of the + * persisted note width so opening chat never narrows the note. Must match the + * second grid column in `.split-chat-open` below. + */ + const CHAT_PANE_WIDTH = 380; + interface Props { open: boolean; title: string; @@ -127,6 +140,21 @@ let noteMarkdown = $derived(noteMarkdownWithTitle(displayTitle, displayContent)); let splitChatOpen = $derived(chatOpen && viewport.canSplit && hasNoteContent); let chatOnly = $derived(chatOpen && (!viewport.canSplit || !hasNoteContent)); + + // Only the note column's width is persisted — shared with WriteNoteModal, + // since editing opens from here. + const dialogWidth = createDialogWidth({ + key: NOTE_DIALOG_WIDTH_KEY, + minWidth: NOTE_DIALOG_MIN_WIDTH, + }); + void dialogWidth.ensureHydrated(); + let resizing = $state(false); + + function handleWidthChange(total: number, commit: boolean) { + resizing = !commit; + dialogWidth.set(total - (splitChatOpen ? CHAT_PANE_WIDTH : 0), commit); + } + let noteSearchAvailable = $derived(hasNoteContent && !chatOnly); let chatToggleLabel = $derived( chatOpen @@ -146,9 +174,15 @@ ? 'Show chat pane' : 'View chat pane' ); + // The transition animates the chat pane sliding in and out; it is dropped + // mid-drag so the right edge tracks the pointer instead of lagging behind it. let contentClass = $derived( - `h-[80vh] max-h-[900px] p-0 gap-0 overflow-hidden flex flex-col transition-[max-width] duration-150 ${splitChatOpen ? 'sm:max-w-[1080px]' : 'sm:max-w-[700px]'}` + `h-[80vh] max-h-[900px] p-0 gap-0 overflow-hidden flex flex-col${ + resizing ? '' : ' transition-[width] duration-150' + }` ); + let totalWidth = $derived(dialogWidth.width + (splitChatOpen ? CHAT_PANE_WIDTH : 0)); + let totalMinWidth = $derived(dialogWidth.minWidth + (splitChatOpen ? CHAT_PANE_WIDTH : 0)); let noteInfo = $derived( displayNoteId ? { @@ -557,6 +591,7 @@ > e.preventDefault()} > @@ -695,6 +730,12 @@ {/if} + dialogWidth.reset()} + /> @@ -713,10 +754,14 @@ min-width: 0; } + /* Fixed chat column: the dialog's width is the note width plus + CHAT_PANE_WIDTH, so the note column keeps its width when chat opens and + absorbs everything a drag adds. Keep the second track in sync with + CHAT_PANE_WIDTH in the script above. */ .note-modal-header-grid.split-chat-open, .modal-body.split-chat-open { display: grid; - grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); + grid-template-columns: minmax(0, 1fr) 380px; } .note-header-pane { @@ -737,8 +782,6 @@ align-items: center; justify-content: flex-end; gap: 4px; - min-width: 340px; - max-width: 390px; flex: 1 1 0; padding: 12px; border-left: 1px solid var(--border-subtle); @@ -809,14 +852,8 @@ background: var(--bg-primary); } - .split-chat-open .chat-pane { - min-width: 340px; - max-width: 390px; - } - .chat-only .chat-pane { border-left: none; - max-width: none; } .modal-content { diff --git a/apps/staged/src/lib/features/notes/WriteNoteModal.svelte b/apps/staged/src/lib/features/notes/WriteNoteModal.svelte index d8d73f705..a6292cb12 100644 --- a/apps/staged/src/lib/features/notes/WriteNoteModal.svelte +++ b/apps/staged/src/lib/features/notes/WriteNoteModal.svelte @@ -13,6 +13,11 @@ import X from '@lucide/svelte/icons/x'; import PencilLine from '@lucide/svelte/icons/pencil-line'; import * as Dialog from '$lib/components/ui/dialog'; + import { + createDialogWidth, + NOTE_DIALOG_MIN_WIDTH, + NOTE_DIALOG_WIDTH_KEY, + } from '$lib/components/ui/dialog/dialogWidth.svelte'; import { Button } from '$lib/components/ui/button'; import Spinner from '../../shared/Spinner.svelte'; import { viewport } from '../../shared/viewport.svelte'; @@ -35,6 +40,14 @@ let saving = $state(false); let error = $state(null); + // Shared with NoteModal: editing opens from the viewer, so the two should be + // the same width. + const dialogWidth = createDialogWidth({ + key: NOTE_DIALOG_WIDTH_KEY, + minWidth: NOTE_DIALOG_MIN_WIDTH, + }); + void dialogWidth.ensureHydrated(); + let isEdit = $derived(!!note); // Keyed so the editor remounts (and re-seeds its document) when the dialog // opens on a different note rather than reusing the previous one's content. @@ -95,7 +108,8 @@ }} > e.preventDefault()} > @@ -160,6 +174,12 @@ Save + dialogWidth.set(next, commit)} + onReset={() => dialogWidth.reset()} + /> diff --git a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte index fdf5f7efe..d9c008aa2 100644 --- a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte @@ -40,6 +40,11 @@ import { buildBranchHashtagItems } from './hashtagItems'; import { foldSnippetsIntoPrompt, snippetLabel, type TextSnippet } from './sessionModalHelpers'; import * as Dialog from '$lib/components/ui/dialog'; + import { + createDialogWidth, + NEW_SESSION_DIALOG_MIN_WIDTH, + NEW_SESSION_DIALOG_WIDTH_KEY, + } from '$lib/components/ui/dialog/dialogWidth.svelte'; import { Button } from '$lib/components/ui/button'; import { subscribeDragDrop } from '../branches/dragDrop'; import { @@ -327,6 +332,12 @@ let dragOver = $state(false); let modalElement: HTMLElement | null = $state(null); + const dialogWidth = createDialogWidth({ + key: NEW_SESSION_DIALOG_WIDTH_KEY, + minWidth: NEW_SESSION_DIALOG_MIN_WIDTH, + }); + void dialogWidth.ensureHydrated(); + // Seed prompt and mode from props once; caller preserves draft across open/close. $effect(() => { if (!initialized) { @@ -524,7 +535,8 @@ !v && handleClose()}> @@ -667,6 +679,12 @@ + dialogWidth.set(next, commit)} + onReset={() => dialogWidth.reset()} + /> diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 12a1c8c90..9d0da3529 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -8,6 +8,11 @@ import { onDestroy } from 'svelte'; import X from '@lucide/svelte/icons/x'; import * as Dialog from '$lib/components/ui/dialog'; + import { + createDialogWidth, + SESSION_DIALOG_MIN_WIDTH, + SESSION_DIALOG_WIDTH_KEY, + } from '$lib/components/ui/dialog/dialogWidth.svelte'; import { Button } from '$lib/components/ui/button'; import InContentSearch from '../../shared/InContentSearch.svelte'; import { registerSearchShortcutTarget } from '../keyboard/searchTargets'; @@ -73,6 +78,12 @@ let currentMatchIndex = $state(0); let unregisterSearchTarget: (() => void) | null = null; + const dialogWidth = createDialogWidth({ + key: SESSION_DIALOG_WIDTH_KEY, + minWidth: SESSION_DIALOG_MIN_WIDTH, + }); + void dialogWidth.ensureHydrated(); + $effect(() => { if (!open) return; const unregister = registerSearchShortcutTarget({ @@ -122,7 +133,8 @@ !v && requestClose()}> e.preventDefault()} > @@ -188,6 +200,12 @@ currentMatchIndex = state.currentIndex; }} /> + dialogWidth.set(next, commit)} + onReset={() => dialogWidth.reset()} + /> diff --git a/apps/staged/src/lib/features/settings/preferences.svelte.ts b/apps/staged/src/lib/features/settings/preferences.svelte.ts index 792e0aa49..82e1d64e8 100644 --- a/apps/staged/src/lib/features/settings/preferences.svelte.ts +++ b/apps/staged/src/lib/features/settings/preferences.svelte.ts @@ -19,6 +19,7 @@ import { type ThemePreviewColors, } from '../diff/highlighter'; import { initPersistentStore, getStoreValue, setStoreValue } from '../../shared/persistentStore'; +import { hydrateDialogWidths } from '../../components/ui/dialog/dialogWidth.svelte'; import { createAdaptiveTheme, themeToVarMap, type ThemeGitColors } from '../../theme'; import { mergeAcpConfigPref, type AcpConfigPref, type AcpConfigPrefPatch } from './acpConfigPrefs'; import type { AcpConfigValueSelection } from '../../types'; @@ -280,6 +281,11 @@ export async function initPreferences(): Promise { // just lengthens the staged reveal on resume. Loading continues below. preferences.loaded = true; + // Saved dialog widths, so the first note/session dialog of the run opens at + // the user's width instead of jumping there from the minimum. Nothing waits + // on these — each dialog also hydrates on mount. + void hydrateDialogWidths(); + // Load diff theme (migrating from the legacy combined `syntax-theme` key). let savedDiffTheme = await getStoreValue(DIFF_THEME_STORE_KEY); if (!savedDiffTheme) { From f14450fc9961cb088783937f845f92a00993b734 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 24 Sep 2026 14:26:59 +1000 Subject: [PATCH 2/4] fix(dialogs): preserve preferred widths through resize gestures Resolve the resizing review by measuring rendered geometry for pointer and keyboard input, keeping previews separate from preferred widths, and persisting only changed gesture results. Cancel on pointer interruption, capture loss, unmount, or layout changes, including pointer-up racing a breakpoint change, while restoring body styles and NoteModal transition state. Add focusable separator semantics with live pixel values and effective bounds. Reserve the agreed 8px desktop inner gutter in note, editor, and session dialogs. Preserve existing defaults, mobile behavior, shared note/editor preference, and the fixed chat addition through temporary viewport compression. Keep hydration nonblocking, retain oversized preferences, ignore stale reads after interaction, and contain read/write failures. Add gesture and storage regressions. Validation: 1096 frontend tests pass across 81 files; static checks have zero errors or warnings; full frontend formatting checks pass. A temporary harness exercised the real four dialogs with mocked backend responses in headless Chrome and Playwright WebKit, including keyboard/focus/ARIA, cancellation, breakpoints, chat, shared widths, and reopen/reload. Scrollbar thumb dragging passed in Chrome; WebKit wheel scrolling and separation passed but native thumb dragging was unavailable. Native WKWebView, VoiceOver, and real on-disk persistence were not verified. Signed-off-by: Matt Toohey --- .../ui/dialog/dialog-resize-handle.svelte | 185 +++++++++++------- .../components/ui/dialog/dialogResize.test.ts | 149 ++++++++++++++ .../lib/components/ui/dialog/dialogResize.ts | 102 ++++++++++ .../ui/dialog/dialogWidth.svelte.ts | 74 +++---- .../components/ui/dialog/dialogWidth.test.ts | 108 +++++++++- .../src/lib/features/notes/NoteModal.svelte | 17 +- .../lib/features/notes/WriteNoteModal.svelte | 4 +- .../features/sessions/NewSessionModal.svelte | 2 +- .../lib/features/sessions/SessionModal.svelte | 4 +- .../features/settings/preferences.svelte.ts | 5 +- 10 files changed, 521 insertions(+), 129 deletions(-) create mode 100644 apps/staged/src/lib/components/ui/dialog/dialogResize.test.ts create mode 100644 apps/staged/src/lib/components/ui/dialog/dialogResize.ts diff --git a/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte b/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte index a1ee6a39b..00d6a2518 100644 --- a/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte +++ b/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte @@ -6,78 +6,123 @@ moves each edge by W/2 — the handle therefore applies twice the pointer delta to keep the right edge under the cursor. - Pointer capture on the button keeps move/up events coming to us even when the + Pointer capture on the separator keeps move/up events coming to us even when the cursor leaves the dialog, and gives us `pointercancel` for free. Dragging out over the overlay is safe: bits-ui only dismisses on a `pointerdown` that starts outside the content. --> + + {#if !viewport.isMobile} - + ondblclick={() => { + resize.cancel(); + onReset(); + }} + > {/if}