feat(web): redesign sidebar with resizable width and 3-level hierarchy (#427)

This commit is contained in:
Femoon
2026-04-11 09:52:56 +08:00
committed by GitHub
parent 79a13d26c6
commit ef87e30727
6 changed files with 944 additions and 84 deletions
+592 -3
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@
"@assistant-ui/react-markdown": "^0.11.9",
"@elevenlabs/react": "^0.13.0",
"@hapi/protocol": "workspace:*",
"@lobehub/icons": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@shikijs/langs": "^3.20.0",
+217 -79
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { SessionSummary } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress'
@@ -7,8 +7,14 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { getSessionModelLabel } from '@/lib/sessionModelLabel'
import { useTranslation } from '@/lib/use-translation'
import ClaudeColor from '@lobehub/icons/es/Claude/components/Color'
import CodexColor from '@lobehub/icons/es/Codex/components/Color'
import CursorMono from '@lobehub/icons/es/Cursor/components/Mono'
import GeminiColor from '@lobehub/icons/es/Gemini/components/Color'
import OpenCodeMono from '@lobehub/icons/es/OpenCode/components/Mono'
type SessionGroup = {
key: string
@@ -20,6 +26,15 @@ type SessionGroup = {
hasActiveSession: boolean
}
type MachineGroup = {
machineId: string | null
label: string
projectGroups: SessionGroup[]
totalSessions: number
hasActiveSession: boolean
latestUpdatedAt: number
}
function getGroupDisplayName(directory: string): string {
if (directory === 'Other') return directory
const parts = directory.split(/[\\/]+/).filter(Boolean)
@@ -80,6 +95,65 @@ function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
})
}
function groupByMachine(
groups: SessionGroup[],
resolveMachineLabel: (id: string | null) => string
): MachineGroup[] {
const map = new Map<string, MachineGroup>()
for (const g of groups) {
const key = g.machineId ?? UNKNOWN_MACHINE_ID
let mg = map.get(key)
if (!mg) {
mg = {
machineId: g.machineId,
label: resolveMachineLabel(g.machineId),
projectGroups: [],
totalSessions: 0,
hasActiveSession: false,
latestUpdatedAt: 0,
}
map.set(key, mg)
}
mg.projectGroups.push(g)
mg.totalSessions += g.sessions.length
if (g.hasActiveSession) mg.hasActiveSession = true
if (g.latestUpdatedAt > mg.latestUpdatedAt) mg.latestUpdatedAt = g.latestUpdatedAt
}
return [...map.values()].sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) return a.hasActiveSession ? -1 : 1
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
function CopyPathButton({ path, className }: { path: string; className?: string }) {
const [copied, setCopied] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(path)
setCopied(true)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 1500)
}
useEffect(() => () => clearTimeout(timerRef.current), [])
return (
<button
type="button"
className={`shrink-0 p-0.5 rounded transition-colors ${copied ? 'text-[var(--app-badge-success-text)]' : 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'} ${className ?? ''}`}
title={copied ? 'Copied!' : `Copy: ${path}`}
onClick={handleClick}
>
{copied
? <CheckIcon className="h-3.5 w-3.5" />
: <CopyIcon className="h-3.5 w-3.5" />
}
</button>
)
}
function PlusIcon(props: { className?: string }) {
return (
<svg
@@ -100,6 +174,21 @@ function PlusIcon(props: { className?: string }) {
)
}
function LoaderIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={props.className}>
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
)
}
function BulbIcon(props: { className?: string }) {
return (
<svg
@@ -166,6 +255,20 @@ function getAgentLabel(session: SessionSummary): string {
return 'unknown'
}
const FLAVOR_ICONS: Record<string, React.ComponentType<{ className?: string; size?: number }>> = {
claude: ClaudeColor,
codex: CodexColor,
cursor: CursorMono,
gemini: GeminiColor,
opencode: OpenCodeMono,
}
function FlavorIcon({ flavor, className }: { flavor?: string | null; className?: string }) {
const Icon = FLAVOR_ICONS[(flavor ?? 'claude').toLowerCase()]
if (!Icon) return <ClaudeColor className={className} />
return <Icon className={className} />
}
function MachineIcon(props: { className?: string }) {
return (
<svg
@@ -239,36 +342,27 @@ function SessionItem(props: {
const sessionName = getSessionTitle(s)
const modelLabel = getSessionModelLabel(s)
const statusDotClass = s.active
? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]')
: 'bg-[var(--app-hint)]'
const todoProgress = getTodoProgress(s)
return (
<>
<button
type="button"
{...longPressHandlers}
className={`session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
className={`session-list-item flex w-full flex-col gap-1 px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none rounded-lg ${selected ? 'bg-[var(--app-secondary-bg)]' : ''}`}
style={{ WebkitTouchCallout: 'none' }}
aria-current={selected ? 'page' : undefined}
>
<div className="flex items-center justify-between gap-3">
<div className={`flex items-center justify-between gap-3 ${!s.active ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
<span
className={`h-2 w-2 rounded-full ${statusDotClass}`}
/>
</span>
<div className="truncate text-base font-medium">
<FlavorIcon flavor={s.metadata?.flavor} className="h-4 w-4 shrink-0" />
<div className={`truncate text-sm font-medium ${s.active ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}>
{sessionName}
</div>
{s.active && s.thinking ? (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 text-[var(--app-hint)] animate-spin-slow" />
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{s.thinking ? (
<span className="text-[#007AFF] animate-pulse">
{t('session.item.thinking')}
</span>
) : null}
{todoProgress ? (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
@@ -290,20 +384,6 @@ function SessionItem(props: {
{s.metadata?.path ?? s.id}
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-2">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
</span>
{getAgentLabel(s)}
</span>
{modelLabel ? (
<span>{t(modelLabel.key)}: {modelLabel.value}</span>
) : null}
{s.metadata?.worktree?.branch ? (
<span>{t('session.item.worktree')}: {s.metadata.worktree.branch}</span>
) : null}
</div>
</button>
<SessionActionMenu
@@ -398,28 +478,70 @@ export function SessionList(props: {
return t('machine.unknown')
}
const machineGroups = useMemo(
() => groupByMachine(groups, resolveMachineLabel),
[groups, machineLabelsById] // eslint-disable-line react-hooks/exhaustive-deps
)
const isMachineCollapsed = (mg: MachineGroup): boolean => {
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const override = collapseOverrides.get(key)
if (override !== undefined) return override
const hasSelected = selectedSessionId
? mg.projectGroups.some(pg => pg.sessions.some(s => s.id === selectedSessionId))
: false
return !mg.hasActiveSession && !hasSelected
}
const toggleMachine = (mg: MachineGroup) => {
const key = `machine::${mg.machineId ?? UNKNOWN_MACHINE_ID}`
const current = isMachineCollapsed(mg)
setCollapseOverrides(prev => {
const next = new Map(prev)
next.set(key, !current)
return next
})
}
// Auto-expand group (and machine) containing selected session
useEffect(() => {
if (!selectedSessionId) return
setCollapseOverrides(prev => {
const group = groups.find(g =>
g.sessions.some(s => s.id === selectedSessionId)
)
if (!group || !prev.has(group.key) || !prev.get(group.key)) return prev
if (!group) return prev
const next = new Map(prev)
next.delete(group.key)
return next
let changed = false
// Expand project group if collapsed
if (prev.has(group.key) && prev.get(group.key)) {
next.delete(group.key)
changed = true
}
// Expand machine group if collapsed
const machineKey = `machine::${group.machineId ?? UNKNOWN_MACHINE_ID}`
if (prev.has(machineKey) && prev.get(machineKey)) {
next.delete(machineKey)
changed = true
}
return changed ? next : prev
})
}, [selectedSessionId, groups])
// Clean up stale collapse overrides
useEffect(() => {
setCollapseOverrides(prev => {
if (prev.size === 0) return prev
const next = new Map(prev)
const knownGroups = new Set(groups.map(group => group.key))
const knownKeys = new Set<string>()
for (const g of groups) {
knownKeys.add(g.key)
knownKeys.add(`machine::${g.machineId ?? UNKNOWN_MACHINE_ID}`)
}
let changed = false
for (const groupKey of next.keys()) {
if (!knownGroups.has(groupKey)) {
next.delete(groupKey)
for (const key of next.keys()) {
if (!knownKeys.has(key)) {
next.delete(key)
changed = true
}
}
@@ -445,53 +567,69 @@ export function SessionList(props: {
</div>
) : null}
<div className="flex flex-col">
{groups.map((group) => {
const isCollapsed = isGroupCollapsed(group)
const machineLabel = resolveMachineLabel(group.machineId)
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(mg)
return (
<div key={group.key} className="mt-2 first:mt-0">
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
{/* Level 1: Machine */}
<button
type="button"
onClick={() => toggleGroup(group.key, isCollapsed)}
className="sticky top-0 z-10 flex w-full flex-col gap-1 px-3 py-2.5 text-left bg-[var(--app-secondary-bg)] border-b border-[var(--app-border)] border-l-[3px] border-l-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)]"
onClick={() => toggleMachine(mg)}
className="flex w-full items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] select-none"
>
<div className="flex items-center gap-2 min-w-0 w-full">
<ChevronIcon
className="h-4 w-4 text-[var(--app-hint)] shrink-0"
collapsed={isCollapsed}
/>
<span className="font-semibold text-sm break-words min-w-0" title={group.directory}>
{group.displayName}
</span>
<span className="shrink-0 rounded-full bg-[var(--app-subtle-bg)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--app-hint)]">
{group.sessions.length}
</span>
</div>
<div className="flex min-w-0 w-full flex-wrap items-center gap-2 pl-6 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-0.5">
<MachineIcon className="h-3 w-3 shrink-0" />
{machineLabel}
</span>
<span className="min-w-0 flex-1 truncate" title={group.directory}>
{group.directory}
</span>
</div>
<ChevronIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" collapsed={machineCollapsed} />
<MachineIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" />
<span className="text-sm font-semibold truncate flex-1">{mg.label}</span>
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">({mg.totalSessions})</span>
</button>
{!isCollapsed ? (
<div className="flex flex-col divide-y divide-[var(--app-divider)] border-b border-[var(--app-divider)] border-l border-l-[var(--app-divider)]">
{group.sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
/>
))}
{/* Level 2: Projects */}
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col ml-3.5 pl-1 mt-0.5">
{mg.projectGroups.map((group) => {
const isCollapsed = isGroupCollapsed(group)
return (
<div key={group.key}>
<div
className="group/project sticky top-0 z-10 flex items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] cursor-pointer min-w-0 w-full select-none"
onClick={() => toggleGroup(group.key, isCollapsed)}
title={group.directory}
>
<ChevronIcon className="h-3.5 w-3.5 text-[var(--app-hint)] shrink-0" collapsed={isCollapsed} />
<span className="font-medium text-sm truncate flex-1">
{group.displayName}
</span>
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
</span>
</div>
{/* Level 3: Sessions */}
<div className="collapsible-panel" data-open={!isCollapsed || undefined}>
<div className="collapsible-inner">
<div className="flex flex-col gap-0.5 ml-3 pl-1 pr-1 py-1">
{group.sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
/>
))}
</div>
</div>
</div>
</div>
)
})}
</div>
) : null}
</div>
</div>
</div>
)
})}
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from 'react'
const STORAGE_KEY = 'hapi-sidebar-width'
const MIN_WIDTH = 280
const MAX_WIDTH = 600
const DEFAULT_WIDTH = 420
function clamp(value: number): number {
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value))
}
function loadWidth(): number {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = Number(stored)
if (Number.isFinite(parsed)) return clamp(parsed)
}
return DEFAULT_WIDTH
}
export function useSidebarResize() {
const [width, setWidth] = useState(loadWidth)
const [isDragging, setIsDragging] = useState(false)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
startXRef.current = e.clientX
startWidthRef.current = width
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}, [width])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging) return
const delta = e.clientX - startXRef.current
setWidth(clamp(startWidthRef.current + delta))
}, [isDragging])
const onPointerUp = useCallback(() => {
if (!isDragging) return
setIsDragging(false)
}, [isDragging])
// Persist width to localStorage when drag ends
useEffect(() => {
if (!isDragging) {
localStorage.setItem(STORAGE_KEY, String(width))
}
}, [isDragging, width])
// Prevent text selection while dragging
useEffect(() => {
if (isDragging) {
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
} else {
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
return () => {
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
}, [isDragging])
return { width, isDragging, onPointerDown, onPointerMove, onPointerUp }
}
+52 -1
View File
@@ -93,6 +93,10 @@ html {
font-size: calc(100% * var(--app-font-scale, 1));
}
button, [role="button"] {
cursor: pointer;
}
html,
body {
height: 100vh;
@@ -139,7 +143,28 @@ body {
}
}
/* Desktop-only: move the sidebar scrollbar to the left without flipping content. */
/* Collapsible panel animation */
.collapsible-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible-panel[data-open] {
grid-template-rows: 1fr;
}
.collapsible-panel > .collapsible-inner {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible-panel[data-open] > .collapsible-inner {
opacity: 1;
}
/* Desktop sidebar: use custom width from CSS variable */
@media (min-width: 1024px) {
.desktop-scrollbar-left {
direction: rtl;
@@ -148,6 +173,28 @@ body {
.desktop-scrollbar-left > * {
direction: ltr;
}
/* Apply resizable width to sidebar */
div[style*="--sidebar-w"] {
width: var(--sidebar-w) !important;
}
}
/* Sidebar resize handle */
.sidebar-resize-handle {
width: 4px;
cursor: col-resize;
background: var(--app-divider);
transition: background-color 0.15s;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle[data-dragging] {
background: var(--app-link);
}
.sidebar-resize-handle[data-dragging] {
opacity: 0.6;
}
/*
@@ -274,6 +321,10 @@ html[data-theme="dark"] .shiki span {
animation: spin 1s linear infinite;
}
.animate-spin-slow {
animation: spin 2s linear infinite;
}
/* New messages indicator bounce animation */
@keyframes bounce-in {
0% {
+13 -1
View File
@@ -19,6 +19,7 @@ import { LoadingState } from '@/components/LoadingState'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { isTelegramApp } from '@/hooks/useTelegram'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import { useMessages } from '@/hooks/queries/useMessages'
import { useMachines } from '@/hooks/queries/useMachines'
import { useSession } from '@/hooks/queries/useSession'
@@ -127,11 +128,13 @@ function SessionsPage() {
const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null
const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/'
const sidebar = useSidebarResize()
return (
<div className="flex h-full min-h-0">
<div
className={`${isSessionsIndex ? 'flex' : 'hidden lg:flex'} w-full lg:w-[420px] xl:w-[480px] shrink-0 flex-col bg-[var(--app-bg)] lg:border-r lg:border-[var(--app-divider)]`}
className={`${isSessionsIndex ? 'flex' : 'hidden lg:flex'} w-full shrink-0 flex-col bg-[var(--app-bg)]`}
style={{ '--sidebar-w': `${sidebar.width}px` } as React.CSSProperties}
>
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center justify-between px-3 py-2">
@@ -182,6 +185,15 @@ function SessionsPage() {
</div>
</div>
{/* Resize handle - desktop only */}
<div
className="sidebar-resize-handle hidden lg:block shrink-0"
data-dragging={sidebar.isDragging || undefined}
onPointerDown={sidebar.onPointerDown}
onPointerMove={sidebar.onPointerMove}
onPointerUp={sidebar.onPointerUp}
/>
<div className={`${isSessionsIndex ? 'hidden lg:flex' : 'flex'} min-w-0 flex-1 flex-col bg-[var(--app-bg)]`}>
<div className="flex-1 min-h-0">
<Outlet />