[eric] onboarding — auto-detect collapsed sidebar and walk user through expanding it before targeting sidebar items; AC popup/cursor polish + step

3-6 fixes
This commit is contained in:
ciregenz
2026-05-11 15:31:57 -07:00
parent 573f4a1ed4
commit dfdd11a271
10 changed files with 144 additions and 26 deletions
@@ -401,6 +401,12 @@ const AppShell: React.FC = () => {
<IconButton
size="small"
onClick={() => 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,
@@ -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<Props> = ({ text, offset = { x: 14, y: 14 } }) => {
const ACPopup: React.FC<Props> = ({ text, offset = { x: 14, y: 14 }, side = 'right' }) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
const ref = useRef<HTMLDivElement>(null);
@@ -88,14 +98,23 @@ const ACPopup: React.FC<Props> = ({ 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<Props> = ({ 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;
@@ -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<AgenticCursorHandle>((_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<string>((resolve) => {
@@ -364,7 +373,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
inherited from the cursor wrapper's pointer-events:none. They
subscribe to cursorStore to track the live position. */}
<AnimatePresence>
{popup && <ACPopup key="popup" text={popup.text} />}
{popup && <ACPopup key="popup" text={popup.text} side={popup.side} />}
{multiChoice && (
<ACMultiChoice
key="multi-choice"
@@ -261,13 +261,23 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
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<void> {
}
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<void> {
// 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<string>([
'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<string>([
'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<HTMLElement>(
'[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
@@ -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).
@@ -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 },
@@ -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' },
@@ -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 },
@@ -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 },
@@ -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 }