feat(desktop): give the app's main surfaces durable handles

Tours and tips both address elements by selector, and everything they most want
to point at — the composer, the model pill, the nav rows, the profile rail, the
right-pane toggle — was reachable only by icon, position, or a translated
aria-label. None of those survive a re-render, a theme, or a locale change.

Two handles are needed per surface, not one, because where an arrow points and
what an outline wraps are different questions: a nav row's label carries the
`data-tour` handle so an arrow lands at the end of the word, and defers the
outline to the row via `data-tip-arrow-only`.

The collector also has to skip panes hidden by the keep-alive stack. An inactive
tab stays mounted under `visibility: hidden` to keep its scroll position, so its
rect is identical to the live tab's and no geometry test separates them — which
is how a tour could spotlight a background tab's composer.
This commit is contained in:
Brooklyn Nicholson
2026-08-27 21:27:08 -05:00
committed by brooklyn!
parent 50f816abaf
commit 46512ee1d6
7 changed files with 89 additions and 4 deletions
@@ -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}
@@ -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({
<Button
aria-label={copy.openModelPicker}
className={pillClass}
data-tour={tourMarker}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
type="button"
@@ -162,7 +166,14 @@ export function ModelPill({
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
<Button
aria-label={title}
className={pillClass}
data-tour={tourMarker}
disabled={disabled}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
+17 -1
View File
@@ -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"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
@@ -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"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{/* 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-<id>`, and both are on screen with Settings open. */}
<span className="min-w-0 truncate" data-tip-arrow-only="" data-tour={`sidebar-nav-${item.id}`}>
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
@@ -392,7 +392,18 @@ export function ProfileRail() {
)
return (
<div aria-label={p.title} className="flex min-w-0 items-center gap-0.5" data-slot="profile-rail" role="group">
// `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.
<div
aria-label={p.title}
className="flex min-w-0 items-center gap-0.5"
data-slot="profile-rail"
data-tip-region=""
data-tour="profile-rail"
role="group"
>
{/* 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 && (
+23
View File
@@ -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
}
@@ -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<typeof us
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
data-tour={tool.tour}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
@@ -351,6 +356,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
data-tour={tool.tour}
disabled={tool.disabled}
onClick={event => {
if (tool.to) {
@@ -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) {