Compare commits

..
Author SHA1 Message Date
Codeman maintainer 572ff202bb fix(path-picker): finish the image preview zoom from #608
The review items offered on #608, applied on top of the contributor's
zoom:

- `object-fit: scale-down` instead of `contain`, so a small image keeps
  its own size instead of being stretched to fill the preview. The pan
  clamp now uses the size the image is actually drawn at (natural size
  fitted with scale-down) rather than the image box, which let a small
  or letterboxed image be panned entirely out of view.
- Wheel zoom is proportional to deltaY (d3-zoom's scale: about 15% per
  100px notch, lines and ctrl+wheel trackpad pinches scaled up), capped
  at sqrt(2) per event, and a wheel event with no vertical delta zooms
  nothing. A trackpad scroll no longer jumps to 600%.
- The zoom controls show only once the image has loaded; on an error
  they are hidden and the gesture listeners are dropped with the image.
  Both handlers now check the request sequence like the text branch, so
  a late load or error from a replaced preview no longer resets the
  current preview's zoom or paints its error over the current image.
- test/path-picker-image-zoom.test.ts drives the real listeners in
  jsdom (focal point under the cursor and the pinch centre, pan clamp,
  wheel scaling, error, close and stale events) in place of the
  source-string checks in test/path-picker-ui.test.ts. 7 of its 10
  tests fail against the contributor's commit alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:09:45 +01:00
shenlvkang-collab ba6bff388a feat(path-picker): pinch, pan and wheel zoom for the image preview
The path picker's image preview gets zoom controls (out, fit/percentage,
in), a two-finger pinch that keeps the pinch centre in place, one-finger
pan while zoomed (clamped to the image bounds), double-click to toggle
2x, and wheel zoom on desktop. closePreview() removes every listener and
drops the zoom state.

Salvaged from #608.
2026-11-09 03:09:45 +01:00
Codeman maintainer f62c1f7e2f fix(local-model): a view read while the job ends no longer shows paused
The install window polls GET /api/local-model every second. A read of the
state file that was still out while the job wrote its last state and let
go of this.job came back with the old `installing`, and settling that with
no job in the process read it as an interrupted job: `paused` with Resume
for one tick before `failed`. Under CI load this made
test/local-model-install-e2e.test.ts fail on a different failure case per
run (master 86502b69, PR #790).

currentState() now reads again when the job ended during the read. The job
writes before it lets go, so the second read has the final state.

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

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'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,7 +62,6 @@ 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',
];
/**
+9 -2
View File
@@ -1053,9 +1053,16 @@ 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. */
/**
* 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.
*/
private async currentState(): Promise<LocalModelStateFile> {
const state = await this.deps.readState();
const jobAtRead = this.job;
let state = await this.deps.readState();
if (jobAtRead && !this.job) state = await this.deps.readState();
return this.jobStarting ? state : this.settled(state);
}
-5
View File
@@ -1232,11 +1232,6 @@ 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?.();
+18 -38
View File
@@ -126,31 +126,20 @@ Object.assign(CodemanApp.prototype, {
this._stopHomeSessionsClock();
},
/**
* 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.
*/
/** Re-render only when showing (called from the tab renderer's tail). */
_refreshHomeSessionsIfVisible() {
if (!this.isHomeSessionsVisible()) return;
this._debouncedCall(
'homeSessions',
() => {
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
},
150
);
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
},
/**
* One delegated click listener for every row. The width listener lives in
* `_wireHomeSessionsBreakpoint()`, installed at startup.
* 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.
*/
_wireHomeSessions(el) {
if (this._homeSessionsWired) return;
this._homeSessionsWired = true;
this._wireHomeSessionsBreakpoint();
el.addEventListener('click', (event) => {
const target = event.target?.closest?.('[data-hs-action]');
@@ -161,29 +150,20 @@ Object.assign(CodemanApp.prototype, {
void this.openWebview?.(target.dataset.hsWebview);
}
});
},
/**
* 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);
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);
}
},
// ═══════════════════════════════════════════════════════════════
+160 -2
View File
@@ -74,6 +74,8 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_previewImageCleanup: null,
_previewImageZoom: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
@@ -563,6 +565,11 @@ 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>
@@ -580,13 +587,30 @@ 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.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
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.src = previewUrl;
body.appendChild(image);
this._bindPreviewImageGestures(image, body, zoomControls);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
@@ -630,8 +654,142 @@ 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;
+14 -30
View File
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
},
/**
* One delegated click listener for every row. The breakpoint listener lives
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
* 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.
*/
_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,34 +426,18 @@ Object.assign(CodemanApp.prototype, {
this.showWebviewModal();
}
});
},
/**
* 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);
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);
}
},
_toggleMobileOverviewRunMenu() {
+46 -4
View File
@@ -16218,6 +16218,29 @@ 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;
@@ -16236,6 +16259,12 @@ 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;
@@ -16250,10 +16279,23 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
max-width: 100%;
max-height: 100%;
margin: auto;
object-fit: contain;
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;
}
.path-preview-frame {
-141
View File
@@ -1,141 +0,0 @@
// 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
@@ -1,151 +0,0 @@
/**
* @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,6 +1938,57 @@ 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
@@ -0,0 +1,309 @@
/**
* @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;/);
});
});