Compare commits

..
Author SHA1 Message Date
Codeman maintainer 7602b6db21 fix(home): swap the home surface on a fold whatever width the page loaded at
Folding a phone on the home screen left the welcome screen up when the page
had loaded wide (#702): the phone overview's breakpoint listener was wired
from its first show, which a wide-loaded page never reaches. Both home
screens' breakpoint listeners are now installed once from init() and act
only while a home surface is up.

A rail render queued just before the fold could also unhide the desktop
rail behind the phone overview; the debounced refresh now asks again when
it fires.

Tests: test/home-breakpoint-listeners.test.ts (gate) and
test/home-breakpoint-swap.browser.test.ts (browser suite: wide-first and
narrow-first across 599/600 and 767/768, plus a height-only resize).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:29:51 +01:00
12 changed files with 374 additions and 612 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
The path picker's image preview can now be zoomed: pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click to toggle 2x, and zoom out / fit / zoom in buttons. A small image is shown at its own size instead of being stretched to fill the preview. Thanks to @shenlvkang-collab, whose #608 this comes from.
+1
View File
@@ -62,6 +62,7 @@ export const BROWSER_TEST_GLOBS = [
'test/phone-press-feedback.browser.test.ts',
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
'test/local-model-ui.browser.test.ts',
'test/home-breakpoint-swap.browser.test.ts',
];
/**
+2 -9
View File
@@ -1053,16 +1053,9 @@ class LocalModelServiceImpl implements LocalModelService {
return progress ? { ...next, progress } : next;
}
/**
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
* this read was out, the read may hold the `installing` from before that write; it is read
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
*/
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
private async currentState(): Promise<LocalModelStateFile> {
const jobAtRead = this.job;
let state = await this.deps.readState();
if (jobAtRead && !this.job) state = await this.deps.readState();
const state = await this.deps.readState();
return this.jobStarting ? state : this.settled(state);
}
+5
View File
@@ -1232,6 +1232,11 @@ class CodemanApp {
// and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout();
this.applyTabOrientation();
// The home screens' breakpoint listeners, once, before any home surface is
// shown: a fold or unfold must swap the surface whichever width the page
// loaded at (#702). Both self-gate on a home screen being up.
this._wireMobileOverviewBreakpoint?.();
this._wireHomeSessionsBreakpoint?.();
this.initTabRailResize?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
+38 -18
View File
@@ -126,20 +126,31 @@ Object.assign(CodemanApp.prototype, {
this._stopHomeSessionsClock();
},
/** Re-render only when showing (called from the tab renderer's tail). */
/**
* Re-render only when showing (called from the tab renderer's tail). Asked
* again when the debounce fires: `renderHomeSessions()` unhides the column,
* so a render queued just before a fold to phone width hid it (#702) would
* otherwise bring it back behind the phone overview.
*/
_refreshHomeSessionsIfVisible() {
if (!this.isHomeSessionsVisible()) return;
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
this._debouncedCall(
'homeSessions',
() => {
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
},
150
);
},
/**
* One delegated click listener for every row, plus a width listener so
* resizing the window while on the home screen adds or drops the column
* instead of leaving it overlapping the content it was sized to clear.
* One delegated click listener for every row. The width listener lives in
* `_wireHomeSessionsBreakpoint()`, installed at startup.
*/
_wireHomeSessions(el) {
if (this._homeSessionsWired) return;
this._homeSessionsWired = true;
this._wireHomeSessionsBreakpoint();
el.addEventListener('click', (event) => {
const target = event.target?.closest?.('[data-hs-action]');
@@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, {
void this.openWebview?.(target.dataset.hsWebview);
}
});
},
if (window.matchMedia) {
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
const onChange = () => {
// Only relevant while the welcome screen is up; entering a session
// re-decides through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
this.showHomeSessions();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
/**
* Add or drop the column when the window crosses its width gate, so it never
* overlaps the content it was sized to clear. Installed ONCE at startup
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
* page that loads on a phone and is unfolded to desktop width gets it too
* (#702). Idempotent.
*/
_wireHomeSessionsBreakpoint() {
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
this._homeSessionsBreakpointWired = true;
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
const onChange = () => {
// Only relevant while the welcome screen is up; entering a session
// re-decides through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
this.showHomeSessions();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
},
// ═══════════════════════════════════════════════════════════════
+2 -160
View File
@@ -74,8 +74,6 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_previewImageCleanup: null,
_previewImageZoom: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
@@ -565,11 +563,6 @@ const PathPicker = {
<strong class="path-preview-title"></strong>
<span class="path-preview-path"></span>
</div>
<div class="path-preview-zoom" hidden aria-label="Image zoom controls">
<button type="button" data-zoom="out" aria-label="Zoom out">−</button>
<button type="button" data-zoom="fit" aria-label="Fit image">Fit</button>
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
</div>
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
<button type="button" class="path-preview-close" aria-label="Close preview">&times;</button>
</div>
@@ -587,30 +580,13 @@ const PathPicker = {
const body = overlay.querySelector('.path-preview-body');
if (entry.previewKind === 'image') {
const zoomControls = overlay.querySelector('.path-preview-zoom');
body.classList.add('path-preview-image-viewport');
const image = document.createElement('img');
image.className = 'path-preview-image';
image.alt = entry.name;
image.draggable = false;
// Both handlers check the sequence like the text branch: an image whose preview was already
// replaced can still finish loading, and `this._previewOverlay` and the zoom state are then
// the newer preview's. The controls show only once there is an image to zoom.
image.addEventListener('load', () => {
if (requestSequence !== this._previewRequestSequence) return;
body.querySelector('.path-preview-loading')?.remove();
zoomControls.hidden = false;
this._setPreviewImageZoom(1);
});
image.addEventListener('error', () => {
if (requestSequence !== this._previewRequestSequence) return;
zoomControls.hidden = true;
this._previewImageCleanup?.();
this.showPreviewError('Image preview failed to load');
});
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
image.src = previewUrl;
body.appendChild(image);
this._bindPreviewImageGestures(image, body, zoomControls);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
@@ -654,142 +630,8 @@ const PathPicker = {
body.replaceChildren(error);
},
_setPreviewImageZoom(scale, focalPoint) {
const state = this._previewImageZoom;
if (!state) return;
const nextScale = Math.max(1, Math.min(6, scale));
if (focalPoint) {
const rect = state.body.getBoundingClientRect();
const qx = focalPoint.x - (rect.left + rect.width / 2);
const qy = focalPoint.y - (rect.top + rect.height / 2);
const ratio = nextScale / state.scale;
state.x = qx + (state.x - qx) * ratio;
state.y = qy + (state.y - qy) * ratio;
}
state.scale = nextScale;
this._clampPreviewImagePan();
state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
const fitButton = state.controls.querySelector('[data-zoom="fit"]');
if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`;
state.image.classList.toggle('is-zoomed', state.scale > 1);
},
/** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never
* larger than its natural size. The box until the image has loaded (no natural size yet). */
_previewImageFitSize(state, rect) {
const naturalWidth = state.image.naturalWidth;
const naturalHeight = state.image.naturalHeight;
if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height };
const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight);
return { width: naturalWidth * fit, height: naturalHeight * fit };
},
/** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's
* edge, never further, and an axis on which it is still smaller than the viewport stays centred.
* Clamping to the image box instead let a small or letterboxed image be panned out of view. */
_clampPreviewImagePan() {
const state = this._previewImageZoom;
if (!state) return;
const rect = state.body.getBoundingClientRect();
const fitted = this._previewImageFitSize(state, rect);
const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2);
const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2);
state.x = Math.max(-maxX, Math.min(maxX, state.x));
state.y = Math.max(-maxY, Math.min(maxY, state.y));
},
/** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per
* 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a
* trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per
* event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta
* (a sideways scroll) zooms nothing. */
_previewWheelZoomFactor(event) {
const deltaY = Number(event?.deltaY) || 0;
if (!deltaY) return 1;
const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002;
const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1);
return 2 ** Math.max(-0.5, Math.min(0.5, exponent));
},
_bindPreviewImageGestures(image, body, controls) {
this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null };
const state = this._previewImageZoom;
const cleanups = [];
const listen = (target, name, handler, options) => {
target.addEventListener(name, handler, options);
cleanups.push(() => target.removeEventListener(name, handler, options));
};
const pointer = (event) => ({ x: event.clientX, y: event.clientY });
const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const startGesture = () => {
const points = Array.from(state.pointers.values());
if (points.length > 1) {
state.gesture = {
kind: 'pinch',
scale: state.scale,
distance: Math.max(1, distance(points[0], points[1])),
midpoint: midpoint(points[0], points[1]),
};
} else if (points.length === 1) {
state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y };
}
};
listen(image, 'pointerdown', (event) => {
event.preventDefault();
image.setPointerCapture?.(event.pointerId);
state.pointers.set(event.pointerId, pointer(event));
startGesture();
});
listen(image, 'pointermove', (event) => {
if (!state.pointers.has(event.pointerId) || !state.gesture) return;
event.preventDefault();
state.pointers.set(event.pointerId, pointer(event));
if (state.pointers.size > 1) {
const points = Array.from(state.pointers.values());
this._setPreviewImageZoom(
state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance,
state.gesture.midpoint
);
} else if (state.gesture.kind === 'pan' && state.scale > 1) {
const point = state.pointers.values().next().value;
state.x = state.gesture.x + point.x - state.gesture.point.x;
state.y = state.gesture.y + point.y - state.gesture.point.y;
this._setPreviewImageZoom(state.scale);
}
});
const endPointer = (event) => {
state.pointers.delete(event.pointerId);
startGesture();
};
listen(image, 'pointerup', endPointer);
listen(image, 'pointercancel', endPointer);
listen(image, 'lostpointercapture', endPointer);
listen(image, 'dblclick', (event) => {
event.preventDefault();
this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event));
});
listen(body, 'wheel', (event) => {
event.preventDefault();
const factor = this._previewWheelZoomFactor(event);
if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event));
}, { passive: false });
listen(controls, 'click', (event) => {
const button = event.target.closest('[data-zoom]');
if (!button) return;
if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1);
else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5));
});
this._previewImageCleanup = () => {
cleanups.forEach((cleanup) => cleanup());
this._previewImageZoom = null;
this._previewImageCleanup = null;
};
},
closePreview(restoreFocus = true) {
this._previewRequestSequence += 1;
this._previewImageCleanup?.();
this._previewOverlay?.remove();
this._previewOverlay = null;
const previousFocus = this._previewPreviousFocus;
+30 -14
View File
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
},
/**
* One delegated click listener for every row, plus a breakpoint listener so
* rotating or unfolding while on the home screen swaps to the right surface
* instead of stranding a phone layout on a tablet-width viewport.
* One delegated click listener for every row. The breakpoint listener lives
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
*/
_wireMobileOverview(el) {
if (this._mobileOverviewWired) return;
this._mobileOverviewWired = true;
this._wireMobileOverviewBreakpoint();
el.addEventListener('click', (event) => {
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
@@ -426,18 +426,34 @@ Object.assign(CodemanApp.prototype, {
this.showWebviewModal();
}
});
},
if (window.matchMedia) {
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
/**
* Swap the home surface when the width crosses the phone breakpoint (rotate,
* fold, unfold), so a phone layout is never stranded on a tablet-width
* viewport and the welcome screen never on a phone-width one.
*
* Installed ONCE at startup (`init()` in app.js), not on the first show: a
* page that loads wide never shows the phone overview, so a listener wired
* from there was missing on exactly the fold that needed it (#702). Idempotent,
* so the call from `_wireMobileOverview()` adds nothing after startup.
*/
_wireMobileOverviewBreakpoint() {
if (this._mobileOverviewBreakpointWired || !window.matchMedia) return;
this._mobileOverviewBreakpointWired = true;
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
const onChange = () => {
// Only relevant while a home surface is up; entering a session re-decides
// through hideWelcome()/showWelcome() anyway, and before the first
// showWelcome() there is nothing on screen to swap.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
const welcomeUp = !!overlay && overlay.classList.contains('visible');
if (!welcomeUp && !this.isMobileOverviewVisible()) return;
if (typeof this.showWelcome === 'function') this.showWelcome();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
},
_toggleMobileOverviewRunMenu() {
+4 -46
View File
@@ -16218,29 +16218,6 @@ body.touch-device.cjk-input-visible .main {
text-decoration: none;
}
.path-preview-zoom {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 3px;
}
.path-preview-zoom[hidden] {
display: none;
}
.path-preview-zoom button {
min-width: 32px;
height: 34px;
padding: 0 6px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
font-size: 0.75rem;
cursor: pointer;
}
.path-preview-close {
width: 38px;
height: 38px;
@@ -16259,12 +16236,6 @@ body.touch-device.cjk-input-visible .main {
background: #111;
}
.path-preview-body.path-preview-image-viewport {
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.path-preview-loading,
.path-preview-error {
padding: 24px;
@@ -16279,23 +16250,10 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
width: 100%;
height: 100%;
/* scale-down, not contain: a small image keeps its own size instead of being blown up to the
viewport (the zoom controls are there to enlarge it). */
object-fit: scale-down;
touch-action: none;
user-select: none;
-webkit-user-drag: none;
cursor: zoom-in;
}
.path-preview-image.is-zoomed {
cursor: grab;
}
.path-preview-image.is-zoomed:active {
cursor: grabbing;
max-width: 100%;
max-height: 100%;
margin: auto;
object-fit: contain;
}
.path-preview-frame {
+141
View File
@@ -0,0 +1,141 @@
// Port: none (the real home-screen modules in a vm context with a fake
// matchMedia; no browser, no server).
//
// #702: folding a phone on the home screen must swap the home surface even when
// the page loaded wide. The phone overview's breakpoint listener used to be
// wired from its first show, which a wide-loaded page never reaches. Both home
// screens' listeners are now installed once at startup and self-gate on a home
// screen being up. The real resize walk lives in
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
// this file keeps the wiring itself under `npm test`.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
type Listener = () => void;
function loadHomeApp(innerWidth = 1440) {
const listeners = new Map<string, Listener[]>();
const elements: Record<string, any> = {
welcomeOverlay: { classList: new Set<string>(), hidden: false },
mobileOverview: { classList: new Set<string>(), hidden: true },
homeSessions: { hidden: true },
};
for (const el of Object.values(elements)) {
if (el.classList) {
const set = el.classList as Set<string>;
el.classList = {
contains: (c: string) => set.has(c),
add: (c: string) => set.add(c),
remove: (c: string) => set.delete(c),
};
}
}
const timers: Array<() => void> = [];
const window: any = {
innerWidth,
matchMedia: (query: string) => ({
matches: false,
addEventListener: (_type: string, fn: Listener) => {
listeners.set(query, [...(listeners.get(query) || []), fn]);
},
}),
};
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window,
document: {
documentElement: { getAttribute: () => null },
getElementById: (id: string) => elements[id] || null,
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
},
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
});
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.loadAppSettingsFromStorage = () => ({});
app.activeSessionId = null;
app.showWelcomeCalls = 0;
app.showWelcome = () => app.showWelcomeCalls++;
app.renderHomeSessionsCalls = 0;
app.renderHomeSessions = () => {
app.renderHomeSessionsCalls++;
elements.homeSessions.hidden = false;
};
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
return { app, window, elements, listeners, fire, flushTimers };
}
const PHONE_QUERY = '(max-width: 599px)';
const RAIL_QUERY = '(min-width: 1180px)';
describe('home-screen breakpoint listeners (#702)', () => {
it('installs each listener once, with nothing shown yet', () => {
const { app, listeners } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
// The lazy wiring from a first show must not add a second copy.
app._wireMobileOverviewBreakpoint();
app._wireHomeSessionsBreakpoint();
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
});
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(1440);
app._wireMobileOverviewBreakpoint();
elements.welcomeOverlay.classList.add('visible');
window.innerWidth = 390;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('an unfold on the phone overview re-decides the surface', () => {
const { app, window, elements, fire } = loadHomeApp(390);
app._wireMobileOverviewBreakpoint();
elements.mobileOverview.classList.add('visible');
window.innerWidth = 820;
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(1);
});
it('stays out of the way inside a session and before any home screen is up', () => {
const { app, elements, fire } = loadHomeApp();
app._wireMobileOverviewBreakpoint();
fire(PHONE_QUERY); // page still loading: no home surface yet
elements.welcomeOverlay.classList.add('visible');
app.activeSessionId = 'abc';
fire(PHONE_QUERY);
expect(app.showWelcomeCalls).toBe(0);
});
it('a rail render queued before a fold does not bring the hidden rail back', () => {
const { app, window, elements, flushTimers } = loadHomeApp(1440);
elements.welcomeOverlay.classList.add('visible');
elements.homeSessions.hidden = false;
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
window.innerWidth = 390; // the fold
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
flushTimers();
expect(app.renderHomeSessionsCalls).toBe(0);
expect(elements.homeSessions.hidden).toBe(true);
});
it('is wired from init(), not from the first show', () => {
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = appJs.indexOf('\n init() {');
const end = appJs.indexOf('\n }\n', start);
const init = appJs.slice(start, end);
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
* surface in place, whichever width the page loaded at (#702).
*
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
* their own surface. A page that loaded wide never showed the phone overview,
* so folding it to phone width left the welcome screen up until something
* called `showWelcome()` again. They are now installed once at startup.
*
* Real server, real Chromium, one page per direction, resized across 599/600
* and 767/768 in both directions with no navigation, plus a height-only resize
* (the on-screen keyboard) that must not re-decide the surface.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
describe('home surface follows a fold or unfold without a reload', () => {
let server: WebServer;
let browser: Browser;
let workDir: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
// One session, so the desktop rail has a row to show (it hides when empty).
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workingDir: workDir }),
});
expect(res.ok).toBe(true);
browser = await chromium.launch({ headless: true });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
if (workDir) rmSync(workDir, { recursive: true, force: true });
}, 60000);
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
const page = await context.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The load selects the one session; Home is where a fold has to swap surfaces.
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.goHome());
await page.waitForFunction(
() =>
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
document.getElementById('mobileOverview')?.classList.contains('visible'),
null,
{ timeout: 30000 }
);
return [context, page];
}
const surface = (page: Page): Promise<Surface> =>
page.evaluate(() => {
const overview = document.getElementById('mobileOverview')!;
const rail = document.getElementById('homeSessions')!;
return {
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
overview: !overview.hidden && overview.classList.contains('visible'),
rail: !rail.hidden,
};
});
/** Resize, then wait until the surface settles on what that width should show. */
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
await page.setViewportSize({ width, height });
await expect
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
.toEqual(expected);
}
const PHONE: Surface = { welcome: false, overview: true, rail: false };
const TABLET: Surface = { welcome: true, overview: false, rail: false };
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
const [context, page] = await open(1440, 900);
try {
expect(await surface(page)).toEqual(DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
await resizeTo(page, 390, 844, PHONE);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
// A straight fold, the way a real device changes posture, in one step.
await resizeTo(page, 390, 844, PHONE);
} finally {
await context.close();
}
}, 60000);
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
const [context, page] = await open(390, 844);
try {
expect(await surface(page)).toEqual(PHONE);
await resizeTo(page, 599, 844, PHONE);
await resizeTo(page, 600, 844, TABLET);
await resizeTo(page, 767, 844, TABLET);
await resizeTo(page, 768, 844, TABLET);
await resizeTo(page, 1440, 900, DESKTOP);
await resizeTo(page, 768, 900, TABLET);
await resizeTo(page, 767, 900, TABLET);
await resizeTo(page, 600, 900, TABLET);
await resizeTo(page, 599, 900, PHONE);
} finally {
await context.close();
}
}, 60000);
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
const [context, page] = await open(1440, 900);
try {
await resizeTo(page, 390, 844, PHONE);
await page.evaluate(() => {
const app = (window as any).app;
app.__showWelcomeCalls = 0;
const original = app.showWelcome;
app.showWelcome = function (...args: unknown[]) {
app.__showWelcomeCalls++;
return original.apply(this, args);
};
});
await page.setViewportSize({ width: 390, height: 480 });
await page.waitForTimeout(400);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(400);
expect(await surface(page)).toEqual(PHONE);
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
} finally {
await context.close();
}
}, 60000);
});
-51
View File
@@ -1938,57 +1938,6 @@ describe('view()', () => {
await h.service.cancel();
});
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
// The window polls every second, so a read of the state file can be under way while the
// job writes its last state and lets go. That read returns the old `installing`, and
// settling it with no job in this process read it as an interrupted job: the window (and
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
const slow = {
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
hold: undefined as undefined | Promise<void>,
entered: undefined as undefined | (() => void),
};
const h = setup({
overrides: {
readState: async () => {
const snapshot = await slow.read!();
const hold = slow.hold;
slow.hold = undefined;
if (hold) {
slow.entered?.();
await hold;
}
return snapshot;
},
},
});
slow.read = h.store.readState;
let failVerify!: () => void;
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
h.deps.verifyModel.mockImplementationOnce(async () => {
await verifying;
return { ok: false, code: 'no-tool-call' } as never;
});
await h.service.install(REQUEST, ADMIN);
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
// A view starts reading while the job is still in the verify step...
let releaseRead!: () => void;
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
const viewing = h.service.view();
await entered;
// ...the job fails, writes it and ends while that read is still out...
failVerify();
await h.service.idle();
expect(h.store.get().state).toBe('failed');
// ...and only then does the read come back, with the file as it was before.
releaseRead();
const view = await viewing;
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
});
it('names the model to try next only when the catalogue still has it', async () => {
const state: LocalModelStateFile = {
...jobAt('verify', 'paused'),
-309
View File
@@ -1,309 +0,0 @@
/**
* @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan,
* double-click, wheel and the out / fit / in buttons. Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the window is built by hand).
*
* These drive the real listeners and read the zoom state back, because what can go wrong is
* geometric, not textual:
*
* 1. the point under the cursor or between the fingers drifting while the scale changes,
* 2. a pan that carries the image out of view (a small image is drawn at its own size with
* `object-fit: scale-down`, so the image box is not the image),
* 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did,
* 4. controls left on screen for an image that failed to load,
* 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced
* preview acting on the current one.
*
* jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size
* are stubbed per test and `load`/`error` are dispatched by hand.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */
const BOX = { left: 0, top: 0, width: 400, height: 300 };
const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 };
type Point = { x: number; y: number };
type ZoomState = { scale: number; x: number; y: number };
function loadPathPicker(): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false });
}
describe('PathPicker image preview zoom', () => {
let PathPicker: any;
beforeEach(() => {
doc.body.replaceChildren();
PathPicker = loadPathPicker();
});
afterEach(() => {
PathPicker?.closePreview?.(false);
doc.body.replaceChildren();
});
/** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */
const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => {
PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' });
const overlay = PathPicker._previewOverlay as HTMLElement;
const body = overlay.querySelector('.path-preview-body') as HTMLElement;
body.getBoundingClientRect = () =>
({
...BOX,
x: BOX.left,
y: BOX.top,
right: BOX.left + BOX.width,
bottom: BOX.top + BOX.height,
toJSON: () => ({}),
}) as DOMRect;
const image = body.querySelector('img.path-preview-image') as HTMLImageElement;
Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true });
Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true });
const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement;
const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement;
return { overlay, body, image, controls, button };
};
const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type));
const state = (): ZoomState => PathPicker._previewImageZoom;
/** Where, in the unzoomed image, the given screen point lands under the current transform. */
const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({
x: (p.x - CENTRE.x - s.x) / s.scale,
y: (p.y - CENTRE.y - s.y) / s.scale,
});
const wheel = (target: EventTarget, init: Record<string, unknown>) => {
const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
};
/** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */
const pointer = (target: EventTarget, type: string, id: number, p: Point) => {
const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y });
Object.defineProperty(event, 'pointerId', { value: id });
target.dispatchEvent(event);
};
const dblclick = (target: EventTarget, p: Point) =>
target.dispatchEvent(
new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y })
);
it('shows the controls only once the image has loaded, and the buttons step the zoom', () => {
const { image, controls, button } = openImage();
expect(controls.hidden).toBe(true);
fire(image, 'load');
expect(controls.hidden).toBe(false);
expect(button('fit').textContent).toBe('100%');
button('in').click();
expect(state().scale).toBeCloseTo(1.5);
expect(button('fit').textContent).toBe('150%');
expect(image.classList.contains('is-zoomed')).toBe(true);
button('fit').click();
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
expect(button('fit').textContent).toBe('100%');
expect(image.classList.contains('is-zoomed')).toBe(false);
button('out').click();
expect(state().scale).toBe(1);
});
it('wheel zoom keeps the image point under the cursor where it was', () => {
const { body, image } = openImage();
fire(image, 'load');
for (const cursor of [
{ x: 300, y: 100 },
{ x: 120, y: 220 },
{ x: 390, y: 290 },
]) {
const before = imagePointUnder(cursor);
const scaleBefore = state().scale;
wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y });
expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2);
const after = imagePointUnder(cursor);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
}
expect(image.style.transform).toContain(`scale(${state().scale})`);
});
it('pinch zooms by the finger distance around the pinch centre', () => {
const { image } = openImage();
fire(image, 'load');
const centre = { x: 150, y: 150 };
const before = imagePointUnder(centre);
pointer(image, 'pointerdown', 1, { x: 100, y: 150 });
pointer(image, 'pointerdown', 2, { x: 200, y: 150 });
pointer(image, 'pointermove', 2, { x: 300, y: 150 });
expect(state().scale).toBeCloseTo(2);
const after = imagePointUnder(centre);
expect(after.x).toBeCloseTo(before.x);
expect(after.y).toBeCloseTo(before.y);
pointer(image, 'pointerup', 2, { x: 300, y: 150 });
pointer(image, 'pointerup', 1, { x: 100, y: 150 });
expect(state().pointers.size).toBe(0);
});
it('pans a zoomed image only until its edge meets the viewport edge', () => {
const { image } = openImage();
fire(image, 'load');
dblclick(image, CENTRE);
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack.
expect(state()).toMatchObject({ x: 200, y: 150 });
pointer(image, 'pointermove', 1, { x: -5000, y: -5000 });
expect(state()).toMatchObject({ x: -200, y: -150 });
pointer(image, 'pointerup', 1, { x: -5000, y: -5000 });
// Without zoom there is nothing to pan.
dblclick(image, CENTRE);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 300, y: 250 });
expect(state()).toMatchObject({ scale: 1 });
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
});
it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => {
// 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box.
const { image } = openImage({ width: 100, height: 50 });
fire(image, 'load');
dblclick(image, { x: 380, y: 280 });
expect(state().scale).toBe(2);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 200x100 still fits inside 400x300 on both axes: it stays centred.
expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
pointer(image, 'pointerup', 1, { x: 5000, y: 5000 });
PathPicker._setPreviewImageZoom(6);
pointer(image, 'pointerdown', 1, CENTRE);
pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
// 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would
// have allowed 1000 and 750, carrying the image entirely out of the viewport.
expect(state().x).toBe(100);
expect(Math.abs(state().y)).toBe(0);
});
it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => {
const factor = (init: Record<string, unknown>) => PathPicker._previewWheelZoomFactor(init);
expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1);
expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008);
expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2);
// Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored.
expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15);
expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2);
expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2);
expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2);
const { body, image } = openImage();
fire(image, 'load');
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 });
expect(sideways.defaultPrevented).toBe(true);
expect(zoom).not.toHaveBeenCalled();
expect(state().scale).toBe(1);
// A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap.
for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 });
expect(state().scale).toBeCloseTo(2 ** 0.24);
expect(state().scale).toBeLessThan(1.25);
});
it('hides the controls and stops zooming when the image fails to load', () => {
const { body, image, controls } = openImage();
fire(image, 'error');
expect(controls.hidden).toBe(true);
expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load');
expect(PathPicker._previewImageZoom).toBeNull();
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(body, { deltaY: -100, clientX: 200, clientY: 150 });
expect(zoom).not.toHaveBeenCalled();
});
it('closing drops the zoom state and every listener, and the next image starts at 100%', () => {
const first = openImage();
fire(first.image, 'load');
dblclick(first.image, { x: 300, y: 100 });
expect(state().scale).toBe(2);
const transformAtClose = first.image.style.transform;
PathPicker.closePreview(false);
expect(PathPicker._previewImageZoom).toBeNull();
expect(PathPicker._previewImageCleanup).toBeNull();
expect(first.overlay.isConnected).toBe(false);
const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 });
dblclick(first.image, CENTRE);
first.button('in').click();
expect(zoom).not.toHaveBeenCalled();
expect(first.image.style.transform).toBe(transformAtClose);
zoom.mockRestore();
const second = openImage({ width: 800, height: 600 }, 'next.png');
fire(second.image, 'load');
expect(state()).toMatchObject({ scale: 1 });
expect(second.button('fit').textContent).toBe('100%');
expect(second.image.classList.contains('is-zoomed')).toBe(false);
});
it('a late load or error from a replaced preview leaves the current one alone', () => {
const stale = openImage({ width: 800, height: 600 }, 'old.png');
const current = openImage({ width: 800, height: 600 }, 'new.png');
fire(current.image, 'load');
dblclick(current.image, { x: 300, y: 100 });
const zoomed = { ...state() };
fire(stale.image, 'load');
expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y });
fire(stale.image, 'error');
expect(current.body.querySelector('.path-preview-error')).toBeNull();
expect(current.image.isConnected).toBe(true);
expect(current.controls.hidden).toBe(false);
expect(PathPicker._previewImageZoom).not.toBeNull();
});
it('draws a small image at its own size rather than stretching it (scale-down)', () => {
const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/);
expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/);
expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/);
});
});