mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
feat(desktop): theme polish, prose chat typography, composer chrome
- DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose - Composer liquid/radius utilities, thread font parity, tool/thinking cues - File tree label scale, preview flex, thread retry loading + streaming tests
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -47,17 +47,21 @@
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.12.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ignore": "^7.0.5",
|
||||
"leva": "^0.10.1",
|
||||
"liquid-glass-react": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -5,7 +5,7 @@ import type { ReactNode } from 'react'
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-t-(--composer-active-radius) border border-b-0',
|
||||
'border border-b-0',
|
||||
'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]',
|
||||
'px-1.5 pb-3 pt-1.5 text-popover-foreground',
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
export type ComposerLiquidGlassMode = 'polar' | 'prominent' | 'shader' | 'standard'
|
||||
|
||||
export interface ComposerGlassTweakOutputs {
|
||||
fadeBackground: string
|
||||
liquid: {
|
||||
aberrationIntensity: number
|
||||
blurAmount: number
|
||||
cornerRadius: number
|
||||
displacementScale: number
|
||||
elasticity: number
|
||||
mode: ComposerLiquidGlassMode
|
||||
saturation: number
|
||||
}
|
||||
liquidKey: string
|
||||
showLibraryRims: boolean
|
||||
}
|
||||
|
||||
const COMPOSER_GLASS_TWEAKS: ComposerGlassTweakOutputs = {
|
||||
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
|
||||
liquid: {
|
||||
aberrationIntensity: 0.95,
|
||||
blurAmount: 0.072,
|
||||
cornerRadius: 24,
|
||||
displacementScale: 46,
|
||||
elasticity: 0,
|
||||
mode: 'standard',
|
||||
saturation: 128
|
||||
},
|
||||
liquidKey: ['standard', '0.950', '0.072', '24', '46', '0.00', '128'].join(':'),
|
||||
showLibraryRims: false
|
||||
}
|
||||
|
||||
export function useComposerGlassTweaks(): ComposerGlassTweakOutputs {
|
||||
return COMPOSER_GLASS_TWEAKS
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
@@ -34,7 +33,6 @@ import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
@@ -52,7 +50,7 @@ import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
const COMPOSER_SHELL_CLASS =
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 max-w-full pt-2 pb-[var(--composer-shell-pad-block-end)]'
|
||||
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]'
|
||||
|
||||
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
@@ -110,17 +108,13 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
return blobs
|
||||
}
|
||||
|
||||
// Below this composer width the input gets cramped — drop controls onto a second row.
|
||||
// Floor matches the natural min-content of contextMenu + 8rem input + controls + gaps;
|
||||
// going higher caused unwanted stacking on empty state when the parent transiently
|
||||
// reported a tiny width before layout settled.
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
const COMPOSER_SCROLLED_DIM_CLASS =
|
||||
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
|
||||
const COMPOSER_FROST_CLASS = cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-(--composer-active-radius)',
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
@@ -131,6 +125,21 @@ const COMPOSER_FROST_CLASS = cn(
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)
|
||||
|
||||
const COMPOSER_GLASS = {
|
||||
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
|
||||
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
|
||||
showLibraryRims: false,
|
||||
liquid: {
|
||||
aberrationIntensity: 0.95,
|
||||
blurAmount: 0.072,
|
||||
cornerRadius: 0,
|
||||
displacementScale: 46,
|
||||
elasticity: 0,
|
||||
mode: 'standard' as const,
|
||||
saturation: 128
|
||||
}
|
||||
}
|
||||
|
||||
interface TriggerState {
|
||||
kind: '@' | '/'
|
||||
query: string
|
||||
@@ -218,8 +227,6 @@ export function ChatBar({
|
||||
|
||||
const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything'
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
|
||||
|
||||
useEffect(() => {
|
||||
@@ -266,8 +273,6 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Threshold deliberately above a single rendered line + padding so font-metric
|
||||
// jitter on an empty/short editor never triggers spurious expansion.
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 56
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
@@ -282,10 +287,6 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// No sync read: getBoundingClientRect() right after mount can return a
|
||||
// transient pre-layout width that briefly flips the composer into stacked
|
||||
// mode. ResizeObserver fires once on observe() with the settled width, then
|
||||
// again on every actual size change.
|
||||
const ro = new ResizeObserver(() => {
|
||||
const width = el.getBoundingClientRect().width
|
||||
|
||||
@@ -439,10 +440,6 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Some clipboard sources deliver an image as a giant `data:image/...;base64,...`
|
||||
// text/plain payload. Without this guard the whole base64 string would be
|
||||
// inserted into the textarea (and persisted as the user message). Drop it
|
||||
// outright — image pastes belong on the image-blob path above.
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -460,8 +457,6 @@ export function ChatBar({
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
// Try caret-anchored detection first; fall back to whole-draft so blur/select-all
|
||||
// edge cases still surface the popover instead of silently closing it.
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -479,8 +474,6 @@ export function ChatBar({
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Strip Chrome's stray <br> when the editor is otherwise empty so :empty
|
||||
// pseudo-class works for the placeholder.
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
@@ -528,8 +521,6 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
// Starters (`@file:`) drill in: insert verbatim and keep the popover live so
|
||||
// the user can keep typing the path. Chips/simple refs commit and close.
|
||||
const starter = serialized.endsWith(':')
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
@@ -545,7 +536,6 @@ export function ChatBar({
|
||||
const node = range?.startContainer
|
||||
const offset = range?.startOffset ?? 0
|
||||
|
||||
// No usable caret range — replace from the end of the draft instead.
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
@@ -928,12 +918,6 @@ export function ChatBar({
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
style={
|
||||
{
|
||||
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
|
||||
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && (
|
||||
@@ -947,31 +931,33 @@ export function ChatBar({
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_GLASS.fadeBackground }}
|
||||
/>
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'composer-liquid-shell-wrap absolute inset-0 isolate overflow-hidden rounded-(--composer-glass-radius,24px) transition-opacity duration-200 ease-out',
|
||||
'group-has-data-[state=open]/composer:rounded-t-none',
|
||||
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
|
||||
)}
|
||||
data-glass-frame="true"
|
||||
data-show-library-rims={glassTweaks.showLibraryRims ? 'true' : undefined}
|
||||
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
|
||||
data-slot="composer-liquid-shell-wrap"
|
||||
ref={glassShellRef}
|
||||
>
|
||||
<LiquidGlass
|
||||
aberrationIntensity={glassTweaks.liquid.aberrationIntensity}
|
||||
blurAmount={glassTweaks.liquid.blurAmount}
|
||||
aberrationIntensity={COMPOSER_GLASS.liquid.aberrationIntensity}
|
||||
blurAmount={COMPOSER_GLASS.liquid.blurAmount}
|
||||
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
|
||||
cornerRadius={glassTweaks.liquid.cornerRadius}
|
||||
displacementScale={glassTweaks.liquid.displacementScale}
|
||||
elasticity={glassTweaks.liquid.elasticity}
|
||||
key={glassTweaks.liquidKey}
|
||||
mode={glassTweaks.liquid.mode}
|
||||
cornerRadius={COMPOSER_GLASS.liquid.cornerRadius}
|
||||
displacementScale={COMPOSER_GLASS.liquid.displacementScale}
|
||||
elasticity={COMPOSER_GLASS.liquid.elasticity}
|
||||
key={COMPOSER_GLASS.liquidKey}
|
||||
mode={COMPOSER_GLASS.liquid.mode}
|
||||
mouseContainer={composerRef}
|
||||
padding="0"
|
||||
saturation={glassTweaks.liquid.saturation}
|
||||
saturation={COMPOSER_GLASS.liquid.saturation}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
|
||||
>
|
||||
<span className="block h-full w-full" />
|
||||
@@ -979,11 +965,11 @@ export function ChatBar({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
'group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
|
||||
'group-has-data-[state=open]/composer:rounded-t-none group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-midground)_18%,var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
'group-focus-within/composer:border-ring/45 group-focus-within/composer:shadow-composer-focus',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
|
||||
dragActive && 'border-primary/70 shadow-composer-focus ring-2 ring-primary/40'
|
||||
dragActive && 'border-midground/70 shadow-composer-focus ring-2 ring-midground/40'
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
>
|
||||
@@ -991,14 +977,14 @@ export function ChatBar({
|
||||
{dragActive && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center rounded-(--composer-active-radius) bg-primary/10 text-sm font-medium text-primary backdrop-blur-[1px]"
|
||||
className="pointer-events-none absolute inset-0 z-3 flex items-center justify-center bg-midground/10 text-sm font-semibold uppercase tracking-[0.18em] text-midground backdrop-blur-[1px]"
|
||||
>
|
||||
Drop files to attach
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
@@ -1006,18 +992,6 @@ export function ChatBar({
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
{/*
|
||||
Single CSS Grid keeps {input} (and the contenteditable inside
|
||||
it) in a stable parent across the stacked/inline toggle.
|
||||
Earlier this was a JSX conditional that rendered {input}
|
||||
inside two different fragments — when `stacked` flipped (e.g.
|
||||
the moment text wrapped past two lines and the auto-expand
|
||||
effect triggered), React reconciled them as different
|
||||
positions and unmounted/remounted the contenteditable. The
|
||||
fresh mount started empty and any in-flight characters were
|
||||
lost. Switching the layout via grid-template-areas keeps the
|
||||
exact same DOM nodes and lets the browser handle the reflow.
|
||||
*/}
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full',
|
||||
@@ -1053,9 +1027,8 @@ export function ChatBarFallback() {
|
||||
<div
|
||||
className={cn(COMPOSER_SHELL_CLASS, 'bg-linear-to-b from-transparent to-background/55')}
|
||||
data-slot="composer-root"
|
||||
style={{ '--composer-active-radius': '1.5rem' } as CSSProperties}
|
||||
>
|
||||
<div className="relative isolate h-(--composer-fallback-height) w-full overflow-hidden rounded-(--composer-active-radius) border border-input/70 shadow-composer">
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-midground)_18%,var(--dt-input))] shadow-composer">
|
||||
<div aria-hidden className={COMPOSER_FROST_CLASS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* liquid-glass-react emits helper nodes that ignore local utility classes. Keep
|
||||
these overrides scoped by class so the rest of app styling stays utility-first. */
|
||||
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
@@ -9,6 +7,7 @@
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -25,6 +24,7 @@
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
box-sizing: border-box;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass,
|
||||
@@ -56,18 +58,19 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--composer-glass-radius, 24px) !important;
|
||||
border-radius: inherit !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > .glass__warp {
|
||||
border-radius: var(--composer-glass-radius, 24px) !important;
|
||||
border-radius: inherit !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > div {
|
||||
|
||||
@@ -45,6 +45,7 @@ import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
@@ -69,30 +70,6 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
function threadLoadingState(
|
||||
loadingSession: boolean,
|
||||
busy: boolean,
|
||||
awaitingResponse: boolean,
|
||||
lastMessageIsUser: boolean
|
||||
) {
|
||||
if (loadingSession) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
// Only show the response spinner when we're actually waiting for an
|
||||
// assistant reply to a user message. Previously any `busy && awaiting`
|
||||
// window showed the spinner — including the brief gateway-hydration blip
|
||||
// right after a session resume, which produced a visible flicker chain:
|
||||
// session spinner → response spinner → content.
|
||||
// Gating on `lastMessageIsUser` means the spinner only appears when the
|
||||
// user actually just sent something and there's no assistant reply yet.
|
||||
if (busy && awaitingResponse && lastMessageIsUser) {
|
||||
return 'response'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
@@ -145,10 +122,9 @@ export function ChatView({
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s `lastMessageIsUser` gate.
|
||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
|
||||
const lastMessageIsUser = messages.at(-1)?.role === 'user'
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser)
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
|
||||
@@ -1351,7 +1351,7 @@ export function PreviewPane({
|
||||
}
|
||||
|
||||
const webview = document.createElement('webview') as PreviewWebview
|
||||
webview.className = 'hermes-preview-webview h-full w-full flex-1 bg-background'
|
||||
webview.className = 'flex h-full w-full flex-1 bg-background'
|
||||
webview.setAttribute('partition', 'persist:hermes-preview')
|
||||
webview.setAttribute('src', target.url)
|
||||
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
|
||||
|
||||
@@ -50,7 +50,7 @@ const sidebarNavItemClass =
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none'
|
||||
|
||||
const sidebarNavItemActiveClass =
|
||||
'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
@@ -114,7 +114,8 @@ export function ChatSidebar({
|
||||
>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
|
||||
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
|
||||
<SidebarGroupLabel className="h-auto px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70">
|
||||
<SidebarGroupLabel className="flex h-auto items-center gap-2 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-midground/75">
|
||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
|
||||
Workspace
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -238,16 +239,22 @@ interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
|
||||
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between px-2 pb-1 pt-1.5">
|
||||
<SidebarGroupLabel asChild className="h-auto p-0 text-muted-foreground">
|
||||
<SidebarGroupLabel asChild className="h-auto p-0">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left text-xs font-bold leading-none"
|
||||
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase leading-none">{label}</span>
|
||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
|
||||
<span className="text-[0.64rem] font-semibold uppercase leading-none tracking-[0.16em] text-midground/75">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
className={cn('size-3 opacity-0 transition group-hover/section-label:opacity-100', !open && '-rotate-90')}
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
|
||||
!open && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
|
||||
export const sidebarSessionRowClass =
|
||||
'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-accent hover:transition-none'
|
||||
'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:transition-none'
|
||||
|
||||
export const sidebarSessionFadeClass =
|
||||
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100'
|
||||
@@ -46,6 +46,7 @@ export function SidebarSessionRow({
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
>
|
||||
{isWorking && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
|
||||
onClick={event => {
|
||||
@@ -65,7 +66,7 @@ export function SidebarSessionRow({
|
||||
{isWorking && (
|
||||
<span
|
||||
aria-label="Session running"
|
||||
className="relative size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_0.625rem_color-mix(in_srgb,var(--primary)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-primary before:opacity-75 before:content-[''] before:animate-ping"
|
||||
className="relative size-1.5 shrink-0 rounded-full bg-midground shadow-[0_0_0.625rem_color-mix(in_srgb,var(--dt-midground)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-midground before:opacity-75 before:content-[''] before:animate-ping"
|
||||
role="status"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
|
||||
function message(id: string, role: ChatMessage['role'], hidden = false): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
parts: [{ type: 'text', text: `${role}:${id}` }],
|
||||
hidden
|
||||
}
|
||||
}
|
||||
|
||||
describe('thread loading state', () => {
|
||||
it('returns session when routed session is still hydrating', () => {
|
||||
expect(threadLoadingState(true, true, true, false)).toBe('session')
|
||||
})
|
||||
|
||||
it('returns response while awaiting an assistant reply to the last visible user message', () => {
|
||||
const messages = [message('u1', 'user'), message('a1', 'assistant', true)]
|
||||
|
||||
expect(lastVisibleMessageIsUser(messages)).toBe(true)
|
||||
expect(threadLoadingState(false, true, true, lastVisibleMessageIsUser(messages))).toBe('response')
|
||||
})
|
||||
|
||||
it('does not show response loading when the last visible message is not user-authored', () => {
|
||||
const messages = [message('u1', 'user'), message('a1', 'assistant')]
|
||||
|
||||
expect(lastVisibleMessageIsUser(messages)).toBe(false)
|
||||
expect(threadLoadingState(false, true, true, lastVisibleMessageIsUser(messages))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
|
||||
const lastVisible = [...messages].reverse().find(message => !message.hidden)
|
||||
|
||||
return lastVisible?.role === 'user'
|
||||
}
|
||||
|
||||
export function threadLoadingState(
|
||||
loadingSession: boolean,
|
||||
busy: boolean,
|
||||
awaitingResponse: boolean,
|
||||
lastVisibleIsUser: boolean
|
||||
) {
|
||||
if (loadingSession) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (busy && awaitingResponse && lastVisibleIsUser) {
|
||||
return 'response'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -69,9 +69,10 @@ export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPane
|
||||
<header className="group/project-header shrink-0 pl-4 pr-2 pb-1 pt-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FadeText
|
||||
className="flex-1 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70"
|
||||
className="flex-1 flex items-center gap-2 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-midground/75"
|
||||
title={hasCwd ? currentCwd : 'No folder selected'}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0 rounded-[1px] text-midground" />
|
||||
{cwdName}
|
||||
</FadeText>
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 24
|
||||
const ROW_HEIGHT = 28
|
||||
const INDENT = 14
|
||||
|
||||
interface ProjectTreeProps {
|
||||
@@ -57,7 +57,6 @@ export function ProjectTree({
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
// Lazy fetch on first expand: children===undefined means "not yet loaded".
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
@@ -117,7 +116,7 @@ function ProjectTreeRow({
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-[0.72rem] leading-none text-foreground/85 transition-colors hover:bg-accent/55',
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-sm font-medium leading-snug text-foreground/90 transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)]',
|
||||
node.isSelected && 'bg-accent/65 text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
@@ -153,9 +152,6 @@ function ProjectTreeRow({
|
||||
return
|
||||
}
|
||||
|
||||
// Custom MIME the composer's drop handler unpacks. text/plain is set
|
||||
// as a fallback so dragging into other apps gets a sensible payload
|
||||
// (the absolute path).
|
||||
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
|
||||
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
@@ -99,7 +100,8 @@ export function AppShell({
|
||||
>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<Backdrop />
|
||||
<main className="relative z-[3] flex h-screen w-full flex-col overflow-hidden pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -44,7 +44,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_10%,transparent)] hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useGpuTier } from '@nous-research/ui/hooks/use-gpu-tier'
|
||||
import { Leva, useControls } from 'leva'
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const BLEND_MODES = [
|
||||
'normal',
|
||||
'multiply',
|
||||
'screen',
|
||||
'overlay',
|
||||
'darken',
|
||||
'lighten',
|
||||
'color-dodge',
|
||||
'color-burn',
|
||||
'hard-light',
|
||||
'soft-light',
|
||||
'difference',
|
||||
'exclusion',
|
||||
'hue',
|
||||
'saturation',
|
||||
'color',
|
||||
'luminosity'
|
||||
] as const
|
||||
|
||||
type BlendMode = (typeof BLEND_MODES)[number]
|
||||
|
||||
function binaryNoiseDataUrl(tile: number, density: number, size: number, color: string): string {
|
||||
if (typeof document === 'undefined') return ''
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const physTile = Math.round(tile * dpr)
|
||||
const block = Math.max(1, Math.round(size * dpr))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = physTile
|
||||
canvas.height = physTile
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return ''
|
||||
|
||||
ctx.fillStyle = color
|
||||
|
||||
for (let y = 0; y < physTile; y += block) {
|
||||
for (let x = 0; x < physTile; x += block) {
|
||||
if (Math.random() < density) {
|
||||
ctx.fillRect(x, y, block, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `url("${canvas.toDataURL('image/png')}")`
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
const gpuTier = useGpuTier()
|
||||
const [controlsOpen, setControlsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
const editing =
|
||||
target?.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) return
|
||||
if (event.shiftKey && event.code === 'KeyY') setControlsOpen(open => !open)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const shape = useControls(
|
||||
'UI / Shape',
|
||||
{
|
||||
radiusScalar: { value: 0.2, min: 0, max: 2, step: 0.1, label: 'radius scalar' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--radius-scalar', String(shape.radiusScalar))
|
||||
}, [shape.radiusScalar])
|
||||
|
||||
const statue = useControls(
|
||||
'Backdrop / Statue',
|
||||
{
|
||||
enabled: { value: true, label: 'on' },
|
||||
opacity: { value: 0.04, min: 0, max: 1, step: 0.005 },
|
||||
blendMode: { value: 'difference' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
invert: { value: true, label: 'invert color' },
|
||||
saturate: { value: 1, min: 0, max: 3, step: 0.05, label: 'saturate' },
|
||||
brightness: { value: 1, min: 0, max: 2, step: 0.05, label: 'brightness' },
|
||||
objectPosition: {
|
||||
value: 'top left',
|
||||
options: ['top left', 'top right', 'bottom left', 'bottom right', 'center', 'top', 'bottom', 'left', 'right'],
|
||||
label: 'position'
|
||||
},
|
||||
scale: { value: 160, min: 100, max: 300, step: 5, label: 'height (dvh)' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const vignette = useControls(
|
||||
'Backdrop / Vignette',
|
||||
{
|
||||
enabled: { value: true, label: 'on' },
|
||||
opacity: { value: 0.22, min: 0, max: 1, step: 0.01 },
|
||||
blendMode: { value: 'lighten' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
useTheme: { value: true, label: 'use --warm-glow' },
|
||||
color: { value: '#ffbd38', label: 'color (override)' },
|
||||
origin: {
|
||||
value: '0% 0%',
|
||||
options: ['0% 0%', '100% 0%', '50% 0%', '0% 100%', '100% 100%', '50% 50%'],
|
||||
label: 'corner'
|
||||
},
|
||||
transparentStop: { value: 60, min: 0, max: 100, step: 1, label: 'fade start %' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const noise = useControls(
|
||||
'Backdrop / Noise',
|
||||
{
|
||||
enabled: { value: true, label: 'on' },
|
||||
opacity: { value: 0.21, min: 0, max: 1.5, step: 0.01, label: 'opacity (× mul)' },
|
||||
blendMode: { value: 'color-dodge' as BlendMode, options: BLEND_MODES, label: 'blend' },
|
||||
color: { value: '#eaeaea', label: 'dot color' },
|
||||
density: { value: 0.11, min: 0, max: 1, step: 0.005, label: 'density' },
|
||||
size: { value: 1, min: 1, max: 10, step: 1, label: 'block px' },
|
||||
tile: { value: 256, min: 64, max: 1024, step: 32, label: 'tile px' },
|
||||
reroll: { value: 0, min: 0, max: 100, step: 1, label: 'reroll' }
|
||||
},
|
||||
{ collapsed: true }
|
||||
)
|
||||
|
||||
const noiseUrl = useMemo(
|
||||
() => binaryNoiseDataUrl(noise.tile, noise.density, noise.size, noise.color),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[noise.tile, noise.density, noise.size, noise.color, noise.reroll]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Leva hidden={!import.meta.env.DEV || !controlsOpen} collapsed titleBar={{ title: 'backdrop', drag: true }} />
|
||||
|
||||
{statue.enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-2"
|
||||
style={{
|
||||
mixBlendMode: statue.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: statue.opacity
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="w-auto min-w-dvw object-cover"
|
||||
fetchPriority="low"
|
||||
src="/ds-assets/filler-bg0.jpg"
|
||||
style={{
|
||||
height: `${statue.scale}dvh`,
|
||||
objectPosition: statue.objectPosition,
|
||||
filter: `${statue.invert ? 'invert(1) ' : ''}saturate(${statue.saturate}) brightness(${statue.brightness})`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vignette.enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-99"
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at ${vignette.origin}, transparent ${vignette.transparentStop}%, ${vignette.useTheme ? 'var(--warm-glow)' : vignette.color} 100%)`,
|
||||
mixBlendMode: vignette.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: vignette.opacity
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noise.enabled && gpuTier > 0 && noiseUrl && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-101"
|
||||
style={{
|
||||
backgroundImage: noiseUrl,
|
||||
backgroundSize: `${noise.tile}px ${noise.tile}px`,
|
||||
backgroundRepeat: 'repeat',
|
||||
imageRendering: 'pixelated',
|
||||
mixBlendMode: noise.blendMode as CSSProperties['mixBlendMode'],
|
||||
opacity: `calc(${noise.opacity} * var(--noise-opacity-mul, 1))`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,10 @@ export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-muted-foreground/45 tabular-nums',
|
||||
// Tinted with --dt-midground (very low alpha) so the timer reads
|
||||
// as part of the same "live signal" cluster as the dither block /
|
||||
// arc-border / working-session dot, instead of being neutral chrome.
|
||||
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -192,7 +192,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
||||
/>
|
||||
</button>
|
||||
<div className="w-full min-w-0 max-w-xl">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
<p className="mb-3 inline-flex items-center gap-1.5 text-xs font-medium uppercase tracking-[0.18em] text-midground/85">
|
||||
<span aria-hidden="true" className="dither inline-block size-1.5 rounded-[1px]" />
|
||||
Hermes Agent
|
||||
</p>
|
||||
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
|
||||
<p className="m-0 leading-normal">{copy.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -85,4 +85,86 @@ describe('preprocessMarkdown', () => {
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain('- Pure white (`#ffffff`)')
|
||||
})
|
||||
|
||||
it('autolinks raw urls in prose', () => {
|
||||
const output = preprocessMarkdown(
|
||||
'Book here:\nhttps://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/'
|
||||
)
|
||||
|
||||
expect(output).toContain('<https://www.getyourguide.com/culebra-island-l145468/from-fajardo-tour-t19894/>')
|
||||
})
|
||||
|
||||
it('strips orphan numeric citation markers outside code spans', () => {
|
||||
const output = preprocessMarkdown('This is the source[0], but keep `items[0]` untouched.')
|
||||
|
||||
expect(output).toContain('source,')
|
||||
expect(output).not.toContain('source[0]')
|
||||
expect(output).toContain('`items[0]`')
|
||||
})
|
||||
|
||||
it('demotes title/url blocks wrapped in malformed inline fences', () => {
|
||||
const input = [
|
||||
'**🚢 TOMORROW (Fajardo, crystal clear cays, pickup avail):**',
|
||||
'',
|
||||
'Icacos Full-Day Catamaran — 6hr, $140, small group, pickup```',
|
||||
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/',
|
||||
'```Sail Getaway Luxury Cat (Cordillera Cays, water slide, unlimited rum) — 6hr, $195```',
|
||||
'https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/'
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain('Sail Getaway Luxury Cat')
|
||||
expect(output).toContain(
|
||||
'<https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/>'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'<https://www.getyourguide.com/fajardo-l882/icacos-all-inclusive-sailing-catamaran-beach-and-snorkel-t466138/>'
|
||||
)
|
||||
})
|
||||
|
||||
it('autolinks urls glued to prices and removes orphan fence tails', () => {
|
||||
const input = [
|
||||
'**🐢 TODAY (from San Juan, no driving):**',
|
||||
'',
|
||||
'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr,',
|
||||
'~$56```https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/ Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99 (drinks, no snorkel)```',
|
||||
'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/'
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain(
|
||||
'~$56<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/> Old San Juan Sunset Cruise'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
|
||||
)
|
||||
})
|
||||
|
||||
it('demotes url-only fenced blocks to clickable markdown links', () => {
|
||||
const input = [
|
||||
'Sea Turtles & Manatees Snorkel + Free Rum — 1.5hr, ~$56',
|
||||
'```',
|
||||
'https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/',
|
||||
'```',
|
||||
'',
|
||||
'Old San Juan Sunset Cruise w/ Drinks + Hotel Pickup — 1.5hr, ~$99',
|
||||
'```',
|
||||
'https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).toContain(
|
||||
'<https://www.getyourguide.com/san-juan-puerto-rico-l355/san-juan-snorkel-sea-turtles-manatees-free-video-rum-t879147/>'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,13 +26,18 @@ import {
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MARKDOWN_CONTAINER_CLASS = cn(
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground',
|
||||
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
|
||||
'prose-headings:text-foreground prose-strong:text-foreground',
|
||||
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
|
||||
'prose-li:marker:text-midground/55',
|
||||
'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none'
|
||||
)
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
// Streamdown can transiently parse stray backticks / incomplete fences as
|
||||
// an empty code block while text is streaming, e.g. "200.``` http://...".
|
||||
// Rendering our header + empty body for that looks like a giant blank
|
||||
// code card. Hide the whole block until there's actual code content.
|
||||
if (!normalizedCode.trim() || isLikelyProseCodeBlock(language, normalizedCode)) {
|
||||
return null
|
||||
}
|
||||
@@ -41,8 +46,11 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
|
||||
<div className="m-0 flex items-stretch justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 pr-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2.5 py-1.5 pl-0 font-mono uppercase tracking-[0.16em]">
|
||||
<span aria-hidden="true" className="self-stretch w-[2px] -my-1.5 bg-midground/60" />
|
||||
<span className="text-midground/85">{label || 'code'}</span>
|
||||
</span>
|
||||
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
|
||||
Copy
|
||||
</CopyButton>
|
||||
@@ -183,7 +191,7 @@ function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-midground/55 wrap-anywhere hover:decoration-midground',
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
@@ -198,8 +206,11 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
className={className}
|
||||
containerClassName="my-3"
|
||||
className={cn(
|
||||
'block h-auto w-auto max-h-(--image-preview-height) max-w-[min(100%,var(--image-preview-max-width))] rounded-[1.125rem] border border-[color-mix(in_srgb,var(--dt-border)_70%,transparent)] object-contain shadow-[0_0.0625rem_0.125rem_color-mix(in_srgb,#000_4%,transparent),0_0.625rem_1.5rem_color-mix(in_srgb,#000_5%,transparent)]',
|
||||
className
|
||||
)}
|
||||
containerClassName="my-3 max-w-[min(100%,var(--image-preview-max-width))]"
|
||||
slot="aui_markdown-image"
|
||||
src={src}
|
||||
{...props}
|
||||
@@ -226,7 +237,7 @@ const MarkdownTextImpl = () => {
|
||||
<h4 className={cn('text-sm font-semibold', className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
|
||||
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
a: MarkdownLink,
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
@@ -234,18 +245,14 @@ const MarkdownTextImpl = () => {
|
||||
),
|
||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||
<blockquote
|
||||
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
|
||||
className={cn('border-l-2 border-midground/40 pl-3 text-muted-foreground italic', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
||||
<ul className={cn('list-disc marker:text-muted-foreground/70', className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
||||
<ol className={cn('list-decimal marker:text-muted-foreground/70', className)} {...props} />
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => <ul className={cn(className)} {...props} />,
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => <ol className={cn(className)} {...props} />,
|
||||
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
||||
<li className={cn('leading-relaxed', className)} {...props} />
|
||||
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
table: ({ className, ...props }: ComponentProps<'table'>) => (
|
||||
<div className="max-w-full overflow-x-auto rounded-md border border-border">
|
||||
@@ -264,7 +271,7 @@ const MarkdownTextImpl = () => {
|
||||
th: ({ className, ...props }: ComponentProps<'th'>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'h-9 px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground',
|
||||
'h-9 px-3 text-left align-middle text-xs font-semibold uppercase tracking-[0.16em] text-midground/75',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -284,7 +291,7 @@ const MarkdownTextImpl = () => {
|
||||
<StreamdownTextPrimitive
|
||||
caret="block"
|
||||
components={components}
|
||||
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
|
||||
containerClassName={MARKDOWN_CONTAINER_CLASS}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown={!isStreaming}
|
||||
|
||||
@@ -102,6 +102,23 @@ function assistantReasoningMessage(text: string): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-multi-1',
|
||||
role: 'assistant',
|
||||
content: texts.map(text => ({ type: 'reasoning', text })),
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -154,6 +171,20 @@ function ReasoningHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function GroupedReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('assistant-ui streaming renderer', () => {
|
||||
beforeEach(() => {
|
||||
resizeObservers.clear()
|
||||
@@ -233,4 +264,18 @@ describe('assistant-ui streaming renderer', () => {
|
||||
'The user is asking what this file is.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups consecutive reasoning parts under one thinking disclosure', () => {
|
||||
const { container } = render(<GroupedReasoningHarness />)
|
||||
|
||||
const disclosures = container.querySelectorAll('[data-slot="tool-block"] > button')
|
||||
expect(disclosures.length).toBe(1)
|
||||
|
||||
fireEvent.click(disclosures[0])
|
||||
|
||||
const reasoningParts = container.querySelectorAll('[data-slot="aui_reasoning-text"]')
|
||||
expect(reasoningParts.length).toBe(2)
|
||||
expect(reasoningParts[0]?.textContent).toBe('First thought.')
|
||||
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,17 +12,6 @@ import {
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import spinners from 'unicode-animations'
|
||||
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
|
||||
// reference implementation that powers bolt.new and several other streaming
|
||||
// chat UIs. It handles everything we care about — spring-animated catch-up,
|
||||
// resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection
|
||||
// pause, subpixel overshoot, programmatic-scroll event suppression — via 665
|
||||
// lines of well-tested edge-case handling that we should NOT hand-roll.
|
||||
//
|
||||
// We only own the thin glue: jump-to-bottom on session switch / send, and
|
||||
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
|
||||
// dim-when-scrolled-away treatment.
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
@@ -66,10 +55,13 @@ import { notifyError } from '@/store/notifications'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
const RESPONSE_SPINNER = spinners.braille
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
|
||||
interface StickyStateFlags {
|
||||
escapedFromLock: boolean
|
||||
isAtBottom: boolean
|
||||
}
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
messageText: string
|
||||
@@ -100,6 +92,17 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
function resetStickyState(state: StickyStateFlags) {
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
}
|
||||
|
||||
function pinElementToBottom(el: HTMLElement) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
return el.scrollTop
|
||||
}
|
||||
|
||||
export const Thread: FC<{
|
||||
intro?: IntroProps
|
||||
loading?: ThreadLoadingState
|
||||
@@ -110,26 +113,6 @@ export const Thread: FC<{
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadPrimitive.ViewportProvider>
|
||||
{/*
|
||||
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
|
||||
* renders an inner scroll container (inline height/width 100%) plus
|
||||
* an inner content div. So:
|
||||
* - `className` on <StickToBottom> = outer wrapper sizing
|
||||
* - `scrollClassName` on <.Content> = scroll container
|
||||
* - `className` on <.Content> = content (flex column)
|
||||
*
|
||||
* `initial: 'instant'`: no animation on first mount.
|
||||
* `resize: 'instant'`: during streaming, snap to bottom each token.
|
||||
* Spring animation ('smooth') visibly lags behind fast token
|
||||
* streams; users read that as jank. 'instant' matches ChatGPT.
|
||||
*
|
||||
* The composer is rendered OUTSIDE the scroller as `position:
|
||||
* absolute; bottom: 0` (floating glass treatment) and overlays the
|
||||
* bottom of the scroll surface. We compensate by putting a tall
|
||||
* bottom spacer (>= composer height + margin) inside the scroll
|
||||
* content so "scroll to bottom" naturally parks the last line of
|
||||
* content above the composer, not hidden behind it.
|
||||
*/}
|
||||
<StickToBottom
|
||||
className="relative h-full min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
initial="instant"
|
||||
@@ -137,7 +120,7 @@ export const Thread: FC<{
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="pb-9 mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
className="scroll-auto pb-(--thread-bottom-pad) mx-auto flex w-full max-w-[calc(var(--composer-width)-2rem)] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
@@ -161,48 +144,10 @@ export const Thread: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll glue for the chat thread. Replaces hand-rolled follow logic with
|
||||
* the exact pattern that assistant-ui's own `useThreadViewportAutoScroll`
|
||||
* uses internally: **raw DOM scroll + an armed behavior ref + a
|
||||
* ResizeObserver loop that re-pins to bottom until we actually reach it.**
|
||||
*
|
||||
* Why not use the library's `scrollToBottom` for sends?
|
||||
* - It wraps its work in `new Promise(requestAnimationFrame)` so even
|
||||
* `animation: 'instant'` is 1+ frame async.
|
||||
* - It does NOT clear `escapedFromLock` on call — if the user had
|
||||
* scrolled up before sending, the library's resize handler keeps
|
||||
* un-setting `isAtBottom` between our scroll and the next resize.
|
||||
* - `ignoreEscapes` only blocks NEW escapes during the animation; it
|
||||
* doesn't unstick an already-escaped state.
|
||||
*
|
||||
* The armed-ref pattern handles all of that:
|
||||
* 1. `thread.runStart` fires after the runtime has committed the user
|
||||
* message to state (so scrollHeight already reflects it).
|
||||
* 2. We arm a ref ('instant') and write `scrollTop = scrollHeight`
|
||||
* synchronously.
|
||||
* 3. A ResizeObserver on the content keeps re-pinning each time the
|
||||
* DOM grows (user message paints, assistant placeholder mounts,
|
||||
* assistant streams) until scrollTop is actually at bottom — then
|
||||
* we disarm.
|
||||
* 4. Any wheel-up or touch-scroll-up disarms immediately so the user
|
||||
* can always escape.
|
||||
*
|
||||
* This mirrors:
|
||||
* - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef
|
||||
* + useOnResizeContent loop)
|
||||
* - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on
|
||||
* container and children + isAtBottom/isUserScrolling flags)
|
||||
*
|
||||
* Must be rendered INSIDE a <StickToBottom> because useStickToBottomContext
|
||||
* reads from that component's context.
|
||||
*/
|
||||
const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => {
|
||||
const { scrollRef, isAtBottom, state } = useStickToBottomContext()
|
||||
const sessionKeyRef = useRef<string | null>(sessionKey ?? null)
|
||||
|
||||
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
|
||||
// ticks until we get there." Null = "user owns the viewport."
|
||||
const armedRef = useRef<ScrollBehavior | null>(null)
|
||||
const pinRafRef = useRef<number | null>(null)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
@@ -221,8 +166,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Slam to bottom + arm the ref. Also forces library state flags off
|
||||
// so its internal resize handler doesn't fight our re-pins.
|
||||
const armAndPin = useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current
|
||||
@@ -232,19 +175,13 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
|
||||
armedRef.current = behavior
|
||||
// Clear the library's escape/at-bottom flags directly on the mutable
|
||||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
resetStickyState(state)
|
||||
suppressNextScrollEventRef.current = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
previousScrollTopRef.current = el.scrollTop
|
||||
previousScrollTopRef.current = pinElementToBottom(el)
|
||||
},
|
||||
[scrollRef, state]
|
||||
)
|
||||
|
||||
// ResizeObserver loop — re-pins to bottom while armed, disarms when
|
||||
// actually at bottom. This is the assistant-ui pattern.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
@@ -273,8 +210,7 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
|
||||
suppressNextScrollEventRef.current = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
previousScrollTopRef.current = el.scrollTop
|
||||
previousScrollTopRef.current = pinElementToBottom(el)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -296,7 +232,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
// User-intent detection — any upward gesture disarms the chase.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
@@ -342,7 +277,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
// (1) Session switch — strong intent to see the bottom of the new thread.
|
||||
useEffect(() => {
|
||||
const next = sessionKey ?? null
|
||||
|
||||
@@ -355,9 +289,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
armAndPin('auto')
|
||||
}, [armAndPin, sessionKey])
|
||||
|
||||
// (2) Bulk message load (session history arriving from storage) — pin
|
||||
// to bottom and stay armed while the thread's markdown/code/images
|
||||
// settle over the next several frames.
|
||||
useEffect(() => {
|
||||
const prev = prevMessageCountRef.current
|
||||
prevMessageCountRef.current = messageCount
|
||||
@@ -367,11 +298,6 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
}
|
||||
}, [armAndPin, messageCount])
|
||||
|
||||
// (3) User send — the runtime event `thread.runStart` fires after the
|
||||
// user message has been committed to state (scrollHeight already reflects
|
||||
// it). This is the canonical signal per assistant-ui's own code. We
|
||||
// arm-and-pin synchronously in the callback, then the RO loop above
|
||||
// keeps us at bottom as the assistant message placeholder + reply stream.
|
||||
useAuiEvent('thread.runStart', () => {
|
||||
armAndPin('instant')
|
||||
})
|
||||
@@ -379,25 +305,11 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invisible bottom spacer whose height matches the currently-measured
|
||||
* composer height (plus a small gap). Because the composer is rendered
|
||||
* OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll
|
||||
* to bottom" would otherwise park the last content line behind it. By
|
||||
* extending the scroll content down with real (blank) space equal to the
|
||||
* composer's footprint, the library's scroll-to-scrollHeight naturally
|
||||
* leaves the last message line sitting above the composer.
|
||||
*
|
||||
* A ResizeObserver on the composer keeps the spacer in sync when the
|
||||
* textarea grows (multi-line input), attachments expand, or the composer
|
||||
* enters a focused/expanded state.
|
||||
*/
|
||||
const COMPOSER_BREATHING_ROOM_PX = 36
|
||||
const DEFAULT_COMPOSER_CLEARANCE_PX = 192
|
||||
|
||||
const ComposerClearance: FC = () => {
|
||||
const [height, setHeight] = useState<number>(() => {
|
||||
// Keep enough space even while the floating composer is still mounting.
|
||||
if (typeof document === 'undefined') {
|
||||
return DEFAULT_COMPOSER_CLEARANCE_PX
|
||||
}
|
||||
@@ -496,7 +408,7 @@ const CenteredThreadSpinner: FC = () => (
|
||||
>
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-12 text-primary/70"
|
||||
className="size-12 text-midground/70"
|
||||
pathSteps={220}
|
||||
role="presentation"
|
||||
strokeScale={0.72}
|
||||
@@ -531,13 +443,14 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
data-slot="aui_assistant-message-root"
|
||||
>
|
||||
<div
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground"
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningPart,
|
||||
Reasoning: ReasoningTextPart,
|
||||
ReasoningGroup: ReasoningAccordionGroup,
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
}}
|
||||
/>
|
||||
@@ -575,23 +488,11 @@ const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const [frame, setFrame] = useState(0)
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(
|
||||
() => setFrame(current => (current + 1) % RESPONSE_SPINNER.frames.length),
|
||||
RESPONSE_SPINNER.interval
|
||||
)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is loading a response">
|
||||
<span aria-hidden="true" className="font-mono text-base leading-none text-muted-foreground/60">
|
||||
{RESPONSE_SPINNER.frames[frame]}
|
||||
</span>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -639,11 +540,11 @@ const ThinkingDisclosure: FC<{
|
||||
<div className="text-sm text-muted-foreground" data-slot="tool-block">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group/thinking-row flex w-full max-w-full min-w-0 items-start gap-2 rounded-md px-2 py-0.5 text-left text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
|
||||
className="group/thinking-row grid w-full min-w-0 cursor-pointer grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left text-muted-foreground transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
<span className="flex h-[1.1rem] items-center justify-center">
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/55 transition-transform group-hover/thinking-row:text-muted-foreground/85',
|
||||
@@ -651,7 +552,7 @@ const ThinkingDisclosure: FC<{
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/75',
|
||||
@@ -666,27 +567,31 @@ const ThinkingDisclosure: FC<{
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-6 pr-2 wrap-anywhere pb-1">{children}</div>
|
||||
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-(--message-text-indent) pr-2 wrap-anywhere pb-1">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
|
||||
const pending = useAuiState(s => s.message.status?.type === 'running')
|
||||
|
||||
return <ThinkingDisclosure pending={pending}>{children}</ThinkingDisclosure>
|
||||
}
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure pending={status?.type === 'running'}>
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
||||
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
data-slot="aui_reasoning-text"
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
</ThinkingDisclosure>
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
||||
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
data-slot="aui_reasoning-text"
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -830,7 +735,7 @@ const MessageTimestamp: FC = () => {
|
||||
}
|
||||
|
||||
const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
<div className="flex min-h-6 flex-col items-start gap-1">
|
||||
<div className="flex min-h-6 flex-col items-start gap-1 pl-(--message-text-indent)">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
@@ -880,7 +785,7 @@ const UserMessage: FC = () => {
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 text-base leading-(--dt-line-height) text-foreground/95">
|
||||
{attachmentRefs.length > 0 && (
|
||||
<div className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
@@ -955,7 +860,7 @@ const UserEditComposer: FC = () => (
|
||||
>
|
||||
<ComposerPrimitive.Input
|
||||
autoFocus
|
||||
className="min-h-8 w-full resize-none bg-transparent leading-[1.48] text-foreground/95 outline-none"
|
||||
className="min-h-8 w-full resize-none bg-transparent text-base leading-(--dt-line-height) text-foreground/95 outline-none"
|
||||
rows={1}
|
||||
submitMode="enter"
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
|
||||
@@ -28,11 +28,7 @@ import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolDisclosureStates, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
// Indent tool detail content via box-sizing-honoring padding, not margin.
|
||||
// Margin-left + max-w-full causes the box to overflow its parent by the
|
||||
// margin amount, which makes wide tool content (preview cards, diffs)
|
||||
// extend past the chat column when right-side panes are open.
|
||||
const TOOL_DETAIL_INDENT_CLASS = 'w-full pl-[1.5rem] pr-2'
|
||||
const TOOL_DETAIL_INDENT_CLASS = 'w-full pl-(--message-text-indent) pr-2'
|
||||
|
||||
type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
@@ -128,6 +124,11 @@ const STATUS_ICON_CLASS: Record<ToolStatus, string> = {
|
||||
warning: 'bg-amber-500/14 text-amber-700 dark:text-amber-300'
|
||||
}
|
||||
|
||||
const DISPLAY_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
|
||||
const BACKTICK_NOISE_RE = /`{3,}/g
|
||||
|
||||
function titleForTool(name: string): string {
|
||||
const normalized = name.replace(/^browser_/, '').replace(/^web_/, '')
|
||||
|
||||
@@ -163,13 +164,19 @@ function toolMeta(name: string): ToolMeta {
|
||||
: { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function compactPreview(value: unknown, max = 72): string {
|
||||
let raw: unknown
|
||||
|
||||
if (typeof value === 'string') {
|
||||
raw = value
|
||||
} else {
|
||||
raw = parseMaybeObject(value).context
|
||||
}
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
if (raw == null) {
|
||||
raw = ''
|
||||
@@ -181,6 +188,7 @@ function compactPreview(value: unknown, max = 72): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const line = (raw as string).replace(/\s+/g, ' ').trim()
|
||||
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||
@@ -205,8 +213,8 @@ function prettyJson(value: unknown): string {
|
||||
}
|
||||
|
||||
function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
if (isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -216,12 +224,26 @@ function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
||||
return isRecord(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapToolPayload(value: unknown): unknown {
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
for (const key of ['data', 'result', 'output', 'response', 'payload']) {
|
||||
const payload = record[key]
|
||||
|
||||
if (payload !== undefined && payload !== null) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): null | number {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
@@ -311,6 +333,56 @@ function looksRedundant(title: string, detail: string): boolean {
|
||||
return norm(title) === norm(detail)
|
||||
}
|
||||
|
||||
function cleanVisibleText(text: string): string {
|
||||
return text
|
||||
.split(INLINE_CODE_SPLIT_RE)
|
||||
.map(part => (part.startsWith('`') ? part : part.replace(BACKTICK_NOISE_RE, '').replace(CITATION_MARKER_RE, '')))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function openExternal(url: string) {
|
||||
void window.hermesDesktop?.openExternal(url)
|
||||
}
|
||||
|
||||
function LinkifiedText({ className, text }: { className?: string; text: string }) {
|
||||
const cleanText = cleanVisibleText(text)
|
||||
const nodes: ReactNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of cleanText.matchAll(DISPLAY_URL_RE)) {
|
||||
const url = match[0]
|
||||
const index = match.index ?? 0
|
||||
|
||||
if (index > cursor) {
|
||||
nodes.push(cleanText.slice(cursor, index))
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<a
|
||||
className="font-medium text-foreground underline underline-offset-4 decoration-midground/55 wrap-anywhere hover:decoration-midground"
|
||||
href={url}
|
||||
key={`${url}-${index}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
openExternal(url)
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
cursor = index + url.length
|
||||
}
|
||||
|
||||
if (cursor < cleanText.length) {
|
||||
nodes.push(cleanText.slice(cursor))
|
||||
}
|
||||
|
||||
return <span className={className}>{nodes.length ? nodes : cleanText}</span>
|
||||
}
|
||||
|
||||
function summarizeBrowserSnapshot(snapshot: string): string {
|
||||
const count = (re: RegExp) => snapshot.match(re)?.length ?? 0
|
||||
|
||||
@@ -340,27 +412,131 @@ function firstStringField(record: Record<string, unknown>, keys: readonly string
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractSearchResults(result: unknown): SearchResultRow[] {
|
||||
const row = parseMaybeObject(result)
|
||||
function formatScalar(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const list = (
|
||||
Array.isArray(row.results)
|
||||
? row.results
|
||||
: Array.isArray(row.items)
|
||||
? row.items
|
||||
: Array.isArray(row.data)
|
||||
? row.data
|
||||
: []
|
||||
) as unknown[]
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeRecord(record: Record<string, unknown>): string {
|
||||
const title = firstStringField(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link'])
|
||||
const subtitle = firstStringField(record, ['url', 'href', 'link', 'status', 'category'])
|
||||
const body = firstStringField(record, ['snippet', 'description', 'summary', 'message', 'preview', 'text', 'content'])
|
||||
|
||||
if (title || subtitle || body) {
|
||||
return cleanVisibleText([title, subtitle !== title ? subtitle : '', body].filter(Boolean).join('\n'))
|
||||
}
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([key, value]) => {
|
||||
const scalar = formatScalar(value)
|
||||
|
||||
return scalar ? `${titleForTool(key)}: ${compactPreview(cleanVisibleText(scalar), 96)}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeList(items: unknown[], max = 5): string {
|
||||
return items
|
||||
.map(item => {
|
||||
if (isRecord(item) || (typeof item === 'string' && item.trim().startsWith('{'))) {
|
||||
return summarizeRecord(parseMaybeObject(item))
|
||||
}
|
||||
|
||||
return compactPreview(item, 140)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, max)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function collectResultItems(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
for (const key of ['web', 'results', 'items', 'organic_results', 'organic', 'matches', 'documents']) {
|
||||
const candidate = record[key]
|
||||
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
if (isRecord(candidate)) {
|
||||
const nested = collectResultItems(candidate)
|
||||
|
||||
if (nested.length) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = unwrapToolPayload(record)
|
||||
|
||||
return payload === record ? [] : collectResultItems(payload)
|
||||
}
|
||||
|
||||
function friendlyJsonSummary(value: unknown, depth = 0): string {
|
||||
if (depth > 2) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return summarizeList(value)
|
||||
}
|
||||
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
if (!Object.keys(record).length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const direct = firstStringField(record, ['message', 'summary', 'preview', 'content', 'text', 'stdout', 'stderr', 'error'])
|
||||
|
||||
if (direct) {
|
||||
return cleanVisibleText(direct)
|
||||
}
|
||||
|
||||
const items = collectResultItems(record)
|
||||
|
||||
if (items.length) {
|
||||
return summarizeList(items)
|
||||
}
|
||||
|
||||
const payload = unwrapToolPayload(record)
|
||||
|
||||
if (payload !== value && payload !== record) {
|
||||
const payloadSummary = friendlyJsonSummary(payload, depth + 1)
|
||||
|
||||
if (payloadSummary) {
|
||||
return payloadSummary
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractSearchResults(result: unknown): SearchResultRow[] {
|
||||
const list = collectResultItems(result)
|
||||
|
||||
return list
|
||||
.map(item => {
|
||||
const r = parseMaybeObject(item)
|
||||
|
||||
return {
|
||||
title: firstStringField(r, ['title', 'name']),
|
||||
title: cleanVisibleText(firstStringField(r, ['title', 'name'])),
|
||||
url: firstStringField(r, ['url', 'href', 'link']),
|
||||
snippet: firstStringField(r, ['snippet', 'description', 'body'])
|
||||
snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body']))
|
||||
}
|
||||
})
|
||||
.filter(hit => hit.title || hit.url)
|
||||
@@ -493,10 +669,10 @@ function fallbackDetailText(args: unknown, result: unknown): string {
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
return prettyJson(result)
|
||||
return friendlyJsonSummary(result) || prettyJson(result)
|
||||
}
|
||||
|
||||
return prettyJson(args)
|
||||
return friendlyJsonSummary(args) || prettyJson(args)
|
||||
}
|
||||
|
||||
function toolSubtitle(
|
||||
@@ -593,7 +769,10 @@ function toolSubtitle(
|
||||
}
|
||||
|
||||
return (
|
||||
compactPreview(resultRecord, 120) || compactPreview(argsRecord, 120) || fallbackDetailText(argsRecord, resultRecord)
|
||||
compactPreview(friendlyJsonSummary(part.result), 120) ||
|
||||
compactPreview(resultRecord, 120) ||
|
||||
compactPreview(argsRecord, 120) ||
|
||||
fallbackDetailText(argsRecord, resultRecord)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -682,17 +861,6 @@ function toolDetailText(
|
||||
return fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the most useful single string for the user to copy from this tool
|
||||
* call.
|
||||
*
|
||||
* Heuristic: prefer the substantive *output* (the thing the user actually
|
||||
* sees in the expanded panel) over the meta target (URL, path, query). The
|
||||
* old behavior was the reverse, which meant clicking copy on a `read_file`
|
||||
* row that had just dumped a 400-line file would copy "src/foo.ts" instead
|
||||
* of the file. Tools where the meta is genuinely more useful than the
|
||||
* output (e.g. a search query) keep their meta-first behavior.
|
||||
*/
|
||||
function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
|
||||
const args = parseMaybeObject(part.args)
|
||||
const result = parseMaybeObject(part.result)
|
||||
@@ -996,10 +1164,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
|
||||
const renderDetailAsCode =
|
||||
view.status !== 'error' &&
|
||||
(part.toolName === 'terminal' ||
|
||||
part.toolName === 'execute_code' ||
|
||||
part.toolName === 'read_file' ||
|
||||
part.toolName === 'web_extract')
|
||||
(part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file')
|
||||
|
||||
const hasExpandableContent = Boolean(
|
||||
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
|
||||
@@ -1019,24 +1184,25 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors',
|
||||
hasExpandableContent && 'hover:bg-accent/35 hover:text-foreground'
|
||||
hasExpandableContent &&
|
||||
'hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-expanded={hasExpandableContent ? open : undefined}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-start gap-2 px-2 py-0.5 text-left',
|
||||
'grid w-full min-w-0 grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left',
|
||||
hasExpandableContent ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
disabled={!hasExpandableContent}
|
||||
onClick={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
<span className="flex h-[1.1rem] items-center justify-center">
|
||||
{hasExpandableContent ? (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/55 transition-transform group-hover/tool-row:text-muted-foreground/85',
|
||||
'size-3 text-midground/55 transition-transform group-hover/tool-row:text-midground',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
@@ -1044,7 +1210,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
<span aria-hidden="true" className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
{showStatusGlyph && (
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
@@ -1062,7 +1228,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
{view.title}
|
||||
</FadeText>
|
||||
{!isPending && view.durationLabel && (
|
||||
<span className="shrink-0 text-[0.625rem] tabular-nums text-muted-foreground/55">
|
||||
<span className="shrink-0 text-[0.625rem] tabular-nums text-midground/60 tracking-[0.04em]">
|
||||
{view.durationLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -1119,7 +1285,9 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
(view.status === 'error' ? (
|
||||
detailSections.summary || detailSections.body ? (
|
||||
<div className="max-w-full text-xs leading-relaxed text-destructive">
|
||||
{detailSections.summary && <p className="font-medium">{detailSections.summary}</p>}
|
||||
{detailSections.summary && (
|
||||
<LinkifiedText className="block font-medium" text={detailSections.summary} />
|
||||
)}
|
||||
{detailSections.body && (
|
||||
<pre
|
||||
className={cn(
|
||||
@@ -1144,7 +1312,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
|
||||
{view.detail}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap wrap-anywhere">{view.detail}</p>
|
||||
<LinkifiedText className="whitespace-pre-wrap wrap-anywhere" text={view.detail} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1196,9 +1364,6 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
const disclosureStates = useStore($toolDisclosureStates)
|
||||
const disclosureId = toolGroupDisclosureId(parts)
|
||||
const open = disclosureStates[disclosureId] ?? isRunning
|
||||
// Auto-collapse once the whole turn settles. While streaming, keep open
|
||||
// so the user can watch progress; on completion we fold it down to a
|
||||
// single Activity row, matching the webui pattern.
|
||||
const wasRunningRef = useRef(isRunning)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1274,11 +1439,11 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
<div className="group/tool-row relative flex w-full max-w-full min-w-0 items-start rounded-md text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 px-2 py-0.5 text-left"
|
||||
className="grid w-full min-w-0 cursor-pointer grid-cols-[var(--message-text-indent)_minmax(0,1fr)] items-start py-0.5 pr-2 text-left"
|
||||
onClick={() => setToolDisclosureOpen(disclosureId, !open)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
<span className="flex h-[1.1rem] items-center justify-center">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/55 transition-transform group-hover/tool-row:text-muted-foreground/85',
|
||||
@@ -1286,7 +1451,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
{showGroupStatusGlyph && (
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(status)}</span>
|
||||
|
||||
@@ -39,6 +39,7 @@ interface CodeSignals {
|
||||
hasMarkdown: boolean
|
||||
proseLines: number
|
||||
trimmed: string
|
||||
urlLines: number
|
||||
}
|
||||
|
||||
export function sanitizeLanguageTag(tag: string): string {
|
||||
@@ -75,7 +76,8 @@ function codeSignals(body: string): CodeSignals {
|
||||
codeSignals: codeSignalCount(trimmed),
|
||||
hasMarkdown: markdownSignals > 0,
|
||||
proseLines: proseLineCount(trimmed),
|
||||
trimmed
|
||||
trimmed,
|
||||
urlLines: (trimmed.match(/^\s*https?:\/\/\S+\s*$/gim) || []).length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +98,11 @@ export function isLikelyProseFence(info: string, body: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasInfoTail && signals.codeSignals <= 2 && (signals.proseLines >= 2 || signals.bulletLines >= 1)) {
|
||||
if (
|
||||
hasInfoTail &&
|
||||
signals.codeSignals <= 2 &&
|
||||
(signals.proseLines >= 2 || signals.bulletLines >= 1 || signals.urlLines >= 1)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { stripPreviewTargets } from '@/lib/preview-targets'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
*
|
||||
* Some Hermes providers stream raw `<think>…</think>` and similar into
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
const RAW_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
|
||||
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi
|
||||
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
|
||||
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
|
||||
const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
|
||||
|
||||
/**
|
||||
* Forcefully scrub backtick noise the model streams that doesn't form a
|
||||
* valid markdown construct.
|
||||
*
|
||||
* Strategy: find every well-formed `\`\`\`…\`\`\`` (or `~~~`) block whose
|
||||
* opener and closer are each on their own line, snapshot those ranges as
|
||||
* "protected", then nuke all other triple-backtick runs in the text.
|
||||
* This handles every failure mode in one shot:
|
||||
*
|
||||
* - mid-line `\`\`\`` (`there:\`\`\` cd /Users/...`)
|
||||
* - orphan opener with no closer
|
||||
* - orphan closer with no opener
|
||||
* - chunk-boundary backticks where the preceding non-newline char isn't
|
||||
* in the same regex window
|
||||
* - empty `` `` `` `` and orphan double backticks
|
||||
*
|
||||
* Real, well-formed fenced blocks survive untouched.
|
||||
*/
|
||||
function scrubBacktickNoise(text: string): string {
|
||||
const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
|
||||
const protectedRanges: { end: number; start: number }[] = []
|
||||
@@ -42,6 +25,24 @@ function scrubBacktickNoise(text: string): string {
|
||||
protectedRanges.push({ end: balancedFenceRe.lastIndex, start })
|
||||
}
|
||||
|
||||
const danglingCodeFenceRe = /(^|\n)[ \t]*(`{3,}|~{3,})([a-z0-9][a-z0-9+#-]{0,15})[ \t]*\n([\s\S]*)$/gi
|
||||
|
||||
while ((match = danglingCodeFenceRe.exec(text)) !== null) {
|
||||
const start = match.index + match[1].length
|
||||
const marker = match[2] || '```'
|
||||
const info = match[3] || ''
|
||||
const body = match[4] || ''
|
||||
const closeRe = new RegExp(`\\n[ \\t]*${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*(?=\\n|$)`)
|
||||
|
||||
if (!closeRe.test(body) && sanitizeLanguageTag(info) && !isLikelyProseFence(info, body)) {
|
||||
protectedRanges.push({ end: text.length, start })
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
protectedRanges.sort((a, b) => a.start - b.start)
|
||||
|
||||
const fenceNoiseRe = /`{3,}/g
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
@@ -54,8 +55,6 @@ function scrubBacktickNoise(text: string): string {
|
||||
|
||||
out += text.slice(cursor).replace(fenceNoiseRe, '')
|
||||
|
||||
// Empty inline code spans (`` `` `` `` with nothing meaningful inside)
|
||||
// render as literal backticks. Two passes catch chains.
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
out = out.replace(/``\s*``/g, '')
|
||||
out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1')
|
||||
@@ -68,6 +67,36 @@ function stripEmptyFenceBlocks(text: string): string {
|
||||
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
|
||||
}
|
||||
|
||||
function isUrlOnlyBlock(lines: string[]): boolean {
|
||||
const nonEmpty = lines.filter(line => line.trim())
|
||||
|
||||
return nonEmpty.length > 0 && nonEmpty.every(line => URL_ONLY_LINE_RE.test(line))
|
||||
}
|
||||
|
||||
function autoLinkRawUrls(text: string): string {
|
||||
return text.replace(RAW_URL_RE, (url: string, index: number) => {
|
||||
const previous = text[index - 1] || ''
|
||||
const beforePrevious = text[index - 2] || ''
|
||||
|
||||
if (previous === '<' || (beforePrevious === ']' && previous === '(')) {
|
||||
return url
|
||||
}
|
||||
|
||||
return `<${url}>`
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeVisibleProse(text: string): string {
|
||||
return text
|
||||
.split(INLINE_CODE_SPLIT_RE)
|
||||
.map(part =>
|
||||
part.startsWith('`')
|
||||
? part
|
||||
: autoLinkRawUrls(part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, ''))
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
@@ -135,6 +164,19 @@ function normalizeFenceBlocks(text: string): string {
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex !== -1 && LOCAL_PREVIEW_ONLY_RE.test(body.trim())) {
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
|
||||
out.push(...bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
@@ -175,8 +217,8 @@ export function preprocessMarkdown(text: string): string {
|
||||
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
|
||||
|
||||
return strippedEmptyFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.split(CODE_FENCE_SPLIT_RE)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : normalizeVisibleProse(stripPreviewTargets(part))))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
+159
-275
@@ -1,16 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@import 'tw-shimmer';
|
||||
/*---break---
|
||||
*/
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/**
|
||||
* @theme inline bridges runtime CSS variables (--dt-*) set by the
|
||||
* ThemeProvider into Tailwind utility tokens. Every time the theme
|
||||
* switches, ThemeProvider writes new --dt-* values onto :root and
|
||||
* all Tailwind utilities (bg-background, text-muted-foreground, …)
|
||||
* update automatically — no class rewrite needed.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-background: var(--dt-background);
|
||||
--color-foreground: var(--dt-foreground);
|
||||
@@ -32,37 +24,33 @@
|
||||
--color-destructive: var(--dt-destructive);
|
||||
--color-destructive-foreground: var(--dt-destructive-foreground);
|
||||
|
||||
--color-midground: var(--dt-midground);
|
||||
--color-midground-foreground: var(--dt-midground-foreground);
|
||||
|
||||
--font-sans: var(--dt-font-sans);
|
||||
--font-mono: var(--dt-font-mono);
|
||||
|
||||
--spacing-mul: var(--dt-spacing-mul, 1);
|
||||
--radius-sm: max(0rem, calc(var(--radius) - 0.25rem));
|
||||
--radius-md: max(0rem, calc(var(--radius) - 0.125rem));
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 0.25rem);
|
||||
|
||||
--radius-xs: calc(var(--radius-scalar) * 0.125rem);
|
||||
--radius-sm: calc(var(--radius-scalar) * 0.5rem);
|
||||
--radius-md: calc(var(--radius-scalar) * 0.625rem);
|
||||
--radius-lg: calc(var(--radius-scalar) * 0.75rem);
|
||||
--radius-xl: calc(var(--radius-scalar) * 1rem);
|
||||
--radius-2xl: calc(var(--radius-scalar) * 1.5rem);
|
||||
--radius-3xl: calc(var(--radius-scalar) * 2rem);
|
||||
--radius-4xl: calc(var(--radius-scalar) * 2.5rem);
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
/* Shadow ink — derived from the foreground so it warms/cools with the theme. */
|
||||
--shadow-ink: var(--dt-foreground);
|
||||
--shadow-sidebar:
|
||||
0.0625rem 0 0.125rem 0 color-mix(in srgb, #000 4%, transparent),
|
||||
0.5rem 0 1.5rem -1rem color-mix(in srgb, #000 5%, transparent),
|
||||
1.25rem 0 3rem -2rem color-mix(in srgb, #000 6%, transparent);
|
||||
--shadow-header:
|
||||
0 0.5rem 0.875rem -0.375rem color-mix(in srgb, var(--dt-background) 96%, transparent),
|
||||
0 1.25rem 2rem -0.875rem color-mix(in srgb, var(--dt-background) 82%, transparent),
|
||||
@@ -74,16 +62,12 @@
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-ring) 14%, transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 26%, transparent),
|
||||
0 0.1875rem 0.625rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
|
||||
--shadow-user-message:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, var(--shadow-ink) 6%, transparent),
|
||||
0 0.25rem 0.75rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Default visual tokens. ThemeProvider only overrides colors and font families. */
|
||||
--dt-background: #f7f7f7;
|
||||
--dt-foreground: #242424;
|
||||
--dt-card: #ffffff;
|
||||
@@ -116,22 +100,20 @@
|
||||
--dt-spacing-mul: 1;
|
||||
|
||||
--radius: 0.75rem;
|
||||
/* Thread ViewportFooter — gap from last msg → composer (scroll only) */
|
||||
--thread-composer-clearance: 8rem;
|
||||
/* Composer geometry — single source of truth for shell + controls. */
|
||||
--radius-scalar: 0.2;
|
||||
|
||||
--thread-composer-clearance: 10dvh;
|
||||
|
||||
--composer-shell-pad-block-end: 2.5rem;
|
||||
--composer-inline-clearance: clamp(1rem, 5vw, 4rem);
|
||||
--composer-min-width: 34rem;
|
||||
--composer-target-width: 68%;
|
||||
--composer-max-width: 56rem;
|
||||
--thread-bottom-pad: clamp(2rem, 4dvh, 3.5rem);
|
||||
|
||||
--message-text-indent: 1.5rem;
|
||||
|
||||
--composer-width: 88%;
|
||||
--composer-control-size: 2rem;
|
||||
/* Send / voice-conversation circle is one notch larger than the ghost
|
||||
* controls so the primary CTA visually anchors the right edge. */
|
||||
--composer-control-primary-size: 2.125rem;
|
||||
--composer-control-gap: 0.375rem;
|
||||
--composer-row-gap: 0.375rem;
|
||||
/* Reference-clean padding: enough breathing room around the input row
|
||||
* that the pill no longer feels cramped against its controls. */
|
||||
--composer-surface-pad-x: 0.625rem;
|
||||
--composer-surface-pad-y: 0.5rem;
|
||||
--composer-input-min-height: 2rem;
|
||||
@@ -142,10 +124,8 @@
|
||||
--image-preview-max-width: 34rem;
|
||||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||||
|
||||
/* Shell layout */
|
||||
--sidebar-width: 14rem;
|
||||
--chat-min-width: 24rem;
|
||||
--shell-gap: 0.625rem;
|
||||
--titlebar-control-size: 1.25rem;
|
||||
--titlebar-control-height: 1.375rem;
|
||||
|
||||
@@ -158,14 +138,17 @@
|
||||
--sidebar-border: var(--dt-sidebar-border);
|
||||
--sidebar-ring: var(--dt-ring);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 42%, transparent);
|
||||
|
||||
--midground: var(--dt-midground);
|
||||
--background: var(--dt-background);
|
||||
--foreground: var(--dt-foreground);
|
||||
|
||||
--warm-glow: color-mix(in srgb, var(--dt-midground) 35%, transparent);
|
||||
--noise-opacity-mul: 1;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 78%, transparent);
|
||||
--shadow-sidebar:
|
||||
0.0625rem 0 0.125rem 0 color-mix(in srgb, #000 82%, transparent),
|
||||
0.75rem 0 1.75rem -1rem color-mix(in srgb, #000 72%, transparent),
|
||||
1.5rem 0 3rem -1.75rem color-mix(in srgb, #000 62%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -200,6 +183,92 @@
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
background: var(--dt-midground);
|
||||
color: var(--dt-midground-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.dither {
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 2px 2px;
|
||||
}
|
||||
|
||||
:root:not([style*='--theme-asset-bg:']) .theme-default-filler {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:root[style*='--theme-asset-bg:'] .theme-default-filler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
[class*='rounded-full'],
|
||||
[class*=':rounded-full'] {
|
||||
border-radius: calc(var(--radius-scalar) * 9999px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arc-border {
|
||||
0% {
|
||||
background-position: 15% 15%;
|
||||
}
|
||||
100% {
|
||||
background-position: 75% 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.arc-border {
|
||||
--arc-c0: color-mix(in srgb, var(--dt-foreground) 0%, transparent);
|
||||
--arc-c1: var(--dt-midground);
|
||||
--arc-c2: var(--dt-background);
|
||||
--arc-angle: 160deg;
|
||||
--arc-width: 1.25px;
|
||||
--arc-inset: -2px;
|
||||
--arc-duration: 2.23s;
|
||||
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
inset: var(--arc-inset);
|
||||
padding: var(--arc-width);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.arc-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
var(--arc-angle),
|
||||
transparent 0%,
|
||||
var(--arc-c0) 15%,
|
||||
var(--arc-c1) 20%,
|
||||
var(--arc-c2) 25%,
|
||||
transparent 35%,
|
||||
transparent 40%,
|
||||
var(--arc-c0) 55%,
|
||||
var(--arc-c1) 60%,
|
||||
var(--arc-c2) 65%,
|
||||
transparent 75%,
|
||||
transparent 80%,
|
||||
var(--arc-c0) 95%,
|
||||
var(--arc-c1) 100%
|
||||
);
|
||||
background-size: 300% 300%;
|
||||
animation: arc-border var(--arc-duration) linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arc-border::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -238,125 +307,54 @@ canvas {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-muted-foreground) 32%, transparent) transparent;
|
||||
@layer components {
|
||||
.scrollbar-dt,
|
||||
.scrollbar-dt * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 18%, transparent) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-dt::-webkit-scrollbar,
|
||||
.scrollbar-dt *::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.scrollbar-dt::-webkit-scrollbar-track,
|
||||
.scrollbar-dt::-webkit-scrollbar-corner,
|
||||
.scrollbar-dt *::-webkit-scrollbar-track,
|
||||
.scrollbar-dt *::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.scrollbar-dt::-webkit-scrollbar-thumb:hover,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-midground) 40%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.scrollbar-dt::-webkit-scrollbar-button,
|
||||
.scrollbar-dt *::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
[data-slot='aui_assistant-message-content'] {
|
||||
padding-left: var(--message-text-indent);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-muted-foreground) 32%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-muted-foreground) 55%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Previously applied `content-visibility: auto` + `contain-intrinsic-size` to
|
||||
* message roots for virtualization-lite perf. REMOVED because it interacts
|
||||
* badly with a stick-to-bottom scroller:
|
||||
*
|
||||
* 1. Session loads, messages render at their real heights.
|
||||
* 2. Scroller pins to `scrollHeight - clientHeight`.
|
||||
* 3. A few seconds later the browser's content-visibility heuristic kicks
|
||||
* in for off-screen messages and collapses them to the 10rem intrinsic
|
||||
* placeholder — shrinking total scrollHeight by a large margin.
|
||||
* 4. The browser clamps scrollTop to the new (smaller) scrollHeight, and
|
||||
* the user's viewport "scrolls up by a weird %" a few seconds after
|
||||
* the session loads. Feels like a scroll bug; actually CSS.
|
||||
*
|
||||
* If we want perf here again, the correct path is a real virtualizer (e.g.
|
||||
* react-virtuoso) with stable item sizing — not a CSS heuristic.
|
||||
*/
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
max-height: var(--image-preview-height);
|
||||
object-fit: contain;
|
||||
border: 0.0625rem solid color-mix(in srgb, var(--dt-border) 70%, transparent);
|
||||
border-radius: 1.125rem;
|
||||
box-shadow:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 4%, transparent),
|
||||
0 0.625rem 1.5rem color-mix(in srgb, #000 5%, transparent);
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='aui_markdown-image'] {
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hermes-preview-webview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-slot='composer-root'] {
|
||||
width: clamp(var(--composer-min-width), var(--composer-target-width), var(--composer-max-width));
|
||||
max-width: calc(100% - var(--composer-inline-clearance));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Thread scroll container (from use-stick-to-bottom).
|
||||
* `scroll-behavior: auto` is critical: use-stick-to-bottom writes scrollTop
|
||||
* directly and temporarily forces this to 'auto' during its programmatic
|
||||
* scrolls, but we default it to 'auto' anyway so no smooth-scroll fight can
|
||||
* ever happen. We leave overflow-anchor at the browser default ('auto'); the
|
||||
* library handles follow-mode imperatively. */
|
||||
[data-slot='aui_thread-content'] {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline `code`: monospace pill with a subtle background. Scoped to plain
|
||||
* inline code only — fenced blocks are handled by the SyntaxHighlighter
|
||||
* component and live inside `[data-streamdown='code-block']`, so we explicitly
|
||||
* unset there to keep the highlighter rendering its own chrome.
|
||||
*/
|
||||
[data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
/* Inline (not inside a fenced code-block). Use plain inline rendering so
|
||||
* the surrounding `<p>` (wrap-anywhere) can break long paths/URLs at any
|
||||
* character. inline-block + white-space: nowrap was leaking long paths
|
||||
* past the chat column under any sibling pane width. */
|
||||
display: inline;
|
||||
max-width: 100%;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.01rem 0.2rem;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, var(--dt-muted) 80%, transparent);
|
||||
color: var(--dt-muted-foreground);
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'] {
|
||||
margin-inline-start: calc(-1 * var(--message-text-indent));
|
||||
width: calc(100% + var(--message-text-indent));
|
||||
max-width: calc(100% + var(--message-text-indent));
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||||
@@ -374,37 +372,6 @@ canvas {
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> img:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown rhythm. Streamdown's wrapper <div> blocks `> *` selectors and
|
||||
* its bundled `space-y-4` lives in node_modules (unscanned by Tailwind v4),
|
||||
* so we drive everything from descendant selectors against tags. Each
|
||||
* block gets a uniform `margin-bottom`; headings add `margin-top` for
|
||||
* section breaks. Margin collapse picks the larger neighbor — producing
|
||||
* "more above headings, less below" without per-pair rules.
|
||||
*/
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md pre,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md table,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'],
|
||||
[data-slot='aui_assistant-message-content'] .aui-md div:has(> table) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* Streamdown wraps every fenced block in <div data-streamdown="code-block">
|
||||
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
|
||||
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
|
||||
* library's wrapper to keep the header flush with the code body. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
@@ -418,91 +385,10 @@ canvas {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h2 {
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h3 {
|
||||
margin: 1.15rem 0 0.45rem;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h4 {
|
||||
margin: 0.95rem 0 0.4rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md hr {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* `padding-left` keeps outside-position list markers in the gutter. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Tight inter-bullet gap; loose items override below. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li + li {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Inside a bullet, hug nested blocks to the lead text. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > p {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ol {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Loose list items (CommonMark wraps each in <p> when any sibling has a
|
||||
block child) need visible separation — the tight rhythm collapses
|
||||
against a trailing heavy block like a code fence. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p) {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Trim edge margins at the container, list items, and blockquotes. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent spacing around inline tool/thinking blocks inside an
|
||||
* assistant message.
|
||||
*
|
||||
* Two cases to balance:
|
||||
* 1. Two tool/thinking rows next to each other → keep them tight so a
|
||||
* multi-step turn reads as one continuous activity column.
|
||||
* 2. A tool/thinking row next to prose (markdown text) → give it real
|
||||
* breathing room so the activity feels like a separate panel and
|
||||
* doesn't crowd the paragraph above or below.
|
||||
*
|
||||
* Markdown text is wrapped in `.aui-md`; tool/thinking rows expose
|
||||
* `data-slot="tool-block"`. Sibling combinators handle the rest.
|
||||
*/
|
||||
[data-slot='tool-block'] + [data-slot='tool-block'] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the previous tool/thinking block is expanded (showing its content),
|
||||
* give the next block visible breathing room so they don't read as one
|
||||
* continuous chunk. The :has() guard keeps the tight rhythm between two
|
||||
* collapsed rows.
|
||||
*/
|
||||
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
@@ -512,8 +398,6 @@ canvas {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
/* When the assistant message starts with a tool block, don't pile padding
|
||||
* above it — the message header already provides spacing. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,11 @@ function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
border,
|
||||
input: mix('#e2e2e6', accent, 0.18),
|
||||
ring: accent,
|
||||
// Brand-accent stroke layer carries the seed's identity color into the
|
||||
// light palette intact (no mix-with-white softening) so DS components
|
||||
// and `bg-midground/N` surfaces still read as branded against white.
|
||||
midground: seed.colors.midground ?? accent,
|
||||
midgroundForeground: readableOn(seed.colors.midground ?? accent),
|
||||
destructive: '#b94a3a',
|
||||
destructiveForeground: '#ffffff',
|
||||
sidebarBackground: mix('#fafafa', accent, 0.05),
|
||||
@@ -220,6 +225,11 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
root.dataset.hermesTheme = skinNameFromTheme(theme, mode)
|
||||
root.classList.toggle('dark', rendered === 'dark')
|
||||
|
||||
// Brand-accent stroke layer. Falls back to ring when the theme doesn't
|
||||
// declare its own midground, so existing/custom themes keep working.
|
||||
const midground = c.midground ?? c.ring
|
||||
const midgroundForeground = c.midgroundForeground ?? readableOn(midground)
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
'--dt-background': c.background,
|
||||
'--dt-foreground': c.foreground,
|
||||
@@ -238,6 +248,8 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
'--dt-border': c.border,
|
||||
'--dt-input': c.input,
|
||||
'--dt-ring': c.ring,
|
||||
'--dt-midground': midground,
|
||||
'--dt-midground-foreground': midgroundForeground,
|
||||
'--dt-destructive': c.destructive,
|
||||
'--dt-destructive-foreground': c.destructiveForeground,
|
||||
'--dt-sidebar-bg': c.sidebarBackground ?? c.background,
|
||||
|
||||
@@ -51,6 +51,7 @@ export const nousLightTheme: DesktopTheme = {
|
||||
border: '#E3DDCF',
|
||||
input: '#D8D1C3',
|
||||
ring: '#A0782A',
|
||||
midground: '#A0782A',
|
||||
destructive: '#b94a3a',
|
||||
destructiveForeground: '#ffffff',
|
||||
sidebarBackground: '#F5F2EC',
|
||||
@@ -74,6 +75,7 @@ export const hermesGoldTheme: DesktopTheme = {
|
||||
accent: '#fbf3d4',
|
||||
accentForeground: '#5a4310',
|
||||
ring: '#d4af37',
|
||||
midground: '#d4af37',
|
||||
userBubble: '#f6efd5'
|
||||
}
|
||||
}
|
||||
@@ -103,6 +105,7 @@ export const nousTheme: DesktopTheme = {
|
||||
border: `color-mix(in srgb, ${NOUS_LENS_BLUE} 22%, transparent)`,
|
||||
input: `color-mix(in srgb, ${NOUS_LENS_BLUE} 30%, transparent)`,
|
||||
ring: NOUS_LENS_BLUE,
|
||||
midground: NOUS_LENS_BLUE,
|
||||
destructive: '#C72E4D',
|
||||
destructiveForeground: '#FFFFFF',
|
||||
sidebarBackground: `color-mix(in srgb, ${NOUS_LENS_BLUE} 2.5%, #FFFFFF)`,
|
||||
@@ -140,6 +143,7 @@ export const defaultTheme: DesktopTheme = {
|
||||
border: '#1e3232',
|
||||
input: '#1e3232',
|
||||
ring: '#6bbfb5',
|
||||
midground: '#6bbfb5',
|
||||
destructive: '#c0473a',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#0a1616',
|
||||
@@ -172,6 +176,7 @@ export const midnightTheme: DesktopTheme = {
|
||||
border: '#1e1e52',
|
||||
input: '#1e1e52',
|
||||
ring: '#8b80e8',
|
||||
midground: '#8b80e8',
|
||||
destructive: '#b03060',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#06061a',
|
||||
@@ -208,6 +213,7 @@ export const emberTheme: DesktopTheme = {
|
||||
border: '#3a1c08',
|
||||
input: '#3a1c08',
|
||||
ring: '#d97316',
|
||||
midground: '#d97316',
|
||||
destructive: '#c43010',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#100600',
|
||||
@@ -244,6 +250,7 @@ export const monoTheme: DesktopTheme = {
|
||||
border: '#2a2a2a',
|
||||
input: '#2a2a2a',
|
||||
ring: '#9a9a9a',
|
||||
midground: '#9a9a9a',
|
||||
destructive: '#a84040',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#0a0a0a',
|
||||
@@ -276,6 +283,7 @@ export const cyberpunkTheme: DesktopTheme = {
|
||||
border: '#003000',
|
||||
input: '#003000',
|
||||
ring: '#00ff41',
|
||||
midground: '#00ff41',
|
||||
destructive: '#ff003c',
|
||||
destructiveForeground: '#000a00',
|
||||
sidebarBackground: '#000600',
|
||||
@@ -312,6 +320,7 @@ export const slateTheme: DesktopTheme = {
|
||||
border: '#30363d',
|
||||
input: '#30363d',
|
||||
ring: '#58a6ff',
|
||||
midground: '#58a6ff',
|
||||
destructive: '#cf4848',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#090d13',
|
||||
|
||||
@@ -49,6 +49,17 @@ export interface DesktopThemeColors {
|
||||
input: string
|
||||
/** Focus ring / primary accent tint. Also `text-ring` in action bars etc. */
|
||||
ring: string
|
||||
/**
|
||||
* Brand-accent stroke layer. Distinct from `primary` (CTA fill) — this is
|
||||
* the "this thing is alive / live / signal" color used on focus rings,
|
||||
* streaming cursors, the active session pill, branded scrollbars, and text
|
||||
* selection. Falls back to `ring` when omitted. Aliased to the DS
|
||||
* `--midground` token so `@nous-research/ui` components inherit the
|
||||
* desktop's active theme without further wiring.
|
||||
*/
|
||||
midground?: string
|
||||
/** Text on `midground` fills (badges etc). Auto-derived from luminance when omitted. */
|
||||
midgroundForeground?: string
|
||||
/** Destructive action (delete, error). */
|
||||
destructive: string
|
||||
/** Text on destructive. */
|
||||
|
||||
Generated
+144
-1
@@ -71,17 +71,21 @@
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.12.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ignore": "^7.0.5",
|
||||
"leva": "^0.10.1",
|
||||
"liquid-glass-react": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
@@ -126,6 +130,48 @@
|
||||
"wait-on": "^9.0.5"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@nous-research/ui": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.12.0.tgz",
|
||||
"integrity": "sha512-OHv7z9J0r5Yp9iOl8uVUZAdIkTtDwqAL2RbgsQczXAhSPMOQle5X6ROWI4j6WK9riYhh4iCtRaCMsLcmSAuaXg==",
|
||||
"dependencies": {
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"nanostores": "^1.0.1",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-animations": "^1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"gsap": "^3.13.0",
|
||||
"leva": "^0.10.1",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@observablehq/plot": {
|
||||
"optional": true
|
||||
},
|
||||
"@react-three/fiber": {
|
||||
"optional": true
|
||||
},
|
||||
"gsap": {
|
||||
"optional": true
|
||||
},
|
||||
"leva": {
|
||||
"optional": true
|
||||
},
|
||||
"three": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -7686,6 +7732,17 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
|
||||
@@ -10449,6 +10506,17 @@
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -12946,6 +13014,32 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -15057,7 +15151,6 @@
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
||||
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-portal": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
@@ -16711,6 +16804,44 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
|
||||
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -17547,6 +17678,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
|
||||
Reference in New Issue
Block a user