diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index dff9371f54..2b2e21f098 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -2,6 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' +import { useTourMarker } from '@/app/chat/tour-marker' import { useHudComposerDrag } from '@/app/hud/composer-drag' import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -175,6 +176,8 @@ export function ChatBar({ // queue uses the stored-session fallback key (prompts can queue pre-resume). const statusSessionId = sessionId ?? null + const composerTourMarker = useTourMarker('composer') + // Coarse edge: re-renders ChatBar only when the stack shows/hides, NOT on // every per-item status mutation or other sessions' churn (see the hook). const statusPresent = useSessionStatusPresence(statusSessionId) @@ -1207,6 +1210,8 @@ export function ChatBar({ data-slot="composer-root" data-status-stack={statusStackVisible ? '' : undefined} data-thread-scrolled-up={scrolledUp ? '' : undefined} + data-tip-region="" + data-tour={composerTourMarker} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 6bf8c9f0ae..0581754353 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' +import { useTourMarker } from '@/app/chat/tour-marker' import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' @@ -44,6 +45,8 @@ export function ModelPill({ model: ChatBarState['model'] }) { const copy = useI18n().t.shell.statusbar + // Two return branches below, one handle: only ever one of them mounts. + const tourMarker = useTourMarker('model-pill') const view = useSessionView() // Prefer the chat-bar snapshot (already view-scoped by ChatView); fall back // to the live SessionView atoms so a mid-flight session.info still paints. @@ -136,6 +139,7 @@ export function ModelPill({ diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a9a868ad00..cc675ac4db 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1465,6 +1465,8 @@ export function ChatSidebar({ 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' )} collapsible="none" + data-tip-region="" + data-tour="sessions-sidebar" > @@ -1500,6 +1502,9 @@ export function ChatSidebar({ !isInteractive && 'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit' )} + // A tip anchored to the label points at the end of the + // word; the row is what it's actually about. + data-tip-region="" onClick={() => { // A plain new session lands in whatever profile the live // gateway is on (= the active switcher context). null → @@ -1523,7 +1528,18 @@ export function ChatSidebar({ type="button" > - {s.nav[item.id] ?? item.label} + {/* Shrink-to-fit, not flex-1: the label carries the row's + `data-tour` handle, and anything anchored to it should + land at the end of the WORD, not out at the sidebar's + edge. Still truncates — `min-w-0` lets it shrink past + its content when the rail is narrow — and the trailing + chip's `ml-auto` was already doing the pushing that + `flex-1` looked like it was for. + Its own `sidebar-nav-` namespace: the overlay nav owns + `nav-`, and both are on screen with Settings open. */} + + {s.nav[item.id] ?? item.label} + {isNewSession && ( + // `data-tour` as well as `data-slot`: only the former is identity to the + // tour collector and the tip catalog, and the rail's one other durable + // handle is a TRANSLATED aria-label, which stops matching the moment the + // app isn't in English. +
{/* Fleet: every gateway carries its own home square inside its group, so the pinned pill is purely the "all profiles on this gateway" toggle. */} {fleet && ( diff --git a/apps/desktop/src/app/chat/tour-marker.ts b/apps/desktop/src/app/chat/tour-marker.ts new file mode 100644 index 0000000000..2ba4c580a1 --- /dev/null +++ b/apps/desktop/src/app/chat/tour-marker.ts @@ -0,0 +1,23 @@ +/** + * `data-tour` handles for the chat surfaces — on the primary one only. + * + * A handle has to be UNIQUE in the document. The composer and the model pill + * are not: side-by-side session tiles mount the same ChatView tree, so a marker + * written unconditionally appears once per tile. Everything that consumes these + * handles then throws the target away — the tour collector only reports a + * selector that resolves back to the element it found, and a tip resolves the + * first match and would point at whichever tile the DOM happened to order + * first. The marker stops working at exactly the moment the app gets + * interesting. + * + * The primary chat is the one a tour or a tip means, so it is the one that gets + * the handle. Everything else keeps its `data-slot` and stays addressable by + * anyone who wants a specific tile. + */ + +import { useSessionView } from '@/app/chat/session-view' + +/** The handle if this is the primary chat, nothing if it is a tile. */ +export function useTourMarker(name: string): string | undefined { + return useSessionView().kind === 'primary' ? name : undefined +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 33d8d4e235..632e7a9bf0 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -51,6 +51,9 @@ export interface TitlebarTool { badge?: number title?: string to?: string + /** Durable `data-tour` handle. Tools are addressed by icon and translated + * label otherwise, and neither survives a theme or a locale change. */ + tour?: string } export type TitlebarToolSide = 'left' | 'right' @@ -195,7 +198,8 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: onSelect: () => { triggerHaptic('tap') rightEdge.toggle() - } + }, + tour: 'right-pane-toggle' } // Static system tools — always pinned to the screen's right edge. @@ -333,6 +337,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType event.stopPropagation()} rel="noreferrer" @@ -351,6 +356,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType { if (tool.to) { diff --git a/apps/desktop/src/lib/tour/collect-targets.ts b/apps/desktop/src/lib/tour/collect-targets.ts index 2a7ad83c28..8b41aeb4c8 100644 --- a/apps/desktop/src/lib/tour/collect-targets.ts +++ b/apps/desktop/src/lib/tour/collect-targets.ts @@ -97,6 +97,19 @@ export function collectTourTargets(doc: Document, max: number): TourTarget[] { } const visible = (el: Element): boolean => { + // An inactive tab in a keep-alive stack stays MOUNTED, hidden with + // `visibility: hidden` so its scroll position survives — which means it + // keeps its layout box and its rect is identical to the visible tab's (see + // components/pane-shell/pane-visibility.ts). No rect test can tell those + // two apart, so the attribute is the only answer, and without it a tour can + // spotlight a background tab's composer. Duplicate `data-tour` handles are + // headed off at the source instead (app/chat/tour-marker.ts). Inlined + // rather than imported because this function is stringified into the + // preview webview, where no pane ever carries the attribute. + if (el.closest('[data-pane-hidden]')) { + return false + } + const rect = el.getBoundingClientRect() if (rect.width < 4 || rect.height < 4) {