diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index a508b8471c51..07c615bdf6fc 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -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' diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 967c9aac2e4c..72b6fbbdfa81 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -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]) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index 9d0d69b06a1d..a909e486fd86 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -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 } 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 } diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index aa2065147502..be4b5d82843f 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -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 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('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' )} > - - - {PREFIX} - {tail} - + cursor + prefix={4} + text={TEXT} + /> ) } diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index df547b91a655..c7541b924d29 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -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(undefined) + const openKeyForm = (envKey?: string) => { setApiKeyInitialEnv(envKey) setOnboardingMode('apikey') } + const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() diff --git a/apps/desktop/src/components/prompt-overlays.test.tsx b/apps/desktop/src/components/prompt-overlays.test.tsx index 7b8cb2148f5e..dd9cdb7ec0b4 100644 --- a/apps/desktop/src/components/prompt-overlays.test.tsx +++ b/apps/desktop/src/components/prompt-overlays.test.tsx @@ -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( - + ) } diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index cf56d62e85ad..c64656e88ef6 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -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 ( <> - - + + ) } diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx index 6f286c3fde7d..c4e46e52361d 100644 --- a/apps/desktop/src/components/ui/badge.tsx +++ b/apps/desktop/src/components/ui/badge.tsx @@ -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 + return } export { badgeVariants } diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx index 0849efdd53c6..1652d68b9f70 100644 --- a/apps/desktop/src/components/ui/context-menu.tsx +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -113,16 +113,30 @@ function ContextMenuSubTrigger({ ) } -function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { +function ContextMenuSubContent({ + className, + collisionPadding = 8, + ...props +}: React.ComponentProps) { return ( - + // 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. + + + ) } diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx new file mode 100644 index 000000000000..e5bc470ab076 --- /dev/null +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -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, '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 ( + + {cursor && } + {staticPrefix} + {tail} + {cursor && ( + + ) +} diff --git a/apps/desktop/src/components/ui/drop-affordance.tsx b/apps/desktop/src/components/ui/drop-affordance.tsx new file mode 100644 index 000000000000..937233f86a34 --- /dev/null +++ b/apps/desktop/src/components/ui/drop-affordance.tsx @@ -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 ( +
+ + {children} +
+ ) +} diff --git a/apps/desktop/src/components/ui/title-menu-trigger.tsx b/apps/desktop/src/components/ui/title-menu-trigger.tsx new file mode 100644 index 000000000000..9705b9b61f2f --- /dev/null +++ b/apps/desktop/src/components/ui/title-menu-trigger.tsx @@ -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, 'children' | 'size' | 'variant'> & { + children: React.ReactNode +}) { + return ( + + ) +}