diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index eecb2a2c..d10ac562 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -80,6 +80,7 @@ export const ELECTRON_EVENT_CHANNELS = { previewEvent: 'desktop:preview:event', petNavigateSession: 'desktop:pets:navigate-session', petVisibilityChanged: 'desktop:pets:visibility-changed', + petPanelPlacementChanged: 'desktop:pets:panel-placement-changed', } as const export const ELECTRON_INTERNAL_CHANNELS = { diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 0f86247f..41a02680 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -331,6 +331,10 @@ function getPetWindowController() { installMainWindowNavigationGuards(window.webContents, { openExternal: openExternalUrl }) }, load: window => loadRendererEntry(window, { petWindow: '1' }), + onPanelPlacementChanged: (window, placement) => { + if (window.isDestroyed()) return + window.webContents.send(ELECTRON_EVENT_CHANNELS.petPanelPlacementChanged, placement) + }, }) return petWindowController } @@ -574,21 +578,19 @@ function registerIpcHandlers() { Menu, ) }) - registerHandler(ELECTRON_IPC_CHANNELS.petsDragWindow, (event, payload) => { + registerHandler(ELECTRON_IPC_CHANNELS.petsDragWindow, (event, payload) => getPetWindowController().dragWindow( currentWindow(event), payload as PetWindowDragPayload, - ) - }) + )) registerHandler(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, (event, payload) => { getPetWindowController().setIgnoreMouseEvents(currentWindow(event), Boolean(payload)) }) - registerHandler(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, (event, payload) => { + registerHandler(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, (event, payload) => getPetWindowController().setInteractiveRegions( currentWindow(event), payload as Electron.Rectangle[], - ) - }) + )) registerHandler(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, (event) => { if (!getPetWindowController().owns(currentWindow(event))) { throw new Error('Pet window IPC sender does not own the companion window') diff --git a/desktop/electron/pet-preload.ts b/desktop/electron/pet-preload.ts index a970da31..f962baed 100644 --- a/desktop/electron/pet-preload.ts +++ b/desktop/electron/pet-preload.ts @@ -5,7 +5,7 @@ import { } from './ipc/capabilities' import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels' import { ELECTRON_EVENT_CHANNELS } from './ipc/channels' -import type { DesktopHost } from '../src/lib/desktopHost/types' +import type { DesktopHost, DesktopPetPanelPlacement } from '../src/lib/desktopHost/types' import type { Locale } from '../src/i18n/locale' function invoke(channel: ElectronIpcChannel, payload?: unknown): Promise { @@ -64,11 +64,21 @@ const petHost = { showContextMenu: (closeLabel: string) => invoke(ELECTRON_IPC_CHANNELS.petsShowContextMenu, { closeLabel }), dragWindow: (payload: { phase: 'start' | 'move' | 'end', x: number, y: number }) => - invoke(ELECTRON_IPC_CHANNELS.petsDragWindow, payload), + invoke(ELECTRON_IPC_CHANNELS.petsDragWindow, payload), setIgnoreMouseEvents: (ignore: boolean) => invoke(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ignore), setInteractiveRegions: (regions: Array<{ x: number, y: number, width: number, height: number }>) => - invoke(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions), + invoke(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions), + onPanelPlacementChanged: (handler: (placement: DesktopPetPanelPlacement) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + placement: DesktopPetPanelPlacement, + ) => handler(placement) + ipcRenderer.on(ELECTRON_EVENT_CHANNELS.petPanelPlacementChanged, listener) + return Promise.resolve(() => { + ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.petPanelPlacementChanged, listener) + }) + }, focusMainWindow: () => invoke(ELECTRON_IPC_CHANNELS.petsFocusMainWindow), focusSession: (sessionId: string) => invoke(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId), diff --git a/desktop/electron/services/petWindow.test.ts b/desktop/electron/services/petWindow.test.ts index e966df7c..24f6f039 100644 --- a/desktop/electron/services/petWindow.test.ts +++ b/desktop/electron/services/petWindow.test.ts @@ -9,9 +9,11 @@ import { type PetWindowPosition, clampPetWindowPosition, getPetWindowBounds, + petPanelBounds, petWindowStatePath, petWindowOptions, readPetWindowPosition, + resolvePetPanelPlacement, writePetWindowPosition, } from './petWindow' @@ -632,6 +634,320 @@ describe('Electron pet window service', () => { }, ) + // Reaching the menu bar puts the window's own top edge above the work area, + // and the activity panel lives in exactly that strip of the window. The + // mascot arriving at the edge is the fix working; the panel arriving behind + // the menu bar with it is the bug (#1140). + // + // These boxes are the real layout: the stack is bottom-aligned with 12px of + // padding, the card sits 12px above the mascot, and the collapse control + // hangs 31px below the card's own bottom edge. + const panelDrag = { + workArea: { x: 0, y: 25, width: 800, height: 575 }, + above: { + mascot: { x: 136, y: 240, width: 112, height: 128 }, + card: { x: 16, y: 88, width: 352, height: 140 }, + toggle: { x: 172, y: 234, width: 25, height: 25 }, + }, + // What the renderer reports once it has flipped: mascot at the top of the + // stack, card below it, control on the card's mascot-facing edge. + below: { + mascot: { x: 136, y: 12, width: 112, height: 128 }, + card: { x: 16, y: 152, width: 352, height: 140 }, + toggle: { x: 172, y: 121, width: 25, height: 25 }, + }, + } + + function panelController(petWindow: ReturnType) { + const onPanelPlacementChanged = vi.fn() + // No cursor sampler, so the drag follows the payload coordinates the way + // the other edge tests drive it. + const controller = new PetWindowController({ + createWindow: vi.fn(() => petWindow) as never, + getCurrentWorkArea: () => panelDrag.workArea, + getWorkAreaForPoint: () => panelDrag.workArea, + load: vi.fn().mockResolvedValue(undefined), + onPanelPlacementChanged, + platform: 'darwin', + preloadPath: '/app/electron-dist/preload.cjs', + }) + return { controller, onPanelPlacementChanged } + } + + it('flips the activity panel below the mascot at the menu bar and holds the mascot there', async () => { + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { constrainTopTo: panelDrag.workArea.y }, + ) + const { controller, onPanelPlacementChanged } = panelController(petWindow) + await controller.show() + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + + const { above, below, workArea } = panelDrag + // Away from the top edge the panel keeps its usual place above the mascot. + expect(controller.setInteractiveRegions(petWindow as never, [ + above.mascot, + above.card, + above.toggle, + ])).toEqual({ vertical: 'above' }) + + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 }) + + // The mascot still reaches the menu bar, which is the behaviour this must + // not regress: the window top is above the work area. + expect(petWindow.getBounds().y).toBe(workArea.y - above.mascot.y) + // The panel would be behind the menu bar there, so it has to change sides. + expect(petWindow.getBounds().y + above.card.y).toBeLessThan(workArea.y) + expect(dragged).toEqual({ vertical: 'below' }) + expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' }) + + // The renderer re-lays out and reports the flipped boxes. + expect(controller.setInteractiveRegions(petWindow as never, [ + below.mascot, + below.card, + below.toggle, + ])).toEqual({ vertical: 'below' }) + + // Flipping moved the mascot up inside the window, so the window has to drop + // by the same amount: the mascot stays on the menu bar rather than jumping + // a panel-height down the screen. + expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y) + // And the whole panel is now inside the work area. + expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y) + }) + + it('holds the mascot still through a flip that happens short of the menu bar', async () => { + // The panel runs out of room before the mascot reaches the edge, so most + // flips happen mid-screen where the clamp has nothing to say. Without the + // window absorbing the flip, the mascot snaps up to the work area top — + // the whole panel height away from where the pointer left it. + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { constrainTopTo: panelDrag.workArea.y }, + ) + const { controller } = panelController(petWindow) + await controller.show() + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + + const { above, below, workArea } = panelDrag + controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle]) + + // Land the mascot 100px under the menu bar: clear of every clamp, but short + // of the ~183px the panel needs above it. + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -55 }) + const mascotScreenY = petWindow.getBounds().y + above.mascot.y + expect(mascotScreenY).toBe(workArea.y + 100) + expect(dragged).toEqual({ vertical: 'below' }) + + controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle]) + + expect(petWindow.getBounds().y + below.mascot.y).toBe(mascotScreenY) + expect(petWindow.getBounds().y + below.toggle.y).toBeGreaterThanOrEqual(workArea.y) + }) + + it('keeps a drag tracking the pointer after the panel flips mid-drag', async () => { + vi.useFakeTimers() + try { + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { constrainTopTo: panelDrag.workArea.y }, + ) + let cursor = { x: 150, y: 180 } + const onPanelPlacementChanged = vi.fn() + const controller = new PetWindowController({ + createWindow: vi.fn(() => petWindow) as never, + getCursorScreenPoint: () => cursor, + getCurrentWorkArea: () => panelDrag.workArea, + getWorkAreaForPoint: () => panelDrag.workArea, + load: vi.fn().mockResolvedValue(undefined), + onPanelPlacementChanged, + platform: 'darwin', + preloadPath: '/app/electron-dist/preload.cjs', + }) + await controller.show() + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + + const { above, below, workArea } = panelDrag + controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle]) + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + + // The renderer sends no move payloads — this process samples the cursor — + // so a flip decided mid-drag has no reply to ride back on and goes out as + // an event instead. + cursor = { x: 150, y: -55 } + vi.advanceTimersByTime(16) + expect(onPanelPlacementChanged).toHaveBeenCalledWith(petWindow, { vertical: 'below' }) + + controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle]) + expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 100) + + // Dragging 20px further has to move the mascot 20px further. The drag maps + // pointer travel from a window origin captured before the flip, so unless + // that origin absorbed the flip too this snaps back. + cursor = { x: 150, y: -75 } + vi.advanceTimersByTime(16) + expect(petWindow.getBounds().y + below.mascot.y).toBe(workArea.y + 80) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the flipped panel flipped instead of oscillating on the threshold', async () => { + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { constrainTopTo: panelDrag.workArea.y }, + ) + const { controller } = panelController(petWindow) + await controller.show() + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + + const { above, below } = panelDrag + controller.setInteractiveRegions(petWindow as never, [above.mascot, above.card, above.toggle]) + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 }) + controller.setInteractiveRegions(petWindow as never, [below.mascot, below.card, below.toggle]) + + // The flip itself frees the space it was testing for: the panel left the + // strip above the mascot. Re-reporting the flipped layout must not read + // that as room to flip back, or the panel changes sides every frame. + expect(controller.setInteractiveRegions(petWindow as never, [ + below.mascot, + below.card, + below.toggle, + ])).toEqual({ vertical: 'below' }) + expect(petWindow.getBounds().y + below.mascot.y).toBe(panelDrag.workArea.y) + }) + + it('reopens a flipped pet where the mascot was, not where the window was', async () => { + // The saved y belongs to the mascot offset it was saved with, and the panel + // being below the mascot puts that offset at the top of the window. The + // renderer always starts the panel above, so restoring the bare window + // position would drop the mascot by the whole panel height. + const { above, below, workArea } = panelDrag + const saved = { x: 100, y: workArea.y - below.mascot.y, region: below.mascot } + let petWindow: ReturnType | undefined + const controller = new PetWindowController({ + createWindow: vi.fn((bounds) => { + petWindow = createFakeWindow( + bounds as { x: number, y: number, width: number, height: number }, + { constrainTopTo: workArea.y }, + ) + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + return petWindow + }) as never, + getCurrentWorkArea: () => workArea, + getWorkAreaForPoint: () => workArea, + load: vi.fn().mockResolvedValue(undefined), + platform: 'darwin', + preloadPath: '/app/electron-dist/preload.cjs', + readPosition: () => saved, + }) + + await controller.show() + // First paint is the renderer's default layout, panel above the mascot. + const placement = controller.setInteractiveRegions(petWindow as never, [ + above.mascot, + above.card, + above.toggle, + ]) + + expect(petWindow?.getBounds().y).toBe(workArea.y - above.mascot.y) + expect((petWindow?.getBounds().y ?? 0) + above.mascot.y).toBe(workArea.y) + // And it is still out of room up there, so it flips straight back. + expect(placement).toEqual({ vertical: 'below' }) + }) + + it('leaves the panel above the mascot when only the mascot is reported', async () => { + // No panel on screen means nothing to protect, and the mascot has to keep + // its full reach. + const petWindow = createFakeWindow( + { x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }, + { constrainTopTo: panelDrag.workArea.y }, + ) + const { controller, onPanelPlacementChanged } = panelController(petWindow) + await controller.show() + petWindow.applyConstructorOptions({ enableLargerThanScreen: true }) + + controller.setInteractiveRegions(petWindow as never, [panelDrag.above.mascot]) + controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 }) + const dragged = controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 }) + + expect(dragged).toEqual({ vertical: 'above' }) + expect(onPanelPlacementChanged).not.toHaveBeenCalled() + expect(petWindow.getBounds().y).toBe(panelDrag.workArea.y - panelDrag.above.mascot.y) + }) + + it('measures the panel against the room above the mascot, not the window', () => { + const mascot = { x: 136, y: 240, width: 112, height: 128 } + const panel = { x: 16, y: 88, width: 352, height: 171 } + const workArea = { x: 0, y: 25, width: 800, height: 575 } + const above = { vertical: 'above' } as const + + // 335px of room, 183px needed. + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: 120 }, + workArea, + mascot, + panel, + previous: above, + })).toEqual({ vertical: 'above' }) + + // Mascot on the menu bar: no room at all. + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: workArea.y - mascot.y }, + workArea, + mascot, + panel, + previous: above, + })).toEqual({ vertical: 'below' }) + + // One pixel short still flips; exactly enough does not. + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 11 }, + workArea, + mascot, + panel, + previous: above, + })).toEqual({ vertical: 'below' }) + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 }, + workArea, + mascot, + panel, + previous: above, + })).toEqual({ vertical: 'above' }) + + // Coming back the other way costs extra, so a mascot parked on the boundary + // does not flutter. + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 12 }, + workArea, + mascot, + panel, + previous: { vertical: 'below' }, + })).toEqual({ vertical: 'below' }) + expect(resolvePetPanelPlacement({ + windowPosition: { x: 100, y: workArea.y - mascot.y + panel.height + 36 }, + workArea, + mascot, + panel, + previous: { vertical: 'below' }, + })).toEqual({ vertical: 'above' }) + }) + + it('bounds the panel by everything hanging off the mascot, not just the card', () => { + // The collapse control overhangs the card, and the badge replaces the card + // entirely. Taking regions[1] alone would under-measure both. + expect(petPanelBounds([ + panelDrag.above.mascot, + panelDrag.above.card, + panelDrag.above.toggle, + ])).toEqual({ x: 16, y: 88, width: 352, height: 171 }) + expect(petPanelBounds([panelDrag.above.mascot])).toBeNull() + }) + it('tracks the native cursor at 60 Hz without renderer move payloads', async () => { vi.useFakeTimers() try { diff --git a/desktop/electron/services/petWindow.ts b/desktop/electron/services/petWindow.ts index 0fca9326..5a21e3f0 100644 --- a/desktop/electron/services/petWindow.ts +++ b/desktop/electron/services/petWindow.ts @@ -52,6 +52,41 @@ export type PetWindowDragPayload = PetWindowPosition & { phase: 'start' | 'move' | 'end' } +/** + * Which side of the mascot the renderer should hang the activity panel on. + * + * Dragging clamps against the mascot alone so it can reach every display edge + * through the window's transparent padding, which by construction pushes the + * rest of the window off-screen. At the top edge that padding is where the + * panel lives, so it ends up behind the menu bar. Only this process knows the + * window position and the work area, so it picks the side and the renderer + * follows. + * + * Left and right are deliberately not handled here. The panel is wider than the + * mascot by more than the padding that remains beside it, so sliding it back + * on-screen inside a fixed-size window just moves the clipping from the display + * edge to the window edge. Fixing those needs the window itself to grow or + * move, which is a different change. + */ +export type PetPanelPlacement = { + vertical: 'above' | 'below' +} + +export const PET_PANEL_DEFAULT_PLACEMENT: PetPanelPlacement = Object.freeze({ + vertical: 'above', +}) + +/** Gap the renderer keeps between the panel and the mascot. */ +const PET_PANEL_GAP = 12 + +/** + * Flipping is sticky, because the flip itself moves the window: the panel + * leaves the space above the mascot, the mascot moves up inside the window, and + * the window drops by that much to hold the mascot still. A bare "does it fit" + * test would then find room again and flip straight back, once per frame. + */ +const PET_PANEL_FLIP_HYSTERESIS = 24 + function isFiniteScreenCoordinate(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) @@ -191,6 +226,54 @@ export function clampPetWindowPosition( } } +/** + * Bounding box of everything the renderer hangs off the mascot — the activity + * card, its collapse control, the task badge. + * + * The first region is the mascot itself: it is the drag anchor, so it is the + * one box that is *meant* to sit flush against a display edge. Everything after + * it has to stay on-screen to be readable, which is what this box represents. + */ +export function petPanelBounds(regions: Rectangle[]): Rectangle | null { + const attachments = regions.slice(1) + if (attachments.length === 0) return null + const left = Math.min(...attachments.map((region) => region.x)) + const top = Math.min(...attachments.map((region) => region.y)) + const right = Math.max(...attachments.map((region) => region.x + region.width)) + const bottom = Math.max(...attachments.map((region) => region.y + region.height)) + return { x: left, y: top, width: right - left, height: bottom - top } +} + +/** + * The measurement is deliberately placement-independent: the panel's *height* + * is the same on either side of the mascot, and the mascot's screen position is + * held across a flip, so both sides of the comparison survive the flip they + * decide. Comparing against how much room the panel currently occupies above + * the mascot would instead be self-referential and oscillate. + */ +export function resolvePetPanelPlacement({ + windowPosition, + workArea, + mascot, + panel, + previous, +}: { + windowPosition: PetWindowPosition + workArea: Rectangle + mascot: Rectangle + panel: Rectangle | null + previous: PetPanelPlacement +}): PetPanelPlacement { + if (!panel) return PET_PANEL_DEFAULT_PLACEMENT + + const required = panel.height + PET_PANEL_GAP + const spaceAbove = windowPosition.y + mascot.y - workArea.y + const threshold = previous.vertical === 'above' + ? required + : required + PET_PANEL_FLIP_HYSTERESIS + return { vertical: spaceAbove >= threshold ? 'above' : 'below' } +} + type PetWindowExtent = { width: number; height: number } function isPositiveExtent(extent: Partial | undefined): boolean { @@ -328,6 +411,11 @@ export type PetWindowControllerOptions = { getWorkAreaForPoint?(point: Point): Rectangle load(window: PetWindow): Promise onCreated?(window: PetWindow): void + /** + * Dragging is driven by a cursor sampler in this process, not by renderer + * calls, so a placement that changes mid-drag has no reply to ride back on. + */ + onPanelPlacementChanged?(window: PetWindow, placement: PetPanelPlacement): void platform?: NodeJS.Platform preloadPath: string readPosition?(): PetWindowState | null @@ -345,6 +433,18 @@ export class PetWindowController { } | null = null private dragTimer: ReturnType | null = null private visibleDragRegion: Rectangle | null = null + private panelBounds: Rectangle | null = null + private panelPlacement: PetPanelPlacement = PET_PANEL_DEFAULT_PLACEMENT + /** + * Screen y the mascot has to keep once the renderer reports its next layout. + * + * Set whenever the mascot is about to move inside the window while the user + * expects it to stay put on screen: a flip moves it by the panel's height, and + * a restart hands the renderer a saved position whose mascot offset belongs to + * whichever side the panel was on when it was saved. Both cases need the + * window to move the opposite way by the same amount. + */ + private pendingMascotAnchorScreenY: number | null = null private pendingRestoredPosition: PetWindowState | null = null private readonly options: PetWindowControllerOptions @@ -354,8 +454,16 @@ export class PetWindowController { private async create(): Promise { const restoredPosition = this.options.readPosition?.() ?? null - this.visibleDragRegion = null + this.resetPanelState() this.pendingRestoredPosition = restoredPosition + // A saved position is only meaningful next to the mascot box it was saved + // with, and that box moves when the panel changes sides. The renderer always + // starts the panel above the mascot, so a position saved with it below would + // otherwise drop the mascot by the panel's height on the next launch. + // Restoring where the *mascot* was survives that, and a resized mascot too. + if (restoredPosition?.region) { + this.pendingMascotAnchorScreenY = restoredPosition.y + restoredPosition.region.y + } const currentWorkArea = restoredPosition && this.options.getWorkAreaForPoint ? this.options.getWorkAreaForPoint(petWindowAnchor(restoredPosition)) : this.options.getCurrentWorkArea() @@ -369,7 +477,7 @@ export class PetWindowController { this.finishDrag(window) if (this.window === window) { this.window = null - this.visibleDragRegion = null + this.resetPanelState() this.pendingRestoredPosition = null } }) @@ -383,7 +491,7 @@ export class PetWindowController { if (!window.isDestroyed()) window.destroy() if (this.window === window) { this.window = null - this.visibleDragRegion = null + this.resetPanelState() this.pendingRestoredPosition = null } throw error @@ -428,10 +536,17 @@ export class PetWindowController { } window.destroy() this.window = null - this.visibleDragRegion = null + this.resetPanelState() this.pendingRestoredPosition = null } + private resetPanelState(): void { + this.visibleDragRegion = null + this.panelBounds = null + this.panelPlacement = PET_PANEL_DEFAULT_PLACEMENT + this.pendingMascotAnchorScreenY = null + } + owns(window: PetWindow | null): boolean { return window !== null && this.window === window && !window.isDestroyed() } @@ -444,7 +559,7 @@ export class PetWindowController { window.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined) } - setInteractiveRegions(window: PetWindow, regions: Rectangle[]): void { + setInteractiveRegions(window: PetWindow, regions: Rectangle[]): PetPanelPlacement { if (!this.owns(window)) { throw new Error('Pet window IPC sender does not own the companion window') } @@ -458,6 +573,8 @@ export class PetWindowController { ? normalizePetWindowRegion(primaryRegion, extent) : null if (dragRegion) this.visibleDragRegion = dragRegion + const panel = petPanelBounds(regions) + this.panelBounds = panel ? normalizePetWindowRegion(panel, extent) : null // A saved edge position leaves the transparent padding off-screen, so // creating the window re-clamps it against the whole window and walks the @@ -465,21 +582,23 @@ export class PetWindowController { // region, which only arrives here — and it arrives on every platform, so // this runs on every platform too. if (dragRegion) { - const requestedPosition = this.pendingRestoredPosition ?? window.getBounds() + const bounds = window.getBounds() + const restoredPosition = this.pendingRestoredPosition this.pendingRestoredPosition = null + const requestedPosition = this.holdMascotAnchor(dragRegion, restoredPosition ?? bounds) // The live region beats whatever was saved: the mascot may have been // resized since, and this is the first measurement of the real one. const anchor = petWindowAnchor({ ...requestedPosition, region: dragRegion }) const workArea = this.options.getWorkAreaForPoint?.(anchor) ?? this.options.getCurrentWorkArea() const nextPosition = clampPetWindowPosition(requestedPosition, workArea, dragRegion) - const bounds = window.getBounds() if (nextPosition.x !== bounds.x || nextPosition.y !== bounds.y) { movePetWindow(window, nextPosition) } + this.updatePanelPlacement(window, nextPosition, workArea, dragRegion) } - if (platform === 'darwin') return + if (platform === 'darwin') return this.panelPlacement const shape = regions.map((region) => normalizePetWindowRegion({ x: region.x - PET_WINDOW_SHAPE_PADDING, @@ -488,9 +607,60 @@ export class PetWindowController { height: region.height + PET_WINDOW_SHAPE_PADDING * 2, }, extent)) if (shape.length > 0) window.setShape(shape) + return this.panelPlacement } - dragWindow(window: PetWindow, payload: PetWindowDragPayload): void { + /** + * Rebases the window on the screen position the mascot has to keep. + * + * The reported region is the first news of where the mascot actually sits + * inside the window, so this is the point where a flip or a restore can be + * turned into a window move that leaves the mascot where the user last saw it. + */ + private holdMascotAnchor( + mascot: Rectangle, + requestedPosition: PetWindowPosition, + ): PetWindowPosition { + const anchorScreenY = this.pendingMascotAnchorScreenY + if (anchorScreenY === null) return requestedPosition + this.pendingMascotAnchorScreenY = null + + const compensated = { x: requestedPosition.x, y: anchorScreenY - mascot.y } + const drag = this.drag + if (drag) { + // A drag maps pointer travel from a fixed window origin, so the origin has + // to absorb the flip too — otherwise the next tick recomputes the pre-flip + // position and drags the mascot straight back. + drag.windowStart = { + ...drag.windowStart, + y: drag.windowStart.y + compensated.y - requestedPosition.y, + } + } + return compensated + } + + private updatePanelPlacement( + window: PetWindow, + windowPosition: PetWindowPosition, + workArea: Rectangle, + mascot: Rectangle, + ): void { + const previous = this.panelPlacement + const next = resolvePetPanelPlacement({ + windowPosition, + workArea, + mascot, + panel: this.panelBounds, + previous, + }) + if (next.vertical === previous.vertical) return + + this.pendingMascotAnchorScreenY = windowPosition.y + mascot.y + this.panelPlacement = next + this.options.onPanelPlacementChanged?.(window, next) + } + + dragWindow(window: PetWindow, payload: PetWindowDragPayload): PetPanelPlacement { if (!this.owns(window)) { throw new Error('Pet window IPC sender does not own the companion window') } @@ -514,7 +684,7 @@ export class PetWindowController { if (this.options.getCursorScreenPoint) { this.dragTimer = setInterval(() => this.sampleDragPosition(), PET_WINDOW_DRAG_INTERVAL_MS) } - return + return this.panelPlacement } const drag = this.drag @@ -531,6 +701,7 @@ export class PetWindowController { if (payload.phase === 'end') { this.finishDrag(window) } + return this.panelPlacement } private readCursorScreenPoint(): PetWindowPosition | null { @@ -572,6 +743,12 @@ export class PetWindowController { movePetWindow(drag.window, nextPosition) drag.lastPosition = nextPosition + // Dragging is the only way the mascot reaches an edge, so it is also where + // the panel runs out of room. Most ticks here come from the cursor sampler + // rather than a renderer call, so this reaches the renderer as an event. + if (this.visibleDragRegion) { + this.updatePanelPlacement(drag.window, nextPosition, workArea, this.visibleDragRegion) + } } private finishDrag(window?: PetWindow): void { diff --git a/desktop/src/features/pets/PetApp.test.tsx b/desktop/src/features/pets/PetApp.test.tsx index 9c3ae68b..a84fd8ca 100644 --- a/desktop/src/features/pets/PetApp.test.tsx +++ b/desktop/src/features/pets/PetApp.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ dragWindow: vi.fn(), setIgnoreMouseEvents: vi.fn(), setInteractiveRegions: vi.fn(), + onPanelPlacementChanged: vi.fn(), })) vi.mock('../../api/desktopUiPreferences', () => ({ @@ -56,6 +57,7 @@ vi.mock('../../lib/desktopHost', () => ({ dragWindow: mocks.dragWindow, setIgnoreMouseEvents: mocks.setIgnoreMouseEvents, setInteractiveRegions: mocks.setInteractiveRegions, + onPanelPlacementChanged: mocks.onPanelPlacementChanged, }, }), })) @@ -145,6 +147,8 @@ describe('PetApp', () => { mocks.fetchSessions.mockResolvedValue(undefined) mocks.showContextMenu.mockResolvedValue(true) mocks.dragWindow.mockResolvedValue(undefined) + mocks.setInteractiveRegions.mockResolvedValue(undefined) + mocks.onPanelPlacementChanged.mockResolvedValue(() => undefined) mocks.focusMainWindow.mockResolvedValue(undefined) mocks.getChatStatus.mockImplementation(async (sessionId: string) => ({ state: sessionId === 'session-running' ? 'thinking' : 'idle', @@ -307,6 +311,49 @@ describe('PetApp', () => { }) }) + it('moves the task card below the mascot when the host says there is no room above', async () => { + // The host clamps the mascot to the display edge through the window's + // transparent padding, which puts the card behind the macOS menu bar. It + // answers the region report with the side the card has to move to (#1140). + mocks.setInteractiveRegions.mockResolvedValue({ vertical: 'below' }) + const { container } = render() + await screen.findByRole('button', { + name: 'Build pet window, pet.window.status.running', + }) + + await waitFor(() => { + expect(container.querySelector('.pet-window-stack')) + .toHaveAttribute('data-panel-placement', 'below') + }) + // The re-laid-out boxes have to go back, or the host holds the mascot + // against the pre-flip layout. + await waitFor(() => { + expect(mocks.setInteractiveRegions.mock.calls.length).toBeGreaterThan(1) + }) + }) + + it('follows a placement the host pushes mid-drag, when no call is in flight', async () => { + // Dragging is driven by a cursor sampler in the host, not by renderer + // calls, so a flip decided mid-drag arrives as an event. + let push: ((placement: { vertical: 'above' | 'below' }) => void) | undefined + mocks.onPanelPlacementChanged.mockImplementation(async (handler: typeof push) => { + push = handler + return () => undefined + }) + const { container } = render() + await screen.findByRole('button', { name: 'pet.window.interact' }) + await waitFor(() => expect(push).toBeDefined()) + + expect(container.querySelector('.pet-window-stack')) + .toHaveAttribute('data-panel-placement', 'above') + + await waitFor(() => { + push?.({ vertical: 'below' }) + expect(container.querySelector('.pet-window-stack')) + .toHaveAttribute('data-panel-placement', 'below') + }) + }) + it('focuses the main desktop window after a short mascot pointer gesture', async () => { render() const mascot = await screen.findByRole('button', { name: 'pet.window.interact' }) diff --git a/desktop/src/features/pets/PetApp.tsx b/desktop/src/features/pets/PetApp.tsx index 9e31a611..1545479e 100644 --- a/desktop/src/features/pets/PetApp.tsx +++ b/desktop/src/features/pets/PetApp.tsx @@ -15,6 +15,7 @@ import { import { sessionsApi, type PetSessionRuntimeStatus } from '../../api/sessions' import { useTranslation } from '../../i18n' import { getDesktopHost } from '../../lib/desktopHost' +import type { DesktopPetPanelPlacement } from '../../lib/desktopHost/types' import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' @@ -38,6 +39,10 @@ const SESSION_REFRESH_INTERVAL_MS = 5_000 const STATUS_REFRESH_INTERVAL_MS = 2_000 const CUSTOM_PET_REFRESH_INTERVAL_MS = 30_000 const PET_DRAG_THRESHOLD_PX = 4 +// The host owns this: it is the only side that knows where the window sits on +// which display. Until it answers, the panel keeps its usual spot above the +// mascot. +const DEFAULT_PANEL_PLACEMENT: DesktopPetPanelPlacement = { vertical: 'above' } type PetDragGesture = { pointerId: number @@ -76,6 +81,9 @@ export function PetApp() { const [isMascotDragging, setIsMascotDragging] = useState(false) const [dragDirection, setDragDirection] = useState<'left' | 'right' | null>(null) const [lookDirection, setLookDirection] = useState(undefined) + const [panelPlacement, setPanelPlacement] = useState( + DEFAULT_PANEL_PLACEMENT, + ) const preferencesRef = useRef(null) const pendingPreferencePatchesRef = useRef(new Map>()) const nextPreferencePatchIdRef = useRef(0) @@ -329,6 +337,29 @@ export function PetApp() { || Boolean(preferences?.showTaskPanel && activities.length > 0) const expanded = showActivityCard + // Ignoring an unchanged placement matters: the host answers every region + // report and every drag tick, and a fresh object each time would re-render the + // pet on every frame of a drag. + const applyPanelPlacement = useCallback((next: DesktopPetPanelPlacement | undefined) => { + if (!next) return + setPanelPlacement((current) => (current.vertical === next.vertical ? current : next)) + }, []) + + useEffect(() => { + let unlisten: (() => void) | undefined + let cancelled = false + void getDesktopHost().pets.onPanelPlacementChanged(applyPanelPlacement) + .then((dispose) => { + if (cancelled) dispose() + else unlisten = dispose + }) + .catch(() => undefined) + return () => { + cancelled = true + unlisten?.() + } + }, [applyPanelPlacement]) + useLayoutEffect(() => { if (!preferences) return const elements = [ @@ -348,7 +379,10 @@ export function PetApp() { height: Math.max(1, Math.ceil(rect.height)), } }) - if (regions.length > 0) void getDesktopHost().pets.setInteractiveRegions(regions) + if (regions.length === 0) return + void getDesktopHost().pets.setInteractiveRegions(regions) + .then(applyPanelPlacement) + .catch(() => undefined) } updateRegions() @@ -361,7 +395,18 @@ export function PetApp() { observer?.disconnect() window.removeEventListener('resize', updateRegions) } - }, [activities.length, expanded, preferences?.size, selectedPet, showActivityCard]) + }, [ + activities.length, + applyPanelPlacement, + expanded, + // A flip re-lays the window out without resizing anything in it, so + // ResizeObserver never fires and the reported boxes would keep describing + // the old side. The host needs the new ones to hold the mascot still. + panelPlacement.vertical, + preferences?.size, + selectedPet, + showActivityCard, + ]) const isInteractivePoint = useCallback((x: number, y: number) => [ mascotRef.current, @@ -398,6 +443,7 @@ export function PetApp() { }, 0) void gesture.startPromise .then(() => getDesktopHost().pets.dragWindow({ phase: 'end', x, y })) + .then(applyPanelPlacement) .catch(() => undefined) } @@ -414,7 +460,7 @@ export function PetApp() { setLookDirection(undefined) void getDesktopHost().pets.setIgnoreMouseEvents(true) } - }, [isInteractivePoint]) + }, [applyPanelPlacement, isInteractivePoint]) const releasePointerPassthrough = useCallback((nextTarget: EventTarget | null) => { if (dragGestureRef.current) return @@ -453,6 +499,7 @@ export function PetApp() {
void getDesktopHost().pets.setIgnoreMouseEvents(false)} onMouseMove={(event) => { if (dragGestureRef.current) return @@ -542,12 +589,13 @@ export function PetApp() { if (distance < PET_DRAG_THRESHOLD_PX) return suppressNextMascotClickRef.current = true setIsMascotDragging(true) - gesture.startPromise = Promise.resolve().then(() => - getDesktopHost().pets.dragWindow({ + gesture.startPromise = Promise.resolve() + .then(() => getDesktopHost().pets.dragWindow({ phase: 'start', x: gesture.startScreenX, y: gesture.startScreenY, })) + .then(applyPanelPlacement) void gesture.startPromise.catch(() => undefined) } const directionDelta = event.screenX - gesture.directionScreenX diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index ea1727a1..24a0a93e 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -165,6 +165,9 @@ export const browserHost: DesktopHost = { async focusSession() { unsupported('Focusing a pet session') }, + async onPanelPlacementChanged() { + return noopUnlisten + }, async onNavigateSession() { return noopUnlisten }, diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index cf5f8241..1d57cf0f 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -133,6 +133,8 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { focusSession: sessionId => invoke(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId), onNavigateSession: handler => subscribe(ELECTRON_EVENT_CHANNELS.petNavigateSession, handler), onVisibilityChanged: handler => subscribe(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, handler), + onPanelPlacementChanged: handler => + subscribe(ELECTRON_EVENT_CHANNELS.petPanelPlacementChanged, handler), }, dialogs: { open: options => invoke(ELECTRON_IPC_CHANNELS.dialogOpen, options), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index c06012dc..52675fc9 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -230,6 +230,18 @@ export type DesktopPetWindowDrag = { y: number } +/** + * Which side of the mascot the host wants the activity panel drawn on. + * + * The mascot is clamped to the display edge through the window's transparent + * padding, so at the top of the screen the panel that shares that padding ends + * up behind the menu bar. Only the host knows the window position and the work + * area, so it decides and the renderer follows. + */ +export type DesktopPetPanelPlacement = { + vertical: 'above' | 'below' +} + export type AppModeConfig = SettingsAppModeConfig export type AppModeSetInput = { @@ -287,13 +299,18 @@ export type DesktopHost = { show(): Promise hide(): Promise showContextMenu(closeLabel: string): Promise - dragWindow(payload: DesktopPetWindowDrag): Promise + dragWindow(payload: DesktopPetWindowDrag): Promise setIgnoreMouseEvents(ignore: boolean): Promise - setInteractiveRegions(regions: DesktopPetInteractiveRegion[]): Promise + setInteractiveRegions( + regions: DesktopPetInteractiveRegion[], + ): Promise focusMainWindow(): Promise focusSession(sessionId: string): Promise onNavigateSession(handler: (sessionId: string) => void): Promise onVisibilityChanged(handler: (visible: boolean) => void): Promise + onPanelPlacementChanged( + handler: (placement: DesktopPetPanelPlacement) => void, + ): Promise } dialogs: { open(options?: DialogOpenOptions): Promise diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 1a4e1dcb..ac544908 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -2132,6 +2132,15 @@ html[data-window-kind='pet'] #root { pointer-events: none; } +/* Dragging clamps against the mascot alone so it can reach every display edge, + which by construction pushes the rest of the window off-screen. At the top + edge that is the menu bar eating the activity panel, so the host asks for the + panel below the mascot instead and the whole stack flips. */ +.pet-window-stack[data-panel-placement='below'] { + justify-content: flex-start; + padding: 12px 12px 0; +} + .pet-mascot-button { position: relative; z-index: 10; @@ -2161,6 +2170,10 @@ html[data-window-kind='pet'] #root { pointer-events: none; } +.pet-window-stack[data-panel-placement='below'] .pet-mascot-wrap { + order: 1; +} + .pet-task-badge { position: absolute; z-index: 12; @@ -2307,6 +2320,12 @@ html[data-window-kind='pet'] #root { padding-right: 7px; } +.pet-window-stack[data-panel-placement='below'] .pet-activity-card { + order: 2; + margin-top: 12px; + margin-bottom: 0; +} + html[data-theme='dark'] .pet-activity-card, html[data-theme='ink-blue'] .pet-activity-card { border-color: rgba(255, 255, 255, 0.12); @@ -2477,6 +2496,13 @@ html[data-theme='ink-blue'] .pet-session-status { transform: translateX(50%); } +/* Flipped, the control stays on the mascot-facing side of the card so the card, + the control, and the mascot remain one compact stack. */ +.pet-window-stack[data-panel-placement='below'] .pet-activity-card .pet-panel-toggle { + top: -31px; + bottom: auto; +} + .pet-panel-toggle:hover { background: rgba(43, 47, 54, 0.88); color: white; diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index ae441e3c..7668af2f 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -244,6 +244,43 @@ describe('desktop theme tokens', () => { expect(cardCss).toContain('z-index: 15;') }) + it('restacks the pet task card under the mascot when the host flips it', () => { + // Reaching the macOS menu bar puts the window's top edge above the work + // area, and the card lives in exactly that strip. The host asks for the + // flip; without every one of these rules the card stays behind the menu + // bar, or lands on the mascot instead of beside it (#1140). + const stackCss = getCssBetween( + ".pet-window-stack[data-panel-placement='below'] {", + '.pet-mascot-button {', + ) + expect(stackCss).toContain('justify-content: flex-start;') + expect(stackCss).toContain('padding: 12px 12px 0;') + + const mascotCss = getCssBetween( + ".pet-window-stack[data-panel-placement='below'] .pet-mascot-wrap {", + '}', + ) + expect(mascotCss).toContain('order: 1;') + + const cardCss = getCssBetween( + ".pet-window-stack[data-panel-placement='below'] .pet-activity-card {", + '}', + ) + expect(cardCss).toContain('order: 2;') + expect(cardCss).toContain('margin-top: 12px;') + expect(cardCss).toContain('margin-bottom: 0;') + + // The collapse control hangs off the card's mascot-facing edge, so flipping + // the card has to flip the control with it. Its selector also has to outrank + // the expanded-state rule that pins `top: auto`, whatever the source order. + const toggleCss = getCssBetween( + ".pet-window-stack[data-panel-placement='below'] .pet-activity-card .pet-panel-toggle {", + '}', + ) + expect(toggleCss).toContain('top: -31px;') + expect(toggleCss).toContain('bottom: auto;') + }) + it('binds the dark variant to the app theme attribute, not the operating system', () => { // The app ships six themes toggled via ``. Tailwind's // stock `dark:` compiles to `prefers-color-scheme`, which fires on the OS