feat: glass ui pass

This commit is contained in:
Brooklyn Nicholson
2026-05-16 19:21:33 -05:00
parent 062eed654d
commit d67a438fec
104 changed files with 5173 additions and 1919 deletions
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
@@ -16,17 +17,17 @@ export function AttachmentList({
}) {
return (
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
{attachments.map(a => (
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
{attachments.map(attachment => (
<AttachmentPill attachment={attachment} key={attachment.id} onRemove={onRemove} />
))}
</div>
)
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@@ -101,7 +102,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
onClick={() => onRemove(attachment.id)}
type="button"
>
<X className="size-2.5" />
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
@@ -3,23 +3,30 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'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',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]',
'data-[state=open]:-mb-2',
'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)]'
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
'w-60 max-w-[calc(100vw-2rem)]',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-lg border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-md',
'backdrop-blur-md'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1',
'text-left text-xs transition-colors',
'hover:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]',
'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')
export function ComposerCompletionDrawer({
@@ -48,9 +55,9 @@ export function ComposerCompletionDrawer({
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<div className="px-3 py-3 text-xs text-(--ui-text-tertiary)">
<p>{title}</p>
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
{children && <p className="mt-1 text-xs text-(--ui-text-tertiary)">{children}</p>}
</div>
)
}
@@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,7 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type IconComponent, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
@@ -38,14 +39,14 @@ export function ContextMenu({
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground')}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Plus size={18} />
<Codicon name="add" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
@@ -107,7 +108,7 @@ export function ContextMenuItem({
}: {
children: string
disabled?: boolean
icon: LucideIcon
icon: IconComponent
onSelect?: () => void
}) {
return (
@@ -1,13 +1,17 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { triggerHaptic } from '@/lib/haptics'
import { ArrowUp, AudioLines, Layers3, Loader2, Mic, MicOff, Square } from '@/lib/icons'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
@@ -89,7 +93,7 @@ export function ComposerControls({
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<ArrowUp size={18} />
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
)}
@@ -136,7 +140,7 @@ function ConversationPill({
type="button"
variant="ghost"
>
{muted ? <MicOff size={16} /> : <Mic size={16} />}
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
{listening && (
<Button
@@ -246,7 +250,7 @@ function DictationButton({
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Mic size={16} />
<Codicon name="mic" size="1rem" />
)}
</Button>
)
@@ -0,0 +1,2 @@
export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out'
export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60'
+103
View File
@@ -0,0 +1,103 @@
/**
* Composer focus + external-insert bus.
*
* Mutations from outside the composer (sidebar attach, drag drop, terminal
* Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes
* and routes the work back into its own ref/state.
*
* `dispatch` defers to a macrotask so synchronous click/keydown handlers
* (react-arborist row focus, picker `node.select()`) finish first and don't
* steal focus from the composer effect.
*/
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
target: ComposerTarget
}
interface InsertDetail {
mode: ComposerInsertMode
target: ComposerTarget
text: string
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
const dispatch = <T>(name: string, detail: T) => {
if (typeof window === 'undefined') {
return
}
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => {
const detail = (event as CustomEvent<T>).detail
if (detail) {
handler(detail)
}
}
window.addEventListener(name, listener)
return () => window.removeEventListener(name, listener)
}
export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerInsert = (
text: string,
{ mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/**
* Focus a composer input across React commit + browser focus restore.
*
* The triple-call survives:
* - sync: contenteditable already mounted
* - rAF: React just committed a `renderComposerContents` swap
* - 0ms: browser focus reclaim from a click target inside an external panel
*/
export const focusComposerInput = (el: HTMLElement | null) => {
if (!el) {
return
}
const focus = () => el.focus({ preventScroll: true })
focus()
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}
+163 -153
View File
@@ -8,16 +8,16 @@ import {
type DragEvent as ReactDragEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -25,28 +25,39 @@ import {
$composerAttachments,
$composerDraft,
clearComposerAttachments,
type ComposerAttachment
type ComposerAttachment,
reconcileComposerTerminalSelections
} from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
removeQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import {
type ComposerInsertMode,
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
placeCaretEnd,
@@ -54,7 +65,6 @@ import {
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
import { QueuePanel } from './queue-panel'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
@@ -104,7 +114,11 @@ export function ChatBar({
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const scrolledUp = useStore($threadScrolledUp)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []
const queuedPrompts = useMemo(
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
[activeQueueSessionKey, queuedPromptsBySession]
)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
@@ -121,10 +135,11 @@ export function ChatBar({
const [tight, setTight] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 480px)')
const narrow = useMediaQuery('(max-width: 30rem)')
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
@@ -132,23 +147,81 @@ export function ChatBar({
const stacked = expanded || narrow || tight
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
const placeholder = disabled ? 'Starting Hermes' : 'Ask anything'
const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
markActiveComposer('main')
}, [])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
}, [])
const appendExternalText = useCallback(
(text: string, mode: ComposerInsertMode) => {
const value = text.trim()
if (!value) {
return
}
const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''
const next = `${base}${sep}${value}`
draftRef.current = next
aui.composer().setText(next)
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
placeCaretEnd(editor)
}
setFocusRequestId(id => id + 1)
},
[aui]
)
useEffect(() => {
if (!disabled) {
focusInput()
}
}, [disabled, focusKey])
}, [disabled, focusInput, focusKey, focusRequestId])
useEffect(() => {
if (disabled) {
return undefined
}
const offFocus = onComposerFocusRequest(target => {
if (target === 'main') {
setFocusRequestId(id => id + 1)
}
})
const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
if (target === 'main') {
appendExternalText(text, mode)
}
})
return () => {
offFocus()
offInsert()
}
}, [appendExternalText, disabled])
useEffect(() => {
draftRef.current = draft
$composerDraft.set(draft)
reconcileComposerTerminalSelections(draft)
const editor = editorRef.current
@@ -232,114 +305,33 @@ export function ChatBar({
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
focusInput()
requestMainFocus()
}
const insertInlineRefs = (refs: string[]) => {
const editor = editorRef.current
if (!refs.length || !editor) {
if (!editor) {
return false
}
const inline = refs.join(' ')
const selection = window.getSelection()
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
editor.focus({ preventScroll: true })
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
range.deleteContents()
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.appendChild(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
const match = ref.match(/^@([^:]+):(.+)$/)
fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref))
if (index < refs.length - 1) {
fragment.appendChild(document.createTextNode(' '))
}
})
const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null
if (trailingSpace) {
fragment.appendChild(trailingSpace)
}
range.insertNode(fragment)
const nextRange = document.createRange()
if (trailingSpace) {
nextRange.setStart(trailingSpace, trailingSpace.length)
} else {
nextRange.setStartAfter(fragment.lastChild || range.startContainer)
}
nextRange.collapse(true)
selection?.removeAllRanges()
selection?.addRange(nextRange)
} else {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
placeCaretEnd(editor)
if (nextDraft === null) {
return false
}
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
requestMainFocus()
return true
}
const droppedFileInlineRef = (candidate: DroppedFile) => {
if (!candidate.path) {
return null
}
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
focusInput()
requestMainFocus()
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
@@ -453,6 +445,7 @@ export function ChatBar({
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
requestMainFocus()
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
@@ -498,7 +491,9 @@ export function ChatBar({
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) void drainNextQueued()
if (!busy) {
void drainNextQueued()
}
return
}
@@ -554,29 +549,13 @@ export function ChatBar({
window.setTimeout(refreshTrigger, 0)
}
const dragHasAttachments = (transfer: DataTransfer | null) => {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
const resetDragState = () => {
dragDepthRef.current = 0
setDragActive(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@@ -589,7 +568,7 @@ export function ChatBar({
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@@ -625,7 +604,9 @@ export function ChatBar({
}
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
@@ -637,13 +618,13 @@ export function ChatBar({
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
focusInput()
requestMainFocus()
}
})
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
@@ -653,12 +634,15 @@ export function ChatBar({
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer)) {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
const candidates = extractDroppedFiles(event.dataTransfer)
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
return
@@ -673,14 +657,14 @@ export function ChatBar({
}
}
const clearDraft = () => {
const clearDraft = useCallback(() => {
aui.composer().setText('')
draftRef.current = ''
if (editorRef.current) {
editorRef.current.replaceChildren()
}
}
}, [aui])
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
@@ -696,7 +680,9 @@ export function ChatBar({
}
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
if (!activeQueueSessionKey || queueEdit) return
if (!activeQueueSessionKey || queueEdit) {
return
}
setQueueEdit({
attachments: cloneAttachments($composerAttachments.get()),
@@ -710,13 +696,17 @@ export function ChatBar({
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) return false
if (!queueEdit) {
return false
}
if (action === 'save') {
const text = draftRef.current
const next = cloneAttachments($composerAttachments.get())
if (!text.trim() && next.length === 0) return false
if (!text.trim() && next.length === 0) {
return false
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text })
triggerHaptic(saved ? 'success' : 'selection')
@@ -732,32 +722,45 @@ export function ChatBar({
}
const queueCurrentDraft = useCallback(() => {
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false
if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) {
return false
}
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) {
return false
}
clearDraft()
clearComposerAttachments()
triggerHaptic('selection')
return true
}, [activeQueueSessionKey, attachments, draft])
}, [activeQueueSessionKey, attachments, clearDraft, draft])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
const runDrain = useCallback(
async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise<boolean> => {
if (drainingQueueRef.current || !activeQueueSessionKey) return false
if (drainingQueueRef.current || !activeQueueSessionKey) {
return false
}
const entry = pickEntry(queuedPrompts)
if (!entry) return false
if (!entry) {
return false
}
drainingQueueRef.current = true
try {
const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }))
const accepted = await Promise.resolve(
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
)
if (accepted === false) return false
if (accepted === false) {
return false
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
@@ -785,7 +788,9 @@ export function ChatBar({
)
const interruptAndSendNextQueued = useCallback(async () => {
if (queuedPrompts.length === 0) return false
if (queuedPrompts.length === 0) {
return false
}
await Promise.resolve(onCancel())
@@ -797,15 +802,22 @@ export function ChatBar({
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
if (busy || !wasBusy || queuedPrompts.length === 0) return
if (busy || !wasBusy || queuedPrompts.length === 0) {
return
}
void drainNextQueued()
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
useEffect(() => {
if (!queueEdit) return
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return
if (!queueEdit) {
return
}
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
return
}
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
setQueueEdit(null)
@@ -815,9 +827,11 @@ export function ChatBar({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
if (hasComposerPayload) queueCurrentDraft()
else if (queuedPrompts.length > 0) void interruptAndSendNextQueued()
else {
if (hasComposerPayload) {
queueCurrentDraft()
} else if (queuedPrompts.length > 0) {
void interruptAndSendNextQueued()
} else {
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -966,6 +980,7 @@ export function ChatBar({
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@@ -1033,10 +1048,11 @@ export function ChatBar({
<div
className={cn(
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
COMPOSER_DROP_FADE_CLASS,
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] 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-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
dragActive && 'border-midground/70 shadow-composer-focus ring-2 ring-midground/40'
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"
ref={composerSurfaceRef}
@@ -1053,14 +1069,6 @@ export function ChatBar({
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
{dragActive && (
<div
aria-hidden
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) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
@@ -1074,7 +1082,9 @@ export function ChatBar({
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">Editing queued turn in composer</div>
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
Editing queued turn in composer
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
@@ -0,0 +1,91 @@
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
}
if (Array.from(transfer.types || []).includes(pathsMime)) {
return true
}
if (Array.from(transfer.types || []).includes('Files')) {
return true
}
return Array.from(transfer.items || []).some(item => item.kind === 'file')
}
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
if (!candidate.path) {
return null
}
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
if (!refs.length) {
return null
}
const refsHtml = refs
.map(ref => {
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
})
.join(' ')
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
editor.focus({ preventScroll: true })
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
} else {
const current = composerPlainText(editor)
placeCaretEnd(editor)
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
}
return composerPlainText(editor)
}
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowUp, ChevronDown, Pencil, Trash2 } from '@/lib/icons'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -29,7 +30,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
onClick={() => setCollapsed(open => !open)}
type="button"
>
<ChevronDown className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')} size={14} />
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{entries.length} Queued</span>
</button>
@@ -15,7 +15,7 @@ import {
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@@ -1,8 +1,45 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
COMPLETION_DRAWER_ROW_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
const AT_ICON_BY_TYPE: Record<string, string> = {
diff: 'diff',
file: 'book',
folder: 'folder',
git: 'git-branch',
image: 'file-media',
simple: 'symbol-misc',
staged: 'diff-added',
tool: 'tools',
url: 'globe'
}
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
if (kind === '/') {
return 'terminal'
}
const meta = item.metadata as { rawText?: string } | undefined
const raw = meta?.rawText || item.label
if (raw.startsWith('@diff')) {
return AT_ICON_BY_TYPE.diff
}
if (raw.startsWith('@staged')) {
return AT_ICON_BY_TYPE.staged
}
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
}
interface ComposerTriggerPopoverProps {
activeIndex: number
@@ -11,6 +48,7 @@ interface ComposerTriggerPopoverProps {
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
placement?: 'bottom' | 'top'
}
export function ComposerTriggerPopover({
@@ -19,11 +57,12 @@ export function ComposerTriggerPopover({
kind,
loading,
onHover,
onPick
onPick,
placement = 'top'
}: ComposerTriggerPopoverProps) {
return (
<div
className={COMPLETION_DRAWER_CLASS}
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
data-state="open"
onMouseDown={event => event.preventDefault()}
@@ -52,7 +91,7 @@ export function ComposerTriggerPopover({
<button
className={cn(
COMPLETION_DRAWER_ROW_CLASS,
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
index === activeIndex && 'bg-(--ui-bg-tertiary)'
)}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
@@ -60,9 +99,14 @@ export function ComposerTriggerPopover({
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
{display}
</span>
{description && (
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
)}
</button>
)
@@ -2,7 +2,13 @@ import { useCallback } from 'react'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import {
addComposerAttachment,
setComposerTerminalSelection,
type ComposerAttachment,
removeComposerAttachment
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageDetachResponse } from '../../types'
@@ -180,19 +186,38 @@ interface ComposerActionsOptions {
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */
const attachToMain = (attachment: ComposerAttachment) => {
addComposerAttachment(attachment)
requestComposerFocus('main')
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
const normalizedLabel = label.trim() || 'selection'
const refText = `@terminal:${formatRefValue(normalizedLabel)}`
if (!trimmed) {
return
}
setComposerTerminalSelection(normalizedLabel, trimmed)
requestComposerInsert(refText, { mode: 'inline' })
}, [])
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
let kind: ComposerAttachment['kind'] = 'file'
const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:')
? 'folder'
: refText.startsWith('@url:')
? 'url'
: 'file'
if (refText.startsWith('@folder:')) {
kind = 'folder'
}
if (refText.startsWith('@url:')) {
kind = 'url'
}
addComposerAttachment({
attachToMain({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
@@ -216,7 +241,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
for (const path of paths) {
const rel = contextPath(path, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId(kind, rel),
kind,
label: pathLabel(path),
@@ -237,7 +262,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const rel = contextPath(filePath, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId('file', rel),
kind: 'file',
label: pathLabel(filePath),
@@ -264,7 +289,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
path: filePath
}
addComposerAttachment(baseAttachment)
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
@@ -361,7 +386,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const rel = contextPath(folderPath, currentCwd)
addComposerAttachment({
attachToMain({
id: attachmentId('folder', rel),
kind: 'folder',
label: pathLabel(folderPath),
@@ -482,7 +507,10 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
return {
addContextRefAttachment,
addTerminalSelectionAttachment,
addTextToDraft,
attachContextFilePath,
attachContextFolderPath,
attachDroppedItems,
attachImageBlob,
attachImagePath,
+26 -24
View File
@@ -16,11 +16,11 @@ import { Button } from '@/components/ui/button'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { ChevronDown } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import {
$activeSessionId,
$awaitingResponse,
@@ -92,32 +92,30 @@ function ChatHeader({
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-(--ui-bg-quinary) px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-tertiary) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-bg-tertiary) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</SessionActionsMenu>
</div>
</header>
)
@@ -271,7 +269,7 @@ export function ChatView({
return (
<div
className={cn(
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-transparent',
'relative flex h-full min-w-0 flex-col overflow-hidden bg-(--glass-chat-surface-background)',
className
)}
>
@@ -285,13 +283,17 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-b-[1.0625rem] bg-transparent contain-[layout_paint]">
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--glass-chat-surface-background) contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
clampToComposer={showChatBar}
cwd={currentCwd}
gateway={gateway}
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
sessionId={activeSessionId}
sessionKey={threadKey}
/>
{showChatBar && (
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { notify } from '@/store/notifications'
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
@@ -186,10 +186,8 @@ export function PreviewConsolePanel({
}
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
const draft = $composerDraft.get()
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
setComposerDraft(next)
requestComposerInsert(block, { mode: 'block', target: 'main' })
consoleState.clearSelection()
notify({
kind: 'success',
@@ -296,7 +296,7 @@ function MarkdownPreview({ text }: { text: string }) {
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
@@ -379,7 +379,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
@@ -512,7 +512,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
@@ -528,7 +528,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
<div className="h-full overflow-auto bg-transparent">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
@@ -3,7 +3,8 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Bug, RefreshCw, X } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
@@ -301,13 +302,13 @@ export function PreviewPane({
]
: []),
{
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
icon: <Codicon name="refresh" spinning={loading} />,
id: `${TITLEBAR_GROUP_ID}-reload`,
label: 'Reload preview',
onSelect: reloadPreview
},
{
icon: <X />,
icon: <Codicon name="close" />,
id: `${TITLEBAR_GROUP_ID}-close`,
label: 'Close preview',
onSelect: onClose
@@ -534,7 +535,7 @@ export function PreviewPane({
}
const webview = document.createElement('webview') as PreviewWebview
webview.className = 'flex h-full w-full flex-1 bg-background'
webview.className = 'flex h-full w-full flex-1 bg-transparent'
webview.setAttribute('partition', 'persist:hermes-preview')
webview.setAttribute('src', target.url)
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
@@ -626,7 +627,7 @@ export function PreviewPane({
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{!embedded && (
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
@@ -645,12 +646,12 @@ export function PreviewPane({
)}
<div
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-transparent"
ref={previewContentRef}
>
<div
className={cn(
'absolute inset-0 flex bg-background',
'absolute inset-0 flex bg-transparent',
(!isWebPreview || loadError) && 'pointer-events-none opacity-0'
)}
ref={hostRef}
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { X } from '@/lib/icons'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
@@ -30,7 +30,7 @@ const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// against --chat-min-width so the chat surface never gets squeezed below it.
// Subtracts the project browser width so preview yields rather than crushing
// the chat when both right-side panes are open.
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0rem, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0rem) - var(--chat-min-width))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
@@ -79,9 +79,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--glass-editor-surface-background) text-(--ui-text-tertiary)">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-(--ui-stroke-tertiary) bg-(--glass-sidebar-surface-background) [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
@@ -90,35 +90,36 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return (
<div
className={cn(
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-background text-foreground'
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
? 'bg-(--glass-editor-surface-background) text-foreground [--tab-bg:var(--glass-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--glass-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:[--tab-bg:var(--chrome-action-hover)]'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />}
<button
aria-selected={active}
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
{tab.label}
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={`Close ${tab.label}`}
className={cn(
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
active && 'grid'
)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<X className="size-3" />
<Codicon name="close" size="0.75rem" />
</button>
</div>
)
+552 -115
View File
@@ -1,8 +1,28 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { KbdGroup } from '@/components/ui/kbd'
import {
Sidebar,
SidebarContent,
@@ -14,19 +34,28 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
pinSession,
reorderPinnedSession,
setSidebarAgentsGrouped,
setSidebarPinsOpen,
setSidebarRecentsOpen,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import {
$selectedStoredSessionId,
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
@@ -35,21 +64,79 @@ import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New chat',
icon: Plus,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
]
const WORKSPACE_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
if (!orderIds.length) {
return items
}
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
out.push(item)
seen.add(id)
}
}
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return out
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || '__no_workspace__'
const label = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() || path || 'No workspace'
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
return [...groups.values()]
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
return {
dragging: isDragging,
dragHandleProps: { ...attributes, ...listeners },
ref: setNodeRef,
reorderable: true as const,
style: { transform: CSS.Transform.toString(transform), transition }
}
}
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onRefreshSessions: () => void
onLoadMoreSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
@@ -57,63 +144,136 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
export function ChatSidebar({
currentView,
onNavigate,
onRefreshSessions,
onLoadMoreSessions,
onResumeSession,
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const recentsOpen = useStore($sidebarRecentsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const workingSessionIds = useStore($workingSessionIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const sortedSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
return bTime - aTime
}),
() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[sessions]
)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
const visiblePinnedIdSet = new Set(visiblePinnedIds)
const visiblePinnedIds = useMemo(
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
[pinnedSessionIds, sessionsById]
)
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
const pinnedSessions = visiblePinnedIds
.map(id => sessionsById.get(id))
.filter((session): session is SessionInfo => Boolean(session))
const pinnedSessions = useMemo(
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
[visiblePinnedIds, sessionsById]
)
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
[sortedSessions, visiblePinnedIdSet]
)
const agentSessions = useMemo(
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
[unpinnedAgentSessions, agentOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds]
)
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
const hasMoreSessions = knownSessionTotal > sortedSessions.length
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
if (newIndex < 0) {
return
}
reorderPinnedSession(String(active.id), newIndex)
}
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const activeId = String(active.id)
const overId = String(over.id)
const activeWs = parseWsId(activeId)
const overWs = parseWsId(overId)
if (activeWs && overWs) {
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
const newIdx = agentGroups.findIndex(g => g.id === overWs)
if (oldIdx < 0 || newIdx < 0) {
return
}
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
return
}
if (activeWs || overWs) {
return
}
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
const newIdx = agentSessions.findIndex(s => s.id === overId)
if (oldIdx < 0 || newIdx < 0) {
return
}
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return (
<Sidebar
className={cn(
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
? 'border-(--sidebar-edge-border) bg-(--glass-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-(--sidebar-content-inline-padding)">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarPanelLabel className="pb-1 pt-1">Workspace</SidebarPanelLabel>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
@@ -124,9 +284,9 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-sidebar-foreground/78 transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
active &&
'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)] hover:border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))]!',
'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
@@ -135,7 +295,14 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
)}
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
@@ -145,102 +312,103 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<SidebarGroup className="shrink-0 p-0 pb-1">
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
{pinsOpen && (
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
{pinnedSessions.length === 0 && (
<div className="italic flex min-h-7 items-center gap-2 rounded-lg pl-2 text-xs text-muted-foreground/80">
<span>Shift click to pin a chat</span>
</div>
)}
{pinnedSessions.map(session => (
<SidebarSessionRow
isPinned
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => unpinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && showSessionSections && (
<SidebarGroup className="min-h-0 flex-1 p-0">
<SidebarSectionHeader
action={
<Button
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
disabled={sessionsLoading}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
onRefreshSessions()
}}
size="icon-xs"
variant="ghost"
>
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
</Button>
}
label="Recent chats"
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
open={recentsOpen}
/>
{recentsOpen && (
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{showSessionSkeletons && <SidebarSessionSkeletons />}
{!showSessionSkeletons && recentSessions.length === 0 && <SidebarAllPinnedState />}
{recentSessions.map(session => (
<SidebarSessionRow
isPinned={false}
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => pinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
agentsGrouped && 'bg-(--ui-bg-tertiary) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Agents"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onDeleteSession={onDeleteSession}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
</SidebarContent>
</Sidebar>
)
}
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
interface SidebarSectionHeaderProps {
label: string
open: boolean
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="flex shrink-0 items-center justify-between pb-1 pt-1.5">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<SidebarPanelLabel>{label}</SidebarPanelLabel>
<ChevronDown
className={cn(
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
!open && '-rotate-90'
)}
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
open={open}
/>
</button>
{action}
@@ -262,7 +430,276 @@ function SidebarSessionSkeletons() {
}
const SidebarAllPinnedState = () => (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-muted-foreground">
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
function SidebarPinnedEmptyState() {
return (
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift click to pin a chat</span>
</div>
)
}
interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
onToggle: () => void
sessions: SessionInfo[]
activeSessionId: null | string
workingSessionIdSet: Set<string>
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
emptyState: React.ReactNode
forceEmptyState?: boolean
headerAction?: React.ReactNode
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
}
function SidebarSessionsSection({
label,
open,
onToggle,
sessions,
activeSessionId,
workingSessionIdSet,
onResumeSession,
onDeleteSession,
onTogglePin,
pinned,
rootClassName,
contentClassName,
emptyState,
forceEmptyState = false,
headerAction,
footer,
groups,
labelMeta,
sortable = false,
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const showEmptyState = forceEmptyState || sessions.length === 0
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
const rowProps = {
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id),
session
}
return sortable ? (
<SortableSidebarSessionRow key={session.id} {...rowProps} />
) : (
<SidebarSessionRow key={session.id} {...rowProps} />
)
}
const renderRows = (items: SessionInfo[]) => items.map(renderRow)
const renderSessionList = (items: SessionInfo[]) =>
dndActive ? (
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
) : (
renderRows(items)
)
let inner: React.ReactNode
if (showEmptyState) {
inner = emptyState
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
) : (
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
)
)
inner = dndActive ? (
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
) : (
groupNodes
)
} else {
inner = renderSessionList(sessions)
}
const body =
dndActive && !showEmptyState ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={contentClassName}>
{body}
{footer}
</SidebarGroupContent>
)}
</SidebarGroup>
)
}
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
function SidebarWorkspaceGroup({
group,
renderRows,
reorderable = false,
dragging = false,
dragHandleProps,
className,
style,
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<button
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
{reorderable && (
<span
{...dragHandleProps}
aria-label={`Reorder workspace ${group.label}`}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<Codicon
className={cn(
'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
)}
</button>
{open && (
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)}
</>
)}
</div>
)
}
interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
interface SortableSessionRowProps {
session: SessionInfo
isPinned: boolean
isSelected: boolean
isWorking: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
}
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
}
interface SidebarLoadMoreRowProps {
loading: boolean
onClick: () => void
step: number
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
return (
<button
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
<span>{label}</span>
</button>
)
}
@@ -1,9 +1,9 @@
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@@ -17,14 +17,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
@@ -56,54 +55,46 @@ export function SessionActionsMenu({
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-40" sideOffset={sideOffset}>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<Pin className="size-3.5" strokeWidth={1.75} />
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<Codicon name="cloud-download" size="0.875rem" />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
setRenameOpen(true)
}}
>
<IconPencil />
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
'text-destructive focus:text-destructive'
)}
className="text-destructive focus:text-destructive"
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
@@ -111,7 +102,7 @@ export function SessionActionsMenu({
}}
variant="destructive"
>
<IconCircleX />
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,14 +1,19 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { MoreVertical } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
@@ -17,6 +22,28 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
onDelete: () => void
onPin: () => void
onResume: () => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
function formatAge(seconds: number): string {
const at = seconds * 1000
const delta = Math.max(0, Date.now() - at)
if (delta < MINUTE) {
return 'now'
}
if (delta < HOUR) {
return `${Math.floor(delta / MINUTE)}m`
}
if (delta < DAY) {
return `${Math.floor(delta / HOUR)}h`
}
return `${Math.floor(delta / DAY)}d`
}
export function SidebarSessionRow({
@@ -26,23 +53,35 @@ export function SidebarSessionRow({
isWorking,
onDelete,
onPin,
onResume
onResume,
reorderable = false,
dragging = false,
dragHandleProps,
className,
style,
ref,
...rest
}: SidebarSessionRowProps) {
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const handleLabel = `Reorder ${title}`
return (
<div
className={cn(
'group relative grid min-h-7 cursor-pointer grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
'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',
isSelected && 'bg-accent',
isWorking && 'text-foreground'
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-bg-quinary) hover:transition-none',
isSelected && 'bg-(--ui-bg-tertiary)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
className
)}
data-working={isWorking ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -57,28 +96,67 @@ export function SidebarSessionRow({
}}
type="button"
>
{isWorking && (
{reorderable ? (
<span
aria-label="Session running"
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"
/>
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-bg-tertiary) hover:text-foreground focus-visible:bg-(--ui-bg-tertiary) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
variant="ghost"
>
<MoreVertical size={15} />
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
return (
<span
aria-label={isWorking ? 'Session running' : undefined}
className={cn(
'rounded-full',
isWorking ? 'size-1.5 bg-(--ui-green)' : 'size-1 bg-(--ui-text-quaternary) opacity-80',
className
)}
role={isWorking ? 'status' : undefined}
/>
)
}