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 (
+
+ )
+}