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}
+
+
+ resize.cancel(event.pointerId)}
+ onlostpointercapture={(event) => resize.cancel(event.pointerId)}
+ onkeydown={handleKeydown}
+ ondblclick={() => {
+ resize.cancel();
+ runImmediateResize(onReset);
+ }}
+ >
+{/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) {