diff --git a/src/components/ChangeTourButton.tsx b/src/components/ChangeTourButton.tsx index 55d08d7ab..5ed8cf496 100644 --- a/src/components/ChangeTourButton.tsx +++ b/src/components/ChangeTourButton.tsx @@ -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'; @@ -28,7 +29,13 @@ export function ChangeTourButton(props: { : 'Start tour'; const helpId = createUniqueId(); const help = createHeldSignal(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({ top: 0, right: 0, maxHeight: 240 }); let controls: HTMLDivElement | undefined; let popover: HTMLDivElement | undefined; diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index d59ace430..52cdf3663 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -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} diff --git a/src/components/FeatureHint.client.test.tsx b/src/components/FeatureHint.client.test.tsx new file mode 100644 index 000000000..384dd01b1 --- /dev/null +++ b/src/components/FeatureHint.client.test.tsx @@ -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) { + const host = document.createElement('div'); + document.body.append(host); + disposers.push(render(view, host)); + settle(); +} + +const notes = () => [...document.querySelectorAll('[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(() => ( + <> + first} title="First"> + Body + + second} title="Second"> + Body + + + )); + + 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(() => ( + <> + + first} title="First"> + Body + + + second} title="Second"> + Body + + + )); + 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(() => ( + anchor} title="Tour"> + Body + + )); + 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(() => ( + anchor} title="Tour"> + Body + + )); + 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(() => ( + anchor} title="Tour"> + Body + + )); + 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(() => ( + anchor} title="Tour"> + Body + + )); + 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(() => ( + + anchor} title="Tour"> + Body + + + )); + 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(() => ( + anchor} + title="Delegate" + actions={[{ label: 'Use example prompt', run }]} + > + Body + + )); + 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(() => ( + <> + outside} title="Outside"> + Body + + setOpen(false)}> +
(inside = boxed(el, 300))} /> + inside} title="Inside"> + Body + +
+ + )); + 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. */} + setOver(false)}> + Settings + + undefined}> +
(inside = boxed(el, 300))} /> + inside} title="Inside"> + Body + +
+ + )); + 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(() => ( + +
(anchor = boxed(el, 50))} /> + anchor} title="Readiness"> + Body + +
+ )); + 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(() => ( + anchor} title="Tour"> + Body + + )); + expect(notes()).toHaveLength(0); + }); +}); diff --git a/src/components/FeatureHint.tsx b/src/components/FeatureHint.tsx new file mode 100644 index 000000000..80ccdad64 --- /dev/null +++ b/src/components/FeatureHint.tsx @@ -0,0 +1,348 @@ +import { + For, + Show, + createEffect, + createMemo, + createSignal, + createUniqueId, + onCleanup, + type JSX, +} from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { topDialog } from '../lib/dialog-stack'; +import { placeHint, type HintPlacement } from '../lib/feature-hint-placement'; +import type { FeatureHintId } from '../lib/feature-hints'; +import { createAnchorEffect } from '../lib/floating'; +import { sf } from '../lib/fontScale'; +import { theme } from '../lib/theme'; +import { + claimFeatureHint, + featureHintWanted, + isFeatureHintActive, + markFeatureHintSeen, + releaseFeatureHint, +} from '../store/onboarding'; + +const WIDTH = 300; +/** The card's height until it renders and can be measured. */ +const ESTIMATED_HEIGHT = 190; +/** + * How long a tip waits once it could show. It lets a dialog finish opening and + * skips a feature that is only passing through, such as a task the user is + * switching past. + */ +const SETTLE_MS = 600; +const CARET = 10; +/** Frames a shown tip keeps following an anchor that has stopped moving. */ +const FOLLOW_FRAMES = 30; +const IDLE_CHECK_MS = 250; + +interface FeatureHintAction { + label: string; + run: () => void; + /** Muted, for an action that leaves what the user is doing, e.g. a dialog. */ + quiet?: boolean; +} + +interface FeatureHintProps { + id: FeatureHintId; + /** The feature has something to work on now, e.g. the task has changes. */ + when: boolean; + anchor: () => HTMLElement | undefined; + title: string; + children: JSX.Element; + /** Tries the feature; taking it counts as having seen the tip. */ + actions?: FeatureHintAction[]; +} + +interface Box { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * The part of `el` the user can see: its box cut by every ancestor that clips + * its overflow, and by the window. Undefined when nothing of it shows. + */ +function visibleBox(el: HTMLElement): Box | undefined { + const rect = el.getBoundingClientRect(); + const box = { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left }; + const clip = (by: Box) => { + box.top = Math.max(box.top, by.top); + box.right = Math.min(box.right, by.right); + box.bottom = Math.min(box.bottom, by.bottom); + box.left = Math.max(box.left, by.left); + }; + for (let parent = el.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent); + if (style.overflowX !== 'visible' || style.overflowY !== 'visible') + clip(parent.getBoundingClientRect()); + } + clip({ top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 }); + return box.right > box.left && box.bottom > box.top ? box : undefined; +} + +function samePlacement(a: HintPlacement | null, b: HintPlacement | null): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * A one-time tip floating next to the control it explains. It overlays the + * page instead of joining its layout, so showing or dismissing it never moves + * content. Only one tip shows at a time, and only while its anchor is in view + * and not covered by a dialog. + */ +export function FeatureHint(props: FeatureHintProps) { + // Identifies this instance in the single tip slot; several may share an id. + const owner = Symbol('feature-hint'); + const titleId = createUniqueId(); + /** The card's content, whose natural height is the card's less its border. */ + let content: HTMLDivElement | undefined; + const [position, setPosition] = createSignal(null, { + equals: samePlacement, + }); + const wanted = () => props.when && featureHintWanted(props.id); + const panel = () => props.anchor()?.closest('.dialog-panel') ?? undefined; + /** A dialog above the anchor would cover the tip, or the tip would float over it. */ + const uncovered = () => { + const top = topDialog(); + const own = panel(); + return own ? own.dataset.dialogId === top : top === null; + }; + + const place = () => { + const anchor = props.anchor(); + const box = anchor && visibleBox(anchor); + if (!box) return setPosition(null); + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const placed = placeHint( + box, + viewport, + Math.min(WIDTH, viewport.width - 24), + content ? content.scrollHeight + 2 : ESTIMATED_HEIGHT, + panel()?.getBoundingClientRect(), + ); + // A dialog's backdrop filter makes its overlay the box that fixed + // positions count from; it spans the window unless the dialog insets it. + const overlay = panel()?.closest('.dialog-overlay')?.getBoundingClientRect(); + setPosition( + overlay + ? { + ...placed, + left: placed.left - overlay.left, + top: placed.top === undefined ? undefined : placed.top - overlay.top, + bottom: + placed.bottom === undefined + ? undefined + : placed.bottom - (viewport.height - overlay.bottom), + } + : placed, + ); + }; + + // Scrolls fire many times a frame, terminals' included; measure once per frame. + let pending = 0; + const schedule = () => { + if (!pending) + pending = requestAnimationFrame(() => { + pending = 0; + place(); + }); + }; + onCleanup(() => cancelAnimationFrame(pending)); + + // Until the tip shows, it only needs to learn when its anchor comes into view. + const watching = () => wanted() && uncovered(); + createAnchorEffect(watching, schedule); + createEffect(() => { + const el = props.anchor(); + if (!watching() || !el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(schedule); + observer.observe(el); + onCleanup(() => observer.disconnect()); + }); + + // A memo, so moving with a scroll does not restart the settle delay. + const eligible = createMemo(() => watching() && position() !== null); + const [settled, setSettled] = createSignal(false); + createEffect(() => { + setSettled(false); + if (!eligible()) return; + const timer = setTimeout(() => setSettled(true), SETTLE_MS); + onCleanup(() => clearTimeout(timer)); + }); + createEffect(() => { + if (eligible() && settled()) claimFeatureHint(owner, props.id); + else releaseFeatureHint(owner); + }); + onCleanup(() => releaseFeatureHint(owner)); + + const shown = () => eligible() && isFeatureHintActive(owner); + // Anchors also move without a scroll or a resize of their own: a panel + // opening with an animation, a split being dragged, a neighbour closing. + // Every frame while the anchor moves, then a slow check for the next move. + createEffect(() => { + if (!shown()) return; + // `place()` can hide the tip, which runs this cleanup from inside `follow`. + let stopped = false; + let frame = 0; + let timer: ReturnType | undefined; + let still = 0; + const follow = () => { + const before = position(); + place(); + if (stopped) return; + still = samePlacement(before, position()) ? still + 1 : 0; + if (still < FOLLOW_FRAMES) frame = requestAnimationFrame(follow); + else timer = setTimeout(follow, IDLE_CHECK_MS); + }; + frame = requestAnimationFrame(follow); + onCleanup(() => { + stopped = true; + cancelAnimationFrame(frame); + clearTimeout(timer); + }); + }); + + const dismiss = () => markFeatureHintSeen(props.id); + + return ( + + {(pos) => ( + (el.style.display = 'contents')} + > +
{ + // Escape while the tip has focus hides it, not the dialog around + // it. A native listener, since a delegated one runs only after + // the dialog's own listener on the document. + el.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + dismiss(); + }); + }} + role="note" + aria-labelledby={titleId} + class="feature-hint" + // Clicking the tip leaves focus where the user was typing. + onMouseDown={(event) => { + if (event.target instanceof HTMLButtonElement) event.preventDefault(); + }} + style={{ + position: 'fixed', + left: `${pos().left}px`, + top: pos().top === undefined ? undefined : `${pos().top}px`, + bottom: pos().bottom === undefined ? undefined : `${pos().bottom}px`, + width: `${WIDTH}px`, + 'max-width': 'calc(100vw - 24px)', + 'box-sizing': 'border-box', + // Under dialogs and menus, which cover it when they open. + 'z-index': '900', + color: theme.fg, + background: theme.bgElevated, + border: `1px solid ${theme.border}`, + 'border-radius': 'var(--radius-sm)', + 'box-shadow': '0 4px 16px rgba(0, 0, 0, 0.3)', + 'font-size': sf(12), + 'line-height': '1.5', + }} + > +
+
+ )} +
+ ); +} + +/** A square turned 45° over the card's edge, bordered on its two outer sides. */ +function caretStyle(caret: HintPlacement['caret']): JSX.CSSProperties { + const line = `1px solid ${theme.border}`; + const along = `${caret.offset - CARET / 2}px`; + const out = `${-CARET / 2 - 1}px`; + const vertical = caret.side === 'top' || caret.side === 'bottom'; + return { + position: 'absolute', + width: `${CARET}px`, + height: `${CARET}px`, + background: theme.bgElevated, + transform: 'rotate(45deg)', + [caret.side]: out, + [vertical ? 'left' : 'top']: along, + 'border-top': caret.side === 'top' || caret.side === 'right' ? line : undefined, + 'border-left': caret.side === 'top' || caret.side === 'left' ? line : undefined, + 'border-bottom': caret.side === 'bottom' || caret.side === 'left' ? line : undefined, + 'border-right': caret.side === 'bottom' || caret.side === 'right' ? line : undefined, + }; +} + +/** Neutral buttons; the main one is marked by full-strength text, not colour. */ +function buttonStyle(main: boolean): JSX.CSSProperties { + return { + padding: '4px 10px', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': 'var(--radius-sm)', + color: main ? theme.fg : theme.fgMuted, + cursor: 'pointer', + 'font-size': sf(12), + }; +} diff --git a/src/components/FinishDialog.client.test.tsx b/src/components/FinishDialog.client.test.tsx index 358e0f240..e24e209de 100644 --- a/src/components/FinishDialog.client.test.tsx +++ b/src/components/FinishDialog.client.test.tsx @@ -3,7 +3,8 @@ import { render } from 'solid-js/web'; import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { FinishDialog } from './FinishDialog'; import { invoke } from '../lib/ipc'; -import { getPrChecks, mergeTask, pushTask } from '../store/store'; +import { setStore } from '../store/core'; +import { getPrChecks, getTaskChecks, mergeTask, pushTask } from '../store/store'; import { getPullRequestDetails, markPullRequestReadyForTask, @@ -20,7 +21,7 @@ vi.mock('../store/store', () => ({ getProject: () => undefined, getPrChecks: vi.fn(() => undefined), getVerifyCommand: () => undefined, - getTaskChecks: () => [], + getTaskChecks: vi.fn(() => []), buildEvidence: vi.fn(async () => {}), getEvidenceUiState: () => ({}), isEvidenceBusy: () => false, @@ -196,6 +197,7 @@ function mount(options: { task?: Task; tour?: ChangeTourController } = {}) { onOpenPullRequest: vi.fn(), onPushDone: vi.fn(), onRegenerateTour: vi.fn(), + onConfigureChecks: vi.fn(), }; dispose = render( () => ( @@ -207,7 +209,6 @@ function mount(options: { task?: Task; tour?: ChangeTourController } = {}) { tourDisabled={false} onTourClick={() => {}} onPushStart={() => {}} - onConfigureChecks={() => {}} onDiffFileClick={() => {}} {...handlers} /> @@ -384,6 +385,52 @@ describe('FinishDialog merge blocker', () => { }); }); +describe('FinishDialog readiness tip', () => { + function mountWithTip() { + vi.useFakeTimers(); + // An earlier test may have dismissed the tip. + setStore('seenFeatureHints', []); + // happy-dom lays nothing out; the tip needs its anchor on screen. + const box = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, 0, 400, 40)); + onTestFinished(() => { + dispose?.(); + dispose = undefined; + vi.runAllTimers(); + box.mockRestore(); + vi.useRealTimers(); + }); + const handlers = mount(); + vi.advanceTimersByTime(650); + return { handlers, note: document.querySelector('[role="note"]') }; + } + const tipButton = (note: Element | null, text: string) => + [...(note?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text); + + it('points first-time users at readiness and offers to configure checks', () => { + const { handlers, note } = mountWithTip(); + expect(note?.textContent).toContain('Check what backs this change'); + expect(note?.textContent).toContain('Add a verify command'); + tipButton(note, 'Configure checks')?.click(); + expect(handlers.onConfigureChecks).toHaveBeenCalledOnce(); + expect(document.querySelector('[role="note"]')).toBeNull(); + }); + + it('offers no setup once the project has any check', () => { + vi.mocked(getTaskChecks).mockReturnValue([ + { id: 'lint', name: 'Lint', kind: 'custom', command: 'npm run lint', run: 'on-demand' }, + ]); + onTestFinished(() => { + vi.mocked(getTaskChecks).mockReturnValue([]); + }); + const { note } = mountWithTip(); + expect(note?.textContent).toContain('Check what backs this change'); + expect(note?.textContent).not.toContain('Add a verify command'); + expect(tipButton(note, 'Configure checks')).toBeUndefined(); + }); +}); + describe('FinishDialog tour', () => { it('offers to regenerate a ready tour', async () => { expect(mount().onRegenerateTour).not.toHaveBeenCalled(); diff --git a/src/components/FinishDialog.tsx b/src/components/FinishDialog.tsx index 438f2faf8..e3a305cd9 100644 --- a/src/components/FinishDialog.tsx +++ b/src/components/FinishDialog.tsx @@ -7,6 +7,7 @@ import { CompletionReport } from './CompletionReport'; import { ConfirmDialog } from './ConfirmDialog'; import { DiffViewerDialog } from './DiffViewerDialog'; import { EvidencePanel } from './EvidencePanel'; +import { FeatureHint } from './FeatureHint'; import { MergeReadinessPanel } from './MergeReadinessPanel'; import { FinishPrSection, PrMergeRoute } from './FinishPrSection'; import { MergeBlockers, MergeChanges, MergeOptions } from './MergeSection'; @@ -15,7 +16,8 @@ import { ReadinessSection } from './ReadinessSection'; import { createMergeState } from './merge-state'; import { PR_MERGE_ACTIONS, createFinishPr, taskPrUrl } from './pr-actions'; import { createPushRun } from './push-run'; -import { getProject } from '../store/store'; +import { getProject, getTaskChecks } from '../store/store'; +import { markFeatureHintSeen } from '../store/onboarding'; import { showNotification } from '../store/notification'; import { theme } from '../lib/theme'; import { GitMergeIcon, SyncIcon, UploadIcon } from './icons'; @@ -61,6 +63,9 @@ const SHOWN_ELSEWHERE = new Set(['Merge safety', 'Verify command', 'Evidence', ' */ export function FinishDialog(props: FinishDialogProps) { const merge = createMergeState(props); + let readinessAnchor: HTMLDivElement | undefined; + // The same test the evidence panel uses to offer Configure checks. + const hasChecks = () => getTaskChecks(props.task.id).length > 0; const [reviewLocation, setReviewLocation] = createSignal<{ file: string; line?: number; @@ -210,18 +215,37 @@ export function FinishDialog(props: FinishDialogProps) { /> - - props.onConfigureChecks()} - onReviewFile={(file, line, side) => setReviewLocation({ file, line, side })} - > - - - + {/* Opening the section or running a check there is taking the tip. */} +
markFeatureHintSeen('finish-review')}> + + props.onConfigureChecks()} + onReviewFile={(file, line, side) => setReviewLocation({ file, line, side })} + > + + + +
+ readinessAnchor} + title="Check what backs this change" + actions={ + hasChecks() + ? undefined + : // It leaves this dialog for the project settings. + [{ label: 'Configure checks', run: props.onConfigureChecks, quiet: true }] + } + > + Readiness gathers the evidence behind this merge: which checks ran, and what the agent + says it did. + Add a verify command to run your tests from here. + void; @@ -450,6 +457,12 @@ export function NewTaskPanel(props: NewTaskPanelProps) { ); const [branchPrefix, setBranchPrefix] = createSignal(''); let promptRef!: HTMLTextAreaElement; + const hasOtherTasks = () => store.taskOrder.length + store.collapsedTaskOrder.length > 0; + function applyDelegationExample(): void { + setPrompt(DELEGATION_EXAMPLE_PROMPT); + promptRef.focus(); + promptRef.setSelectionRange(promptRef.value.length, promptRef.value.length); + } const titleId = createUniqueId(); const branchInputId = createUniqueId(); const advancedOptionsId = createUniqueId(); @@ -1344,6 +1357,18 @@ export function NewTaskPanel(props: NewTaskPanelProps) { resize: 'vertical', }} /> + promptRef} + title="Let one agent split the work" + actions={[{ label: 'Use example prompt', run: applyDelegationExample }]} + > + {selectedAgent()?.name ?? 'This agent'} can start its own sub-tasks. Ask it to split a + change into parts: each part runs as its own task in its own worktree, and the agent + keeps track of them. + You review each result before it merges. + {/* Project selector */} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 4d050419a..002c9a7d1 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -56,6 +56,7 @@ import { updateStatus, checkForUpdates, } from '../store/store'; +import { setFeatureHintsEnabled } from '../store/onboarding'; import { CustomAgentEditor } from './CustomAgentEditor'; import { AgentEnvFileEditor } from './AgentEnvFileEditor'; import { SuperProductivitySettings } from './SuperProductivitySettings'; @@ -629,6 +630,12 @@ export function SettingsDialog(props: SettingsDialogProps) { onChange={setShowSidebarTips} description="Keyboard shortcut hints at the bottom of the sidebar" /> + { const id = props.task.id; @@ -206,11 +210,30 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) { {(tour) => ( - props.onTourClick?.()} - /> +
+ props.onTourClick?.()} + /> + tourAnchor} + title="Take a tour of these changes" + > + A short guided walkthrough of this task's diff, stop by stop. It generates in the + background, so you can keep working, and sends the diff to {askCodeModelLabel()}. + +
)}
diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index 889426994..8c0da49c6 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -30,6 +30,7 @@ import { referenceTourCard, openCanvasDocument, } from '../store/store'; +import { markFeatureHintSeen } from '../store/onboarding'; import { useFocusRegistration } from '../lib/focus-registration'; import { isTaskColumnHighlighted, scheduleTaskFocus } from '../store/focused-panel'; import { ResizablePanel, type PanelChild } from './ResizablePanel'; @@ -182,6 +183,7 @@ export function TaskPanel(props: TaskPanelProps) { selectedCommit: selectedCommit(), }); const openOrGenerateTour = () => { + markFeatureHintSeen('change-tour'); if (tour.stops().length === 0) return generateTour(); setFinishAction(null); setStartTour(true); diff --git a/src/lib/feature-hint-placement.test.ts b/src/lib/feature-hint-placement.test.ts new file mode 100644 index 000000000..f19f05d59 --- /dev/null +++ b/src/lib/feature-hint-placement.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { placeHint } from './feature-hint-placement'; + +const viewport = { width: 1400, height: 700 }; +const rect = (left: number, top: number, width: number, height: number) => ({ + left, + top, + right: left + width, + bottom: top + height, +}); + +describe('placeHint', () => { + it('hangs under the anchor, right-aligned, with the caret at its centre', () => { + expect(placeHint(rect(400, 100, 200, 30), viewport, 300, 190)).toEqual({ + left: 300, + top: 138, + maxHeight: 550, + caret: { side: 'top', offset: 200 }, + }); + }); + + it('sits above an anchor near the bottom, held by its bottom edge', () => { + const placed = placeHint(rect(400, 600, 200, 30), viewport, 300, 190); + expect(placed.top).toBeUndefined(); + expect(placed.bottom).toBe(108); + expect(placed.caret.side).toBe('bottom'); + }); + + it('keeps the card and its caret inside the viewport', () => { + const placed = placeHint(rect(0, 100, 40, 30), viewport, 300, 190); + expect(placed.left).toBe(12); + expect(placed.caret.offset).toBe(16); + }); + + it('sits beside a dialog, level with the anchor, covering none of it', () => { + const panel = rect(420, 240, 560, 220); + const placed = placeHint(rect(450, 340, 500, 300), viewport, 300, 190, panel); + expect(placed).toEqual({ + left: 988, + top: 324, + maxHeight: 364, + caret: { side: 'left', offset: 32 }, + }); + }); + + it('takes the left side of a dialog when the right has no room', () => { + const panel = rect(800, 240, 560, 220); + const placed = placeHint(rect(830, 300, 500, 20), viewport, 300, 190, panel); + expect(placed.left).toBe(492); + expect(placed.caret.side).toBe('right'); + }); + + it('goes under, then over, a dialog too wide to sit beside', () => { + const narrow = { width: 940, height: 640 }; + const under = placeHint(rect(220, 310, 500, 20), narrow, 300, 190, rect(190, 214, 560, 212)); + expect(under.top).toBe(434); + expect(under.caret.side).toBe('top'); + + const over = placeHint(rect(220, 420, 500, 20), narrow, 300, 190, rect(190, 300, 560, 300)); + expect(over.bottom).toBe(348); + expect(over.caret.side).toBe('bottom'); + }); + + it('overlaps a dialog only when there is no room around it', () => { + const placed = placeHint(rect(220, 200, 500, 20), viewport, 300, 190, rect(0, 60, 1400, 580)); + expect(placed.top).toBe(228); + }); +}); diff --git a/src/lib/feature-hint-placement.ts b/src/lib/feature-hint-placement.ts new file mode 100644 index 000000000..1a9efabac --- /dev/null +++ b/src/lib/feature-hint-placement.ts @@ -0,0 +1,94 @@ +import type { Viewport } from './floating'; + +interface Rect { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface HintPlacement { + left: number; + /** Set when the card hangs below its anchor or beside a dialog. */ + top?: number; + /** Set when the card sits above its anchor, so its own height never opens a gap. */ + bottom?: number; + maxHeight: number; + /** The card's edge that points at the anchor, and where along it, in px. */ + caret: { side: 'top' | 'bottom' | 'left' | 'right'; offset: number }; +} + +/** Kept clear of the viewport's edges. */ +const EDGE = 12; +/** Between the anchor and the card, with room for the caret. */ +const GAP = 8; +/** The caret stays clear of the card's rounded corners. */ +const CARET_INSET = 16; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), Math.max(min, max)); + +/** + * Where a tip goes. Inside a dialog it stays off the panel, so it covers none + * of the dialog: beside it, level with the anchor's first line, else under or + * over it. Without room for that, and outside dialogs, it hangs under the + * anchor, right-aligned, or above it when there is more room above. `height` + * is an estimate: it only decides where the card fits. + */ +export function placeHint( + anchor: Rect, + viewport: Viewport, + width: number, + height: number, + panel?: Rect, +): HintPlacement { + if (panel) { + const right = panel.right + GAP; + const left = panel.left - GAP - width; + const side = + right + width <= viewport.width - EDGE ? 'left' : left >= EDGE ? 'right' : undefined; + if (side) { + const line = anchor.top + Math.min((anchor.bottom - anchor.top) / 2, 16); + const top = clamp(line - CARET_INSET * 2, EDGE, viewport.height - EDGE - height); + return { + left: side === 'left' ? right : left, + top, + maxHeight: Math.max(120, viewport.height - EDGE - top), + caret: { side, offset: Math.max(CARET_INSET, line - top) }, + }; + } + } + + const left = clamp(anchor.right - width, EDGE, viewport.width - width - EDGE); + const center = (anchor.left + anchor.right) / 2; + const offset = clamp(center - left, CARET_INSET, width - CARET_INSET); + if (panel) { + const under = viewport.height - panel.bottom - GAP - EDGE; + if (under >= height) + return { left, top: panel.bottom + GAP, maxHeight: under, caret: { side: 'top', offset } }; + const over = panel.top - GAP - EDGE; + if (over >= height) + return { + left, + bottom: viewport.height - panel.top + GAP, + maxHeight: over, + caret: { side: 'bottom', offset }, + }; + } + const below = viewport.height - anchor.bottom - GAP - EDGE; + const above = anchor.top - GAP - EDGE; + if (height > below && above > below) { + return { + left, + bottom: viewport.height - anchor.top + GAP, + maxHeight: Math.max(120, above), + caret: { side: 'bottom', offset }, + }; + } + return { + left, + top: anchor.bottom + GAP, + maxHeight: Math.max(120, below), + caret: { side: 'top', offset }, + }; +} diff --git a/src/lib/feature-hints.ts b/src/lib/feature-hints.ts new file mode 100644 index 000000000..5844aaf78 --- /dev/null +++ b/src/lib/feature-hints.ts @@ -0,0 +1,12 @@ +/** + * One-time tips that point at a feature the first time it can be used, e.g. a + * change tour once a task has changes. They are not a tour of the app: each + * shows only once its feature has something to work on. + */ +const FEATURE_HINT_IDS = ['change-tour', 'finish-review', 'delegate-subtasks'] as const; + +export type FeatureHintId = (typeof FEATURE_HINT_IDS)[number]; + +export function isFeatureHintId(value: unknown): value is FeatureHintId { + return FEATURE_HINT_IDS.some((id) => id === value); +} diff --git a/src/store/autosave.ts b/src/store/autosave.ts index 2d54a5695..01d8878c7 100644 --- a/src/store/autosave.ts +++ b/src/store/autosave.ts @@ -59,6 +59,8 @@ function structuralSnapshot(): string { mcpOrchestrationEnabled: store.mcpOrchestrationEnabled, documentWorkspacesEnabled: store.documentWorkspacesEnabled, coordinatorControlHintDismissed: store.coordinatorControlHintDismissed, + featureHintsEnabled: store.featureHintsEnabled, + seenFeatureHints: store.seenFeatureHints, autoStartRemoteAccess: store.autoStartRemoteAccess, shareDockerAgentAuth: store.shareDockerAgentAuth, appearanceMode: store.appearanceMode, diff --git a/src/store/core.ts b/src/store/core.ts index fbfeacc99..e7fba1373 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -101,6 +101,8 @@ export const [store, setStore] = createStore({ keybindingPreset: 'default', keybindingOverridesByPreset: {}, keybindingMigrationDismissed: false, + featureHintsEnabled: true, + seenFeatureHints: [], focusMode: false, taskSplitMode: {}, verboseLogging: false, diff --git a/src/store/onboarding.test.ts b/src/store/onboarding.test.ts new file mode 100644 index 000000000..e470ae9de --- /dev/null +++ b/src/store/onboarding.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockInvoke } = vi.hoisted(() => ({ mockInvoke: vi.fn() })); +vi.mock('../lib/ipc', () => ({ invoke: mockInvoke })); + +import { setStore, store } from './core'; +import { + activeFeatureHint, + claimFeatureHint, + featureHintWanted, + isFeatureHintActive, + markFeatureHintSeen, + releaseFeatureHint, + setFeatureHintsEnabled, +} from './onboarding'; + +beforeEach(() => { + vi.useFakeTimers(); + mockInvoke.mockResolvedValue(undefined); + setStore('featureHintsEnabled', true); + setStore('seenFeatureHints', []); +}); + +afterEach(() => { + // Let any cooldown run out so it does not leak into the next test. + vi.runAllTimers(); + vi.useRealTimers(); +}); + +describe('feature tips', () => { + it('shows one tip at a time and hands the slot on when it is released', () => { + const first = Symbol('first'); + const second = Symbol('second'); + expect(claimFeatureHint(first, 'change-tour')).toBe(true); + expect(claimFeatureHint(second, 'finish-review')).toBe(false); + expect(activeFeatureHint()).toBe('change-tour'); + + releaseFeatureHint(second); + expect(isFeatureHintActive(first)).toBe(true); + + releaseFeatureHint(first); + expect(activeFeatureHint()).toBeNull(); + expect(claimFeatureHint(second, 'finish-review')).toBe(true); + releaseFeatureHint(second); + }); + + it('frees the slot once the tip on screen is seen, then holds the next one back', () => { + const first = Symbol('first'); + const second = Symbol('second'); + claimFeatureHint(first, 'change-tour'); + // Dismissing the tip and using its feature both land here. + markFeatureHintSeen('change-tour'); + + expect(featureHintWanted('change-tour')).toBe(false); + expect(store.seenFeatureHints).toEqual(['change-tour']); + expect(isFeatureHintActive(first)).toBe(false); + expect(claimFeatureHint(second, 'finish-review')).toBe(false); + + vi.advanceTimersByTime(60_000); + expect(claimFeatureHint(second, 'finish-review')).toBe(true); + releaseFeatureHint(second); + }); + + it('leaves the tip on screen alone when another feature is used', () => { + const owner = Symbol('owner'); + claimFeatureHint(owner, 'change-tour'); + markFeatureHintSeen('finish-review'); + expect(isFeatureHintActive(owner)).toBe(true); + releaseFeatureHint(owner); + }); + + it('records a used feature once', () => { + markFeatureHintSeen('finish-review'); + markFeatureHintSeen('finish-review'); + expect(store.seenFeatureHints).toEqual(['finish-review']); + }); + + it('shows no tips while turned off, and every tip again once turned back on', () => { + markFeatureHintSeen('change-tour'); + setFeatureHintsEnabled(false); + expect(featureHintWanted('delegate-subtasks')).toBe(false); + + setFeatureHintsEnabled(true); + expect(store.seenFeatureHints).toEqual([]); + expect(featureHintWanted('change-tour')).toBe(true); + }); +}); diff --git a/src/store/onboarding.ts b/src/store/onboarding.ts new file mode 100644 index 000000000..05a1479e5 --- /dev/null +++ b/src/store/onboarding.ts @@ -0,0 +1,76 @@ +import { batch, createSignal } from 'solid-js'; +import { store, setStore } from './core'; +import { saveState } from './persistence'; +import type { FeatureHintId } from '../lib/feature-hints'; + +/** + * After a tip is dismissed or its feature used, the next one waits this long, + * so tips never chain. A tip that only leaves the view starts no wait. + */ +const COOLDOWN_MS = 60_000; + +/** The one tip on screen. Several can be eligible at once; the first claim wins. */ +const [active, setActive] = createSignal<{ owner: symbol; id: FeatureHintId } | null>(null); +const [coolingDown, setCoolingDown] = createSignal(false); +let cooldownTimer: ReturnType | undefined; + +function isFeatureHintSeen(id: FeatureHintId): boolean { + return store.seenFeatureHints.includes(id); +} + +function startCooldown(): void { + setCoolingDown(true); + if (cooldownTimer !== undefined) clearTimeout(cooldownTimer); + cooldownTimer = setTimeout(() => { + cooldownTimer = undefined; + setCoolingDown(false); + }, COOLDOWN_MS); +} + +/** Whether a tip wants to show; it still has to win `claimFeatureHint`. */ +export function featureHintWanted(id: FeatureHintId): boolean { + return store.featureHintsEnabled && !isFeatureHintSeen(id); +} + +/** Takes the single tip slot for `owner`. Reactive: a waiting tip retries when the slot frees. */ +export function claimFeatureHint(owner: symbol, id: FeatureHintId): boolean { + const current = active(); + if (current?.owner === owner) return true; + if (current || coolingDown()) return false; + setActive({ owner, id }); + return true; +} + +export function releaseFeatureHint(owner: symbol): void { + if (active()?.owner === owner) setActive(null); +} + +export function isFeatureHintActive(owner: symbol): boolean { + return active()?.owner === owner; +} + +/** The tip on screen, if any, so its feature's own help can stay out of its way. */ +export function activeFeatureHint(): FeatureHintId | null { + return active()?.id ?? null; +} + +/** Records that the user saw or used a feature, so its tip does not show again. */ +export function markFeatureHintSeen(id: FeatureHintId): void { + if (isFeatureHintSeen(id)) return; + // One batch: a waiting tip must see the cooldown before it sees the free slot. + batch(() => { + if (active()?.id === id) { + startCooldown(); + setActive(null); + } + setStore('seenFeatureHints', (seen) => [...seen, id]); + }); + // Persist at once: autosave might not run before the app closes. + void saveState(); +} + +/** Turning tips back on shows every tip again. */ +export function setFeatureHintsEnabled(enabled: boolean): void { + setStore('featureHintsEnabled', enabled); + if (enabled) setStore('seenFeatureHints', []); +} diff --git a/src/store/persistence-startup.test.ts b/src/store/persistence-startup.test.ts index bf3907438..78ddb5913 100644 --- a/src/store/persistence-startup.test.ts +++ b/src/store/persistence-startup.test.ts @@ -73,6 +73,22 @@ describe('startup persistence', () => { }); }, 20_000); + it('starts a new install with feature tips on and no shortcut migration notice', async () => { + const { loadState, saveState } = await import('./persistence'); + const { store } = await import('./core'); + mockInvoke.mockResolvedValue(null); + await loadState(); + expect(store.featureHintsEnabled).toBe(true); + expect(store.keybindingMigrationDismissed).toBe(true); + + await saveState(); + const saved = mockInvoke.mock.calls.find(([channel]) => channel === IPC.SaveAppState); + expect(JSON.parse(saved?.[1].json)).toMatchObject({ + featureHintsEnabled: true, + keybindingMigrationDismissed: true, + }); + }, 20_000); + it('allows a first-run save only after the empty state has been loaded', async () => { const { loadState, saveState } = await import('./persistence'); let finishLoad: (state: null) => void = () => undefined; diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index 130edab08..0462ec8d9 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -88,6 +88,9 @@ beforeEach(async () => { setStore('availableAgents', []); setStore('customAgents', []); setStore('coordinatorControlHintDismissed', false); + setStore('keybindingMigrationDismissed', false); + setStore('featureHintsEnabled', true); + setStore('seenFeatureHints', []); setStore('autoStartRemoteAccess', false); setStore('terminalScreenReaderMode', false); setStore('preferUiMode', false); @@ -1638,6 +1641,44 @@ describe('document full width persistence', () => { }); }); +describe('feature tips persistence', () => { + function stateJson(extra: Record): string { + return JSON.stringify({ + projects: [], + taskOrder: [], + collapsedTaskOrder: [], + tasks: {}, + ...extra, + }); + } + + async function lastSaved(): Promise> { + mockInvoke.mockResolvedValueOnce(undefined); + await saveState(); + const lastCall = mockInvoke.mock.calls[mockInvoke.mock.calls.length - 1]; + return JSON.parse(lastCall[1].json) as Record; + } + + it('keeps tips off for an install that predates them', async () => { + mockInvoke.mockResolvedValueOnce(stateJson({})); + await loadState(); + expect(store.featureHintsEnabled).toBe(false); + expect(store.seenFeatureHints).toEqual([]); + // Stored explicitly, so the next load does not mistake it for an older install. + expect((await lastSaved()).featureHintsEnabled).toBe(false); + }); + + it('round-trips seen tips and drops unknown ones', async () => { + mockInvoke.mockResolvedValueOnce( + stateJson({ featureHintsEnabled: true, seenFeatureHints: ['change-tour', 'retired', 3] }), + ); + await loadState(); + expect(store.featureHintsEnabled).toBe(true); + expect(store.seenFeatureHints).toEqual(['change-tour']); + expect((await lastSaved()).seenFeatureHints).toEqual(['change-tour']); + }); +}); + describe('code Q&A model persistence', () => { function stateJson(extra: Record): string { return JSON.stringify({ diff --git a/src/store/persistence.ts b/src/store/persistence.ts index ded34f142..53f9234e2 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -28,6 +28,7 @@ import { randomPastelColor, sortProjects } from './projects'; import { markAgentSpawned } from './taskStatus'; import { clampCoordinatorConcurrentTasks } from '../lib/coordinator-limits'; import { MAX_PROMPT_HISTORY } from '../lib/prompt-history'; +import { isFeatureHintId } from '../lib/feature-hints'; import { normalizeReasoningProfile } from '../investigation/profiles'; import { restoreReasoningWorkspaces } from '../investigation/editing'; import { isValidSpId } from '../../electron/shared/super-productivity'; @@ -442,6 +443,9 @@ export async function saveState(): Promise { agentEnvFiles: Object.keys(store.agentEnvFiles).length > 0 ? { ...store.agentEnvFiles } : undefined, keybindingMigrationDismissed: store.keybindingMigrationDismissed || undefined, + // Always stored: a missing value marks an install that predates the tips. + featureHintsEnabled: store.featureHintsEnabled, + seenFeatureHints: store.seenFeatureHints.length > 0 ? [...store.seenFeatureHints] : undefined, focusMode: store.focusMode || undefined, verboseLogging: store.verboseLogging || undefined, coordinatorNotificationDelayMs: @@ -643,6 +647,8 @@ interface LegacyPersistedState { agentEnvFiles?: unknown; terminals?: unknown; keybindingMigrationDismissed?: unknown; + featureHintsEnabled?: unknown; + seenFeatureHints?: unknown; focusMode?: unknown; verboseLogging?: unknown; coordinatorNotificationDelayMs?: unknown; @@ -670,6 +676,8 @@ export async function loadState(): Promise { stateLoaded = false; const json = await invoke(IPC.LoadAppState); if (!json) { + // A new install has no shortcuts to migrate. + setStore('keybindingMigrationDismissed', true); await delegationRequest({ action: 'orchestrationSetting', enabled: true }); stateLoaded = true; return; @@ -1014,6 +1022,13 @@ export async function loadState(): Promise { s.keybindingMigrationDismissed = raw.keybindingMigrationDismissed; } + // Users who set the app up before feature tips existed already found + // their way around; they can turn the tips on in Settings. + s.featureHintsEnabled = raw.featureHintsEnabled === true; + s.seenFeatureHints = Array.isArray(raw.seenFeatureHints) + ? raw.seenFeatureHints.filter(isFeatureHintId) + : []; + // Make custom agents findable during task restoration for (const ca of s.customAgents) { if (!s.availableAgents.some((a) => a.id === ca.id)) { diff --git a/src/store/types.ts b/src/store/types.ts index f0eeb956d..5cafbb7d7 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -33,6 +33,7 @@ import type { import type { DockerSource } from '../lib/docker'; import type { LookPreset, AppearanceMode } from '../lib/look'; import type { KeyBinding } from '../lib/keybindings'; +import type { FeatureHintId } from '../lib/feature-hints'; import type { CustomTheme } from '../lib/custom-theme'; import type { ReasoningProfile } from '../investigation/profiles'; import type { MindMapDocument } from '../graph/model'; @@ -527,6 +528,8 @@ export interface PersistedState { customAgents?: AgentDef[]; agentEnvFiles?: Record; keybindingMigrationDismissed?: boolean; + featureHintsEnabled?: boolean; + seenFeatureHints?: FeatureHintId[]; focusMode?: boolean; verboseLogging?: boolean; activeCustomThemeId?: string | null; @@ -690,6 +693,9 @@ export interface AppStore { /** Per-preset user overrides. Outer key = preset ID, inner = binding ID → override. */ keybindingOverridesByPreset: Record>; keybindingMigrationDismissed: boolean; + /** One-time feature tips; on for new installs, off for installs that predate them. */ + featureHintsEnabled: boolean; + seenFeatureHints: FeatureHintId[]; focusMode: boolean; /** Per-task flag: true when the task is rendering its focus-mode two-column layout. */ taskSplitMode: Record; diff --git a/src/styles.css b/src/styles.css index 861410f66..73d4a3613 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3230,6 +3230,11 @@ html:is( animation: statusPulse 1.9s ease-in-out infinite; } +/* A one-time tip fades in over the page; it never pushes content aside. */ +.feature-hint { + animation: fadeIn 0.18s ease; +} + @keyframes fadeIn { from { opacity: 0; @@ -5662,6 +5667,10 @@ button.tour-save-state:not(:disabled):hover { transition: none; } + .feature-hint { + animation: none; + } + .task-appearing, .new-task-appearing, .task-item-appearing,