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:
committed by
brooklyn!
parent
50f816abaf
commit
46512ee1d6
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user