mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
fix(desktop): keep the pet task panel clear of the macOS menu bar #1140
Dragging clamps against the mascot alone, so the mascot can reach a display edge through the window's transparent padding. At the top edge that means asking for a negative window y on purpose -- and the activity panel lives in exactly the padding that goes off-screen with it. Measured against the shipped layout: of a 96px panel, 78px ends up above the work area, leaving an 18px sliver under the menu bar. This is not a regression in 8f3a2f092; it is that fix's other half. The mascot reaching the menu bar and the panel following it off-screen are the same negative y. So the panel changes sides instead. The main process is the only side that knows the window position and the work area, so it decides and the renderer follows, the way the Codex overlay does it. Three things that are load-bearing: - The test is placement-independent -- panel height against the room above the mascot -- because the flip frees the very space a "does it still fit above?" test would measure next, and would then flip back once per frame. A 24px hysteresis covers the boundary. - Flipping moves the mascot inside the window, so the window moves the opposite way to hold it still on screen. Mid-drag that has to rebase the drag's window origin too, or the next tick recomputes the pre-flip position. A restore needs the same treatment: a saved y belongs to the mascot offset it was saved with, and the renderer always starts the panel above, so restoring the bare window position would drop the mascot by the panel's height on the next launch. - The renderer only sends drag start and end -- the cursor sampler in this process drives everything between -- so a flip decided mid-drag has no reply to ride back on and goes out as an event. The panel box is the union of every reported region past the mascot, which keeps the IPC payload shape unchanged. Left and right are deliberately untouched. The panel is 352px wide in a 384px window, so it can only slide +/-16px before the window itself clips it, while reaching a side edge needs about 120px. Those need the window to grow or move, which is a different change. Falsified each layer by reverting it: the placement test, the window compensation, the drag rebase, and the restore anchor each turn their own case red.
This commit is contained in:
parent
bd44d0a17d
commit
0480d2f1ec
@ -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 = {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
|
||||
@ -64,11 +64,21 @@ const petHost = {
|
||||
showContextMenu: (closeLabel: string) =>
|
||||
invoke<boolean>(ELECTRON_IPC_CHANNELS.petsShowContextMenu, { closeLabel }),
|
||||
dragWindow: (payload: { phase: 'start' | 'move' | 'end', x: number, y: number }) =>
|
||||
invoke<void>(ELECTRON_IPC_CHANNELS.petsDragWindow, payload),
|
||||
invoke<DesktopPetPanelPlacement>(ELECTRON_IPC_CHANNELS.petsDragWindow, payload),
|
||||
setIgnoreMouseEvents: (ignore: boolean) =>
|
||||
invoke<void>(ELECTRON_IPC_CHANNELS.petsSetIgnoreMouseEvents, ignore),
|
||||
setInteractiveRegions: (regions: Array<{ x: number, y: number, width: number, height: number }>) =>
|
||||
invoke<void>(ELECTRON_IPC_CHANNELS.petsSetInteractiveRegions, regions),
|
||||
invoke<DesktopPetPanelPlacement>(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<void>(ELECTRON_IPC_CHANNELS.petsFocusMainWindow),
|
||||
focusSession: (sessionId: string) =>
|
||||
invoke<void>(ELECTRON_IPC_CHANNELS.petsFocusSession, sessionId),
|
||||
|
||||
@ -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<typeof createFakeWindow>) {
|
||||
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<typeof createFakeWindow> | 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 {
|
||||
|
||||
@ -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<PetWindowExtent> | undefined): boolean {
|
||||
@ -328,6 +411,11 @@ export type PetWindowControllerOptions = {
|
||||
getWorkAreaForPoint?(point: Point): Rectangle
|
||||
load(window: PetWindow): Promise<void>
|
||||
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<typeof setInterval> | 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<PetWindow> {
|
||||
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 {
|
||||
|
||||
@ -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(<PetApp />)
|
||||
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(<PetApp />)
|
||||
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(<PetApp />)
|
||||
const mascot = await screen.findByRole('button', { name: 'pet.window.interact' })
|
||||
|
||||
@ -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<PetLookDirection | null | undefined>(undefined)
|
||||
const [panelPlacement, setPanelPlacement] = useState<DesktopPetPanelPlacement>(
|
||||
DEFAULT_PANEL_PLACEMENT,
|
||||
)
|
||||
const preferencesRef = useRef<DesktopPetPreferences | null>(null)
|
||||
const pendingPreferencePatchesRef = useRef(new Map<number, Partial<DesktopPetPreferences>>())
|
||||
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() {
|
||||
<div
|
||||
ref={stackRef}
|
||||
className="pet-window-stack"
|
||||
data-panel-placement={panelPlacement.vertical}
|
||||
onMouseEnter={() => 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
|
||||
|
||||
@ -165,6 +165,9 @@ export const browserHost: DesktopHost = {
|
||||
async focusSession() {
|
||||
unsupported('Focusing a pet session')
|
||||
},
|
||||
async onPanelPlacementChanged() {
|
||||
return noopUnlisten
|
||||
},
|
||||
async onNavigateSession() {
|
||||
return noopUnlisten
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<void>
|
||||
hide(): Promise<void>
|
||||
showContextMenu(closeLabel: string): Promise<boolean>
|
||||
dragWindow(payload: DesktopPetWindowDrag): Promise<void>
|
||||
dragWindow(payload: DesktopPetWindowDrag): Promise<DesktopPetPanelPlacement>
|
||||
setIgnoreMouseEvents(ignore: boolean): Promise<void>
|
||||
setInteractiveRegions(regions: DesktopPetInteractiveRegion[]): Promise<void>
|
||||
setInteractiveRegions(
|
||||
regions: DesktopPetInteractiveRegion[],
|
||||
): Promise<DesktopPetPanelPlacement>
|
||||
focusMainWindow(): Promise<void>
|
||||
focusSession(sessionId: string): Promise<void>
|
||||
onNavigateSession(handler: (sessionId: string) => void): Promise<DesktopHostUnlisten>
|
||||
onVisibilityChanged(handler: (visible: boolean) => void): Promise<DesktopHostUnlisten>
|
||||
onPanelPlacementChanged(
|
||||
handler: (placement: DesktopPetPanelPlacement) => void,
|
||||
): Promise<DesktopHostUnlisten>
|
||||
}
|
||||
dialogs: {
|
||||
open(options?: DialogOpenOptions): Promise<string | string[] | null>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 `<html data-theme>`. Tailwind's
|
||||
// stock `dark:` compiles to `prefers-color-scheme`, which fires on the OS
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user