feat(desktop): add structured desktop chat app

Introduce the Electron desktop app with a split app/chat/settings structure and shared nanostore state so UI areas own their state instead of routing it through the root.
This commit is contained in:
Brooklyn Nicholson
2026-05-01 12:49:12 -05:00
parent e5dad4ac57
commit 7b61f86529
96 changed files with 29076 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { Layers3 } from 'lucide-react'
import type * as React from 'react'
import { titlebarHeaderClass } from '../shell/titlebar'
export function ArtifactsView(props: React.ComponentProps<'section'>) {
return (
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
</header>
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
<div className="max-w-md space-y-3">
<Layers3 className="mx-auto size-8 text-muted-foreground" />
<h3 className="text-lg font-semibold">Artifacts view is ready</h3>
<p className="text-sm text-muted-foreground">
Generated files and visual outputs now have a dedicated route and view module instead of being folded into
App.tsx.
</p>
</div>
</div>
</section>
)
}
+250
View File
@@ -0,0 +1,250 @@
import { AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown } from 'lucide-react'
import type * as React from 'react'
import { Suspense, useMemo } from 'react'
import { Thread } from '@/components/assistant-ui/thread'
import { ChatBar, ChatBarFallback, type ChatBarState } from '@/components/chat-bar'
import { NotificationStack } from '@/components/notifications'
import { Button } from '@/components/ui/button'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import type { ModelOptionsResponse } from '@/types/hermes'
import { $pinnedSessionIds } from '@/store/layout'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$contextSuggestions,
$currentModel,
$currentProvider,
$freshDraftReady,
$gatewayState,
$introPersonality,
$introSeed,
$messages,
$selectedStoredSessionId,
$sessions
} from '@/store/session'
import { routeSessionId } from '../routes'
import { titlebarHeaderClass } from '../shell/titlebar'
import { ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
onCancel: () => void
onAddContextRef: (refText: string, label?: string, detail?: string) => void
onAddUrl: (url: string) => void
onPasteClipboardImage: () => void
onPickFiles: () => void
onPickFolders: () => void
onPickImages: () => void
onRemoveAttachment: (id: string) => void
onSubmit: (text: string) => void
onChangeCwd: (cwd: string) => void
onBrowseCwd: () => void
onOpenModelPicker: () => void
onSelectPersonality: (name: string) => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onReload: (parentId: string | null) => Promise<void>
}
export function ChatView({
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
onAddContextRef,
onAddUrl,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSubmit,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onSelectPersonality,
onThreadMessagesChange,
onReload
}: ChatViewProps) {
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId())
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
const loadingSession = isRoutedSessionView && messages.length === 0
const threadLoading = loadingSession ? 'session' : busy && awaitingResponse ? 'response' : undefined
const showChatBar = !loadingSession
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of messages) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
items.push({ message: toRuntimeMessage(message), parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
onNew: async () => {
// Submission is handled explicitly by ChatBar.
// Keeping this no-op avoids duplicate prompt.submit calls.
},
onCancel: async () => onCancel(),
onReload
})
return (
<>
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
<header className={titlebarHeaderClass}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="end"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sideOffset={8}
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]"
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>
)}
</div>
</header>
<NotificationStack />
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
disabled={!gatewayOpen}
focusKey={activeSessionId}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
state={chatBarState}
/>
</Suspense>
)}
</AssistantRuntimeProvider>
</div>
</div>
<ChatRightRail
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={onOpenModelPicker}
onSelectPersonality={onSelectPersonality}
/>
</>
)
}
export { SESSION_INSPECTOR_WIDTH } from './right-rail'
@@ -0,0 +1,58 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { $inspectorOpen } from '@/store/layout'
import {
$availablePersonalities,
$busy,
$currentBranch,
$currentCwd,
$currentModel,
$currentPersonality,
$currentProvider,
$gatewayState
} from '@/store/session'
interface ChatRightRailProps
extends Pick<React.ComponentProps<typeof SessionInspector>, 'onBrowseCwd' | 'onChangeCwd'> {
onOpenModelPicker: () => void
onSelectPersonality: (name: string) => void
}
export function ChatRightRail({
onBrowseCwd,
onChangeCwd,
onOpenModelPicker,
onSelectPersonality
}: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const gatewayOpen = useStore($gatewayState) === 'open'
const busy = useStore($busy)
const cwd = useStore($currentCwd)
const branch = useStore($currentBranch)
const model = useStore($currentModel)
const provider = useStore($currentProvider)
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
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}
/>
)
}
export { SESSION_INSPECTOR_WIDTH }
+279
View File
@@ -0,0 +1,279 @@
import { useStore } from '@nanostores/react'
import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$isSidebarResizing,
$pinnedSessionIds,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
pinSession,
setSidebarPinsOpen,
setSidebarRecentsOpen,
unpinSession
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New session',
icon: Plus,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: Sparkles, route: SKILLS_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
]
const sidebarNavItemClass =
'flex h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:bg-accent hover:text-foreground hover:transition-none'
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onRefreshSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
export function ChatSidebar({
currentView,
onNavigate,
onRefreshSessions,
onResumeSession,
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const pinnedSessionIds = useStore($pinnedSessionIds)
const isSidebarResizing = useStore($isSidebarResizing)
const pinsOpen = useStore($sidebarPinsOpen)
const recentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sortedSessions = [...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
})
const sessionsById = new Map(sessions.map(session => [session.id, session]))
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
const visiblePinnedIdSet = new Set(visiblePinnedIds)
const pinnedSessions = visiblePinnedIds
.map(id => sessionsById.get(id))
.filter((session): session is SessionInfo => Boolean(session))
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
return (
<Sidebar
className={cn(
'relative h-screen min-w-0 overflow-hidden rounded-tr-[0.9375rem] rounded-br-[0.9375rem] border-r border-t-0 border-l-0 border-b-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
isSidebarResizing
? 'transition-none'
: 'transition-[opacity,transform,border-color,box-shadow,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
sidebarOpen
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100 shadow-(--shadow-sidebar)'
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0 shadow-none'
)}
collapsible="none"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<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 === 'artifacts' && currentView === 'artifacts')
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
sidebarNavItemClass,
active && 'bg-accent text-foreground',
!isInteractive && 'cursor-default hover:bg-transparent hover:text-muted-foreground'
)}
onClick={() => onNavigate(item)}
tooltip={item.label}
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>}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{sidebarOpen && (
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-1 pt-0">
<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="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground opacity-50">
<Pin size={14} />
<span>Shift+click to pin</span>
</div>
)}
{pinnedSessions.map(session => (
<SidebarSessionRow
isPinned
isSelected={session.id === selectedSessionId}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => unpinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)}
{sidebarOpen && (
<SidebarGroup className="min-h-0 flex-1 pl-4 pr-2 py-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="Sessions"
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 && sortedSessions.length === 0 && <SidebarEmptySessionState />}
{!showSessionSkeletons && sortedSessions.length > 0 && recentSessions.length === 0 && (
<SidebarAllPinnedState />
)}
{recentSessions.map(session => (
<SidebarSessionRow
isPinned={false}
isSelected={session.id === selectedSessionId}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => pinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
)
}
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
label: string
open: boolean
onToggle: () => void
action?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
return (
<div className="flex shrink-0 items-center justify-between px-2 pb-1 pt-1.5">
<SidebarGroupLabel asChild className="h-auto p-0 text-muted-foreground">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left text-xs font-bold leading-none"
onClick={onToggle}
type="button"
>
<span className="text-xs font-semibold uppercase leading-none">{label}</span>
<ChevronDown
className={cn('size-3 opacity-0 transition group-hover/section-label:opacity-100', !open && '-rotate-90')}
/>
</button>
</SidebarGroupLabel>
{action}
</div>
)
}
function SidebarSessionSkeletons() {
const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24']
return (
<div aria-hidden="true" className="grid gap-px">
{widths.map((width, index) => (
<div
className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg px-2"
key={`${width}-${index}`}
>
<Skeleton className={cn('h-3.5 rounded-full', width)} />
<Skeleton className="mx-auto size-4 rounded-md opacity-60" />
</div>
))}
</div>
)
}
function SidebarEmptySessionState() {
return (
<div className="grid min-h-35 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Recent chats will appear here.
</div>
)
}
function SidebarAllPinnedState() {
return (
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Pinned sessions stay above.
</div>
)
}
@@ -0,0 +1,65 @@
import { Archive, Pencil, Pin, Trash2 } from 'lucide-react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
interface SessionActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: ReactNode
title: string
pinned?: boolean
onPin?: () => void
onDelete?: () => void
}
export function SessionActionsMenu({
children,
title,
pinned = false,
onPin,
onDelete,
align = 'end',
sideOffset = 6
}: SessionActionsMenuProps) {
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem className={itemClass} disabled={!onPin} onSelect={onPin}>
<Pin />
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<Pencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem className={itemClass}>
<Archive />
<span>Add to project</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
disabled={!onDelete}
onSelect={onDelete}
variant="destructive"
>
<Trash2 />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,70 @@
import { MoreVertical } from 'lucide-react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
export const sidebarSessionRowClass =
'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-accent hover:transition-none'
export const sidebarSessionFadeClass =
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
isSelected: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
}
export function SidebarSessionRow({
session,
isPinned,
isSelected,
onDelete,
onPin,
onResume
}: SidebarSessionRowProps) {
const title = sessionTitle(session)
return (
<div className={cn(sidebarSessionRowClass, sidebarSessionFadeClass, isSelected && 'bg-accent')}>
<button
className="z-0 flex min-w-0 items-center bg-transparent py-1 pl-2 text-left"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
onPin()
return
}
onResume()
}}
type="button"
>
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} 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 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
size="icon"
title="Session actions"
variant="ghost"
>
<MoreVertical size={15} />
</Button>
</SessionActionsMenu>
</div>
</div>
)
}
@@ -0,0 +1,171 @@
import { useCallback } from 'react'
import { contextPath, attachmentId, pathLabel } from '@/lib/chat-runtime'
import {
addComposerAttachment,
removeComposerAttachment,
type ComposerAttachment
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageAttachResponse, ImageDetachResponse } from '../types'
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
let kind: ComposerAttachment['kind'] = 'file'
if (refText.startsWith('@folder:')) {
kind = 'folder'
}
if (refText.startsWith('@url:')) {
kind = 'url'
}
addComposerAttachment({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
detail,
refText
})
}, [])
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await window.hermesDesktop?.selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
})
if (!paths?.length) {
return
}
for (const path of paths) {
const rel = contextPath(path, currentCwd)
addComposerAttachment({
id: attachmentId(kind, rel),
kind,
label: pathLabel(path),
detail: rel,
refText: `@${kind}:${rel}`,
path
})
}
},
[currentCwd]
)
const pickImages = useCallback(async () => {
if (!activeSessionId) {
return
}
const paths = await window.hermesDesktop?.selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [
{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
}
]
})
if (!paths?.length) {
return
}
for (const path of paths) {
try {
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: activeSessionId,
path
})
const attachedPath = result.path || path
if (result.attached) {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(attachedPath)
addComposerAttachment({
id: attachmentId('image', attachedPath),
kind: 'image',
label: pathLabel(attachedPath),
detail: attachedPath,
previewUrl,
path: attachedPath
})
}
} catch (err) {
notifyError(err, 'Image attach failed')
}
}
}, [activeSessionId, currentCwd, requestGateway])
const pasteClipboardImage = useCallback(async () => {
if (!activeSessionId) {
return
}
try {
const result = await requestGateway<ImageAttachResponse>('clipboard.paste', {
session_id: activeSessionId
})
if (!result.attached) {
notify({
kind: 'warning',
title: 'Clipboard',
message: result.message || 'No image found in clipboard'
})
return
}
const attachedPath = result.path || 'clipboard'
const previewUrl = result.path && (await window.hermesDesktop?.readFileDataUrl(result.path))
addComposerAttachment({
id: attachmentId('image', attachedPath),
kind: 'image',
label: pathLabel(attachedPath),
detail: attachedPath,
previewUrl: previewUrl || undefined,
path: result.path
})
} catch (err) {
notifyError(err, 'Clipboard paste failed')
}
}, [activeSessionId, requestGateway])
const removeAttachment = useCallback(
async (id: string) => {
const removed = removeComposerAttachment(id)
if (removed?.kind === 'image' && removed.path && activeSessionId) {
await requestGateway<ImageDetachResponse>('image.detach', {
session_id: activeSessionId,
path: removed.path
}).catch(() => undefined)
}
},
[activeSessionId, requestGateway]
)
return {
addContextRefAttachment,
pasteClipboardImage,
pickContextPaths,
pickImages,
removeAttachment
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import {
$activeSessionId,
$currentModel,
$currentProvider,
$gatewayState,
$modelPickerOpen,
setModelPickerOpen
} from '@/store/session'
interface ModelPickerOverlayProps {
gateway?: HermesGateway
onSelect: React.ComponentProps<typeof ModelPickerDialog>['onSelect']
}
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
const activeSessionId = useStore($activeSessionId)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)
if (!gatewayOpen) {
return null
}
return (
<ModelPickerDialog
currentModel={currentModel}
currentProvider={currentProvider}
gw={gateway}
onOpenChange={setModelPickerOpen}
onSelect={onSelect}
open={open}
sessionId={activeSessionId}
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
export const SESSION_ROUTE_PREFIX = '#/sessions/'
export const NEW_CHAT_ROUTE = '#/new'
export const SETTINGS_ROUTE = '#/settings'
export const SKILLS_ROUTE = '#/skills'
export const ARTIFACTS_ROUTE = '#/artifacts'
export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts'
export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts'
export interface AppRoute {
id: AppRouteId
hash: string
view: AppView
}
export const APP_ROUTES = [
{ id: 'new', hash: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', hash: SETTINGS_ROUTE, view: 'settings' },
{ id: 'skills', hash: SKILLS_ROUTE, view: 'skills' },
{ id: 'artifacts', hash: ARTIFACTS_ROUTE, view: 'artifacts' }
] as const satisfies readonly AppRoute[]
const APP_VIEW_BY_HASH = new Map<string, AppView>(APP_ROUTES.map(route => [route.hash, route.view]))
export function currentRouteHash(): string {
return window.location.hash || NEW_CHAT_ROUTE
}
export function routeSessionId(hash = currentRouteHash()): string | null {
if (!hash.startsWith(SESSION_ROUTE_PREFIX)) {
return null
}
const id = hash.slice(SESSION_ROUTE_PREFIX.length)
return id ? decodeURIComponent(id) : null
}
export function writeRoute(hash: string, replace = false) {
if (window.location.hash === hash) {
return
}
const nextUrl = `${window.location.pathname}${window.location.search}${hash}`
if (replace) {
window.history.replaceState(null, '', nextUrl)
} else {
window.history.pushState(null, '', nextUrl)
}
}
export function writeSessionRoute(sessionId: string, replace = false) {
writeRoute(`${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`, replace)
}
export function appViewForHash(hash = currentRouteHash()): AppView {
return APP_VIEW_BY_HASH.get(hash) ?? 'chat'
}
export const currentAppView = appViewForHash
@@ -0,0 +1,109 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { ChatMessage } from '@/lib/chat-messages'
import { $busy } from '@/store/session'
import type { ClientSessionState } from '../types'
interface SessionStateCacheOptions {
activeSessionId: string | null
busyRef: MutableRefObject<boolean>
selectedStoredSessionId: string | null
setAwaitingResponse: (awaiting: boolean) => void
setBusy: (busy: boolean) => void
setMessages: (messages: ChatMessage[]) => void
}
export function useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
}: SessionStateCacheOptions) {
const busy = useStore($busy)
const activeSessionIdRef = useRef<string | null>(null)
const selectedStoredSessionIdRef = useRef<string | null>(null)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
useEffect(() => {
busyRef.current = busy
}, [busy, busyRef])
useEffect(() => {
selectedStoredSessionIdRef.current = selectedStoredSessionId
}, [selectedStoredSessionId])
const ensureSessionState = useCallback((sessionId: string, storedSessionId?: string | null) => {
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
if (existing) {
if (storedSessionId !== undefined) {
existing.storedSessionId = storedSessionId
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
}
}
return existing
}
const created = createClientSessionState(storedSessionId ?? null)
sessionStateByRuntimeIdRef.current.set(sessionId, created)
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
}
return created
}, [])
const syncSessionStateToView = useCallback(
(sessionId: string, state: ClientSessionState) => {
if (sessionId !== activeSessionIdRef.current) {
return
}
setMessages(state.messages)
setBusy(state.busy)
busyRef.current = state.busy
setAwaitingResponse(state.awaitingResponse)
},
[busyRef, setAwaitingResponse, setBusy, setMessages]
)
const updateSessionState = useCallback(
(
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => {
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
syncSessionStateToView(sessionId, next)
return next
},
[ensureSessionState, syncSessionStateToView]
)
return {
activeSessionIdRef,
ensureSessionState,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
}
}
+7
View File
@@ -0,0 +1,7 @@
import type * as React from 'react'
import { SettingsPage } from '@/components/settings-page'
export function SettingsView(props: React.ComponentProps<typeof SettingsPage>) {
return <SettingsPage {...props} />
}
+146
View File
@@ -0,0 +1,146 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback } from 'react'
import { SidebarProvider } from '@/components/ui/sidebar'
import { cn } from '@/lib/utils'
import {
$inspectorOpen,
$isSidebarResizing,
$sidebarOpen,
$sidebarWidth,
setSidebarOpen,
setSidebarResizing,
setSidebarWidth
} from '@/store/layout'
import { $connection } from '@/store/session'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
inspectorWidth: string
rightRailOpen: boolean
settingsOpen: boolean
sidebar: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
}
export function AppShell({
children,
inspectorWidth,
rightRailOpen,
settingsOpen,
sidebar,
onOpenSettings,
overlays
}: AppShellProps) {
const sidebarWidth = useStore($sidebarWidth)
const connection = useStore($connection)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
const isSidebarResizing = useStore($isSidebarResizing)
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8)
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
const showRightRail = rightRailOpen && inspectorOpen
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
setSidebarResizing(true)
const startX = event.clientX
const startWidth = sidebarWidth
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const handleMove = (moveEvent: PointerEvent) => {
setSidebarWidth(startWidth + moveEvent.clientX - startX)
}
const handleUp = () => {
setSidebarResizing(false)
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', handleUp)
}
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', handleUp, { once: true })
},
[sidebarWidth]
)
return (
<SidebarProvider
className="h-screen min-h-0 bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={
{
'--sidebar-width': `${displayedSidebarWidth}px`,
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`
} as CSSProperties
}
>
<TitlebarControls
onOpenSettings={onOpenSettings}
settingsOpen={settingsOpen}
showInspectorToggle={rightRailOpen}
/>
<main
className={cn(
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75',
isSidebarResizing
? 'transition-none'
: 'transition-[grid-template-columns,gap] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
sidebarOpen || showRightRail ? 'gap-2.5' : 'gap-0'
)}
style={
{
'--inspector-width': inspectorWidth,
'--inspector-col': showRightRail ? inspectorWidth : '0px'
} as CSSProperties
}
>
<div
aria-hidden="true"
className="absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="absolute right-20 top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] [-webkit-app-region:drag]"
/>
{sidebar}
{sidebarOpen && (
<div
aria-label="Resize sidebar"
aria-orientation="vertical"
className="group absolute bottom-0 top-0 left-[calc(var(--sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
onPointerDown={startSidebarResize}
role="separator"
tabIndex={0}
>
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
</div>
)}
{children}
</main>
{overlays}
</SidebarProvider>
)
}
@@ -0,0 +1,77 @@
import { useStore } from '@nanostores/react'
import { NotebookTabs, Search, Settings, SlidersHorizontal } from 'lucide-react'
import type * as React from 'react'
import { cn } from '@/lib/utils'
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
settingsOpen: boolean
showInspectorToggle: boolean
onOpenSettings: () => void
}
export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSettings }: TitlebarControlsProps) {
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
return (
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-50 grid translate-y-[2px] grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
>
<button
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={toggleSidebarOpen}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<NotebookTabs />
</button>
<button
aria-label="Search"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<Search size={TITLEBAR_ICON_SIZE} />
</button>
</div>
{!settingsOpen && (
<div
aria-label="App controls"
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
>
{showInspectorToggle && (
<button
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={toggleInspectorOpen}
onPointerDown={event => event.stopPropagation()}
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
type="button"
>
<SlidersHorizontal />
</button>
)}
<button
aria-label="Open settings"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={onOpenSettings}
onPointerDown={event => event.stopPropagation()}
title="Settings"
type="button"
>
<Settings />
</button>
</div>
)}
</>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { HermesConnection } from '@/global'
export const TITLEBAR_HEIGHT = 34
export const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
export const TITLEBAR_ICON_SIZE = 12
export const TITLEBAR_CONTROL_OFFSET_X = 60
export const TITLEBAR_CONTROL_HEIGHT = 22
export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2
const WINDOW_BUTTON_FALLBACK = {
x: 24,
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
}
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
export const titlebarHeaderClass =
"relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 shadow-header backdrop-blur-sm after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
return {
left: position.x + TITLEBAR_CONTROL_OFFSET_X,
top: Math.max(0, TITLEBAR_CONTROLS_TOP)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Sparkles } from 'lucide-react'
import type * as React from 'react'
import { titlebarHeaderClass } from '../shell/titlebar'
export function SkillsView(props: React.ComponentProps<'section'>) {
return (
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
</header>
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
<div className="max-w-md space-y-3">
<Sparkles className="mx-auto size-8 text-muted-foreground" />
<h3 className="text-lg font-semibold">Skills view is ready</h3>
<p className="text-sm text-muted-foreground">
Skill management already lives in Settings. This route gives it a dedicated view boundary so the real screen
can move here without touching the app shell again.
</p>
</div>
</div>
</section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import type { LucideIcon } from 'lucide-react'
import type { ChatMessage } from '@/lib/chat-messages'
export interface ContextSuggestion {
text: string
display: string
meta?: string
}
export interface ImageAttachResponse {
attached?: boolean
path?: string
text?: string
message?: string
}
export interface ImageDetachResponse {
detached?: boolean
count?: number
}
export interface SlashExecResponse {
output?: string
warning?: string
}
export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin'
output?: string
}
export interface AliasCommandDispatchResponse {
type: 'alias'
target: string
}
export interface SkillCommandDispatchResponse {
type: 'skill'
name: string
message?: string
}
export interface SendCommandDispatchResponse {
type: 'send'
message: string
}
export type CommandDispatchResponse =
| ExecCommandDispatchResponse
| AliasCommandDispatchResponse
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
export type SidebarNavId = 'new-session' | 'skills' | 'artifacts'
export interface SidebarNavItem {
id: SidebarNavId
label: string
icon: LucideIcon
route?: string
action?: 'new-session'
}
export interface ClientSessionState {
storedSessionId: string | null
messages: ChatMessage[]
busy: boolean
awaitingResponse: boolean
streamId: string | null
sawAssistantPayload: boolean
pendingBranchGroup: string | null
interrupted: boolean
}