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:
Brooklyn Nicholson
2026-05-11 10:25:23 -04:00
parent 61fb5a48b7
commit bff052d61f
32 changed files with 1180 additions and 676 deletions
@@ -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
}
+39 -66
View File
@@ -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 {
+3 -27
View File
@@ -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')
+13 -6
View File
@@ -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
}
+2 -1
View File
@@ -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
+2 -6
View File
@@ -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'
+3 -1
View File
@@ -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()