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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user