feat: glass ui pass
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -14,19 +34,28 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Layers3, MessageCircle, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarRecentsOpen,
|
||||
pinSession,
|
||||
reorderPinnedSession,
|
||||
setSidebarAgentsGrouped,
|
||||
setSidebarPinsOpen,
|
||||
setSidebarRecentsOpen,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
import {
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
$workingSessionIds
|
||||
} from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
@@ -35,21 +64,79 @@ import type { SidebarNavItem } from '../../types'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New chat',
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: MessageCircle, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
|
||||
const WORKSPACE_PAGE = 5
|
||||
const WS_ID_PREFIX = 'workspace:'
|
||||
|
||||
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
|
||||
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
|
||||
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
|
||||
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
|
||||
|
||||
function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
|
||||
if (!orderIds.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const byId = new Map(items.map(item => [getId(item), item]))
|
||||
const seen = new Set<string>()
|
||||
const out: T[] = []
|
||||
|
||||
for (const id of orderIds) {
|
||||
const item = byId.get(id)
|
||||
|
||||
if (item) {
|
||||
out.push(item)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (!seen.has(getId(item))) {
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean).pop() || path || 'No workspace'
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
function useSortableBindings(id: string) {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
|
||||
return {
|
||||
dragging: isDragging,
|
||||
dragHandleProps: { ...attributes, ...listeners },
|
||||
ref: setNodeRef,
|
||||
reorderable: true as const,
|
||||
style: { transform: CSS.Transform.toString(transform), transition }
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onRefreshSessions: () => void
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
}
|
||||
@@ -57,63 +144,136 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
export function ChatSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
onRefreshSessions,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const recentsOpen = useStore($sidebarRecentsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
const dndSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(
|
||||
() =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const aTime = a.last_active || a.started_at || 0
|
||||
const bTime = b.last_active || b.started_at || 0
|
||||
|
||||
return bTime - aTime
|
||||
}),
|
||||
() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
|
||||
const visiblePinnedIdSet = new Set(visiblePinnedIds)
|
||||
const visiblePinnedIds = useMemo(
|
||||
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
|
||||
[pinnedSessionIds, sessionsById]
|
||||
)
|
||||
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
|
||||
|
||||
const pinnedSessions = visiblePinnedIds
|
||||
.map(id => sessionsById.get(id))
|
||||
.filter((session): session is SessionInfo => Boolean(session))
|
||||
const pinnedSessions = useMemo(
|
||||
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
|
||||
[visiblePinnedIds, sessionsById]
|
||||
)
|
||||
|
||||
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
|
||||
const unpinnedAgentSessions = useMemo(
|
||||
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
|
||||
[sortedSessions, visiblePinnedIdSet]
|
||||
)
|
||||
|
||||
const agentSessions = useMemo(
|
||||
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
|
||||
[unpinnedAgentSessions, agentOrderIds]
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
|
||||
const hasMoreSessions = knownSessionTotal > sortedSessions.length
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
|
||||
|
||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
|
||||
|
||||
if (newIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
reorderPinnedSession(String(active.id), newIndex)
|
||||
}
|
||||
|
||||
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = String(active.id)
|
||||
const overId = String(over.id)
|
||||
const activeWs = parseWsId(activeId)
|
||||
const overWs = parseWsId(overId)
|
||||
|
||||
if (activeWs && overWs) {
|
||||
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
|
||||
const newIdx = agentGroups.findIndex(g => g.id === overWs)
|
||||
|
||||
if (oldIdx < 0 || newIdx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (activeWs || overWs) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
|
||||
const newIdx = agentSessions.findIndex(s => s.id === overId)
|
||||
|
||||
if (oldIdx < 0 || newIdx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
|
||||
? 'border-(--sidebar-edge-border) bg-(--glass-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-(--sidebar-content-inline-padding)">
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
|
||||
<SidebarPanelLabel className="pb-1 pt-1">Workspace</SidebarPanelLabel>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
|
||||
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
@@ -124,9 +284,9 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-sidebar-foreground/78 transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
|
||||
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--chrome-action-hover) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)] hover:border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))]!',
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
@@ -135,7 +295,14 @@ export function ChatSidebar({
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
@@ -145,102 +312,103 @@ export function ChatSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<SidebarGroup className="shrink-0 p-0 pb-1">
|
||||
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
|
||||
{pinsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
|
||||
{pinnedSessions.length === 0 && (
|
||||
<div className="italic flex min-h-7 items-center gap-2 rounded-lg pl-2 text-xs text-muted-foreground/80">
|
||||
<span>Shift click to pin a chat</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => unpinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
||||
onTogglePin={unpinSession}
|
||||
open={pinsOpen}
|
||||
pinned
|
||||
rootClassName="shrink-0 p-0 pb-1"
|
||||
sessions={pinnedSessions}
|
||||
sortable={pinnedSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<SidebarGroup className="min-h-0 flex-1 p-0">
|
||||
<SidebarSectionHeader
|
||||
action={
|
||||
<Button
|
||||
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
|
||||
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
|
||||
disabled={sessionsLoading}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
onRefreshSessions()
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
}
|
||||
label="Recent chats"
|
||||
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
|
||||
open={recentsOpen}
|
||||
/>
|
||||
|
||||
{recentsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{showSessionSkeletons && <SidebarSessionSkeletons />}
|
||||
{!showSessionSkeletons && recentSessions.length === 0 && <SidebarAllPinnedState />}
|
||||
{recentSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned={false}
|
||||
isSelected={session.id === activeSidebarSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => pinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-bg-tertiary) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Agents"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={agentSessions}
|
||||
sortable={agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
|
||||
interface SidebarSectionHeaderProps {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
action?: React.ReactNode
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
|
||||
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-2 bg-transparent text-left leading-none"
|
||||
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/70 opacity-0 transition group-hover/section-label:opacity-100',
|
||||
!open && '-rotate-90'
|
||||
)}
|
||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{action}
|
||||
@@ -262,7 +430,276 @@ function SidebarSessionSkeletons() {
|
||||
}
|
||||
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-muted-foreground">
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSessionGroup {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
sessions: SessionInfo[]
|
||||
activeSessionId: null | string
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
emptyState: React.ReactNode
|
||||
forceEmptyState?: boolean
|
||||
headerAction?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
groups?: SidebarSessionGroup[]
|
||||
labelMeta?: React.ReactNode
|
||||
sortable?: boolean
|
||||
onReorder?: (event: DragEndEvent) => void
|
||||
dndSensors?: ReturnType<typeof useSensors>
|
||||
}
|
||||
|
||||
function SidebarSessionsSection({
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
emptyState,
|
||||
forceEmptyState = false,
|
||||
headerAction,
|
||||
footer,
|
||||
groups,
|
||||
labelMeta,
|
||||
sortable = false,
|
||||
onReorder,
|
||||
dndSensors
|
||||
}: SidebarSessionsSectionProps) {
|
||||
const showEmptyState = forceEmptyState || sessions.length === 0
|
||||
const dndActive = sortable && !!onReorder
|
||||
|
||||
const renderRow = (session: SessionInfo) => {
|
||||
const rowProps = {
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
session
|
||||
}
|
||||
|
||||
return sortable ? (
|
||||
<SortableSidebarSessionRow key={session.id} {...rowProps} />
|
||||
) : (
|
||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
||||
)
|
||||
}
|
||||
|
||||
const renderRows = (items: SessionInfo[]) => items.map(renderRow)
|
||||
|
||||
const renderSessionList = (items: SessionInfo[]) =>
|
||||
dndActive ? (
|
||||
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderRows(items)}
|
||||
</SortableContext>
|
||||
) : (
|
||||
renderRows(items)
|
||||
)
|
||||
|
||||
let inner: React.ReactNode
|
||||
|
||||
if (showEmptyState) {
|
||||
inner = emptyState
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
) : (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
)
|
||||
)
|
||||
|
||||
inner = dndActive ? (
|
||||
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
|
||||
{groupNodes}
|
||||
</SortableContext>
|
||||
) : (
|
||||
groupNodes
|
||||
)
|
||||
} else {
|
||||
inner = renderSessionList(sessions)
|
||||
}
|
||||
|
||||
const body =
|
||||
dndActive && !showEmptyState ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
|
||||
{inner}
|
||||
</DndContext>
|
||||
) : (
|
||||
inner
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
||||
{open && (
|
||||
<SidebarGroupContent className={contentClassName}>
|
||||
{body}
|
||||
{footer}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
|
||||
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder workspace ${group.label}`}
|
||||
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
|
||||
}
|
||||
|
||||
function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
}
|
||||
|
||||
interface SortableSessionRowProps {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||
}
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
loading: boolean
|
||||
onClick: () => void
|
||||
step: number
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,14 +17,13 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Pin } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
|
||||
@@ -56,54 +55,46 @@ export function SessionActionsMenu({
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-40" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<Pin className="size-3.5" strokeWidth={1.75} />
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<CopyButton
|
||||
appearance="menu-item"
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
errorMessage="Could not copy session ID"
|
||||
label="Copy ID"
|
||||
text={sessionId}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<Codicon name="cloud-download" size="0.875rem" />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil />
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
|
||||
'text-destructive focus:text-destructive'
|
||||
)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
@@ -111,7 +102,7 @@ export function SessionActionsMenu({
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { MoreVertical } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
@@ -17,6 +22,28 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
const at = seconds * 1000
|
||||
const delta = Math.max(0, Date.now() - at)
|
||||
|
||||
if (delta < MINUTE) {
|
||||
return 'now'
|
||||
}
|
||||
|
||||
if (delta < HOUR) {
|
||||
return `${Math.floor(delta / MINUTE)}m`
|
||||
}
|
||||
|
||||
if (delta < DAY) {
|
||||
return `${Math.floor(delta / HOUR)}h`
|
||||
}
|
||||
|
||||
return `${Math.floor(delta / DAY)}d`
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
@@ -26,23 +53,35 @@ export function SidebarSessionRow({
|
||||
isWorking,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume
|
||||
onResume,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative grid min-h-7 cursor-pointer grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100',
|
||||
isSelected && 'bg-accent',
|
||||
isWorking && 'text-foreground'
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-bg-quinary) hover:transition-none',
|
||||
isSelected && 'bg-(--ui-bg-tertiary)',
|
||||
isWorking && 'text-foreground',
|
||||
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
@@ -57,28 +96,67 @@ export function SidebarSessionRow({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isWorking && (
|
||||
{reorderable ? (
|
||||
<span
|
||||
aria-label="Session running"
|
||||
className="relative size-1.5 shrink-0 rounded-full bg-midground shadow-[0_0_0.625rem_color-mix(in_srgb,var(--dt-midground)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-midground before:opacity-75 before:content-[''] before:animate-ping"
|
||||
role="status"
|
||||
/>
|
||||
{...dragHandleProps}
|
||||
aria-label={handleLabel}
|
||||
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
isWorking={isWorking}
|
||||
/>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
|
||||
<SidebarRowDot isWorking={isWorking} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-6 place-items-center">
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-bg-tertiary) hover:text-foreground focus-visible:bg-(--ui-bg-tertiary) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-bg-tertiary) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreVertical size={15} />
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking ? 'size-1.5 bg-(--ui-green)' : 'size-1 bg-(--ui-text-quaternary) opacity-80',
|
||||
className
|
||||
)}
|
||||
role={isWorking ? 'status' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user