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
9 changes: 8 additions & 1 deletion src/components/ChangeTourButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type BelowAnchor,
} from '../lib/floating';
import { store } from '../store/store';
import { activeFeatureHint } from '../store/onboarding';
import { askCodeLabel } from './understanding/ask-code-label';
import { TourModelMenu } from './understanding/TourModelMenu';

Expand All @@ -28,7 +29,13 @@ export function ChangeTourButton(props: {
: 'Start tour';
const helpId = createUniqueId();
const help = createHeldSignal<boolean>(150);
const helpOpen = () => !!help.value() && !props.disabled && !props.tour.loading() && !ready();
// A first-use tip about the tour already explains it in the same spot.
const helpOpen = () =>
!!help.value() &&
!props.disabled &&
!props.tour.loading() &&
!ready() &&
activeFeatureHint() !== 'change-tour';
const [position, setPosition] = createSignal<BelowAnchor>({ top: 0, right: 0, maxHeight: 240 });
let controls: HTMLDivElement | undefined;
let popover: HTMLDivElement | undefined;
Expand Down
1 change: 1 addition & 0 deletions src/components/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function Dialog(props: DialogProps) {
ref={panelRef}
tabIndex={0}
role="dialog"
data-dialog-id={dialogId}
aria-modal={isTopmost(dialogId) ? 'true' : undefined}
aria-labelledby={props.labelledBy}
aria-label={props.labelledBy ? undefined : props.ariaLabel}
Expand Down
309 changes: 309 additions & 0 deletions src/components/FeatureHint.client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { Show, createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('../lib/ipc', () => ({ invoke: vi.fn(async () => undefined) }));

import { setStore, store } from '../store/core';
import { Dialog } from './Dialog';
import { FeatureHint } from './FeatureHint';

const disposers: (() => void)[] = [];

beforeEach(() => {
vi.useFakeTimers();
// happy-dom lays nothing out: give every element a window-sized box, and
// anchors a box of their own.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, window.innerWidth, window.innerHeight),
);
setStore('featureHintsEnabled', true);
setStore('seenFeatureHints', []);
});

afterEach(() => {
// Dispose first: a shown tip follows its anchor every frame.
disposers.splice(0).forEach((dispose) => dispose());
// A dismissal's cooldown must not hold back the next test's tips.
vi.runAllTimers();
document.body.replaceChildren();
vi.restoreAllMocks();
vi.useRealTimers();
});

function boxed(el: HTMLElement, top: number, left = 100): HTMLElement {
el.getBoundingClientRect = () => new DOMRect(left, top, 200, 24);
return el;
}
const anchorAt = (top: number) => boxed(document.createElement('div'), top);

/** The first frame measures the anchor; the tip shows 600ms after that. */
const settle = () => vi.advanceTimersByTime(650);

/** Renders, then waits out the delay before a tip may show. */
function mount(view: () => ReturnType<typeof FeatureHint>) {
const host = document.createElement('div');
document.body.append(host);
disposers.push(render(view, host));
settle();
}

const notes = () => [...document.querySelectorAll<HTMLElement>('[role="note"]')];
const button = (label: string) =>
[...document.querySelectorAll('button')].find((b) => b.textContent === label);

describe('FeatureHint', () => {
it('shows one tip at a time and the next one after a pause once it is dismissed', () => {
const first = anchorAt(50);
const second = anchorAt(300);
mount(() => (
<>
<FeatureHint id="change-tour" when anchor={() => first} title="First">
Body
</FeatureHint>
<FeatureHint id="finish-review" when anchor={() => second} title="Second">
Body
</FeatureHint>
</>
));

expect(notes()).toHaveLength(1);
expect(notes()[0].textContent).toContain('First');

button('Got it')?.click();
expect(store.seenFeatureHints).toEqual(['change-tour']);
expect(notes()).toHaveLength(0);

vi.advanceTimersByTime(60_000);
settle();
expect(notes()).toHaveLength(1);
expect(notes()[0].textContent).toContain('Second');
});

it('hands the slot to a waiting tip when the one on screen goes away', () => {
const first = anchorAt(50);
const second = anchorAt(300);
const [showFirst, setShowFirst] = createSignal(true);
mount(() => (
<>
<Show when={showFirst()}>
<FeatureHint id="change-tour" when anchor={() => first} title="First">
Body
</FeatureHint>
</Show>
<FeatureHint id="finish-review" when anchor={() => second} title="Second">
Body
</FeatureHint>
</>
));
expect(notes()[0].textContent).toContain('First');

setShowFirst(false);
settle();
expect(notes()).toHaveLength(1);
expect(notes()[0].textContent).toContain('Second');
expect(store.seenFeatureHints).toEqual([]);
});

it('waits until its feature has something to work on', () => {
const anchor = anchorAt(50);
const [ready, setReady] = createSignal(false);
mount(() => (
<FeatureHint id="change-tour" when={ready()} anchor={() => anchor} title="Tour">
Body
</FeatureHint>
));
expect(notes()).toHaveLength(0);
setReady(true);
// A feature only passing through does not get a tip.
vi.advanceTimersByTime(300);
expect(notes()).toHaveLength(0);
setReady(false);
setReady(true);
vi.advanceTimersByTime(300);
expect(notes()).toHaveLength(0);
vi.advanceTimersByTime(350);
expect(notes()).toHaveLength(1);
});

it('shows nothing for an anchor scrolled out of its container', () => {
const scroller = boxed(document.createElement('div'), 0);
scroller.style.overflow = 'auto';
const anchor = anchorAt(400);
scroller.append(anchor);
document.body.append(scroller);
mount(() => (
<FeatureHint id="change-tour" when anchor={() => anchor} title="Tour">
Body
</FeatureHint>
));
expect(notes()).toHaveLength(0);

boxed(anchor, 10);
window.dispatchEvent(new Event('resize'));
vi.advanceTimersByTime(20);
settle();
expect(notes()).toHaveLength(1);
});

it('follows its anchor when it moves without a scroll', () => {
const anchor = anchorAt(50);
mount(() => (
<FeatureHint id="change-tour" when anchor={() => anchor} title="Tour">
Body
</FeatureHint>
));
const top = notes()[0].style.top;
boxed(anchor, 120);
vi.advanceTimersByTime(20);
expect(notes()[0].style.top).not.toBe(top);
});

it('slows to an occasional check once its anchor holds still, and still follows it', () => {
const anchor = anchorAt(50);
const measure = vi.spyOn(anchor, 'getBoundingClientRect');
mount(() => (
<FeatureHint id="change-tour" when anchor={() => anchor} title="Tour">
Body
</FeatureHint>
));
vi.advanceTimersByTime(1_000);
measure.mockClear();
vi.advanceTimersByTime(1_000);
expect(measure.mock.calls.length).toBeLessThanOrEqual(5);

const top = notes()[0].style.top;
boxed(anchor, 200);
vi.advanceTimersByTime(300);
expect(notes()[0].style.top).not.toBe(top);
});

it('stops measuring once a tip whose anchor left the view is gone', () => {
const scroller = boxed(document.createElement('div'), 0);
scroller.style.overflow = 'auto';
const anchor = anchorAt(10);
scroller.append(anchor);
document.body.append(scroller);
const [mounted, setMounted] = createSignal(true);
mount(() => (
<Show when={mounted()}>
<FeatureHint id="change-tour" when anchor={() => anchor} title="Tour">
Body
</FeatureHint>
</Show>
));
expect(notes()).toHaveLength(1);

// Hidden from inside the tip's own follow step.
boxed(anchor, 400);
vi.advanceTimersByTime(20);
expect(notes()).toHaveLength(0);

setMounted(false);
const measure = vi.spyOn(anchor, 'getBoundingClientRect');
vi.advanceTimersByTime(2_000);
expect(measure).not.toHaveBeenCalled();
});

it('runs an action, counts it as seen, and keeps focus where it was', () => {
const anchor = anchorAt(50);
const run = vi.fn();
mount(() => (
<FeatureHint
id="delegate-subtasks"
when
anchor={() => anchor}
title="Delegate"
actions={[{ label: 'Use example prompt', run }]}
>
Body
</FeatureHint>
));
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
button('Use example prompt')?.dispatchEvent(press);
expect(press.defaultPrevented).toBe(true);

button('Use example prompt')?.click();
expect(run).toHaveBeenCalledOnce();
expect(store.seenFeatureHints).toEqual(['delegate-subtasks']);
expect(notes()).toHaveLength(0);
});

it('steps aside for a dialog, and inside one lives in its panel', () => {
const outside = anchorAt(50);
let inside: HTMLElement | undefined;
const [open, setOpen] = createSignal(false);
mount(() => (
<>
<FeatureHint id="change-tour" when anchor={() => outside} title="Outside">
Body
</FeatureHint>
<Dialog open={open()} onClose={() => setOpen(false)}>
<div ref={(el) => (inside = boxed(el, 300))} />
<FeatureHint id="finish-review" when anchor={() => inside} title="Inside">
Body
</FeatureHint>
</Dialog>
</>
));
expect(notes().map((note) => note.textContent)).toEqual([expect.stringContaining('Outside')]);

setOpen(true);
expect(notes()).toHaveLength(0);
settle();
expect(notes()).toHaveLength(1);
expect(notes()[0].textContent).toContain('Inside');
expect(notes()[0].closest('.dialog-panel')).not.toBeNull();
});

it('hides under a dialog opened above its own, wherever that one sits in the page', () => {
let inside: HTMLElement | undefined;
const [over, setOver] = createSignal(false);
mount(() => (
<>
{/* Created first, so its panel comes earlier in the page. */}
<Dialog open={over()} onClose={() => setOver(false)}>
Settings
</Dialog>
<Dialog open onClose={() => undefined}>
<div ref={(el) => (inside = boxed(el, 300))} />
<FeatureHint id="finish-review" when anchor={() => inside} title="Inside">
Body
</FeatureHint>
</Dialog>
</>
));
expect(notes()).toHaveLength(1);

setOver(true);
expect(notes()).toHaveLength(0);
});

it('dismisses on Escape without closing the dialog around it', () => {
let anchor: HTMLElement | undefined;
const onClose = vi.fn();
mount(() => (
<Dialog open onClose={onClose}>
<div ref={(el) => (anchor = boxed(el, 50))} />
<FeatureHint id="finish-review" when anchor={() => anchor} title="Readiness">
Body
</FeatureHint>
</Dialog>
));
notes()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(notes()).toHaveLength(0);
expect(store.seenFeatureHints).toEqual(['finish-review']);
expect(onClose).not.toHaveBeenCalled();
});

it('shows nothing while tips are turned off', () => {
setStore('featureHintsEnabled', false);
const anchor = anchorAt(50);
mount(() => (
<FeatureHint id="change-tour" when anchor={() => anchor} title="Tour">
Body
</FeatureHint>
));
expect(notes()).toHaveLength(0);
});
});
Loading
Loading