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:
HeavyGee
2026-06-19 17:36:36 +08:00
committed by GitHub
co-authored by Cursor
parent 02a0aa6733
commit b1910b6b2e
7 changed files with 210 additions and 62 deletions
+21 -4
View File
@@ -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) => {
+24 -11
View File
@@ -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>
+2
View File
@@ -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',
+2
View File
@@ -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',
+16
View File
@@ -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,
})
+55 -47
View File
@@ -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>