feat(web): Add session sidebar for desktop, keep mobile single-page layout (#112)

* feat(web): Responsive session sidebar + split chat layout

* Update sidebar width

* Fixed dialog

* Move the sidebar scrollbar to the left without flipping content
This commit is contained in:
therainisme
2026-01-31 10:06:43 +08:00
committed by GitHub
parent 7cad11ca27
commit e9cc98b7ee
4 changed files with 120 additions and 63 deletions
+7 -3
View File
@@ -166,9 +166,10 @@ function SessionItem(props: {
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
selected?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api } = props
const { session: s, onSelect, showPath = true, api, selected = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
@@ -205,8 +206,9 @@ function SessionItem(props: {
<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"
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)]' : ''}`}
style={{ WebkitTouchCallout: 'none' }}
aria-current={selected ? 'page' : undefined}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
@@ -317,9 +319,10 @@ export function SessionList(props: {
isLoading: boolean
renderHeader?: boolean
api: ApiClient | null
selectedSessionId?: string | null
}) {
const { t } = useTranslation()
const { renderHeader = true, api } = props
const { renderHeader = true, api, selectedSessionId } = props
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
@@ -407,6 +410,7 @@ export function SessionList(props: {
onSelect={props.onSelect}
showPath={false}
api={api}
selected={s.id === selectedSessionId}
/>
))}
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content
ref={ref}
className={cn(
+11
View File
@@ -121,6 +121,17 @@ body {
}
}
/* Desktop-only: move the sidebar scrollbar to the left without flipping content. */
@media (min-width: 1024px) {
.desktop-scrollbar-left {
direction: rtl;
}
.desktop-scrollbar-left > * {
direction: ltr;
}
}
/* Markdown styles */
.markdown-content a { color: var(--app-link); text-decoration: underline; }
.markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; }
+101 -59
View File
@@ -2,9 +2,12 @@ import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Navigate,
Outlet,
createRootRoute,
createRoute,
createRouter,
useLocation,
useMatchRoute,
useNavigate,
useParams,
} from '@tanstack/react-router'
@@ -94,6 +97,8 @@ function SettingsIcon(props: { className?: string }) {
function SessionsPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const pathname = useLocation({ select: location => location.pathname })
const matchRoute = useMatchRoute()
const { t } = useTranslation()
const { sessions, isLoading, error, refetch } = useSessions(api)
@@ -102,57 +107,76 @@ function SessionsPage() {
}, [refetch])
const projectCount = new Set(sessions.map(s => s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other')).size
const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null
const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/'
return (
<div className="flex h-full flex-col">
<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">
<div className="text-xs text-[var(--app-hint)]">
{t('sessions.count', { n: sessions.length, m: projectCount })}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate({ to: '/settings' })}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors"
title={t('settings.title')}
>
<SettingsIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => navigate({ to: '/sessions/new' })}
className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors"
title={t('sessions.new')}
>
<PlusIcon className="h-5 w-5" />
</button>
<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)]`}
>
<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">
<div className="text-xs text-[var(--app-hint)]">
{t('sessions.count', { n: sessions.length, m: projectCount })}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate({ to: '/settings' })}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors"
title={t('settings.title')}
>
<SettingsIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => navigate({ to: '/sessions/new' })}
className="session-list-new-button p-1.5 rounded-full text-[var(--app-link)] transition-colors"
title={t('sessions.new')}
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto desktop-scrollbar-left">
{error ? (
<div className="mx-auto w-full max-w-content px-3 py-2">
<div className="text-sm text-red-600">{error}</div>
</div>
) : null}
<SessionList
sessions={sessions}
selectedSessionId={selectedSessionId}
onSelect={(sessionId) => navigate({
to: '/sessions/$sessionId',
params: { sessionId },
})}
onNewSession={() => navigate({ to: '/sessions/new' })}
onRefresh={handleRefresh}
isLoading={isLoading}
renderHeader={false}
api={api}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{error ? (
<div className="mx-auto w-full max-w-content px-3 py-2">
<div className="text-sm text-red-600">{error}</div>
</div>
) : null}
<SessionList
sessions={sessions}
onSelect={(sessionId) => navigate({
to: '/sessions/$sessionId',
params: { sessionId },
})}
onNewSession={() => navigate({ to: '/sessions/new' })}
onRefresh={handleRefresh}
isLoading={isLoading}
renderHeader={false}
api={api}
/>
<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 />
</div>
</div>
</div>
)
}
function SessionsIndexPage() {
return null
}
function SessionPage() {
const { api } = useAppContext()
const goBack = useAppGoBack()
@@ -281,6 +305,15 @@ function SessionPage() {
)
}
function SessionDetailRoute() {
const pathname = useLocation({ select: location => location.pathname })
const { sessionId } = useParams({ from: '/sessions/$sessionId' })
const basePath = `/sessions/${sessionId}`
const isChat = pathname === basePath || pathname === `${basePath}/`
return isChat ? <SessionPage /> : <Outlet />
}
function NewSessionPage() {
const { api } = useAppContext()
const navigate = useNavigate()
@@ -353,21 +386,27 @@ const sessionsRoute = createRoute({
component: SessionsPage,
})
const sessionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId',
component: SessionPage,
const sessionsIndexRoute = createRoute({
getParentRoute: () => sessionsRoute,
path: '/',
component: SessionsIndexPage,
})
const sessionDetailRoute = createRoute({
getParentRoute: () => sessionsRoute,
path: '$sessionId',
component: SessionDetailRoute,
})
const sessionFilesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/files',
getParentRoute: () => sessionDetailRoute,
path: 'files',
component: FilesPage,
})
const sessionTerminalRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/terminal',
getParentRoute: () => sessionDetailRoute,
path: 'terminal',
component: TerminalPage,
})
@@ -377,8 +416,8 @@ type SessionFileSearch = {
}
const sessionFileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/$sessionId/file',
getParentRoute: () => sessionDetailRoute,
path: 'file',
validateSearch: (search: Record<string, unknown>): SessionFileSearch => {
const path = typeof search.path === 'string' ? search.path : ''
const staged = search.staged === true || search.staged === 'true'
@@ -393,8 +432,8 @@ const sessionFileRoute = createRoute({
})
const newSessionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/sessions/new',
getParentRoute: () => sessionsRoute,
path: 'new',
component: NewSessionPage,
})
@@ -406,12 +445,15 @@ const settingsRoute = createRoute({
export const routeTree = rootRoute.addChildren([
indexRoute,
sessionsRoute,
sessionRoute,
sessionTerminalRoute,
sessionFilesRoute,
sessionFileRoute,
newSessionRoute,
sessionsRoute.addChildren([
sessionsIndexRoute,
newSessionRoute,
sessionDetailRoute.addChildren([
sessionTerminalRoute,
sessionFilesRoute,
sessionFileRoute,
]),
]),
settingsRoute,
])