feat(desktop): shared UI — per-session prompt overlays, gateway overlays, tab primitives

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent 63a9bde77b
commit aae35c5ee4
12 changed files with 271 additions and 104 deletions

View file

@ -1,5 +1,5 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

View file

@ -13,6 +13,7 @@ import {
useState
} from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
@ -21,7 +22,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
@ -184,7 +185,11 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
const request = useStore($clarifyRequest)
// The tool row is in whichever session's transcript rendered it — read THAT
// session's clarify (primary or tile), not the globally-active one.
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId])
const request = useStore($request)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])

View file

@ -1,8 +1,9 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -20,11 +21,11 @@ import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import {
$approvalInlineVisible,
$approvalRequest,
type ApprovalRequest,
clearApprovalRequest,
registerApprovalInlineAnchor
registerApprovalInlineAnchor,
sessionApprovalInlineVisible,
sessionApprovalRequest
} from '@/store/prompts'
import type { ToolPart } from './fallback-model'
@ -48,7 +49,11 @@ export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const request = useStore($approvalRequest)
// The tool row lives in whichever session's transcript rendered it — read
// THAT session's approval (works for the primary and every tile).
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
const request = useStore($request)
if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
return null
@ -58,15 +63,18 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
}
const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
useEffect(() => registerApprovalInlineAnchor(), [])
useEffect(() => registerApprovalInlineAnchor(request.sessionId), [request.sessionId])
return <ApprovalBar request={request} surface="inline" />
}
export const PendingApprovalFallback: FC = () => {
const { t } = useI18n()
const request = useStore($approvalRequest)
const inlineVisible = useStore($approvalInlineVisible)
const sessionId = useStore(useSessionView().$runtimeId)
const $request = useMemo(() => sessionApprovalRequest(sessionId), [sessionId])
const $inlineVisible = useMemo(() => sessionApprovalInlineVisible(sessionId), [sessionId])
const request = useStore($request)
const inlineVisible = useStore($inlineVisible)
if (!request || inlineVisible) {
return null
@ -119,8 +127,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
const respond = useCallback(
async (choice: ApprovalChoice) => {
// Another bar (or the keyboard path) may have already resolved this
// approval; the atom is the single source of truth, so bail if it's gone.
if (busy || !$approvalRequest.get()) {
// approval; the map is the single source of truth, so bail if this
// session's request is gone.
if (busy || !sessionApprovalRequest(request.sessionId).get()) {
return
}

View file

@ -1,20 +1,15 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { DecodeText } from '@/components/ui/decode-text'
import { cn } from '@/lib/utils'
import { $desktopBoot } from '@/store/boot'
import { $gatewaySwitching } from '@/store/gateway-switch'
import { $gatewayState } from '@/store/session'
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
// the render level means no timer logic (even a stale HMR one) can ever
// scramble "CONN".
const PREFIX = 'CONN'
const TAIL = 'ECTING'
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
// nousnet-web download-button decode effect).
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
// Decode mechanics live in the shared <DecodeText> primitive
// (components/ui/decode-text.tsx). "CONN" stays legible via prefix={4}.
const TEXT = 'CONNECTING'
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
const TEXT_OUT_MS = 360
@ -40,18 +35,11 @@ function forcedPreview(): boolean {
}
}
function scrambledTail(resolvedCount: number): string {
return Array.from(TAIL, (ch, i) =>
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
).join('')
}
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const gatewaySwitching = useStore($gatewaySwitching)
const [previewing] = useState(forcedPreview)
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
// Once cold boot has completed once, never resurrect the fullscreen overlay
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
@ -67,12 +55,14 @@ export function GatewayConnectingOverlay() {
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting =
!coldBootDoneRef.current &&
!gatewaySwitching &&
gatewayState !== 'open' &&
!boot.error &&
initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.
@ -82,36 +72,6 @@ export function GatewayConnectingOverlay() {
shownRef.current = true
}
// Decode loop — only while live (freeze the resolved word during the exit).
useEffect(() => {
if (phase !== 'live' || (!previewing && !connecting)) {
return
}
let resolved = 0
let hold = 0
const id = window.setInterval(() => {
if (resolved >= TAIL.length) {
hold += 1
if (hold > 16) {
resolved = 0
hold = 0
}
setTail(TAIL)
return
}
resolved += 0.5
setTail(scrambledTail(Math.floor(resolved)))
}, TICK_MS)
return () => window.clearInterval(id)
}, [phase, previewing, connecting])
// Kick off the exit when connected: real connect, or a faked timer in preview.
useEffect(() => {
if (phase !== 'live') {
@ -119,16 +79,12 @@ export function GatewayConnectingOverlay() {
}
if (previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('text-out')
}, PREVIEW_CONNECT_MS)
const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS)
return () => window.clearTimeout(id)
}
if (gatewayState === 'open' && shownRef.current) {
setTail(TAIL)
setPhase('text-out')
}
}, [phase, previewing, gatewayState])
@ -149,10 +105,7 @@ export function GatewayConnectingOverlay() {
// Preview replays so we can keep watching the transition.
if (phase === 'gone' && previewing) {
const id = window.setTimeout(() => {
setTail(TAIL)
setPhase('live')
}, PREVIEW_REPLAY_MS)
const id = window.setTimeout(() => setPhase('live'), PREVIEW_REPLAY_MS)
return () => window.clearTimeout(id)
}
@ -183,21 +136,16 @@ export function GatewayConnectingOverlay() {
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
<span
<DecodeText
active={phase === 'live' && (previewing || connecting)}
className={cn(
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
'pl-[0.4em] text-(--theme-primary) transition duration-300 ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
{PREFIX}
{tail}
<span
aria-hidden="true"
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
style={{ animation: 'gco-cursor 1s step-end infinite' }}
/>
</span>
cursor
prefix={4}
text={TEXT}
/>
</div>
)
}

View file

@ -410,10 +410,12 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
// Which key-form option to preselect when we flip to 'apikey' mode. The
// OpenRouter row selects its key; the generic link lands on the first option.
const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined)
const openKeyForm = (envKey?: string) => {
setApiKeyInitialEnv(envKey)
setOnboardingMode('apikey')
}
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()

View file

@ -12,10 +12,10 @@ import { PromptOverlays } from './prompt-overlays'
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() }))
function renderPrompts() {
function renderPrompts(sessionId: string | null = 's1') {
render(
<I18nProvider configClient={null}>
<PromptOverlays />
<PromptOverlays sessionId={sessionId} />
</I18nProvider>
)
}

View file

@ -1,7 +1,7 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { PendingApprovalFallback } from '@/components/assistant-ui/tool/approval'
import { Button } from '@/components/ui/button'
@ -20,7 +20,12 @@ import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts'
import {
clearSecretRequest,
clearSudoRequest,
sessionSecretRequest,
sessionSudoRequest
} from '@/store/prompts'
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
// password and skill secret capture. Dangerous-command / execute_code approval
@ -35,10 +40,11 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr
// fire a second `*.respond` alongside onOpenChange (double-send) or block the
// backdrop-dismiss path.
function SudoDialog() {
function SudoDialog({ sessionId }: { sessionId: string | null }) {
const { t } = useI18n()
const copy = t.prompts
const request = useStore($sudoRequest)
const $request = useMemo(() => sessionSudoRequest(sessionId), [sessionId])
const request = useStore($request)
const gateway = useStore($gateway)
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
@ -137,10 +143,11 @@ function SudoDialog() {
)
}
function SecretDialog() {
function SecretDialog({ sessionId }: { sessionId: string | null }) {
const { t } = useI18n()
const copy = t.prompts
const request = useStore($secretRequest)
const $request = useMemo(() => sessionSecretRequest(sessionId), [sessionId])
const request = useStore($request)
const gateway = useStore($gateway)
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
@ -237,12 +244,15 @@ function SecretDialog() {
)
}
export function PromptOverlays() {
/** Mid-turn prompt surfaces for ONE session. Mounted by both the primary chat
* and each tile with its own session id, so a background/tiled session's
* blocking prompt renders instead of silently stalling. */
export function PromptOverlays({ sessionId }: { sessionId: string | null }) {
return (
<>
<PendingApprovalFallback />
<SudoDialog />
<SecretDialog />
<SudoDialog sessionId={sessionId} />
<SecretDialog sessionId={sessionId} />
</>
)
}

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
// Small status/metadata tag. App radius (not a full pill); tones map to the
// shared accent/muted/destructive surfaces so badges read consistently.
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none',
'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] font-medium leading-none whitespace-nowrap [&_svg]:pointer-events-none',
{
variants: {
variant: {
@ -16,9 +16,13 @@ const badgeVariants = cva(
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
destructive: 'bg-destructive/10 text-destructive',
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground'
},
size: {
default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3',
xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5'
}
},
defaultVariants: { variant: 'default' }
defaultVariants: { variant: 'default', size: 'default' }
}
)
@ -26,10 +30,10 @@ export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps<t
asChild?: boolean
}
export function Badge({ asChild = false, className, variant, ...props }: BadgeProps) {
export function Badge({ asChild = false, className, size, variant, ...props }: BadgeProps) {
const Comp = asChild ? Slot.Root : 'span'
return <Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
return <Comp className={cn(badgeVariants({ size, variant }), className)} data-slot="badge" {...props} />
}
export { badgeVariants }

View file

@ -113,16 +113,30 @@ function ContextMenuSubTrigger({
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
function ContextMenuSubContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
'z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="context-menu-sub-content"
{...props}
/>
// Portal the submenu out of the parent Content so it escapes that Content's
// `overflow` clip — without this a submenu opening from a scrollable /
// overflow-hidden menu gets visually cut off at the parent's edges. Radix
// Popper still anchors it to the SubTrigger, so portaling is safe. Mirrors
// DropdownMenuSubContent.
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
className={cn(
// `max-h-80` (not the Radix available-height var, which is published
// only on Content) so a long submenu scrolls instead of collapsing.
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
collisionPadding={collisionPadding}
data-slot="context-menu-sub-content"
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}

View file

@ -0,0 +1,113 @@
import { type ComponentProps, useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/**
* DecodeText the "CONNECTING" scramble-decode effect as a reusable
* primitive (extracted from gateway-connecting-overlay.tsx; same mechanics):
*
* - Even-weight mono ascii charset so cycling glyphs never jump width
* (matches the nousnet-web download-button decode effect).
* - Decode resolves half a character per 45ms tick; when fully resolved it
* holds for 16 ticks, then (in loop mode) replays.
* - The first `prefix` characters NEVER scramble split at render level so
* no timer logic (even a stale HMR one) can garble them.
* - Optional blinking dither-cursor square.
*
* Typography (mono, small, uppercase, wide tracking) is baked in; color comes
* from the caller via className/text color so the same primitive works on the
* boot overlay (--theme-primary) and quiet surfaces (--ui-text-quaternary).
*/
export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
const HOLD_TICKS = 16
function scrambled(tail: string, resolvedCount: number): string {
return Array.from(tail, (ch, i) =>
ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0]
).join('')
}
export interface DecodeTextProps extends Omit<ComponentProps<'span'>, 'prefix'> {
text: string
/** Leading character count that stays legible at all times. */
prefix?: number
/** Run the decode. When false, renders the plain resolved text (used to
* freeze the word during exit choreography). */
active?: boolean
/** Replay after the hold, or resolve once and stop. */
loop?: boolean
/** Blinking dither-cursor square after the text. */
cursor?: boolean
}
export function DecodeText({
active = true,
className,
cursor = false,
loop = true,
prefix = 0,
text,
...props
}: DecodeTextProps) {
const staticPrefix = text.slice(0, prefix)
const tailText = text.slice(prefix)
const [tail, setTail] = useState(tailText)
useEffect(() => {
if (!active) {
setTail(tailText)
return
}
let resolved = 0
let hold = 0
const id = window.setInterval(() => {
if (resolved >= tailText.length) {
hold += 1
if (hold > HOLD_TICKS) {
if (loop) {
resolved = 0
hold = 0
} else {
window.clearInterval(id)
}
}
setTail(tailText)
return
}
resolved += 0.5
setTail(scrambled(tailText, Math.floor(resolved)))
}, TICK_MS)
return () => window.clearInterval(id)
}, [active, loop, tailText])
return (
<span
className={cn(
'inline-flex items-center font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums',
className
)}
{...props}
>
{cursor && <style>{'@keyframes decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>}
{staticPrefix}
{tail}
{cursor && (
<span
aria-hidden="true"
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
style={{ animation: 'decode-cursor 1s step-end infinite' }}
/>
)}
</span>
)
}

View file

@ -0,0 +1,29 @@
/**
* Shared drag-and-drop visual language ONE dashed sheet + ONE floating pill,
* used by every drop affordance (the composer file/session overlay, the layout
* zone targets) so "you can drop here" reads identically everywhere.
*/
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/** The sheet: a dashed region marking where a drop would land. */
export const DROP_SHEET_CLASS = 'rounded-2xl border-2 border-dashed'
/** Soft blur for the LIVE sheet only — idle outlines must not fog the app. */
export const DROP_SHEET_BLUR_CLASS = 'backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]'
/** The pill: icon + label floating over a sheet, naming the outcome. */
export function DropPill({ children, className, icon }: React.ComponentProps<'div'> & { icon: string }) {
return (
<div
className={cn(
'flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer',
className
)}
>
<Codicon className="shrink-0 text-(--ui-accent)" name={icon} size="1rem" />
{children}
</div>
)
}

View file

@ -0,0 +1,33 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/**
* Compact "Label ▾" chrome trigger. Domain-agnostic drop in as the child of
* `DropdownMenuTrigger asChild` (or any asChild menu trigger). Sessions,
* projects, filters, etc. own their menus; this only owns the trigger look.
*/
export function TitleMenuTrigger({
children,
className,
...props
}: Omit<React.ComponentProps<typeof Button>, 'children' | 'size' | 'variant'> & {
children: React.ReactNode
}) {
return (
<Button
className={cn(
'pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]',
className
)}
type="button"
variant="ghost"
{...props}
>
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{children}</span>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
)
}