From 6aebc2bdd78c912fac18648d38b43539ed52ad22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:24:39 +0000 Subject: [PATCH 1/6] feat(onboarding): add one-time feature tips Tips point at a feature the first time it has something to work on, rather than touring the app up front: - change tour, once the active task has changes - readiness and evidence, when the Finish dialog opens - delegating to sub-tasks, in the New Task panel once other tasks exist and the agent supports orchestration, with an example prompt One tip shows at a time, and only while its anchor is on screen and not covered by a dialog. Dismissing one holds the next back for a minute. Tips float over the page, so they never move content. New installs get tips on; installs that predate them keep them off and can turn them on in Settings, which also shows every tip again. New installs also no longer see the keyboard shortcut migration notice, since there is nothing to migrate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Cf9ACGvzjah4kib977pkZ --- src/components/FeatureHint.client.test.tsx | 144 ++++++++++++++++ src/components/FeatureHint.tsx | 192 +++++++++++++++++++++ src/components/FinishDialog.tsx | 38 ++-- src/components/NewTaskPanel.tsx | 24 +++ src/components/SettingsDialog.tsx | 7 + src/components/TaskChangedFilesSection.tsx | 27 ++- src/lib/feature-hints.ts | 12 ++ src/store/autosave.ts | 2 + src/store/core.ts | 2 + src/store/onboarding.test.ts | 76 ++++++++ src/store/onboarding.ts | 66 +++++++ src/store/persistence.test.ts | 48 ++++++ src/store/persistence.ts | 15 ++ src/store/types.ts | 6 + 14 files changed, 642 insertions(+), 17 deletions(-) create mode 100644 src/components/FeatureHint.client.test.tsx create mode 100644 src/components/FeatureHint.tsx create mode 100644 src/lib/feature-hints.ts create mode 100644 src/store/onboarding.test.ts create mode 100644 src/store/onboarding.ts diff --git a/src/components/FeatureHint.client.test.tsx b/src/components/FeatureHint.client.test.tsx new file mode 100644 index 000000000..6554c31a1 --- /dev/null +++ b/src/components/FeatureHint.client.test.tsx @@ -0,0 +1,144 @@ +import { 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(); + setStore('featureHintsEnabled', true); + setStore('seenFeatureHints', []); +}); + +afterEach(() => { + // A dismissal's cooldown must not hold back the next test's tips. + vi.runAllTimers(); + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); + vi.useRealTimers(); +}); + +/** happy-dom lays nothing out, so give the anchor an on-screen box. */ +function anchorAt(top: number): HTMLDivElement { + const el = document.createElement('div'); + el.getBoundingClientRect = () => new DOMRect(100, top, 200, 24); + return el; +} + +function mount(view: () => ReturnType) { + const host = document.createElement('div'); + document.body.append(host); + disposers.push(render(view, host)); +} + +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); + expect(notes()).toHaveLength(1); + expect(notes()[0].textContent).toContain('Second'); + }); + + 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); + expect(notes()).toHaveLength(1); + }); + + it('runs an action and counts it as seen', () => { + const anchor = anchorAt(50); + const run = vi.fn(); + mount(() => ( + anchor} + title="Delegate" + actions={[{ label: 'Use example prompt', run }]} + > + Body + + )); + 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: HTMLDivElement | undefined; + const [open, setOpen] = createSignal(false); + mount(() => ( + <> + outside} title="Outside"> + Body + + setOpen(false)}> +
{ + el.getBoundingClientRect = () => new DOMRect(100, 300, 200, 24); + inside = el; + }} + /> + inside} title="Inside"> + Body + +
+ + )); + expect(notes().map((note) => note.textContent)).toEqual([expect.stringContaining('Outside')]); + + setOpen(true); + expect(notes()).toHaveLength(1); + expect(notes()[0].textContent).toContain('Inside'); + expect(notes()[0].closest('.dialog-panel')).not.toBeNull(); + }); + + 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..2d239e4ce --- /dev/null +++ b/src/components/FeatureHint.tsx @@ -0,0 +1,192 @@ +import { + For, + Show, + createEffect, + createSignal, + createUniqueId, + onCleanup, + type JSX, +} from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { topDialog } from '../lib/dialog-stack'; +import type { FeatureHintId } from '../lib/feature-hints'; +import { createAnchorEffect, placeBelow, type BelowAnchor } from '../lib/floating'; +import { sf } from '../lib/fontScale'; +import { theme } from '../lib/theme'; +import { + claimFeatureHint, + dismissFeatureHint, + featureHintWanted, + isFeatureHintActive, + releaseFeatureHint, +} from '../store/onboarding'; + +const WIDTH = 300; +/** Roughly the card's height, so an anchor near the bottom gets it above instead. */ +const HEIGHT = 170; + +interface FeatureHintAction { + label: string; + run: () => void; +} + +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[]; +} + +function onScreen(rect: DOMRect): boolean { + return ( + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth + ); +} + +/** The top dialog's panel; dialogs portal to the body in the order they open. */ +function topDialogPanel(): Element | undefined { + const panels = document.querySelectorAll('.dialog-panel'); + return panels[panels.length - 1]; +} + +/** + * 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 on + * screen 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(); + const [position, setPosition] = createSignal(null); + 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 === topDialogPanel() : top === null; + }; + + const place = () => { + const rect = props.anchor()?.getBoundingClientRect(); + setPosition( + rect && onScreen(rect) + ? placeBelow( + rect, + Math.min(WIDTH, window.innerWidth - 24), + { width: window.innerWidth, height: window.innerHeight }, + 12, + HEIGHT, + ) + : null, + ); + }; + createAnchorEffect(wanted, place); + // Panels resize and collapse without scrolling the window. + createEffect(() => { + const el = props.anchor(); + if (!wanted() || !el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(place); + observer.observe(el); + onCleanup(() => observer.disconnect()); + }); + + createEffect(() => { + if (wanted() && uncovered() && position()) claimFeatureHint(owner); + else releaseFeatureHint(owner); + }); + onCleanup(() => releaseFeatureHint(owner)); + + const shown = () => wanted() && uncovered() && isFeatureHintActive(owner); + + return ( + + {(pos) => ( + (el.style.display = 'contents')} + > +
+ + {props.title} + +
{props.children}
+
+ + {(action) => ( + + )} + + +
+
+
+ )} +
+ ); +} + +/** 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.tsx b/src/components/FinishDialog.tsx index 438f2faf8..6a835ce6f 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'; @@ -61,6 +62,7 @@ const SHOWN_ELSEWHERE = new Set(['Merge safety', 'Verify command', 'Evidence', ' */ export function FinishDialog(props: FinishDialogProps) { const merge = createMergeState(props); + let readinessAnchor: HTMLDivElement | undefined; const [reviewLocation, setReviewLocation] = createSignal<{ file: string; line?: number; @@ -210,18 +212,30 @@ export function FinishDialog(props: FinishDialogProps) { /> - - props.onConfigureChecks()} - onReviewFile={(file, line, side) => setReviewLocation({ file, line, side })} - > - - - +
+ + props.onConfigureChecks()} + onReviewFile={(file, line, side) => setReviewLocation({ file, line, side })} + > + + + +
+ readinessAnchor} + title="Check what backs this change" + > + Readiness collects the evidence for a merge: which checks ran, what the agent says it + did, and anything that blocks merging. Add a verify command in the project settings 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 useDelegationExample(): 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,17 @@ export function NewTaskPanel(props: NewTaskPanelProps) { resize: 'vertical', }} /> + promptRef} + title="Let one agent split the work" + actions={[{ label: 'Use example prompt', run: useDelegationExample }]} + > + {selectedAgent()?.name ?? 'This agent'} can start its own sub-tasks. Ask it to split a + change into parts, and each part runs in its own worktree while it keeps track. 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 +209,25 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) { {(tour) => ( - props.onTourClick?.()} - /> +
+ { + markFeatureHintSeen('change-tour'); + props.onTourClick?.(); + }} + /> + tourAnchor} + title="Take a tour of these changes" + > + A short walkthrough of what the agent changed and why, stop by stop through the diff. + It generates in the background while you keep working. + +
)}
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..26530e209 --- /dev/null +++ b/src/store/onboarding.test.ts @@ -0,0 +1,76 @@ +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 { + claimFeatureHint, + dismissFeatureHint, + 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)).toBe(true); + expect(claimFeatureHint(second)).toBe(false); + expect(isFeatureHintActive(second)).toBe(false); + + releaseFeatureHint(second); + expect(isFeatureHintActive(first)).toBe(true); + + releaseFeatureHint(first); + expect(claimFeatureHint(second)).toBe(true); + releaseFeatureHint(second); + }); + + it('marks a dismissed tip seen and holds the next one back for a while', () => { + const first = Symbol('first'); + const second = Symbol('second'); + claimFeatureHint(first); + dismissFeatureHint('change-tour', first); + + expect(featureHintWanted('change-tour')).toBe(false); + expect(store.seenFeatureHints).toEqual(['change-tour']); + expect(claimFeatureHint(second)).toBe(false); + + vi.advanceTimersByTime(60_000); + expect(claimFeatureHint(second)).toBe(true); + releaseFeatureHint(second); + }); + + 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..8a719871e --- /dev/null +++ b/src/store/onboarding.ts @@ -0,0 +1,66 @@ +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, the next one waits this long, so tips never chain. */ +const COOLDOWN_MS = 60_000; + +/** The one tip on screen. Several can be eligible at once; the first claim wins. */ +const [activeOwner, setActiveOwner] = createSignal(null); +const [coolingDown, setCoolingDown] = createSignal(false); +let cooldownTimer: ReturnType | undefined; + +function isFeatureHintSeen(id: FeatureHintId): boolean { + return store.seenFeatureHints.includes(id); +} + +/** 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): boolean { + const current = activeOwner(); + if (current === owner) return true; + if (current !== null || coolingDown()) return false; + setActiveOwner(owner); + return true; +} + +export function releaseFeatureHint(owner: symbol): void { + if (activeOwner() === owner) setActiveOwner(null); +} + +export function isFeatureHintActive(owner: symbol): boolean { + return activeOwner() === owner; +} + +/** 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; + setStore('seenFeatureHints', (seen) => [...seen, id]); + // Persist at once: autosave might not run before the app closes. + void saveState(); +} + +export function dismissFeatureHint(id: FeatureHintId, owner: symbol): void { + // One batch: a waiting tip must see the cooldown before it sees the free slot. + batch(() => { + setCoolingDown(true); + markFeatureHintSeen(id); + releaseFeatureHint(owner); + }); + if (cooldownTimer !== undefined) clearTimeout(cooldownTimer); + cooldownTimer = setTimeout(() => { + cooldownTimer = undefined; + setCoolingDown(false); + }, COOLDOWN_MS); +} + +/** 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.test.ts b/src/store/persistence.test.ts index 130edab08..f0b853ac0 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -1638,6 +1638,54 @@ 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('starts a new install with tips on and no shortcut migration notice', async () => { + setStore('featureHintsEnabled', true); + setStore('keybindingMigrationDismissed', false); + mockInvoke.mockResolvedValueOnce(null); + await loadState(); + expect(store.keybindingMigrationDismissed).toBe(true); + expect(store.featureHintsEnabled).toBe(true); + expect((await lastSaved()).featureHintsEnabled).toBe(true); + }); + + 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; From b30b7d39d285e9b343630922f157f02377ca89de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:28:31 +0000 Subject: [PATCH 2/6] feat(onboarding): polish feature tips - Wait 600ms before a tip shows, so it does not pop up while a dialog opens or for a task the user is only switching past. - Fade tips in (no motion with reduced motion) and label them as tips. - Escape dismisses a tip without closing the dialog around it. - The change tour tip stays away once a tour is generating or ready, and starting a tour from the Finish dialog also counts as seeing it. - The readiness tip offers "Set up checks" only when the project has no verify command, and using the section counts as seeing it. - The sub-task tip only promises a review before merging while sub-tasks are not merged automatically. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Cf9ACGvzjah4kib977pkZ --- src/components/FeatureHint.client.test.tsx | 37 ++++++++++++++++ src/components/FeatureHint.tsx | 50 +++++++++++++++++++--- src/components/FinishDialog.tsx | 28 +++++++++--- src/components/NewTaskPanel.tsx | 5 ++- src/components/TaskChangedFilesSection.tsx | 12 ++++-- src/styles.css | 9 ++++ 6 files changed, 125 insertions(+), 16 deletions(-) diff --git a/src/components/FeatureHint.client.test.tsx b/src/components/FeatureHint.client.test.tsx index 6554c31a1..e8cbfc962 100644 --- a/src/components/FeatureHint.client.test.tsx +++ b/src/components/FeatureHint.client.test.tsx @@ -31,12 +31,16 @@ function anchorAt(top: number): HTMLDivElement { return el; } +/** 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 settle = () => vi.advanceTimersByTime(600); + const notes = () => [...document.querySelectorAll('[role="note"]')]; const button = (label: string) => [...document.querySelectorAll('button')].find((b) => b.textContent === label); @@ -64,6 +68,7 @@ describe('FeatureHint', () => { expect(notes()).toHaveLength(0); vi.advanceTimersByTime(60_000); + settle(); expect(notes()).toHaveLength(1); expect(notes()[0].textContent).toContain('Second'); }); @@ -78,9 +83,39 @@ describe('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(300); expect(notes()).toHaveLength(1); }); + it('dismisses on Escape without closing the dialog around it', () => { + let anchor: HTMLDivElement | undefined; + const onClose = vi.fn(); + mount(() => ( + +
{ + el.getBoundingClientRect = () => new DOMRect(100, 50, 200, 24); + anchor = el; + }} + /> + 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('runs an action and counts it as seen', () => { const anchor = anchorAt(50); const run = vi.fn(); @@ -126,6 +161,8 @@ describe('FeatureHint', () => { 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(); diff --git a/src/components/FeatureHint.tsx b/src/components/FeatureHint.tsx index 2d239e4ce..d1bbf591b 100644 --- a/src/components/FeatureHint.tsx +++ b/src/components/FeatureHint.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, + createMemo, createSignal, createUniqueId, onCleanup, @@ -23,7 +24,13 @@ import { const WIDTH = 300; /** Roughly the card's height, so an anchor near the bottom gets it above instead. */ -const HEIGHT = 170; +const 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; interface FeatureHintAction { label: string; @@ -102,13 +109,23 @@ export function FeatureHint(props: FeatureHintProps) { onCleanup(() => observer.disconnect()); }); + // A memo, so moving with a scroll does not restart the settle delay. + const eligible = createMemo(() => wanted() && uncovered() && 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 (wanted() && uncovered() && position()) claimFeatureHint(owner); + if (eligible() && settled()) claimFeatureHint(owner); else releaseFeatureHint(owner); }); onCleanup(() => releaseFeatureHint(owner)); - const shown = () => wanted() && uncovered() && isFeatureHintActive(owner); + const shown = () => eligible() && isFeatureHintActive(owner); + const dismiss = () => dismissFeatureHint(props.id, owner); return ( @@ -121,6 +138,18 @@ export function FeatureHint(props: FeatureHintProps) {
+ el.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + dismiss(); + }) + } style={{ position: 'fixed', top: `${pos().top}px`, @@ -142,6 +171,17 @@ export function FeatureHint(props: FeatureHintProps) { 'line-height': '1.5', }} > + + Tip + {props.title} @@ -154,7 +194,7 @@ export function FeatureHint(props: FeatureHintProps) { class="btn-secondary" style={buttonStyle(true)} onClick={() => { - dismissFeatureHint(props.id, owner); + dismiss(); action.run(); }} > @@ -166,7 +206,7 @@ export function FeatureHint(props: FeatureHintProps) { type="button" class="btn-secondary" style={buttonStyle(!props.actions?.length)} - onClick={() => dismissFeatureHint(props.id, owner)} + onClick={dismiss} > Got it diff --git a/src/components/FinishDialog.tsx b/src/components/FinishDialog.tsx index 6a835ce6f..832744f62 100644 --- a/src/components/FinishDialog.tsx +++ b/src/components/FinishDialog.tsx @@ -16,7 +16,9 @@ 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 { VERIFY_CHECK_ID } from '../../electron/shared/evidence'; import { showNotification } from '../store/notification'; import { theme } from '../lib/theme'; import { GitMergeIcon, SyncIcon, UploadIcon } from './icons'; @@ -63,6 +65,8 @@ const SHOWN_ELSEWHERE = new Set(['Merge safety', 'Verify command', 'Evidence', ' export function FinishDialog(props: FinishDialogProps) { const merge = createMergeState(props); let readinessAnchor: HTMLDivElement | undefined; + const hasVerifyCommand = () => + Boolean(getTaskChecks(props.task.id).find((check) => check.id === VERIFY_CHECK_ID)?.command); const [reviewLocation, setReviewLocation] = createSignal<{ file: string; line?: number; @@ -184,7 +188,10 @@ export function FinishDialog(props: FinishDialogProps) { > { + markFeatureHintSeen('change-tour'); + props.onTourClick(); + }} disabled={props.tourDisabled} > 0 && !props.tour.loading()}> @@ -212,7 +219,8 @@ export function FinishDialog(props: FinishDialogProps) { /> -
+ {/* Opening the section or running a check there is taking the tip. */} +
markFeatureHintSeen('finish-review')}> readinessAnchor} title="Check what backs this change" + actions={ + hasVerifyCommand() + ? undefined + : [{ label: 'Set up checks', run: () => props.onConfigureChecks() }] + } > - Readiness collects the evidence for a merge: which checks ran, what the agent says it - did, and anything that blocks merging. Add a verify command in the project settings to - run your tests from here. + Readiness gathers the evidence for this merge: which checks ran, what the agent says + it did, and anything that blocks merging. + + {' '} + Add a verify command to run your tests from here. + {selectedAgent()?.name ?? 'This agent'} can start its own sub-tasks. Ask it to split a - change into parts, and each part runs in its own worktree while it keeps track. You - review each result before it merges. + 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.
diff --git a/src/components/TaskChangedFilesSection.tsx b/src/components/TaskChangedFilesSection.tsx index 5916d9e9c..894b52c11 100644 --- a/src/components/TaskChangedFilesSection.tsx +++ b/src/components/TaskChangedFilesSection.tsx @@ -220,12 +220,18 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) { /> tourAnchor} title="Take a tour of these changes" > - A short walkthrough of what the agent changed and why, stop by stop through the diff. - It generates in the background while you keep working. + Get a short guided walkthrough of what changed, stop by stop through the diff. It + generates in the background, so you can keep working.
)} 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, From d4597abf1b5357da38b8c358a1e09c25bd477b47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:34:53 +0000 Subject: [PATCH 3/6] feat(onboarding): point tips at their anchor and keep them off dialogs A tip now carries a caret toward the control it explains. Inside a dialog it sits beside the panel, else under or over it, so it no longer covers the dialog's own buttons; it overlaps only without room around. A tip above its anchor is held by its bottom edge, so a card shorter than estimated leaves no gap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Cf9ACGvzjah4kib977pkZ --- src/components/FeatureHint.tsx | 125 ++++++++++++++++--------- src/lib/feature-hint-placement.test.ts | 68 ++++++++++++++ src/lib/feature-hint-placement.ts | 94 +++++++++++++++++++ 3 files changed, 241 insertions(+), 46 deletions(-) create mode 100644 src/lib/feature-hint-placement.test.ts create mode 100644 src/lib/feature-hint-placement.ts diff --git a/src/components/FeatureHint.tsx b/src/components/FeatureHint.tsx index d1bbf591b..2e88137e4 100644 --- a/src/components/FeatureHint.tsx +++ b/src/components/FeatureHint.tsx @@ -11,7 +11,8 @@ import { import { Portal } from 'solid-js/web'; import { topDialog } from '../lib/dialog-stack'; import type { FeatureHintId } from '../lib/feature-hints'; -import { createAnchorEffect, placeBelow, type BelowAnchor } from '../lib/floating'; +import { placeHint, type HintPlacement } from '../lib/feature-hint-placement'; +import { createAnchorEffect } from '../lib/floating'; import { sf } from '../lib/fontScale'; import { theme } from '../lib/theme'; import { @@ -75,7 +76,7 @@ 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(); - const [position, setPosition] = createSignal(null); + const [position, setPosition] = createSignal(null); 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. */ @@ -89,12 +90,12 @@ export function FeatureHint(props: FeatureHintProps) { const rect = props.anchor()?.getBoundingClientRect(); setPosition( rect && onScreen(rect) - ? placeBelow( + ? placeHint( rect, - Math.min(WIDTH, window.innerWidth - 24), { width: window.innerWidth, height: window.innerHeight }, - 12, + Math.min(WIDTH, window.innerWidth - 24), HEIGHT, + panel()?.getBoundingClientRect(), ) : null, ); @@ -152,18 +153,16 @@ export function FeatureHint(props: FeatureHintProps) { } style={{ position: 'fixed', - top: `${pos().top}px`, - right: `${pos().right}px`, + 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)', - 'max-height': `${pos().maxHeight}px`, 'box-sizing': 'border-box', - overflow: 'auto', // Under dialogs and menus, which cover it when they open. 'z-index': '900', - padding: '12px 14px', - background: theme.bgElevated, 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)', @@ -171,45 +170,56 @@ export function FeatureHint(props: FeatureHintProps) { 'line-height': '1.5', }} > -