From b1910b6b2e0f133045d603358a2553046018a619 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:36:36 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Co-authored-by: Cursor --- scripts/dev/session-view-toggles-handoff.mjs | 90 ++++++++++++++++ web/src/components/SessionChat.tsx | 25 ++++- web/src/components/SessionHeader.tsx | 35 +++++-- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/router.tsx | 16 +++ web/src/routes/sessions/files.tsx | 102 ++++++++++--------- 7 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 scripts/dev/session-view-toggles-handoff.mjs diff --git a/scripts/dev/session-view-toggles-handoff.mjs b/scripts/dev/session-view-toggles-handoff.mjs new file mode 100644 index 00000000..e9a41856 --- /dev/null +++ b/scripts/dev/session-view-toggles-handoff.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Playwright handoff for session header view toggles (files + outline). + * Usage: node scripts/dev/session-view-toggles-handoff.mjs [screenshotPath] + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const sessionId = process.argv[2] +const cliToken = process.argv[3] +const screenshotPath = resolve(process.argv[4] ?? 'localdocs/playwright-runs/session-view-toggles-handoff.png') + +if (!sessionId || !cliToken) { + console.error('usage: session-view-toggles-handoff.mjs [screenshotPath]') + process.exit(2) +} + +function launchOptions() { + const chromePath = process.env.PLAYWRIGHT_CHROME_PATH?.trim() + if (chromePath) return { headless: true, executablePath: chromePath } + if (process.platform === 'linux' && !process.env.PLAYWRIGHT_BUNDLED_CHROMIUM) { + return { headless: true, channel: 'chrome' } + } + return { headless: true } +} + +const baseUrl = 'http://127.0.0.1:3006' +const storageKey = `hapi_access_token::${baseUrl}` +const url = `${baseUrl}/sessions/${sessionId}` +const browser = await chromium.launch(launchOptions()) +const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + serviceWorkers: 'block', +}) +const page = await context.newPage() +await page.addInitScript(({ key, token }) => { + localStorage.setItem(key, token) +}, { key: storageKey, token: cliToken }) +const consoleMessages = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('pageerror', (err) => consoleMessages.push(`pageerror: ${err.message}`)) + +try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + + const login = page.getByPlaceholder('Access token') + if (await login.isVisible({ timeout: 3000 }).catch(() => false)) { + await login.fill(cliToken) + await page.getByRole('button', { name: /sign in|login|connect/i }).click() + await page.waitForLoadState('domcontentloaded', { timeout: 60000 }) + } + + await page.getByRole('button', { name: 'Files' }).first().waitFor({ state: 'visible', timeout: 60000 }) + + // Toggle into files mode — button should become pressed. + await page.getByRole('button', { name: 'Files' }).first().click() + await page.getByPlaceholder('Search files').waitFor({ timeout: 30000 }) + await page.getByRole('button', { name: 'Refresh filesystem view' }).waitFor({ timeout: 10000 }) + + const filesBtn = page.getByRole('button', { name: 'Return to conversation' }) + await filesBtn.waitFor({ timeout: 5000 }) + const pressed = await filesBtn.getAttribute('aria-pressed') + if (pressed !== 'true') { + throw new Error(`Expected files toggle aria-pressed=true, got ${pressed}`) + } + + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }) + + console.log(JSON.stringify({ + ok: true, + screenshot: screenshotPath, + url: page.url().replace(/token=[^&]+/, 'token='), + filesTogglePressed: pressed, + }, null, 2)) +} catch (error) { + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {}) + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + screenshot: screenshotPath, + bodyText: (await page.locator('body').innerText().catch(() => '')).slice(0, 500), + consoleMessages, + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index af612bce..8edc884d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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>(new Map()) const visibleGroupsRef = useRef([]) 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(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) { setOutlineOpen(true)} + onToggleFiles={props.session.metadata?.path ? handleToggleFiles : undefined} + filesActive={false} + onToggleOutline={handleToggleOutline} + outlineActive={outlineOpen} api={props.api} onSessionDeleted={props.onBack} onSessionReopened={(newSessionId) => { diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index a65f84d0..c80ac68a 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -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 ( 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: { - {props.onViewFiles ? ( + {props.onToggleFiles ? ( ) : null} - {props.onOpenOutline ? ( + {props.onToggleOutline ? ( diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d174da5a..60b119bc 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a093dda4..a44efebd 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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', diff --git a/web/src/router.tsx b/web/src/router.tsx index 40847b86..84222fef 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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): { outline?: boolean } => { + const outline = search.outline === true || search.outline === 'true' + return outline ? { outline: true } : {} + }, component: SessionDetailRoute, }) diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index a77e05b7..0d253239 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -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 ( - - - - ) -} - function RefreshIcon(props: { className?: string }) { return ( (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 ( +
+ +
+ ) + } + return (
-
-
- -
-
{t('files.page.title')}
-
{subtitle}
-
- -
-
+ { + navigate({ + to: '/sessions/$sessionId/files', + params: { sessionId: newSessionId }, + replace: true, + }) + }} + />
-
-
- +
+
+ 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" />
+