mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): redesign sidebar with resizable width and 3-level hierarchy (#427)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user