feat: better pane management and toolbar api

This commit is contained in:
Brooklyn Nicholson
2026-05-02 15:22:18 -05:00
parent a66303eaef
commit 6dcf5bcbc0
17 changed files with 772 additions and 348 deletions
+9 -5
View File
@@ -40,11 +40,12 @@ import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
import { ChatBar, ChatBarFallback } from './composer'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { ChatRightRail } from './right-rail'
import { ChatPreviewRail, ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
@@ -72,6 +73,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}
function threadLoadingState(
@@ -123,7 +125,8 @@ export function ChatView({
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio
onTranscribeAudio,
setTitlebarToolGroup
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
@@ -255,7 +258,7 @@ export function ChatView({
return (
<>
<div className={cn('relative flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
<div className={cn('relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
@@ -269,7 +272,7 @@ export function ChatView({
title={title}
>
<Button
className="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]"
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"
>
@@ -283,7 +286,7 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent">
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
@@ -321,6 +324,7 @@ export function ChatView({
</div>
</div>
<ChatPreviewRail setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatRightRail
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
+37 -18
View File
@@ -2,8 +2,10 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { cn } from '@/lib/utils'
import { $inspectorOpen } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import {
$availablePersonalities,
$busy,
@@ -32,7 +34,6 @@ export function ChatRightRail({
onSelectPersonality
}: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const previewTarget = useStore($previewTarget)
const gatewayOpen = useStore($gatewayState) === 'open'
const busy = useStore($busy)
const cwd = useStore($currentCwd)
@@ -42,26 +43,44 @@ export function ChatRightRail({
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
if (previewTarget) {
return <PreviewPane target={previewTarget} />
return (
<div className="col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden">
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
/>
</div>
)
}
export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) {
const inspectorOpen = useStore($inspectorOpen)
const previewTarget = useStore($previewTarget)
if (!previewTarget) {
return <aside aria-hidden="true" className="col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden" />
}
return (
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
/>
<div
className={cn(
'pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden',
inspectorOpen && 'border-r border-border/60'
)}
>
<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={previewTarget} />
</div>
)
}
@@ -1,7 +1,8 @@
import { Bug, Check, Copy, ExternalLink, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
@@ -23,6 +24,12 @@ interface ConsoleEntry {
source?: string
}
interface PreviewLoadErrorState {
code?: number
description: string
url: string
}
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
@@ -38,6 +45,8 @@ const consoleLevelClass: Record<number, string> = {
}
const CONSOLE_BOTTOM_THRESHOLD = 24
const CONSOLE_DEFAULT_HEIGHT = 240
const CONSOLE_HEADER_HEIGHT = 32
const FILE_RELOAD_DEBOUNCE_MS = 200
function compactUrl(value: string): string {
@@ -69,6 +78,20 @@ function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
}
function clampConsoleHeight(value: number): number {
return Math.max(value, CONSOLE_HEADER_HEIGHT)
}
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
return 'Server not found'
}
return 'Preview failed to load'
}
interface ConsoleRowProps {
log: ConsoleEntry
onCopy: () => void | Promise<void>
@@ -129,6 +152,67 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo
)
}
function PreviewLoadError({
consoleHeight = 0,
error,
onRetry
}: {
consoleHeight?: number
error: PreviewLoadErrorState
onRetry: () => void
}) {
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-6 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-72 justify-items-center gap-4">
<svg aria-hidden="true" className="size-16 text-muted-foreground/35" viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" stroke="currentColor" strokeWidth="0.9" opacity="0.45" />
</svg>
<div className="grid gap-1.5">
<div className="text-sm font-medium text-foreground">{loadErrorTitle(error)}</div>
<div className="text-xs leading-5 text-muted-foreground">
<a
className="pointer-events-auto cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
href={error.url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(error.url)
}}
>
{compactUrl(error.url)}
</a>
{error.code ? ` (${error.code})` : ''}
</div>
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
</div>
<button
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
onClick={onRetry}
type="button"
>
Try again
</button>
</div>
</div>
)
}
async function writeClipboardText(text: string) {
if (!text) {
return
@@ -145,12 +229,20 @@ async function writeClipboardText(text: string) {
}
}
export function PreviewPane({ target }: { target: PreviewTarget }) {
export function PreviewPane({
setTitlebarToolGroup,
target
}: {
setTitlebarToolGroup?: SetTitlebarToolGroup
target: PreviewTarget
}) {
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
const consoleShouldStickRef = useRef(true)
const hostRef = useRef<HTMLDivElement | null>(null)
const logIdRef = useRef(0)
const previewContentRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<PreviewWebview | null>(null)
const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT)
const [consoleOpen, setConsoleOpen] = useState(false)
const [currentUrl, setCurrentUrl] = useState(target.url)
const [devtoolsOpen, setDevtoolsOpen] = useState(false)
@@ -158,8 +250,72 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
const [selectedLogIds, setSelectedLogIds] = useState<Set<number>>(() => new Set())
const [copiedAll, setCopiedAll] = useState(false)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PreviewLoadErrorState | null>(null)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const currentLabel = compactUrl(currentUrl)
const previewLabel =
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
const startConsoleResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
const handle = event.currentTarget
const pointerId = event.pointerId
const startY = event.clientY
const startHeight = consoleHeight
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
let active = true
handle.setPointerCapture?.(pointerId)
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
const handleMove = (moveEvent: PointerEvent) => {
if (!active) {
return
}
setConsoleHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY))
}
const cleanup = () => {
if (!active) {
return
}
active = false
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
handle.releasePointerCapture?.(pointerId)
window.removeEventListener('pointermove', handleMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
window.removeEventListener('blur', cleanup)
handle.removeEventListener('lostpointercapture', cleanup)
}
window.addEventListener('pointermove', handleMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
window.addEventListener('blur', cleanup)
handle.addEventListener('lostpointercapture', cleanup)
},
[consoleHeight]
)
const reloadPreview = useCallback(() => {
setLoadError(null)
if (webviewRef.current?.reloadIgnoringCache) {
webviewRef.current.reloadIgnoringCache()
} else {
webviewRef.current?.reload?.()
}
}, [])
function toggleLogSelection(id: number) {
setSelectedLogIds(prev => {
@@ -204,7 +360,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
})
}
function toggleDevTools() {
const toggleDevTools = useCallback(() => {
const webview = webviewRef.current
if (!webview?.openDevTools) {
@@ -220,7 +376,52 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
webview.openDevTools()
setDevtoolsOpen(true)
}
}, [])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
const tools: TitlebarTool[] = [
{
active: consoleOpen,
icon: (
<>
<PanelBottom />
{logs.length > 0 && <span className="sr-only">{logs.length} console messages</span>}
</>
),
id: 'preview-console',
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
onSelect: () => setConsoleOpen(open => !open)
},
{
active: devtoolsOpen,
icon: <Bug />,
id: 'preview-devtools',
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
onSelect: toggleDevTools
},
{
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
id: 'preview-reload',
label: 'Reload preview',
onSelect: reloadPreview
},
{
className: 'mr-(--shell-preview-toolbar-gap)',
icon: <X />,
id: 'preview-close',
label: 'Close preview',
onSelect: () => setPreviewTarget(null)
}
]
setTitlebarToolGroup('preview', tools)
return () => setTitlebarToolGroup('preview', [])
}, [consoleOpen, currentUrl, devtoolsOpen, loading, logs.length, reloadPreview, setTitlebarToolGroup, toggleDevTools])
useEffect(() => {
if (consoleOpen && consoleShouldStickRef.current) {
@@ -228,7 +429,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
}
}, [consoleOpen, logs])
}, [consoleHeight, consoleOpen, logs])
useEffect(() => {
if (consoleOpen) {
@@ -275,11 +476,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
}
])
if (webviewRef.current?.reloadIgnoringCache) {
webviewRef.current.reloadIgnoringCache()
} else {
webviewRef.current?.reload?.()
}
reloadPreview()
}
const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => {
@@ -334,7 +531,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
}
}
}, [target.kind, target.url])
}, [reloadPreview, target.kind, target.url])
useEffect(() => {
const host = hostRef.current
@@ -347,6 +544,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
webviewRef.current = null
setCurrentUrl(target.url)
setDevtoolsOpen(false)
setLoadError(null)
setLogs([])
setLoading(true)
@@ -381,6 +579,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
const detail = event as Event & { url?: string }
if (detail.url) {
setLoadError(null)
setCurrentUrl(detail.url)
}
}
@@ -391,13 +590,23 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
errorDescription?: string
validatedURL?: string
}
const errorCode = detail.errorCode
if (errorCode === -3) {
return
}
appendLog({
level: 3,
message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
detail.errorDescription || detail.validatedURL || 'unknown error'
}`
})
setLoadError({
code: errorCode,
description: detail.errorDescription || 'The preview page could not be reached.',
url: detail.validatedURL || currentUrl || target.url
})
setLoading(false)
}
@@ -425,157 +634,129 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
}, [target.url])
return (
<aside className="relative flex h-screen min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-sm">
<div className="flex items-center gap-1.5 border-b border-border/60 px-2 py-2">
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<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">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{target.label || 'Preview'}</div>
<div className="truncate font-mono text-[0.625rem] text-muted-foreground">{compactUrl(currentUrl)}</div>
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
</div>
<Button
aria-label={consoleOpen ? 'Hide preview console' : 'Show preview console'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={() => setConsoleOpen(open => !open)}
size="xs"
title={consoleOpen ? 'Hide Console' : 'Show Console'}
type="button"
variant="ghost"
>
<PanelBottom className="size-3.5" />
Console
{logs.length > 0 && (
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{logs.length}
</span>
)}
</Button>
<Button
aria-label={devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools'}
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
onClick={toggleDevTools}
size="xs"
title={devtoolsOpen ? 'Hide DevTools' : 'Open DevTools'}
type="button"
variant="ghost"
>
<Bug className="size-3.5" />
{devtoolsOpen ? 'Hide DevTools' : 'DevTools'}
</Button>
<Button
aria-label="Reload preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => webviewRef.current?.reload?.()}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
<Button
aria-label="Open preview externally"
className="size-7 shrink-0 rounded-lg"
onClick={() => void window.hermesDesktop?.openExternal(currentUrl)}
size="icon"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<Button
aria-label="Close preview"
className="size-7 shrink-0 rounded-lg"
onClick={() => setPreviewTarget(null)}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 bg-background" ref={hostRef} />
<div className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background" ref={previewContentRef}>
<div
className={cn('absolute inset-0 flex bg-background', loadError && 'pointer-events-none opacity-0')}
ref={hostRef}
/>
{loadError && (
<PreviewLoadError
consoleHeight={consoleOpen ? consoleHeight : 0}
error={loadError}
onRetry={reloadPreview}
/>
)}
{consoleOpen && (
<div className="min-h-44 border-t border-border/60 bg-background/95">
<div className="flex h-8 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
{consoleOpen && (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => setConsoleHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={async () => {
await copyConsoleText(
sendableLogs,
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
)
setCopiedAll(true)
setTimeout(() => setCopiedAll(false), 1500)
}}
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
type="button"
>
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
Copy
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={() => {
setLogs([])
setSelectedLogIds(new Set())
}}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
key={log.id}
log={log}
onCopy={() => copyConsoleText([log], 'Log entry copied')}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => toggleLogSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={async () => {
await copyConsoleText(
sendableLogs,
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
)
setCopiedAll(true)
setTimeout(() => setCopiedAll(false), 1500)
}}
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
type="button"
>
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
Copy
</button>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={() => {
setLogs([])
setSelectedLogIds(new Set())
}}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div className="h-40 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
key={log.id}
log={log}
onCopy={() => copyConsoleText([log], 'Log entry copied')}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => toggleLogSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)}
)}
</div>
</div>
</aside>
)