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