Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/i18n-inventory.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# i18n inventory (egdev6/quorb#66)

Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `66597e43adce43d22dd4e2e44970e9a3d64669c0` and `src-tauri/src/` = `326cdad748aad8375ed24f832d81c7d175cd3e8f`.
Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `252ab0dd90465b16a55ad09173b55d0a4c277f80` and `src-tauri/src/` = `326cdad748aad8375ed24f832d81c7d175cd3e8f`.
Any commit whose `git rev-parse <commit>:src` and `<commit>:src-tauri/src` match has these exact texts.
Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`.

Expand Down
116 changes: 116 additions & 0 deletions odd/tasks/222-reorder-reopen.md

Large diffs are not rendered by default.

30 changes: 28 additions & 2 deletions src/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,20 @@ import { createSessionMonitor } from './lib/session-monitor';
import { needsRefresh } from './lib/widget-refresh';
import { createWidgetRecovery, shouldAuditNativePosition } from './lib/widget-recovery';
import { placementOutcome, readPlacementWithRetries, reasonKey, samePlacement, type WidgetPlacementReason, type WidgetPlacementReport } from './lib/widget-placement';
import { clickPanel, closePanel, hoverPanel, isReorderGesture, NO_PANEL, panelHoldsOpen, type PanelState } from './lib/anchor-panel';
import {
canPointerOpen,
clickPanel,
closePanel,
holdAfterReorderDrop,
hoverPanel,
isReorderGesture,
NO_PANEL,
NO_REORDER_HOLD,
panelHoldsOpen,
type PanelState,
releaseOnStripLeave,
type ReorderHold,
} from './lib/anchor-panel';
import { createFocusTracker, FOCUS_EVENT, type OwnWindow } from './lib/anchor-focus';
import { createLatestQueue, physicalPanelBounds, samePhysical, DETAIL_EVENTS, DETAIL_WINDOW, isCurrentDetailInteraction, panelWidthFor, type DetailInteraction, type DetailState, type PhysicalBounds } from './lib/detail-panel';
import { createDetailPresence, presenceFromPoint, shouldSampleNativePresence } from './lib/detail-presence';
Expand Down Expand Up @@ -238,6 +251,8 @@ export function Widget() {
const drag = useRef<{ sx: number; sy: number; start: PhysicalPosition | null; scale: number; raf: number; pointerId: number; ending: boolean } | null>(null);
const orbDrag = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
const lastReorderAt = useRef(0);
// Tras reordenar, el hover no abre hasta que el puntero salga de la tira (#222).
const reorderHold = useRef<ReorderHold>(NO_REORDER_HOLD);
const openPeakRef = useRef<() => void>(() => {});
const dragWork = useRef<Promise<void>>(Promise.resolve());
const dragMounted = useRef(true);
Expand Down Expand Up @@ -771,6 +786,7 @@ export function Widget() {
cancelTimers();
detailInsideRef.current = false;
insideRef.current = false;
reorderHold.current = NO_REORDER_HOLD;
setInside(false);
setPanel(closePanel());
}));
Expand Down Expand Up @@ -1196,7 +1212,13 @@ export function Widget() {
} catch {
/* ya liberado */
}
if (od?.moved) lastReorderAt.current = Date.now();
if (od?.moved) {
lastReorderAt.current = Date.now();
// `.dock` es `inset: 0`: dentro de la ventana es dentro de la tira. Con
// la captura, `insideRef` queda desactualizado: se usan las coordenadas.
const inside = e.clientX >= 0 && e.clientY >= 0 && e.clientX < window.innerWidth && e.clientY < window.innerHeight;
reorderHold.current = holdAfterReorderDrop(inside);
}
if (od?.moved && reorder) {
const next = reorder.order;
setReorder(null);
Expand Down Expand Up @@ -1231,6 +1253,7 @@ export function Widget() {
}}
onMouseLeave={() => {
insideRef.current = false;
reorderHold.current = releaseOnStripLeave(reorderHold.current);
setInside(false);
scheduleClose();
}}
Expand Down Expand Up @@ -1293,6 +1316,9 @@ export function Widget() {
threshold={config?.alerts.threshold}
dragging={reorder?.id === r.id}
onHover={(el) => openFor(r.id, el)}
onPointerEnter={(el) => {
if (canPointerOpen(reorderHold.current)) openFor(r.id, el);
}}
onClick={(el) => anchorOn(r.id, el)}
pointer={orbHandlers(r.id)}
/>
Expand Down
31 changes: 31 additions & 0 deletions src/components/ProviderGauge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ describe('ProviderGauge popup focus', () => {
);
});

describe('ProviderGauge pointer-only enter', () => {
function gauge(onPointerEnter?: (el: HTMLElement) => void) {
vi.stubGlobal('navigator', { userAgent: 'X11; Linux x86_64' });
const onHover = vi.fn();
const report: UsageReport = {
id: 'claude', name: 'Claude', kind: 'subscription', status: 'ok', plan: null,
meters: [], spend: null, message: null, action: null, fetchedAt: '',
};
const button = ProviderGauge({ report, small: false, size: 60, active: false, onHover, onPointerEnter });
const target = { matches: vi.fn(() => true) };
return { button, event: { currentTarget: target }, onHover, target };
}

it('routes mouseenter to onPointerEnter and not to onHover, while focus still opens', () => {
const onPointerEnter = vi.fn();
const { button, event, onHover, target } = gauge(onPointerEnter);
button.props.onMouseEnter(event);
expect(onPointerEnter).toHaveBeenCalledWith(target);
expect(onHover).not.toHaveBeenCalled();
button.props.onFocus(event);
expect(onHover).toHaveBeenCalledWith(target);
expect(onPointerEnter).toHaveBeenCalledTimes(1);
});

it('keeps mouseenter on onHover when onPointerEnter is not given', () => {
const { button, event, onHover, target } = gauge();
button.props.onMouseEnter(event);
expect(onHover).toHaveBeenCalledWith(target);
});
});

describe('ProviderGauge semantic colors', () => {
it.each(['nan', 'nan:work', 'gemini', 'cursor', 'copilot', 'openrouter'])('keeps one configured color per ring for %s', (id) => {
const meters = [meter('Modelo A', 2592000), meter('Modelo B', null), meter('Modelo C', 86400)];
Expand Down
8 changes: 7 additions & 1 deletion src/components/ProviderGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function ProviderGauge({
threshold,
dragging = false,
onHover,
onPointerEnter,
onClick,
pointer,
}: {
Expand All @@ -58,6 +59,11 @@ export function ProviderGauge({
/** Umbral de aviso (%) */
threshold?: number;
onHover: (el: HTMLElement) => void;
/**
* Entrada del puntero (#222): solo `mouseenter`. Si no se pasa, la entrada
* abre como `onHover`; el foco y el clic siguen usando `onHover`.
*/
onPointerEnter?: (el: HTMLElement) => void;
/**
* Clic sobre el orbe (#170): ancla el panel. Si no se pasa, el clic abre el
* panel como hace el hover.
Expand Down Expand Up @@ -102,7 +108,7 @@ export function ProviderGauge({
title={account || reason || validity || resets ? accessibleTitle : undefined}
aria-expanded={active}
style={{ '--size': `${size}px`, '--w': `${w}px`, '--gap': `${gap}px` } as CSSProperties}
onMouseEnter={(e) => onHover(e.currentTarget)}
onMouseEnter={(e) => (onPointerEnter ?? onHover)(e.currentTarget)}
onFocus={(e) => {
// Windows can restore pointer-acquired focus when the popup hides.
// Treating that as hover reopens it outside both windows, causing a loop.
Expand Down
59 changes: 59 additions & 0 deletions src/lib/anchor-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {
clickPanel,
closePanel,
createPanelCoordinator,
canPointerOpen,
hoverPanel,
holdAfterReorderDrop,
isReorderGesture,
NO_PANEL,
NO_REORDER_HOLD,
releaseOnStripLeave,
panelHoldsOpen,
type PanelState,
REORDER_GUARD_MS,
Expand Down Expand Up @@ -92,6 +96,61 @@ describe('the reorder guard', () => {
});
});

describe('the reorder hold (hover after a reorder, #222)', () => {
it('holds the hover when a reorder is dropped with the pointer inside the strip', () => {
expect(NO_REORDER_HOLD).toBe(false);
expect(holdAfterReorderDrop(true)).toBe(true);
});

it('keeps the pointer from opening while held, however much time passes', () => {
// El `mouseenter` diferido llega entre 0,2 s y 2,3 s después de soltar:
// por eso `canPointerOpen` no recibe ninguna marca de tiempo.
const held = holdAfterReorderDrop(true);
expect(canPointerOpen.length).toBe(1);
expect(canPointerOpen(held)).toBe(false);
expect(canPointerOpen(held)).toBe(false);
});

it('releases the hold only with a real strip leave, and then the pointer opens', () => {
const held = holdAfterReorderDrop(true);
const released = releaseOnStripLeave(held);
expect(released).toBe(false);
expect(canPointerOpen(released)).toBe(true);
});

it('does not hold when the reorder is dropped outside the strip', () => {
// Con la captura del puntero se puede soltar fuera de la ventana: su
// `mouseleave` ya ocurrió y nada liberaría la retención después.
const hold = holdAfterReorderDrop(false);
expect(hold).toBe(false);
expect(canPointerOpen(hold)).toBe(true);
});

it('keeps a strip leave idempotent while nothing is held', () => {
expect(releaseOnStripLeave(NO_REORDER_HOLD)).toBe(false);
expect(canPointerOpen(releaseOnStripLeave(NO_REORDER_HOLD))).toBe(true);
});

it('never changes the panel state: hover and click do not take the hold', () => {
const states: PanelState[] = [NO_PANEL, { providerId: 'claude', anchored: false }, { providerId: 'claude', anchored: true }];
const results = (state: PanelState) => ({
hover: hoverPanel(state, 'codex'),
click: clickPanel(state, 'codex'),
close: closePanel(),
});
for (const state of states) {
const held = holdAfterReorderDrop(true);
const whileHeld = results(state);
const released = releaseOnStripLeave(held);
const afterRelease = results(state);
expect(held).not.toBe(released);
expect(afterRelease).toEqual(whileHeld);
expect(hoverPanel.length).toBe(2);
expect(clickPanel.length).toBe(2);
}
});
});

describe('the panel coordinator (delayed open and escape cancellation)', () => {
it('cancels pending open when Escape is pressed before the delay expires', () => {
let timerRun: (() => void) | null = null;
Expand Down
28 changes: 28 additions & 0 deletions src/lib/anchor-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,31 @@ export const REORDER_GUARD_MS = 400;
*/
export const isReorderGesture = (lastReorderAt: number, now: number): boolean =>
now - lastReorderAt < REORDER_GUARD_MS;

/**
* La retención del hover tras reordenar (#222): `true` mientras el puntero no
* haya salido de la tira desde que se soltó el orbe.
*
* Una ventana de tiempo no puede acotar el `mouseenter` sintetizado tarde por
* el navegador (medido entre 0,2 s y 2,3 s después de soltar): el orbe se
* mueve bajo un puntero quieto y la entrada llega con el siguiente movimiento.
* Por eso solo la libera el `mouseleave` real de `.dock`, nunca un
* `mouseenter`; pasar a otro orbe tampoco la libera (para abrir otro orbe
* queda el clic). Solo frena la vía del puntero: foco y clic no la consultan.
*/
export type ReorderHold = boolean;

/** Sin retención: el hover abre con normalidad. */
export const NO_REORDER_HOLD: ReorderHold = false;

/**
* Al soltar un reordenado: retiene solo si el puntero sigue dentro de la
* ventana. Fuera, el `mouseleave` ya ocurrió y nada la liberaría después.
*/
export const holdAfterReorderDrop = (pointerInside: boolean): ReorderHold => pointerInside;

/** El `mouseleave` real de la tira libera la retención (y es idempotente). */
export const releaseOnStripLeave = (_hold: ReorderHold): ReorderHold => false;

/** True si el hover del puntero puede abrir el panel; no depende del tiempo. */
export const canPointerOpen = (hold: ReorderHold): boolean => !hold;
Loading