mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
feat(web): session header files and outline view toggles (#952)
* feat(web): session header files and outline view toggles Files and outline icons in SessionHeader act as depressed toggles; files view shares the session header and places refresh beside the search box. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web): add Playwright handoff script for session view toggles Supports new-feature-intake visual gate: files toggle pressed + refresh beside search. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dev): handoff script avoid networkidle on live hub SSE HAPI keeps connections open on :3006; domcontentloaded is the correct wait. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): place filesystem refresh outside search field Refresh is a sibling of the search pill, not inside it, so the control is visually and structurally separate from file search. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -374,6 +374,8 @@ type SessionChatProps = {
|
||||
// user dismisses or starts editing.
|
||||
sendError?: ComposerSendError | null
|
||||
onClearSendError?: () => void
|
||||
initialOutlineOpen?: boolean
|
||||
onInitialOutlineConsumed?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +409,15 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
const visibleGroupsRef = useRef<ToolGroupBlock[]>([])
|
||||
const [forceScrollToken, setForceScrollToken] = useState(0)
|
||||
const [outlineOpen, setOutlineOpen] = useState(false)
|
||||
const [outlineOpen, setOutlineOpen] = useState(props.initialOutlineOpen ?? false)
|
||||
useEffect(() => {
|
||||
if (!props.initialOutlineOpen) {
|
||||
return
|
||||
}
|
||||
setOutlineOpen(true)
|
||||
props.onInitialOutlineConsumed?.()
|
||||
}, [props.initialOutlineOpen, props.onInitialOutlineConsumed])
|
||||
|
||||
const [cursorSelectedBase, setCursorSelectedBase] = useState('auto')
|
||||
const lastSyncedCursorModelRef = useRef<string | null | undefined>(undefined)
|
||||
const scratchlist = useScratchlist(props.session.id)
|
||||
@@ -995,13 +1005,18 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
props.onRefresh()
|
||||
}, [switchSession, props.onRefresh])
|
||||
|
||||
const handleViewFiles = useCallback(() => {
|
||||
const handleToggleFiles = useCallback(() => {
|
||||
setOutlineOpen(false)
|
||||
navigate({
|
||||
to: '/sessions/$sessionId/files',
|
||||
params: { sessionId: props.session.id }
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const handleToggleOutline = useCallback(() => {
|
||||
setOutlineOpen((open) => !open)
|
||||
}, [])
|
||||
|
||||
const handleViewTerminal = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId/terminal',
|
||||
@@ -1089,8 +1104,10 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
<SessionHeader
|
||||
session={props.session}
|
||||
onBack={props.onBack}
|
||||
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
|
||||
onOpenOutline={() => setOutlineOpen(true)}
|
||||
onToggleFiles={props.session.metadata?.path ? handleToggleFiles : undefined}
|
||||
filesActive={false}
|
||||
onToggleOutline={handleToggleOutline}
|
||||
outlineActive={outlineOpen}
|
||||
api={props.api}
|
||||
onSessionDeleted={props.onBack}
|
||||
onSessionReopened={(newSessionId) => {
|
||||
|
||||
@@ -70,6 +70,14 @@ function OutlineIcon(props: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function headerToggleClass(active: boolean): string {
|
||||
return `flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--app-button)] text-[var(--app-button-text)] hover:opacity-90'
|
||||
: 'text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]'
|
||||
}`
|
||||
}
|
||||
|
||||
function MoreVerticalIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -90,8 +98,10 @@ function MoreVerticalIcon(props: { className?: string }) {
|
||||
export function SessionHeader(props: {
|
||||
session: Session
|
||||
onBack: () => void
|
||||
onViewFiles?: () => void
|
||||
onOpenOutline?: () => void
|
||||
onToggleFiles?: () => void
|
||||
filesActive?: boolean
|
||||
onToggleOutline?: () => void
|
||||
outlineActive?: boolean
|
||||
api: ApiClient | null
|
||||
onSessionDeleted?: () => void
|
||||
onSessionReopened?: (newSessionId: string) => void
|
||||
@@ -194,24 +204,27 @@ export function SessionHeader(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.onViewFiles ? (
|
||||
{props.onToggleFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onViewFiles}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('session.title')}
|
||||
onClick={props.onToggleFiles}
|
||||
className={headerToggleClass(props.filesActive ?? false)}
|
||||
title={props.filesActive ? t('session.view.returnToChat') : t('session.title')}
|
||||
aria-label={props.filesActive ? t('session.view.returnToChat') : t('session.title')}
|
||||
aria-pressed={props.filesActive ?? false}
|
||||
>
|
||||
<FilesIcon />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{props.onOpenOutline ? (
|
||||
{props.onToggleOutline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onOpenOutline}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('session.outline.open')}
|
||||
aria-label={t('session.outline.open')}
|
||||
onClick={props.onToggleOutline}
|
||||
className={headerToggleClass(props.outlineActive ?? false)}
|
||||
title={props.outlineActive ? t('session.outline.close') : t('session.outline.open')}
|
||||
aria-label={props.outlineActive ? t('session.outline.close') : t('session.outline.open')}
|
||||
aria-pressed={props.outlineActive ?? false}
|
||||
>
|
||||
<OutlineIcon />
|
||||
</button>
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
|
||||
// Session header
|
||||
'session.title': 'Files',
|
||||
'session.view.returnToChat': 'Return to conversation',
|
||||
'session.more': 'More actions',
|
||||
'session.outline.open': 'Conversation outline',
|
||||
'session.outline.close': 'Close outline',
|
||||
@@ -284,6 +285,7 @@ export default {
|
||||
// Files page
|
||||
'files.page.title': 'Files',
|
||||
'files.page.refresh': 'Refresh',
|
||||
'files.page.refreshFilesystem': 'Refresh filesystem view',
|
||||
'files.page.searchPlaceholder': 'Search files',
|
||||
'files.projectRoot': 'project root',
|
||||
'files.branch.detached': 'detached HEAD',
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
|
||||
// Session header
|
||||
'session.title': '文件',
|
||||
'session.view.returnToChat': '返回会话',
|
||||
'session.more': '更多操作',
|
||||
'session.outline.open': '会话大纲',
|
||||
'session.outline.close': '关闭大纲',
|
||||
@@ -288,6 +289,7 @@ export default {
|
||||
// Files page
|
||||
'files.page.title': '文件',
|
||||
'files.page.refresh': '刷新',
|
||||
'files.page.refreshFilesystem': '刷新文件系统视图',
|
||||
'files.page.searchPlaceholder': '搜索文件',
|
||||
'files.projectRoot': '项目根目录',
|
||||
'files.branch.detached': '游离 HEAD',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearch,
|
||||
} from '@tanstack/react-router'
|
||||
import { getScrollRestorationKey } from '@/lib/scrollRestorationKey'
|
||||
import { App } from '@/App'
|
||||
@@ -609,6 +610,7 @@ function SessionPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
const { sessionId } = useParams({ from: '/sessions/$sessionId' })
|
||||
const { outline } = useSearch({ from: '/sessions/$sessionId' })
|
||||
const {
|
||||
session,
|
||||
error: sessionError,
|
||||
@@ -856,6 +858,14 @@ function SessionPage() {
|
||||
void refetchMessages()
|
||||
}, [refetchMessages, refetchSession])
|
||||
|
||||
const handleInitialOutlineConsumed = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId',
|
||||
params: { sessionId },
|
||||
replace: true,
|
||||
})
|
||||
}, [navigate, sessionId])
|
||||
|
||||
if (!session) {
|
||||
if (sessionError) {
|
||||
return (
|
||||
@@ -912,6 +922,8 @@ function SessionPage() {
|
||||
availableSlashCommands={slashCommands}
|
||||
sendError={sendError}
|
||||
onClearSendError={clearSendError}
|
||||
initialOutlineOpen={outline}
|
||||
onInitialOutlineConsumed={handleInitialOutlineConsumed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1097,6 +1109,10 @@ const sessionsIndexRoute = createRoute({
|
||||
const sessionDetailRoute = createRoute({
|
||||
getParentRoute: () => sessionsRoute,
|
||||
path: '$sessionId',
|
||||
validateSearch: (search: Record<string, unknown>): { outline?: boolean } => {
|
||||
const outline = search.outline === true || search.outline === 'true'
|
||||
return outline ? { outline: true } : {}
|
||||
},
|
||||
component: SessionDetailRoute,
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import type { FileSearchItem, GitFileStatus } from '@/types/api'
|
||||
import { FileIcon } from '@/components/FileIcon'
|
||||
import { DirectoryTree } from '@/components/SessionFiles/DirectoryTree'
|
||||
import { SessionHeader } from '@/components/SessionHeader'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { useAppContext } from '@/lib/app-context'
|
||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles'
|
||||
@@ -19,25 +21,6 @@ import { queryKeys } from '@/lib/query-keys'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
function BackIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -302,7 +285,6 @@ export default function FilesPage() {
|
||||
}, [activeTab, navigate, sessionId])
|
||||
|
||||
const branchLabel = getDetachedBranchLabel(gitStatus?.branch, t)
|
||||
const subtitle = session?.metadata?.path ?? sessionId
|
||||
const showGitErrorBanner = Boolean(gitError)
|
||||
const gitErrorMessage = useMemo(
|
||||
() => (gitError ? formatGitStatusError(gitError, t) : null),
|
||||
@@ -346,45 +328,71 @@ export default function FilesPage() {
|
||||
})
|
||||
}, [navigate, sessionId])
|
||||
|
||||
const handleToggleFiles = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId',
|
||||
params: { sessionId },
|
||||
})
|
||||
}, [navigate, sessionId])
|
||||
|
||||
const handleToggleOutline = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId',
|
||||
params: { sessionId },
|
||||
search: { outline: true },
|
||||
})
|
||||
}, [navigate, sessionId])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<LoadingState label={t('loading.files')} className="text-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 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 gap-2 p-3 border-b border-[var(--app-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold">{t('files.page.title')}</div>
|
||||
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('files.page.refresh')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SessionHeader
|
||||
session={session}
|
||||
onBack={goBack}
|
||||
onToggleFiles={session.metadata?.path ? handleToggleFiles : undefined}
|
||||
filesActive={true}
|
||||
onToggleOutline={handleToggleOutline}
|
||||
outlineActive={false}
|
||||
api={api}
|
||||
onSessionDeleted={goBack}
|
||||
onSessionReopened={(newSessionId) => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId/files',
|
||||
params: { sessionId: newSessionId },
|
||||
replace: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-[var(--app-bg)]">
|
||||
<div className="mx-auto w-full max-w-content p-3 border-b border-[var(--app-border)]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2">
|
||||
<SearchIcon className="text-[var(--app-hint)]" />
|
||||
<div className="mx-auto flex w-full max-w-content items-center gap-2 border-b border-[var(--app-border)] p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2">
|
||||
<SearchIcon className="shrink-0 text-[var(--app-hint)]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t('files.page.searchPlaceholder')}
|
||||
className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('files.page.refreshFilesystem')}
|
||||
aria-label={t('files.page.refreshFilesystem')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user