diff --git a/apps/staged/scripts/test-dialog-resize.mjs b/apps/staged/scripts/test-dialog-resize.mjs new file mode 100644 index 000000000..f9126fb64 --- /dev/null +++ b/apps/staged/scripts/test-dialog-resize.mjs @@ -0,0 +1,271 @@ +// Run from apps/staged: node scripts/test-dialog-resize.mjs +// Reuse the workspace's Playwright installation; no backend or real preferences are used. +// Install browsers with: pnpm --dir ../penpal/e2e exec playwright install chromium webkit +// Or point CHROMIUM_EXECUTABLE_PATH / WEBKIT_EXECUTABLE_PATH at existing browsers. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, it } from 'node:test'; +import { createServer } from 'vite'; + +const { chromium, webkit, expect } = createRequire( + new URL('../../penpal/e2e/package.json', import.meta.url) +)('@playwright/test'); +const root = fileURLToPath(new URL('../', import.meta.url)); +const key = 'note-dialog-width'; +let server; +let url; + +before(async () => { + server = await createServer({ + root, + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, strictPort: false, forwardConsole: false }, + plugins: [ + { + name: 'dialog-resize-probe', + resolveId(id) { + if (id === 'dialog-resize-probe') return '\0dialog-resize-probe'; + }, + load(id) { + if (id !== '\0dialog-resize-probe') return; + return ` + import '/src/app.css'; + import { mount } from 'svelte'; + import NoteModal from '/src/lib/features/notes/NoteModal.svelte'; + import { initPersistentStore } from '/src/lib/shared/persistentStore.ts'; + import { createDialogWidth } from '/src/lib/components/ui/dialog/dialogWidth.svelte.ts'; + await initPersistentStore(); + const width = createDialogWidth({ key: '${key}', minWidth: 700 }); + await width.ensureHydrated(); + if (new URLSearchParams(location.search).has('pause-entrance')) { + const style = document.createElement('style'); + style.textContent = '[data-slot="dialog-content"] { animation-play-state: paused !important; }'; + document.head.append(style); + } + mount(NoteModal, { target: document.body, props: { + open: true, title: 'Resize regression', content: 'A note with a chat pane.', + sessionId: 'resize-probe', onClose() {} + }}); + window.probe = { width }; + `; + }, + configureServer(vite) { + vite.middlewares.use('/__dialog-resize', (_request, response) => { + response.setHeader('Content-Type', 'text/html'); + response.end( + '' + ); + }); + }, + }, + ], + }); + await server.listen(); + url = `http://127.0.0.1:${server.httpServer.address().port}/__dialog-resize`; +}); + +after(async () => server?.close()); + +for (const [name, engine] of Object.entries({ chromium, webkit })) { + describe(name, { timeout: 120_000 }, () => { + let browser; + let context; + let page; + let stored; + let writes; + let holdNextSave; + let pendingSave; + let errors; + + before(async () => { + browser = await engine.launch({ + executablePath: process.env[`${name.toUpperCase()}_EXECUTABLE_PATH`], + }); + }); + after(async () => browser?.close()); + + async function open({ savedWidth = 700, pauseEntrance = false } = {}) { + await context?.close(); + context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }); + page = await context.newPage(); + stored = savedWidth; + writes = []; + holdNextSave = false; + pendingSave = null; + errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/api/invoke/*', async (route) => { + const command = new URL(route.request().url()).pathname.split('/').pop(); + const args = route.request().postDataJSON(); + if (command === 'set_preference') { + assert.equal(args.key, key); + writes.push(args.value); + if (holdNextSave) { + holdNextSave = false; + pendingSave = { route, value: args.value }; + return; + } + stored = args.value; + } + await route.fulfill({ + json: command === 'get_preference' ? stored : command === 'get_session' ? null : [], + }); + }); + // The event stream is unrelated to these layout and HTTP ordering probes. + await page.routeWebSocket('**/api/events*', () => {}); + await page.goto(pauseEntrance ? `${url}?pause-entrance` : url); + await page.locator('[data-slot="dialog-resize-handle"]').waitFor(); + if (pauseEntrance) { + const scaledWidth = await page.locator('[data-slot="dialog-content"]').evaluate((el) => { + const animation = el.getAnimations().find((item) => item.animationName === 'enter'); + if (!animation) throw new Error('Dialog entrance did not animate'); + animation.currentTime = 25; + return el.getBoundingClientRect().width; + }); + assert.ok(scaledWidth < savedWidth, `Expected entrance scale, got ${scaledWidth}`); + } else { + await page.evaluate(async () => { + await Promise.all(document.getAnimations().map((animation) => animation.finished)); + }); + } + await page.locator('[data-slot="dialog-resize-handle"]').focus(); + } + + async function expectWidth(width, preference = width) { + assert.equal( + await page.locator('[data-slot="dialog-content"]').evaluate((el) => el.offsetWidth), + width + ); + assert.equal(await page.evaluate(() => window.probe.width.width), preference); + assert.deepEqual(errors, []); + } + + async function toggleDuringAnimation(label) { + await page.getByRole('button', { name: label, exact: true }).click(); + const middle = await page.locator('[data-slot="dialog-content"]').evaluate((el) => { + const animation = el.getAnimations().find((item) => item.transitionProperty === 'width'); + if (!animation) throw new Error('Chat toggle did not animate width'); + animation.pause(); + animation.currentTime = 35; + return el.getBoundingClientRect().width; + }); + assert.ok(middle > 700 && middle < 1080, `Expected intermediate chat width, got ${middle}`); + await page.locator('[data-slot="dialog-resize-handle"]').focus(); + } + + it('resizes from the saved layout width during the entrance animation', async () => { + await open({ savedWidth: 1000, pauseEntrance: true }); + const box = await page.locator('[data-slot="dialog-resize-handle"]').boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await expectWidth(1000); + await page.mouse.move(x + 20, y); + await expectWidth(1040); + await page.mouse.up(); + await expect.poll(() => stored).toBe(1040); + assert.deepEqual(writes, [1040]); + }); + + it('applies keyboard steps to the layout width during the entrance animation', async () => { + await open({ savedWidth: 1000, pauseEntrance: true }); + await page.keyboard.press('ArrowRight'); + await expectWidth(1016); + await expect.poll(() => stored).toBe(1016); + assert.deepEqual(writes, [1016]); + }); + + it('keeps End at the maximum when ArrowRight follows 35ms later', async () => { + await open(); + await page.keyboard.press('End'); + // Deliberately reproduce a second command inside the old 150ms transition. + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 35))); + await page.keyboard.press('ArrowRight'); + await expectWidth(1536); + await expect.poll(() => stored).toBe(1536); + assert.deepEqual(writes, [1536]); + }); + + it('applies every rapid ArrowRight step without animation lag', async () => { + await open(); + await page.evaluate(async () => { + const handle = document.querySelector('[data-slot="dialog-resize-handle"]'); + for (let step = 0; step < 10; step++) { + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); + await expectWidth(860); + await expect.poll(() => stored).toBe(860); + assert.deepEqual( + writes, + Array.from({ length: 10 }, (_, step) => 716 + step * 16) + ); + await page.keyboard.press('ArrowLeft'); + await expectWidth(844); + }); + + it('preserves chat animation and settles it before a keyboard command', async () => { + await open(); + await toggleDuringAnimation('Show chat pane'); + await page.keyboard.press('ArrowRight'); + await expectWidth(1096, 716); + await page.keyboard.press('Home'); + await expectWidth(1080, 700); + await toggleDuringAnimation('Hide chat pane'); + await page.keyboard.press('ArrowRight'); + await expectWidth(716); + await page.locator('[data-slot="dialog-resize-handle"]').dblclick(); + await expectWidth(700); + await toggleDuringAnimation('Show chat pane'); + await page.keyboard.press('End'); + await expectWidth(1536, 1156); + }); + + it('settles chat animation before measuring a pointer gesture', async () => { + await open(); + await toggleDuringAnimation('Show chat pane'); + const box = await page.locator('[data-slot="dialog-resize-handle"]').boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 20, y); + await expectWidth(1120, 740); + await page.mouse.up(); + await expect.poll(() => stored).toBe(740); + }); + + it('waits for a delayed resize save before persisting a reset, including reload', async () => { + await open(); + holdNextSave = true; + await page.keyboard.press('End'); + await expect.poll(() => pendingSave?.value).toBe(1536); + await page.locator('[data-slot="dialog-resize-handle"]').dblclick(); + await expectWidth(700); + assert.deepEqual(writes, [1536]); + stored = pendingSave.value; + await pendingSave.route.fulfill({ json: null }); + await expect.poll(() => stored).toBe(700); + assert.deepEqual(writes, [1536, 700]); + await page.reload(); + await page.locator('[data-slot="dialog-resize-handle"]').waitFor(); + await expectWidth(700); + }); + + it('persists the next choice after an HTTP save fails', async () => { + await open(); + holdNextSave = true; + await page.keyboard.press('ArrowRight'); + await expect.poll(() => pendingSave?.value).toBe(716); + await page.keyboard.press('ArrowRight'); + await expectWidth(732); + assert.deepEqual(writes, [716]); + await pendingSave.route.fulfill({ status: 500, json: { error: 'Expected save failure' } }); + await expect.poll(() => stored).toBe(732); + assert.deepEqual(writes, [716, 732]); + }); + }); +} 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..4c0dd68f8 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialog-resize-handle.svelte @@ -0,0 +1,228 @@ + + + + + +{#if !viewport.isMobile} + + + +{/if} + + diff --git a/apps/staged/src/lib/components/ui/dialog/dialogResize.test.ts b/apps/staged/src/lib/components/ui/dialog/dialogResize.test.ts new file mode 100644 index 000000000..bb0ec4ee5 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogResize.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDialogResize, resizeBounds, type ResizeGeometry } from './dialogResize'; + +function setup(initial: ResizeGeometry = { width: 1136, min: 700, max: 1136 }) { + let geometry = initial; + const preview = vi.fn(); + const commit = vi.fn(); + const end = vi.fn(); + const resize = createDialogResize({ geometry: () => geometry, preview, commit, end }); + return { resize, preview, commit, end, layout: (next: ResizeGeometry) => (geometry = next) }; +} + +const pointer = (clientX: number, pointerId = 1) => ({ clientX, pointerId }); + +describe('dialog resize gestures', () => { + it('starts at the rendered cap and tracks a centered right edge immediately', () => { + const { resize, preview, commit, end } = setup(); + resize.start(pointer(1168)); + resize.move(pointer(1158)); + expect(preview).toHaveBeenLastCalledWith(1116); + resize.release(pointer(1158)); + resize.release(pointer(1158)); + expect(commit).toHaveBeenCalledExactlyOnceWith(1116); + expect(end).toHaveBeenCalledTimes(1); + }); + + it.each(['click', 'vertical', 'bound', 'return'])( + 'preserves a capped preference after %s', + (kind) => { + const { resize, commit, end } = setup(); + resize.start(pointer(100)); + if (kind === 'return') resize.move(pointer(90)); + const finalX = kind === 'bound' ? 200 : 100; + resize.move(pointer(finalX)); + resize.release(pointer(finalX)); + expect(commit).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledTimes(1); + } + ); + + it('commits the final pointer position even without a final move event', () => { + const { resize, commit } = setup(); + resize.start(pointer(100)); + resize.release(pointer(90)); + expect(commit).toHaveBeenCalledExactlyOnceWith(1116); + }); + + it('ignores secondary pointers and an unrelated cancellation', () => { + const { resize, preview, commit, end } = setup(); + resize.start(pointer(100)); + expect(resize.start(pointer(100, 2))).toBe(false); + resize.move(pointer(50, 2)); + resize.release(pointer(50, 2)); + resize.cancel(2); + expect(preview).toHaveBeenCalledTimes(1); + expect(commit).not.toHaveBeenCalled(); + expect(end).not.toHaveBeenCalled(); + resize.move(pointer(90)); + resize.release(pointer(90)); + expect(commit).toHaveBeenCalledExactlyOnceWith(1116); + }); + + it.each([1, undefined])('discards interrupted previews once (pointer=%s)', (id) => { + const { resize, commit, end } = setup(); + resize.start(pointer(100)); + resize.move(pointer(80)); + resize.cancel(id); + resize.cancel(id); + resize.release(pointer(80)); + expect(commit).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledTimes(1); + }); + + it('clamps drag previews to both bounds', () => { + const { resize, preview, commit } = setup(); + resize.start(pointer(100)); + resize.move(pointer(-1000)); + expect(preview).toHaveBeenLastCalledWith(700); + resize.release(pointer(2000)); + expect(preview).toHaveBeenLastCalledWith(1136); + expect(commit).not.toHaveBeenCalled(); + }); + + it('uses fresh geometry after a cancelled layout change', () => { + const { resize, layout, commit } = setup(); + resize.start(pointer(100)); + resize.move(pointer(80)); + resize.cancel(); + layout({ width: 1400, min: 1080, max: 1536 }); + resize.start(pointer(100)); + resize.release(pointer(90)); + expect(commit).toHaveBeenCalledExactlyOnceWith(1380); + }); + + it.each([ + { width: 730, min: 700, max: 935 }, + { width: 1110, min: 1080, max: 1136 }, + ])('cancels when release precedes layout cleanup: %o', (geometry) => { + const { resize, layout, commit, end } = setup(); + resize.start(pointer(100)); + resize.move(pointer(90)); + layout(geometry); + resize.release(pointer(90)); + expect(commit).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledTimes(1); + }); +}); + +describe('dialog keyboard resizing', () => { + it.each([ + ['ArrowLeft', 1120], + ['Home', 700], + ])('starts %s at rendered width', (key, expected) => { + const { resize, commit } = setup(); + expect(resize.key(key as string)).toBe(true); + expect(commit).toHaveBeenCalledExactlyOnceWith(expected); + }); + + it.each(['ArrowRight', 'End'])('does not persist %s beyond the rendered cap', (key) => { + const { resize, commit } = setup(); + expect(resize.key(key)).toBe(true); + expect(commit).not.toHaveBeenCalled(); + }); + + it.each(['ArrowLeft', 'Home'])('does not persist %s below the minimum', (key) => { + const { resize, commit } = setup({ width: 700, min: 700, max: 1136 }); + resize.key(key); + expect(commit).not.toHaveBeenCalled(); + }); + + it('does not let keyboard actions commit during a pointer gesture', () => { + const { resize, commit } = setup(); + resize.start(pointer(100)); + resize.key('ArrowLeft'); + expect(resize.key('Tab')).toBe(false); + resize.cancel(); + expect(commit).not.toHaveBeenCalled(); + }); + + it.each([936, 1079])('exposes a collapsed range below the split minimum at %spx', (available) => { + const bounds = resizeBounds(1080, available); + expect(bounds).toEqual({ min: available, max: available }); + const { resize, commit } = setup({ width: available, ...bounds }); + for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) resize.key(key); + resize.start(pointer(100)); + resize.release(pointer(80)); + expect(commit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/components/ui/dialog/dialogResize.ts b/apps/staged/src/lib/components/ui/dialog/dialogResize.ts new file mode 100644 index 000000000..211d387c2 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogResize.ts @@ -0,0 +1,102 @@ +export interface ResizeGeometry { + width: number; + min: number; + max: number; +} + +export function resizeBounds(minWidth: number, availableWidth: number) { + const max = Math.max(0, Math.round(availableWidth)); + return { min: Math.min(minWidth, max), max }; +} + +function clamp(width: number, bounds: { min: number; max: number }) { + return Math.max(bounds.min, Math.min(bounds.max, Math.round(width))); +} + +type ResizePointer = Pick; + +/** Gesture state is local; only a changed final width becomes a preference. */ +export function createDialogResize(callbacks: { + geometry: () => ResizeGeometry; + preview: (width: number) => void; + commit: (width: number) => void; + end: () => void; +}) { + let gesture: { + pointerId: number; + startX: number; + startWidth: number; + candidate: number; + bounds: ResizeGeometry; + } | null = null; + + function move(event: ResizePointer) { + if (!gesture || gesture.pointerId !== event.pointerId) return; + // Pointer-up can arrive before the resize event or reactive layout cleanup. + const bounds = callbacks.geometry(); + if (bounds.min !== gesture.bounds.min || bounds.max !== gesture.bounds.max) { + finish(false); + return; + } + const next = clamp(gesture.startWidth + 2 * (event.clientX - gesture.startX), gesture.bounds); + if (next !== gesture.candidate) { + gesture.candidate = next; + callbacks.preview(next); + } + } + + function finish(commit: boolean) { + if (!gesture) return; + const { candidate, startWidth } = gesture; + gesture = null; + try { + if (commit && candidate !== startWidth) callbacks.commit(candidate); + } finally { + callbacks.end(); + } + } + + return { + start(event: ResizePointer) { + if (gesture) return false; + const bounds = callbacks.geometry(); + const startWidth = clamp(bounds.width, bounds); + gesture = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth, + candidate: startWidth, + bounds, + }; + // Claim the preview even for a click so a delayed read cannot overwrite it. + callbacks.preview(startWidth); + return true; + }, + move, + release(event: ResizePointer) { + if (gesture?.pointerId !== event.pointerId) return; + move(event); + finish(true); + }, + cancel(pointerId?: number) { + if (pointerId !== undefined && gesture?.pointerId !== pointerId) return; + finish(false); + }, + key(key: string) { + const bounds = callbacks.geometry(); + const width = clamp(bounds.width, bounds); + const widths: Record = { + ArrowLeft: width - 16, + ArrowRight: width + 16, + Home: bounds.min, + End: bounds.max, + }; + const requested = widths[key]; + if (requested === undefined) return false; + if (gesture) return true; + const next = clamp(requested, bounds); + if (next !== width) callbacks.commit(next); + return true; + }, + }; +} 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..4cf0ed6d0 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogWidth.svelte.ts @@ -0,0 +1,144 @@ +/** + * 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. + * + * Preferred widths are independent of the viewport. CSS caps rendering and the + * resize handle constrains gestures to the available space. Previews never + * replace the preference, so cancelling a gesture restores it without a write. + */ + +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; + +/** 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; + /** Preview or preferred width in px, independent of the viewport cap. */ + readonly width: number; + readonly hydrated: boolean; + /** `width` as an inline style, ready for `Dialog.Content`'s `style` prop. */ + readonly style: string; + /** Apply a preferred width, or a temporary preview when `persist` is false. */ + set(width: number, persist?: boolean): void; + /** Discard the preview without changing or persisting the preferred width. */ + clearPreview(): 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, preview: null as number | null, hydrated: false }); + let hydration: Promise | null = null; + let persistence = Promise.resolve(); + let interacted = false; + + function normalize(width: number): number { + return Math.max(minWidth, Math.round(width)); + } + + async function hydrate(): Promise { + try { + const saved = await getStoreValue(key); + if (!interacted && typeof saved === 'number' && Number.isFinite(saved)) { + inner.width = normalize(saved); + } + } catch (error) { + console.error(`[DialogWidth] Failed to read ${key}:`, error); + } finally { + inner.hydrated = true; + } + } + + const instance: DialogWidth = { + key, + minWidth, + get width() { + return inner.preview ?? inner.width; + }, + get hydrated() { + return inner.hydrated; + }, + get style() { + return dialogWidthStyle(instance.width); + }, + set(width: number, persist = true) { + if (!Number.isFinite(width)) return; + interacted = true; + const normalized = normalize(width); + if (persist) { + inner.width = normalized; + inner.preview = null; + // Keep writes ordered across all mounts sharing this preference key. + persistence = persistence + .then(() => setStoreValue(key, normalized)) + .catch((error) => { + console.error(`[DialogWidth] Failed to write ${key}:`, error); + }); + } else { + inner.preview = normalized; + } + }, + clearPreview() { + inner.preview = null; + }, + reset() { + instance.set(minWidth); + }, + ensureHydrated() { + hydration ??= hydrate(); + return hydration; + }, + }; + + instances.set(key, instance); + return instance; +} + +/** + * Start reading saved widths without blocking startup. A dialog opened before + * hydration settles may still change width when its preference arrives. + */ +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..3b4c52f32 --- /dev/null +++ b/apps/staged/src/lib/components/ui/dialog/dialogWidth.test.ts @@ -0,0 +1,361 @@ +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'); +} + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +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(); + vi.restoreAllMocks(); +}); + +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('keeps the preferred width independent of the viewport cap', async () => { + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(5000); + + expect(width.width).toBe(5000); + expect(width.style).toBe('width:5000px;max-width:calc(100vw - 64px);'); + }); + + 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(500); + + expect(width.width).toBe(700); + }); + + it('restores an oversized preference after starting in a small window and growing', async () => { + savedWidth = 1400; + const { createDialogWidth } = await importDialogWidth(); + + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await width.ensureHydrated(); + + expect(getStoreValue).toHaveBeenCalledWith('test-width'); + expect(width.width).toBe(1400); + expect(width.hydrated).toBe(true); + vi.stubGlobal('window', { innerWidth: 1800 }); + expect(width.style).toBe('width:1400px;max-width:calc(100vw - 64px);'); + expect(setStoreValue).not.toHaveBeenCalled(); + }); + + it.each([NaN, Infinity, -Infinity, '900', null])( + 'ignores invalid saved width %s', + async (saved) => { + savedWidth = saved; + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await width.ensureHydrated(); + expect(width.width).toBe(700); + expect(width.hydrated).toBe(true); + } + ); + + it.each([ + [200, 700], + [900.7, 901], + ])('normalizes saved width %s to %s', async (saved, expected) => { + savedWidth = saved; + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await width.ensureHydrated(); + expect(width.width).toBe(expected); + }); + + it('settles a failed hydration once at the current width', async () => { + const error = new Error('read failed'); + getStoreValue.mockRejectedValue(error); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await expect(width.ensureHydrated()).resolves.toBeUndefined(); + await width.ensureHydrated(); + expect(width.hydrated).toBe(true); + expect(width.width).toBe(700); + expect(getStoreValue).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[DialogWidth] Failed to read test-width:', error); + }); + + it.each([true, false])( + 'ignores a delayed read after interaction (persist=%s)', + async (persist) => { + let resolve!: (width: number) => void; + getStoreValue.mockReturnValue(new Promise((done) => (resolve = done))); + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + const hydration = width.ensureHydrated(); + width.set(1000, persist); + resolve(800); + await hydration; + expect(width.width).toBe(1000); + width.clearPreview(); + expect(width.width).toBe(persist ? 1000 : 700); + } + ); + + it('does not apply a stale preference when hydration starts after a resize', async () => { + savedWidth = 800; + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(1000); + await width.ensureHydrated(); + expect(width.width).toBe(1000); + }); + + it('logs rejected writes while keeping the new preference in memory', async () => { + const error = new Error('write failed'); + setStoreValue.mockRejectedValue(error); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(900); + expect(width.width).toBe(900); + await vi.waitFor(() => + expect(log).toHaveBeenCalledWith('[DialogWidth] Failed to write test-width:', error) + ); + }); + + it('finishes a delayed resize save before a reset from another mount of the same key', async () => { + const firstSave = deferred(); + const lastSave = deferred(); + setStoreValue + .mockImplementationOnce(async (_key, value) => { + await firstSave.promise; + savedWidth = value; + }) + .mockImplementationOnce(async (_key, value) => { + await lastSave.promise; + savedWidth = value; + }); + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(900); + await vi.waitFor(() => expect(setStoreValue).toHaveBeenCalledTimes(1)); + + createDialogWidth({ key: 'test-width', minWidth: 700 }).reset(); + // Make the newer request ready to finish first, reproducing the HTTP race. + lastSave.resolve(); + await lastSave.promise; + expect(width.width).toBe(700); + expect(setStoreValue).toHaveBeenCalledTimes(1); + + firstSave.resolve(); + await vi.waitFor(() => expect(savedWidth).toBe(700)); + expect(setStoreValue.mock.calls).toEqual([ + ['test-width', 900], + ['test-width', 700], + ]); + }); + + it('continues queued saves after an earlier save rejects', async () => { + const firstSave = deferred(); + const error = new Error('write failed'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + setStoreValue + .mockReturnValueOnce(firstSave.promise) + .mockImplementationOnce(async (_key, value) => { + savedWidth = value; + }); + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + width.set(900); + width.set(1000); + await vi.waitFor(() => expect(setStoreValue).toHaveBeenCalledTimes(1)); + + firstSave.reject(error); + await vi.waitFor(() => expect(savedWidth).toBe(1000)); + expect(width.width).toBe(1000); + expect(log).toHaveBeenCalledExactlyOnceWith('[DialogWidth] Failed to write test-width:', error); + }); + + it('does not block another preference key behind a pending save', async () => { + const pendingSave = deferred(); + setStoreValue.mockImplementation(async (key, value) => { + if (key === 'note-width') await pendingSave.promise; + else savedWidth = value; + }); + const { createDialogWidth } = await importDialogWidth(); + createDialogWidth({ key: 'note-width', minWidth: 700 }).set(900); + createDialogWidth({ key: 'session-width', minWidth: 700 }).set(800); + await vi.waitFor(() => expect(savedWidth).toBe(800)); + pendingSave.resolve(); + }); + + it('discards a preview without losing an oversized preference or writing', async () => { + savedWidth = 1400; + const { createDialogWidth } = await importDialogWidth(); + const width = createDialogWidth({ key: 'test-width', minWidth: 700 }); + await width.ensureHydrated(); + width.set(1000, false); + expect(width.style).toContain('width:1000px;'); + width.clearPreview(); + expect(width.width).toBe(1400); + expect(setStoreValue).not.toHaveBeenCalled(); + }); + + 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); + + await vi.waitFor(() => + 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); + + await vi.waitFor(() => 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); + await vi.waitFor(() => 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); + expect(setStoreValue).not.toHaveBeenCalled(); + }); +}); + +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..5df7d4346 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 preserves it when space permits. Matches the + * second grid column in `.split-chat-open` below. + */ + const CHAT_PANE_WIDTH = 380; + interface Props { open: boolean; title: string; @@ -127,6 +140,20 @@ 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) { + dialogWidth.set(total - (splitChatOpen ? CHAT_PANE_WIDTH : 0), commit); + } + let noteSearchAvailable = $derived(hasNoteContent && !chatOnly); let chatToggleLabel = $derived( chatOpen @@ -146,9 +173,15 @@ ? 'Show chat pane' : 'View chat pane' ); + // Keep chat toggles animated; the resize handle suspends this transition + // while measuring and applying pointer, keyboard, and reset commands. 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]'}` + `dialog-resize-gutter h-[80vh] max-h-[900px] p-0 gap-0 overflow-hidden flex flex-col${ + resizing ? ' transition-none' : ' 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 +590,7 @@ > e.preventDefault()} > @@ -695,6 +729,16 @@ {/if} + (resizing = true)} + onResizeEnd={() => { + dialogWidth.clearPreview(); + resizing = false; + }} + onReset={() => dialogWidth.reset()} + /> @@ -713,10 +757,14 @@ min-width: 0; } + /* Fixed chat column: the preferred dialog width is the note width plus + CHAT_PANE_WIDTH. The note column absorbs resizing and viewport compression. + 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 +785,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 +855,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..40f63e314 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)} + onResizeEnd={() => dialogWidth.clearPreview()} + 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..95c5b61ae 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)} + onResizeEnd={() => dialogWidth.clearPreview()} + 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..51aa735a2 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)} + onResizeEnd={() => dialogWidth.clearPreview()} + 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..8204b289a 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,10 @@ export async function initPreferences(): Promise { // just lengthens the staged reveal on resume. Loading continues below. preferences.loaded = true; + // Preload dialog widths without blocking startup. An early opening may still + // resize when hydration finishes; 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) {