mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user