diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index b3c0fb7b..89c8c41e 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -401,6 +401,12 @@ const AppShell: React.FC = () => { setSidebarCollapsed((prev) => !prev)} + // Onboarding handle — the runtime reads aria-expanded to + // detect a collapsed sidebar and walks the user through + // clicking this toggle before targeting any sidebar-* item, + // mirroring the customization-collapse preflight. + data-onboarding="sidebar-toggle" + aria-expanded={!sidebarCollapsed} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, diff --git a/frontend/src/app/components/Onboarding/ac/ACPopup.tsx b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx index e51fc1dd..4f39e134 100644 --- a/frontend/src/app/components/Onboarding/ac/ACPopup.tsx +++ b/frontend/src/app/components/Onboarding/ac/ACPopup.tsx @@ -8,6 +8,16 @@ interface Props { text: string; /** Offset from cursor tip in px when there's room. */ offset?: { x: number; y: number }; + /** + * Preferred horizontal side of the cursor. Default `'right'` (the + * bubble appears to the bottom-right of the tip). Pass `'left'` for + * cursors that land on an icon whose right-hand neighbors would + * otherwise be covered by the bubble (toolbar [+ grid globe history + * note], chat input [cursor-circle clip mic], etc.). The viewport + * clip-flip still wins — if the chosen side would clip, we flip to + * the other side. + */ + side?: 'left' | 'right'; } const SAFE_PAD = 8; @@ -34,7 +44,7 @@ const STREAM_MIN_CHARS = 5; * when the chosen position would clip past the viewport. Re-evaluates * whenever the cursor moves (cursorStore subscription). */ -const ACPopup: React.FC = ({ text, offset = { x: 14, y: 14 } }) => { +const ACPopup: React.FC = ({ text, offset = { x: 14, y: 14 }, side = 'right' }) => { const c = useClaudeTokens(); const { x, y, visible } = useCursorPosition(); const ref = useRef(null); @@ -88,14 +98,23 @@ const ACPopup: React.FC = ({ text, offset = { x: 14, y: 14 } }) => { const vw = window.innerWidth; const vh = window.innerHeight; - let nx = x + offset.x; + // Start on the preferred side. `flipX = true` means the bubble is + // drawn to the LEFT of the cursor (and its tail anchors on the + // bubble's right edge). Default preference is right. + let flipX = side === 'left'; + let nx = flipX ? x - w - offset.x : x + offset.x; let ny = y + offset.y; - let flipX = false; let flipY = false; - if (nx + w + SAFE_PAD > vw) { + // Viewport clip: if the preferred side would overflow, flip to the + // other side. The flip wins over the preference so the bubble stays + // on-screen no matter what the caller asked for. + if (!flipX && nx + w + SAFE_PAD > vw) { nx = x - w - offset.x; flipX = true; + } else if (flipX && nx < SAFE_PAD) { + nx = x + offset.x; + flipX = false; } if (ny + h + SAFE_PAD > vh) { ny = y - h - offset.y; @@ -105,7 +124,7 @@ const ACPopup: React.FC = ({ text, offset = { x: 14, y: 14 } }) => { ny = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD)); setPos({ x: nx, y: ny, flipX, flipY }); - }, [x, y, offset.x, offset.y, text, streamCount]); + }, [x, y, offset.x, offset.y, side, text, streamCount]); if (!visible) return null; diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index 9e2e0007..40b028df 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -51,8 +51,16 @@ export interface AgenticCursorHandle { * showPopup replaces it. The runtime calls hidePopup() before any op * that physically moves the cursor or types, so the popup naturally * disappears when the cursor's "instruction" no longer applies. + * + * `side` overrides the default "render bottom-right of cursor" + * placement. Use `'left'` when the cursor lands on an icon whose + * right-hand neighbors would otherwise be covered by the bubble (the + * dashboard toolbar's [+ grid globe history note] cluster, the chat + * input's [cursor-circle clip mic] cluster, etc.). The bubble still + * auto-flips back to the other side if the chosen side would clip the + * viewport. */ - showPopup: (text: string) => void; + showPopup: (text: string, opts?: { side?: 'left' | 'right' }) => void; /** * Single-select multi-choice. Resolves with the chosen option id; the * panel that calls this can route the rest of the flow accordingly. @@ -64,6 +72,7 @@ export interface AgenticCursorHandle { interface PopupState { text: string; + side?: 'left' | 'right'; } interface MultiChoiceState { @@ -273,11 +282,11 @@ const AgenticCursor = forwardRef((_props, ref) => { stopTracking() { stopTrackingInternal(); }, - showPopup(text) { + showPopup(text, opts) { // Non-blocking — replaces any existing popup. Caller advances the // flow; popup auto-clears on the next op that physically moves the // cursor (move_to / click / type_into / drag_select / outro). - setPopup({ text }); + setPopup({ text, side: opts?.side }); }, showMultiChoice(question, options) { return new Promise((resolve) => { @@ -364,7 +373,7 @@ const AgenticCursor = forwardRef((_props, ref) => { inherited from the cursor wrapper's pointer-events:none. They subscribe to cursorStore to track the live position. */} - {popup && } + {popup && } {multiChoice && ( { switch (op.kind) { case 'move_to': { - // Pre-flight: if the target lives inside a collapsed Customization - // section, walk the user through expanding it first. This is the - // "asks me to click on it twice" fix — without this guard, AC's - // popup pointed at an Actions/Skills/Modes item that wasn't yet - // visible, the user would click Customization to reveal it (which - // didn't satisfy the wait), then click the item, looking like a - // duplicate prompt. + // Pre-flight order matters: open the whole sidebar first (so + // sub-section markers exist in DOM), THEN check the Customization + // collapse, THEN target. + // + // Sidebar collapsed case ("AC freezes when user had sidebar + // hidden") — without this guard, waitForSelector for any + // sidebar-* target would hit its 2.5s lost-target timeout because + // the entire panel is unrendered. + const expandSidebarOps = maybeBuildExpandSidebarOps(op.target); + if (expandSidebarOps) { + await runOps(expandSidebarOps, ctx); + } + // Customization collapsed case ("asks me to click on it twice") + // — without this guard, AC's popup pointed at an Actions/Skills/ + // Modes item that wasn't yet visible, the user would click + // Customization to reveal it (which didn't satisfy the wait), + // then click the item, looking like a duplicate prompt. const expandOps = maybeBuildExpandCustomizationOps(op.target); if (expandOps) { await runOps(expandOps, ctx); @@ -374,7 +384,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { } case 'popup': { if (ctx.silent) return; - ac.showPopup(op.text); + ac.showPopup(op.text, { side: op.side }); return; } case 'multi_choice': { @@ -411,7 +421,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { // op (move_to / click / type_into / drag_select / outro) or at // step-end in the runStep finally block. if (op.popup && !ctx.silent) { - ac.showPopup(op.popup); + ac.showPopup(op.popup, { side: op.popupSide }); } // Optional minimum dwell so very-fast paths still register the // glow visually. Defaults to a short beat; explicit durationMs @@ -707,6 +717,61 @@ const CUSTOMIZATION_AREA_TARGETS = new Set([ 'sidebar-modes', ]); +// Targets that live anywhere inside the sidebar (top-level nav rows, +// section headers, items revealed by an expanded section). If a step's +// move_to points at one of these and the WHOLE sidebar is collapsed +// (the AppShell ViewSidebar toggle hides the entire panel), the target +// element isn't in the DOM at all and waitForSelector would freeze the +// AC for a full 2.5s lost-target timeout before giving up. +// +// `sidebar-toggle` is deliberately excluded — it lives in the top bar +// and is the thing we click to expand. Recursing on it would loop. +const SIDEBAR_AREA_TARGETS = new Set([ + 'sidebar-settings-button', + 'sidebar-dashboards', + 'sidebar-customization', + 'sidebar-skills', + 'sidebar-actions', + 'sidebar-modes', + 'sidebar-apps', + 'dashboard-row-first', +]); + +/** + * If the requested target lives inside the sidebar panel and the panel + * is currently collapsed (aria-expanded="false" on the top-bar + * ViewSidebar toggle), return ops to walk the user through clicking the + * toggle. Otherwise return null. Caller should runOps() the result + * before its own move_to. + * + * This guard MUST run before maybeBuildExpandCustomizationOps because + * the Customization header itself lives inside the collapsible panel — + * checking for an expanded Customization on a hidden panel would always + * read "not expanded" and queue an impossible click. + */ +function maybeBuildExpandSidebarOps(target: string): ACOp[] | null { + if (!SIDEBAR_AREA_TARGETS.has(target)) return null; + const toggle = document.querySelector( + '[data-onboarding="sidebar-toggle"]', + ); + // aria-expanded reflects !sidebarCollapsed (true = sidebar visible). + // Missing / undefined means we couldn't find the toggle — assume + // visible and let waitForSelector handle the (unlikely) real failure + // so we don't gate on a missing marker. + const expanded = + toggle?.getAttribute('aria-expanded') === 'true' || toggle === null; + if (expanded) return null; + return [ + { kind: 'move_to', target: 'sidebar-toggle' }, + { kind: 'popup', text: 'Pop the sidebar back open.' }, + { + kind: 'wait_user', + condition: { kind: 'click_target', target: 'sidebar-toggle' }, + timeoutMs: 60000, + }, + ]; +} + /** * If the requested target lives inside the Customization collapse and the * section is currently closed, return ops to walk the user through diff --git a/frontend/src/app/components/Onboarding/selectors.ts b/frontend/src/app/components/Onboarding/selectors.ts index 5da00ad4..df0eb0bd 100644 --- a/frontend/src/app/components/Onboarding/selectors.ts +++ b/frontend/src/app/components/Onboarding/selectors.ts @@ -16,6 +16,12 @@ export const S = { // new — sidebar sidebarSettingsButton: 'sidebar-settings-button', sidebarDashboards: 'sidebar-dashboards', + // The ViewSidebar icon in AppShell's top bar that hides/shows the + // whole sidebar. Wears aria-expanded={!sidebarCollapsed} so the + // runtime's expand-sidebar preflight can detect a collapsed state and + // walk the user through clicking it before targeting anything else + // in the sidebar. + sidebarToggle: 'sidebar-toggle', // First row inside the expanded Dashboards section. The "click into a // dashboard" hop targets this so the user lands inside a dashboard // route (where the toolbar + and browser button actually exist). diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts index 84098eb6..434264c1 100644 --- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts +++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts @@ -14,7 +14,10 @@ export const step03: OnboardingStep = { requiresDashboard: true, ops: [ { kind: 'move_to', target: S.newAgentButton }, - { kind: 'popup', text: 'Tap the plus to start a fresh chat.' }, + // side:'left' — the + lives at the left edge of the toolbar cluster + // (+ grid globe history note). Default right-side popup would cover + // all four neighbors. + { kind: 'popup', text: 'Tap the plus to start a fresh chat.', side: 'left' }, { kind: 'wait_user', condition: { kind: 'click_target', target: S.newAgentButton }, diff --git a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts index afcad6c3..134b1075 100644 --- a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts @@ -18,7 +18,9 @@ export const step04: OnboardingStep = { requiresDashboard: true, ops: [ { kind: 'move_to', target: S.browserButton }, - { kind: 'popup', text: 'Pop open a browser.' }, + // side:'left' — globe sits to the left of history + note; default + // right-side popup would visually point at history. + { kind: 'popup', text: 'Pop open a browser.', side: 'left' }, { kind: 'wait_user', condition: { kind: 'event_bus', event: 'browser:spawned' }, diff --git a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts index 7530b6f3..f5b71f4b 100644 --- a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts @@ -13,7 +13,9 @@ export const step05: OnboardingStep = { dependsOn: [{ stepId: 'use_browser', reopen: 'walk_again' }], ops: [ { kind: 'move_to', target: S.newAgentButton }, - { kind: 'popup', text: 'Time for a fresh chat that surfs the web.' }, + // side:'left' — same as step03; + is the leftmost icon in the + // toolbar cluster. + { kind: 'popup', text: 'Time for a fresh chat that surfs the web.', side: 'left' }, { kind: 'wait_user', condition: { kind: 'click_target', target: S.newAgentButton }, @@ -25,7 +27,10 @@ export const step05: OnboardingStep = { // (-10, -10) puts the body's visual center over this icon's // center, where it belongs. { kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } }, - { kind: 'popup', text: 'Tap here to plug a browser into this chat.' }, + // side:'left' — cursor-circle is the leftmost of the chat-input + // right-side icons (cursor-circle clip mic). Right popup overlapped + // the paperclip / mic. + { kind: 'popup', text: 'Tap here to plug a browser into this chat.', side: 'left' }, { kind: 'wait_user', condition: { kind: 'click_target', target: S.elementSelectionToggle }, diff --git a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts index bb5d0cad..777db905 100644 --- a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts +++ b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts @@ -19,7 +19,8 @@ export const step06: OnboardingStep = { text: "Pretend this chat already did the homework. Now we'll have a fresh one boss it around.", }, { kind: 'move_to', target: S.newAgentButton }, - { kind: 'popup', text: "Make a new chat. This one's the boss." }, + // side:'left' — same as step03/step05; + is the leftmost icon. + { kind: 'popup', text: "Make a new chat. This one's the boss.", side: 'left' }, { kind: 'wait_user', condition: { kind: 'click_target', target: S.newAgentButton }, @@ -27,7 +28,9 @@ export const step06: OnboardingStep = { // See step05 — same nudge so the cursor's visual body center sits // over the select-mode icon, not the adjacent paperclip. { kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } }, - { kind: 'popup', text: 'Tap here to hook in the older chat.' }, + // side:'left' — same as step05; cursor-circle is leftmost of the + // chat-input right cluster. + { kind: 'popup', text: 'Tap here to hook in the older chat.', side: 'left' }, { kind: 'wait_user', condition: { kind: 'click_target', target: S.elementSelectionToggle }, diff --git a/frontend/src/app/components/Onboarding/steps/types.ts b/frontend/src/app/components/Onboarding/steps/types.ts index 93d5e0bc..3480eb58 100644 --- a/frontend/src/app/components/Onboarding/steps/types.ts +++ b/frontend/src/app/components/Onboarding/steps/types.ts @@ -20,9 +20,9 @@ export type ACMultiChoiceOption = { export type ACOp = | { kind: 'move_to'; target: Selector; offset?: { x: number; y: number } } - | { kind: 'popup'; text: string; cta?: string } + | { kind: 'popup'; text: string; cta?: string; side?: 'left' | 'right' } | { kind: 'multi_choice'; opId: string; question: string; options: ACMultiChoiceOption[] } - | { kind: 'highlight_section'; target: Selector; popup?: string; durationMs?: number } + | { kind: 'highlight_section'; target: Selector; popup?: string; popupSide?: 'left' | 'right'; durationMs?: number } | { kind: 'type_into'; target: Selector; text: string; speedMs?: number } | { kind: 'click'; target: Selector; simulate?: boolean } | { kind: 'drag_select'; target: Selector }