{tabs.map(tab => {
@@ -90,35 +90,36 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return (
- {active && }
+ {active && }
+
)
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 4b3e22fa2e1..06253583129 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -1,8 +1,28 @@
+import {
+ closestCenter,
+ DndContext,
+ type DragEndEvent,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { DisclosureCaret } from '@/components/ui/disclosure-caret'
+import { KbdGroup } from '@/components/ui/kbd'
import {
Sidebar,
SidebarContent,
@@ -14,19 +34,28 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
-import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
+ $sidebarAgentsGrouped,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
pinSession,
+ reorderPinnedSession,
+ setSidebarAgentsGrouped,
setSidebarPinsOpen,
setSidebarRecentsOpen,
+ SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
-import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
+import {
+ $selectedStoredSessionId,
+ $sessions,
+ $sessionsLoading,
+ $sessionsTotal,
+ $workingSessionIds
+} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
@@ -35,21 +64,79 @@ import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
const SIDEBAR_NAV: SidebarNavItem[] = [
- {
- id: 'new-session',
- label: 'New chat',
- icon: Plus,
- action: 'new-session'
- },
- { id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
- { id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
- { id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
+ { id: 'new-session', label: 'New agent', icon: props =>
, action: 'new-session' },
+ { id: 'skills', label: 'Skills', icon: props =>
, route: SKILLS_ROUTE },
+ { id: 'messaging', label: 'Messaging', icon: props =>
, route: MESSAGING_ROUTE },
+ { id: 'artifacts', label: 'Artifacts', icon: props =>
, route: ARTIFACTS_ROUTE }
]
+const WORKSPACE_PAGE = 5
+const WS_ID_PREFIX = 'workspace:'
+
+const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
+const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
+const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
+const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
+
+function orderByIds
(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
+ if (!orderIds.length) {
+ return items
+ }
+
+ const byId = new Map(items.map(item => [getId(item), item]))
+ const seen = new Set()
+ const out: T[] = []
+
+ for (const id of orderIds) {
+ const item = byId.get(id)
+
+ if (item) {
+ out.push(item)
+ seen.add(id)
+ }
+ }
+
+ for (const item of items) {
+ if (!seen.has(getId(item))) {
+ out.push(item)
+ }
+ }
+
+ return out
+}
+
+function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
+ const groups = new Map()
+
+ for (const session of sessions) {
+ const path = session.cwd?.trim() || ''
+ const id = path || '__no_workspace__'
+ const label = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() || path || 'No workspace'
+
+ const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
+ group.sessions.push(session)
+ groups.set(id, group)
+ }
+
+ return [...groups.values()]
+}
+
+function useSortableBindings(id: string) {
+ const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
+
+ return {
+ dragging: isDragging,
+ dragHandleProps: { ...attributes, ...listeners },
+ ref: setNodeRef,
+ reorderable: true as const,
+ style: { transform: CSS.Transform.toString(transform), transition }
+ }
+}
+
interface ChatSidebarProps extends React.ComponentProps {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
- onRefreshSessions: () => void
+ onLoadMoreSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
@@ -57,63 +144,136 @@ interface ChatSidebarProps extends React.ComponentProps {
export function ChatSidebar({
currentView,
onNavigate,
- onRefreshSessions,
+ onLoadMoreSessions,
onResumeSession,
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
+ const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
- const recentsOpen = useStore($sidebarRecentsOpen)
+ const agentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
- const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
+ const sessionsTotal = useStore($sessionsTotal)
const workingSessionIds = useStore($workingSessionIds)
+ const [agentOrderIds, setAgentOrderIds] = useState([])
+ const [workspaceOrderIds, setWorkspaceOrderIds] = useState([])
+
+ const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
+
+ const dndSensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ )
const sortedSessions = useMemo(
- () =>
- [...sessions].sort((a, b) => {
- const aTime = a.last_active || a.started_at || 0
- const bTime = b.last_active || b.started_at || 0
-
- return bTime - aTime
- }),
+ () => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[sessions]
)
- const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
+ const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
- const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
- const visiblePinnedIdSet = new Set(visiblePinnedIds)
+ const visiblePinnedIds = useMemo(
+ () => pinnedSessionIds.filter(id => sessionsById.has(id)),
+ [pinnedSessionIds, sessionsById]
+ )
+ const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
- const pinnedSessions = visiblePinnedIds
- .map(id => sessionsById.get(id))
- .filter((session): session is SessionInfo => Boolean(session))
+ const pinnedSessions = useMemo(
+ () => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
+ [visiblePinnedIds, sessionsById]
+ )
- const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
+ const unpinnedAgentSessions = useMemo(
+ () => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
+ [sortedSessions, visiblePinnedIdSet]
+ )
+
+ const agentSessions = useMemo(
+ () => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
+ [unpinnedAgentSessions, agentOrderIds]
+ )
+
+ const agentGroups = useMemo(
+ () => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
+ [agentSessions, workspaceOrderIds]
+ )
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
+ const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
+ const hasMoreSessions = knownSessionTotal > sortedSessions.length
+ const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
+
+ const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
+ if (!over || active.id === over.id) {
+ return
+ }
+
+ const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
+
+ if (newIndex < 0) {
+ return
+ }
+
+ reorderPinnedSession(String(active.id), newIndex)
+ }
+
+ const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
+ if (!over || active.id === over.id) {
+ return
+ }
+
+ const activeId = String(active.id)
+ const overId = String(over.id)
+ const activeWs = parseWsId(activeId)
+ const overWs = parseWsId(overId)
+
+ if (activeWs && overWs) {
+ const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
+ const newIdx = agentGroups.findIndex(g => g.id === overWs)
+
+ if (oldIdx < 0 || newIdx < 0) {
+ return
+ }
+
+ setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
+
+ return
+ }
+
+ if (activeWs || overWs) {
+ return
+ }
+
+ const oldIdx = agentSessions.findIndex(s => s.id === activeId)
+ const newIdx = agentSessions.findIndex(s => s.id === overId)
+
+ if (oldIdx < 0 || newIdx < 0) {
+ return
+ }
+
+ setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
+ }
return (
-
-
- Workspace
+
+
{SIDEBAR_NAV.map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
-
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'messaging' && currentView === 'messaging') ||
@@ -124,9 +284,9 @@ export function ChatSidebar({
- {sidebarOpen && {item.label}}
+ {sidebarOpen && (
+ <>
+ {item.label}
+ {item.id === 'new-session' && (
+
+ )}
+ >
+ )}
)
@@ -145,102 +312,103 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && (
-
- setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
- {pinsOpen && (
-
- {pinnedSessions.length === 0 && (
-
- Shift click to pin a chat
-
- )}
- {pinnedSessions.map(session => (
- onDeleteSession(session.id)}
- onPin={() => unpinSession(session.id)}
- onResume={() => onResumeSession(session.id)}
- session={session}
- />
- ))}
-
- )}
-
+ }
+ label="Pinned"
+ onDeleteSession={onDeleteSession}
+ onReorder={handlePinnedDragEnd}
+ onResumeSession={onResumeSession}
+ onToggle={() => setSidebarPinsOpen(!pinsOpen)}
+ onTogglePin={unpinSession}
+ open={pinsOpen}
+ pinned
+ rootClassName="shrink-0 p-0 pb-1"
+ sessions={pinnedSessions}
+ sortable={pinnedSessions.length > 1}
+ workingSessionIdSet={workingSessionIdSet}
+ />
)}
{sidebarOpen && showSessionSections && (
-
- {
- event.stopPropagation()
- setSidebarRecentsOpen(true)
- onRefreshSessions()
- }}
- size="icon-xs"
- variant="ghost"
- >
-
-
- }
- label="Recent chats"
- onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
- open={recentsOpen}
- />
-
- {recentsOpen && (
-
- {showSessionSkeletons && }
- {!showSessionSkeletons && recentSessions.length === 0 && }
- {recentSessions.map(session => (
- onDeleteSession(session.id)}
- onPin={() => pinSession(session.id)}
- onResume={() => onResumeSession(session.id)}
- session={session}
- />
- ))}
-
- )}
-
+ : }
+ footer={
+ !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
+
+ ) : null
+ }
+ forceEmptyState={showSessionSkeletons}
+ groups={agentsGrouped ? agentGroups : undefined}
+ headerAction={
+
+ }
+ label="Agents"
+ labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
+ onDeleteSession={onDeleteSession}
+ onReorder={handleAgentDragEnd}
+ onResumeSession={onResumeSession}
+ onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
+ onTogglePin={pinSession}
+ open={agentsOpen}
+ pinned={false}
+ rootClassName="min-h-0 flex-1 p-0"
+ sessions={agentSessions}
+ sortable={agentSessions.length > 1}
+ workingSessionIdSet={workingSessionIdSet}
+ />
)}
)
}
-interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
+interface SidebarSectionHeaderProps {
label: string
open: boolean
onToggle: () => void
action?: React.ReactNode
+ meta?: React.ReactNode
}
-function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
+function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
-
+
{action}
@@ -262,7 +430,276 @@ function SidebarSessionSkeletons() {
}
const SidebarAllPinnedState = () => (
-
+
Everything here is pinned. Unpin a chat to show it in recents.
)
+
+function SidebarPinnedEmptyState() {
+ return (
+
+
+
+
+ Shift click to pin a chat
+
+ )
+}
+
+interface SidebarSessionGroup {
+ id: string
+ label: string
+ path: null | string
+ sessions: SessionInfo[]
+}
+
+interface SidebarSessionsSectionProps {
+ label: string
+ open: boolean
+ onToggle: () => void
+ sessions: SessionInfo[]
+ activeSessionId: null | string
+ workingSessionIdSet: Set
+ onResumeSession: (sessionId: string) => void
+ onDeleteSession: (sessionId: string) => void
+ onTogglePin: (sessionId: string) => void
+ pinned: boolean
+ rootClassName?: string
+ contentClassName?: string
+ emptyState: React.ReactNode
+ forceEmptyState?: boolean
+ headerAction?: React.ReactNode
+ footer?: React.ReactNode
+ groups?: SidebarSessionGroup[]
+ labelMeta?: React.ReactNode
+ sortable?: boolean
+ onReorder?: (event: DragEndEvent) => void
+ dndSensors?: ReturnType
+}
+
+function SidebarSessionsSection({
+ label,
+ open,
+ onToggle,
+ sessions,
+ activeSessionId,
+ workingSessionIdSet,
+ onResumeSession,
+ onDeleteSession,
+ onTogglePin,
+ pinned,
+ rootClassName,
+ contentClassName,
+ emptyState,
+ forceEmptyState = false,
+ headerAction,
+ footer,
+ groups,
+ labelMeta,
+ sortable = false,
+ onReorder,
+ dndSensors
+}: SidebarSessionsSectionProps) {
+ const showEmptyState = forceEmptyState || sessions.length === 0
+ const dndActive = sortable && !!onReorder
+
+ const renderRow = (session: SessionInfo) => {
+ const rowProps = {
+ isPinned: pinned,
+ isSelected: session.id === activeSessionId,
+ isWorking: workingSessionIdSet.has(session.id),
+ onDelete: () => onDeleteSession(session.id),
+ onPin: () => onTogglePin(session.id),
+ onResume: () => onResumeSession(session.id),
+ session
+ }
+
+ return sortable ? (
+
+ ) : (
+
+ )
+ }
+
+ const renderRows = (items: SessionInfo[]) => items.map(renderRow)
+
+ const renderSessionList = (items: SessionInfo[]) =>
+ dndActive ? (
+ s.id)} strategy={verticalListSortingStrategy}>
+ {renderRows(items)}
+
+ ) : (
+ renderRows(items)
+ )
+
+ let inner: React.ReactNode
+
+ if (showEmptyState) {
+ inner = emptyState
+ } else if (groups?.length) {
+ const groupNodes = groups.map(group =>
+ dndActive ? (
+
+ ) : (
+
+ )
+ )
+
+ inner = dndActive ? (
+ wsId(g.id))} strategy={verticalListSortingStrategy}>
+ {groupNodes}
+
+ ) : (
+ groupNodes
+ )
+ } else {
+ inner = renderSessionList(sessions)
+ }
+
+ const body =
+ dndActive && !showEmptyState ? (
+
+ {inner}
+
+ ) : (
+ inner
+ )
+
+ return (
+
+
+ {open && (
+
+ {body}
+ {footer}
+
+ )}
+
+ )
+}
+
+interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
+ group: SidebarSessionGroup
+ renderRows: (sessions: SessionInfo[]) => React.ReactNode
+ reorderable?: boolean
+ dragging?: boolean
+ dragHandleProps?: React.HTMLAttributes
+}
+
+function SidebarWorkspaceGroup({
+ group,
+ renderRows,
+ reorderable = false,
+ dragging = false,
+ dragHandleProps,
+ className,
+ style,
+ ref,
+ ...rest
+}: SidebarWorkspaceGroupProps) {
+ const [open, setOpen] = useState(true)
+ const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
+ const visibleSessions = group.sessions.slice(0, visibleCount)
+ const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
+ const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
+
+ return (
+
+
+ {open && (
+ <>
+ {renderRows(visibleSessions)}
+ {hiddenCount > 0 && (
+
+ )}
+ >
+ )}
+
+ )
+}
+
+interface SortableWorkspaceProps {
+ group: SidebarSessionGroup
+ renderRows: (sessions: SessionInfo[]) => React.ReactNode
+}
+
+function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
+ return
+}
+
+function SidebarCount({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+interface SortableSessionRowProps {
+ session: SessionInfo
+ isPinned: boolean
+ isSelected: boolean
+ isWorking: boolean
+ onDelete: () => void
+ onPin: () => void
+ onResume: () => void
+}
+
+function SortableSidebarSessionRow(props: SortableSessionRowProps) {
+ return
+}
+
+interface SidebarLoadMoreRowProps {
+ loading: boolean
+ onClick: () => void
+ step: number
+}
+
+function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
+ const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
+
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index 5c58e1b10f6..3c1a014d3bd 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -1,9 +1,9 @@
-import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
@@ -17,14 +17,13 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
+import { Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
-import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
@@ -56,54 +55,46 @@ export function SessionActionsMenu({
<>
{children}
-
+
{
triggerHaptic('selection')
onPin?.()
}}
>
- {pinned ? : }
+
{pinned ? 'Unpin' : 'Pin'}
{
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
-
+
Export
{
triggerHaptic('selection')
setRenameOpen(true)
}}
>
-
+
Rename
-
{
triggerHaptic('warning')
@@ -111,7 +102,7 @@ export function SessionActionsMenu({
}}
variant="destructive"
>
-
+
Delete
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index dfd88d940ef..2ca6df5b2db 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -1,14 +1,19 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
-import { MoreVertical } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
+const SECOND = 1000
+const MINUTE = 60 * SECOND
+const HOUR = 60 * MINUTE
+const DAY = 24 * HOUR
+
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
@@ -17,6 +22,28 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
onDelete: () => void
onPin: () => void
onResume: () => void
+ reorderable?: boolean
+ dragging?: boolean
+ dragHandleProps?: React.HTMLAttributes
+}
+
+function formatAge(seconds: number): string {
+ const at = seconds * 1000
+ const delta = Math.max(0, Date.now() - at)
+
+ if (delta < MINUTE) {
+ return 'now'
+ }
+
+ if (delta < HOUR) {
+ return `${Math.floor(delta / MINUTE)}m`
+ }
+
+ if (delta < DAY) {
+ return `${Math.floor(delta / HOUR)}h`
+ }
+
+ return `${Math.floor(delta / DAY)}d`
}
export function SidebarSessionRow({
@@ -26,23 +53,35 @@ export function SidebarSessionRow({
isWorking,
onDelete,
onPin,
- onResume
+ onResume,
+ reorderable = false,
+ dragging = false,
+ dragHandleProps,
+ className,
+ style,
+ ref,
+ ...rest
}: SidebarSessionRowProps) {
const title = sessionTitle(session)
+ const age = formatAge(session.last_active || session.started_at)
+ const handleLabel = `Reorder ${title}`
return (
- {isWorking &&
}
-
+
+ {!isWorking && (
+
+ {age}
+
+ )}
)
}
+
+function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
+ return (
+
+ )
+}
diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx
index 30d09973aca..800946a1815 100644
--- a/apps/desktop/src/app/command-center/index.tsx
+++ b/apps/desktop/src/app/command-center/index.tsx
@@ -113,7 +113,7 @@ interface SectionSearchEntry {
}
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
- { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
+ { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{
diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx
index d3e156194b0..f79ee39b602 100644
--- a/apps/desktop/src/app/cron/index.tsx
+++ b/apps/desktop/src/app/cron/index.tsx
@@ -24,13 +24,13 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
-import { AlertTriangle, Clock, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2, X, Zap } from '@/lib/icons'
+import { Codicon } from '@/components/ui/codicon'
+import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
+import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
-import { titlebarHeaderBaseClass } from '../shell/titlebar'
-import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const DEFAULT_DELIVER = 'local'
@@ -295,12 +295,10 @@ function matchesQuery(job: CronJob, q: string): boolean {
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
- setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function CronView({
setStatusbarItemGroup: _setStatusbarItemGroup,
- setTitlebarToolGroup,
...props
}: CronViewProps) {
const [jobs, setJobs] = useState
(null)
@@ -329,24 +327,6 @@ export function CronView({
void refresh()
}, [refresh])
- useEffect(() => {
- if (!setTitlebarToolGroup) {
- return
- }
-
- setTitlebarToolGroup('cron', [
- {
- disabled: refreshing,
- icon: ,
- id: 'refresh-cron',
- label: refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs',
- onSelect: () => void refresh()
- }
- ])
-
- return () => setTitlebarToolGroup('cron', [])
- }, [refresh, refreshing, setTitlebarToolGroup])
-
const visibleJobs = useMemo(() => {
if (!jobs) {
return []
@@ -437,78 +417,66 @@ export function CronView({
}
return (
-
-
- Cron
-
- {totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
-
-
-
-
-
-
-
-
-
-
-
- setQuery(event.target.value)}
- placeholder="Search cron jobs..."
- value={query}
- />
- {query && (
-
- )}
-
-
+
+
+
+ }
+ onSearchChange={setQuery}
+ searchPlaceholder="Search cron jobs..."
+ searchTrailingAction={
+
+ }
+ searchValue={query}
+ >
+ {!jobs ? (
+
+ ) : visibleJobs.length === 0 ? (
+
setEditor({ mode: 'create' }) : undefined}
+ title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
+ />
+ ) : (
+
+
+ {visibleJobs.map(job => (
+ setPendingDelete(job)}
+ onEdit={() => setEditor({ mode: 'edit', job })}
+ onPauseResume={() => void handlePauseResume(job)}
+ onTrigger={() => void handleTrigger(job)}
+ />
+ ))}
-
- {!jobs ? (
-
- ) : visibleJobs.length === 0 ? (
- setEditor({ mode: 'create' }) : undefined}
- title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
- />
- ) : (
-
-
- {visibleJobs.map(job => (
- setPendingDelete(job)}
- onEdit={() => setEditor({ mode: 'edit', job })}
- onPauseResume={() => void handlePauseResume(job)}
- onTrigger={() => void handleTrigger(job)}
- />
- ))}
-
-
- )}
+ )}
+
+ {totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}
setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
@@ -536,7 +504,7 @@ export function CronView({
-
+
)
}
@@ -656,7 +624,7 @@ function EmptyState({
{description}
{actionLabel && onAction && (
)}
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 0fad6f588ca..1838b5bd6e8 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -12,6 +12,8 @@ import { getSessionMessages, listSessions } from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import {
$pinnedSessionIds,
+ $sessionsLimit,
+ bumpSessionsLimit,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
@@ -33,7 +35,8 @@ import {
setCurrentProvider,
setMessages,
setSessions,
- setSessionsLoading
+ setSessionsLoading,
+ setSessionsTotal
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
@@ -46,10 +49,10 @@ import {
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
-import { FileBrowserPane } from './file-browser'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
+import { RightSidebarPane } from './right-sidebar'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
@@ -182,10 +185,12 @@ export function DesktopController() {
setSessionsLoading(true)
try {
- const result = await listSessions(50)
+ const limit = $sessionsLimit.get()
+ const result = await listSessions(limit)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
+ setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
@@ -194,6 +199,11 @@ export function DesktopController() {
}
}, [])
+ const loadMoreSessions = useCallback(() => {
+ bumpSessionsLimit()
+ void refreshSessions()
+ }, [refreshSessions])
+
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@@ -210,10 +220,27 @@ export function DesktopController() {
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
- const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
+ const updateActiveSessionRuntimeInfo = useCallback(
+ (info: { branch?: string; cwd?: string }) => {
+ const sessionId = activeSessionIdRef.current
+
+ if (!sessionId) {
+ return
+ }
+
+ updateSessionState(sessionId, state => ({
+ ...state,
+ branch: info.branch ?? state.branch,
+ cwd: info.cwd ?? state.cwd
+ }))
+ },
+ [activeSessionIdRef, updateSessionState]
+ )
+
+ const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
- currentCwd,
+ onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
requestGateway
})
@@ -315,6 +342,31 @@ export function DesktopController() {
updateSessionState
})
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement | null
+
+ const editing =
+ target?.isContentEditable ||
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement
+
+ if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
+ return
+ }
+
+ if (event.shiftKey && event.code === 'KeyN') {
+ event.preventDefault()
+ startFreshSessionDraft()
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [startFreshSessionDraft])
+
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -388,7 +440,6 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
- browseSessionCwd,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@@ -405,8 +456,8 @@ export function DesktopController() {
void removeSession(sessionId)}
+ onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
- onRefreshSessions={() => void refreshSessions()}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
@@ -497,8 +548,10 @@ export function DesktopController() {
return (
openCommandCenterSection('sessions')}
onOpenSettings={openSettings}
overlays={overlays}
statusbarItems={statusbarItems}
@@ -521,7 +574,7 @@ export function DesktopController() {
-
+
}
path="skills"
@@ -529,10 +582,7 @@ export function DesktopController() {
-
+
}
path="messaging"
@@ -540,10 +590,7 @@ export function DesktopController() {
-
+
}
path="artifacts"
@@ -551,7 +598,7 @@ export function DesktopController() {
-
+
}
path="cron"
@@ -590,6 +637,7 @@ export function DesktopController() {
-
+
)
diff --git a/apps/desktop/src/app/file-browser/index.tsx b/apps/desktop/src/app/file-browser/index.tsx
deleted file mode 100644
index 42e27c8efb6..00000000000
--- a/apps/desktop/src/app/file-browser/index.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useStore } from '@nanostores/react'
-
-import { Button } from '@/components/ui/button'
-import { FadeText } from '@/components/ui/fade-text'
-import { FolderOpen, RefreshCw } from '@/lib/icons'
-import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
-import { cn } from '@/lib/utils'
-import { notifyError } from '@/store/notifications'
-import { setCurrentSessionPreviewTarget } from '@/store/preview'
-import { $currentCwd } from '@/store/session'
-
-import { SidebarPanelLabel } from '../shell/sidebar-label'
-
-import { ProjectTree } from './tree'
-import { useProjectTree } from './use-project-tree'
-
-interface FileBrowserPaneProps {
- /** Activates a file row — drops the path into the composer as `@file:` ref. */
- onActivateFile: (path: string) => void
- onChangeCwd: (path: string) => Promise | void
-}
-
-export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
- const currentCwd = useStore($currentCwd).trim()
- const hasCwd = currentCwd.length > 0
-
- const cwdName = hasCwd
- ? (currentCwd
- .split(/[\\/]+/)
- .filter(Boolean)
- .pop() ?? currentCwd)
- : 'No folder selected'
-
- const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
-
- const chooseFolder = async () => {
- const selected = await window.hermesDesktop?.selectPaths({
- title: 'Change working directory',
- defaultPath: hasCwd ? currentCwd : undefined,
- directories: true,
- multiple: false
- })
-
- if (selected?.[0]) {
- await onChangeCwd(selected[0])
- }
- }
-
- const previewFile = async (path: string) => {
- try {
- const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
-
- if (!preview) {
- throw new Error(`Could not preview ${path}`)
- }
-
- setCurrentSessionPreviewTarget(preview, 'file-browser', path)
- } catch (error) {
- notifyError(error, 'Preview unavailable')
- }
- }
-
- return (
-
- )
-}
-
-interface FileTreeBodyProps {
- cwd: string
- data: ReturnType['data']
- error: string | null
- loading: boolean
- onActivateFile: (path: string) => void
- onLoadChildren: (id: string) => void | Promise
- onNodeOpenChange: (id: string, open: boolean) => void
- onPreviewFile?: (path: string) => void
- openState: ReturnType['openState']
-}
-
-function FileTreeBody({
- cwd,
- data,
- error,
- loading,
- onActivateFile,
- onLoadChildren,
- onNodeOpenChange,
- onPreviewFile,
- openState
-}: FileTreeBodyProps) {
- if (!cwd) {
- return
- }
-
- if (error) {
- return
- }
-
- if (loading && data.length === 0) {
- return
- }
-
- if (data.length === 0) {
- return
- }
-
- return (
-
- )
-}
-
-function EmptyState({ body, title }: { body: string; title: string }) {
- return (
-
- )
-}
diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx
index 63b2040f422..f4194b13903 100644
--- a/apps/desktop/src/app/messaging/index.tsx
+++ b/apps/desktop/src/app/messaging/index.tsx
@@ -12,18 +12,17 @@ import {
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
-import { AlertTriangle, ChevronDown, ExternalLink, RefreshCw, Save, Trash2 } from '@/lib/icons'
+import { DisclosureCaret } from '@/components/ui/disclosure-caret'
+import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
+import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
-import { titlebarHeaderBaseClass } from '../shell/titlebar'
-import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
interface MessagingViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
- setTitlebarToolGroup?: SetTitlebarToolGroup
}
type EditMap = Record>
@@ -209,11 +208,11 @@ function fieldCopy(field: MessagingEnvVarInfo) {
export function MessagingView({
setStatusbarItemGroup: _setStatusbarItemGroup,
- setTitlebarToolGroup,
...props
}: MessagingViewProps) {
const [platforms, setPlatforms] = useState(null)
const [edits, setEdits] = useState({})
+ const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(null)
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
@@ -263,24 +262,6 @@ export function MessagingView({
}
}, [refreshPlatforms])
- useEffect(() => {
- if (!setTitlebarToolGroup) {
- return
- }
-
- setTitlebarToolGroup('messaging', [
- {
- disabled: refreshing,
- icon: ,
- id: 'refresh-messaging',
- label: refreshing ? 'Refreshing messaging' : 'Refresh messaging',
- onSelect: () => void refreshPlatforms()
- }
- ])
-
- return () => setTitlebarToolGroup('messaging', [])
- }, [refreshPlatforms, refreshing, setTitlebarToolGroup])
-
const selected = useMemo(() => {
if (!platforms) {
return null
@@ -289,7 +270,23 @@ export function MessagingView({
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
- const enabledCount = platforms?.filter(platform => platform.enabled).length || 0
+ const visiblePlatforms = useMemo(() => {
+ if (!platforms) {
+ return []
+ }
+
+ const q = query.trim().toLowerCase()
+
+ if (!q) {
+ return platforms
+ }
+
+ return platforms.filter(platform =>
+ [platform.id, platform.name, platform.description, platform.state]
+ .filter(Boolean)
+ .some(value => String(value).toLowerCase().includes(q))
+ )
+ }, [platforms, query])
async function handleToggle(platform: MessagingPlatformInfo, enabled: boolean) {
setSaving(`enabled:${platform.id}`)
@@ -367,22 +364,20 @@ export function MessagingView({
}
return (
-
-
- Messaging
-
- {enabledCount === 0 ? 'No platforms enabled' : `${enabledCount} enabled`}
-
-
-
-
- {!platforms ? (
-
- ) : (
-
-
-
+ )}
+
)
}
@@ -434,15 +428,15 @@ function PlatformRow({
return (
@@ -453,8 +447,8 @@ function PlatformAvatar({ platformId, platformName }: { platformId: string; plat
return (
{platformName.charAt(0).toUpperCase()}
@@ -491,12 +485,12 @@ function PlatformDetail({
return (
-
-
+
+
-
{platform.name}
-
{platform.description}
+
{platform.name}
+
{platform.description}
{stateLabel(platform.state)}
@@ -509,7 +503,7 @@ function PlatformDetail({
{platform.error_message && (
-
+
@@ -517,7 +511,7 @@ function PlatformDetail({
Get your credentials
- {introCopy(platform)}
+ {introCopy(platform)}
-