mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 04:09:44 +02:00
[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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user