mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): shared UI — per-session prompt overlays, gateway overlays, tab primitives
This commit is contained in:
parent
63a9bde77b
commit
aae35c5ee4
12 changed files with 271 additions and 104 deletions
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
113
apps/desktop/src/components/ui/decode-text.tsx
Normal file
113
apps/desktop/src/components/ui/decode-text.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
apps/desktop/src/components/ui/drop-affordance.tsx
Normal file
29
apps/desktop/src/components/ui/drop-affordance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/desktop/src/components/ui/title-menu-trigger.tsx
Normal file
33
apps/desktop/src/components/ui/title-menu-trigger.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue