mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): collapse session search into the sidebar toolbar row
Move the session-list search out of its dedicated full-width row and into the sidebar toolbar, so the sidebar gets one compact header row instead of two stacked rows. Behavior: - Collapsed by default: a search icon sits at the left of the toolbar row, on the same line as the existing tool icons (codex import, refresh, browse, settings, new session). - Clicking the icon expands the input to fill the entire row width (tool icons hide) and focuses the input; when focus leaves the search container the input collapses back to the icon. - Query and date range stay applied while collapsed; the icon shows an indicator dot whenever any filter is active. - The date-range picker stays inside the expanded input. Implementation: - SessionList gains a headerActions prop and renders a single header row: [collapsible search] [spacer] [optional renderHeader plus] [headerActions]. - SessionsPage drops its own toolbar wrapper and passes its five buttons through headerActions instead. - The safe-area top inset moves to the router container wrapping both the error banner and the list, so iOS PWA error text no longer sits beneath the status bar/notch. Focus handling (review follow-ups): - Controls that unmount themselves on click (clear-query X, picker backdrop, range-end selection, picker footer Clear) silently moved focus to <body>, leaving the search expanded but no longer collapsible via blur. Each now returns focus to the input; the picker footer Clear is wired through a dedicated onClear prop. - Header actions can no longer be suppressed by a stale expanded state: they render whenever the search control itself is absent (e.g. the session list empties via SSE), and the expansion resets when the list becomes empty. i18n: add sessions.search.open (en / zh-CN). Tests: adapt existing search/date-filter tests to expand the search before interacting; add regressions for collapse-on-blur query persistence, focus restore after clearing the query and after clearing the date range, and header actions surviving an emptied session list.
This commit is contained in:
@@ -120,9 +120,11 @@ describe('SessionList directory action', () => {
|
||||
const listContent = projectHeader.parentElement?.parentElement
|
||||
expect(listContent).not.toHaveClass('pt-1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
const searchInput = screen.getByPlaceholderText(/Search sessions/)
|
||||
const searchWrapper = searchInput.parentElement?.parentElement
|
||||
expect(searchWrapper).toHaveClass('pb-1')
|
||||
const headerRow = searchInput.parentElement?.parentElement
|
||||
expect(headerRow).toHaveClass('px-2')
|
||||
expect(headerRow).toHaveClass('py-1')
|
||||
})
|
||||
|
||||
it('hides the directory action for sessions without path metadata', () => {
|
||||
@@ -182,6 +184,7 @@ describe('SessionList time filter', () => {
|
||||
expect(screen.getByRole('button', { name: /Recent session/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Old session/ })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' }))
|
||||
const emptyDate = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() })
|
||||
const activeDate = screen.getByRole('button', { name: `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity` })
|
||||
@@ -215,6 +218,7 @@ describe('SessionList time filter', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter sessions by last activity' }))
|
||||
const today = screen.getByRole('button', { name: new Date(2026, 6, 18).toLocaleDateString() })
|
||||
const anotherDay = screen.getByRole('button', { name: new Date(2026, 6, 17).toLocaleDateString() })
|
||||
@@ -244,6 +248,7 @@ describe('SessionList time filter', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' })
|
||||
fireEvent.click(filterButton)
|
||||
const startDate = screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() })
|
||||
@@ -256,6 +261,41 @@ describe('SessionList time filter', () => {
|
||||
expect(filterButton).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(filterButton).toHaveAttribute('title', '2026-07-01 – 2026-07-18')
|
||||
})
|
||||
|
||||
it('returns focus to the search input after clearing the date range', () => {
|
||||
const session = makeSession({
|
||||
id: 'session-1',
|
||||
updatedAt: Date.now(),
|
||||
metadata: { path: '/work/hapi', name: 'Session' }
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<SessionList
|
||||
sessions={[session]}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
api={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
const input = screen.getByPlaceholderText('Search sessions…')
|
||||
const filterButton = screen.getByRole('button', { name: 'Filter sessions by last activity' })
|
||||
fireEvent.click(filterButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: new Date(2026, 6, 1).toLocaleDateString() }))
|
||||
fireEvent.click(screen.getByRole('button', { name: `${new Date(2026, 6, 18).toLocaleDateString()}, has session activity` }))
|
||||
|
||||
// The footer Clear button unmounts with the range; focus must not drop to body.
|
||||
fireEvent.click(filterButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
|
||||
expect(input).toHaveFocus()
|
||||
expect(filterButton).toHaveAttribute('title', 'Filter sessions by last activity')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionList action menu parity', () => {
|
||||
@@ -432,6 +472,7 @@ describe('SessionList collapse behavior', () => {
|
||||
}))
|
||||
|
||||
render(renderSessionList(sessions, null))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search sessions…'), {
|
||||
target: { value: 'Matching task' },
|
||||
})
|
||||
@@ -547,3 +588,124 @@ describe('SessionList collapse behavior', () => {
|
||||
expect(screen.getByRole('button', { name: 'Expand 1' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionList search toggle', () => {
|
||||
it('expands on icon click and keeps filtering after collapsing on blur', () => {
|
||||
const sessions = [
|
||||
makeSession({
|
||||
id: 'session-match',
|
||||
updatedAt: 100,
|
||||
metadata: { path: '/work/hapi', name: 'Matching task', flavor: 'codex' },
|
||||
}),
|
||||
makeSession({
|
||||
id: 'session-other',
|
||||
updatedAt: 90,
|
||||
metadata: { path: '/work/hapi', name: 'Other task', flavor: 'codex' },
|
||||
}),
|
||||
]
|
||||
|
||||
renderWithProviders(
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
api={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// Collapsed by default: only the toggle icon is rendered.
|
||||
expect(screen.queryByPlaceholderText('Search sessions…')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
const input = screen.getByPlaceholderText('Search sessions…')
|
||||
expect(input).toHaveFocus()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Matching' } })
|
||||
expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull()
|
||||
|
||||
// Blur collapses back to the icon; the query stays applied.
|
||||
fireEvent.blur(input)
|
||||
expect(screen.queryByPlaceholderText('Search sessions…')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Search sessions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Matching task/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Other task/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('stays expanded with focus on the input after clearing the query', () => {
|
||||
renderWithProviders(
|
||||
<SessionList
|
||||
sessions={[makeSession({
|
||||
id: 'session-1',
|
||||
updatedAt: 100,
|
||||
metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' },
|
||||
})]}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
api={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
const input = screen.getByPlaceholderText('Search sessions…')
|
||||
fireEvent.change(input, { target: { value: 'Task' } })
|
||||
|
||||
// The clear button unmounts itself; focus must return to the input so a
|
||||
// later outside click still collapses the search via the wrapper blur.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
|
||||
expect(input).toHaveFocus()
|
||||
expect(input).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('Search sessions…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps header actions visible when sessions become empty while search is expanded', () => {
|
||||
const renderList = (sessions: SessionSummary[]) => (
|
||||
<QueryClientProvider client={new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
}
|
||||
})}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedSessionId={null}
|
||||
onSelect={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
isLoading={false}
|
||||
renderHeader={false}
|
||||
headerActions={<button type="button">Refresh</button>}
|
||||
api={null}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
const { rerender } = render(renderList([
|
||||
makeSession({
|
||||
id: 'session-1',
|
||||
updatedAt: 100,
|
||||
metadata: { path: '/work/hapi', name: 'Task', flavor: 'codex' },
|
||||
}),
|
||||
]))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
|
||||
|
||||
rerender(renderList([]))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Search sessions' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,7 @@ describe('SessionList machine filter', () => {
|
||||
})
|
||||
])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search sessions…'), { target: { value: 'alpha' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Teemo \(1\)/ }))
|
||||
|
||||
|
||||
@@ -537,6 +537,7 @@ function SessionDateRangePicker(props: {
|
||||
end: string
|
||||
sessionActivityDates: ReadonlySet<string>
|
||||
onChange: (start: string, end: string) => void
|
||||
onClear: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -628,7 +629,7 @@ function SessionDateRangePicker(props: {
|
||||
: `${props.start} – ${props.end}`}
|
||||
</span>
|
||||
{props.start ? (
|
||||
<button type="button" onClick={() => props.onChange('', '')} className="text-[var(--app-link)]">
|
||||
<button type="button" onClick={props.onClear} className="text-[var(--app-link)]">
|
||||
{t('sessions.timeFilter.clear')}
|
||||
</button>
|
||||
) : null}
|
||||
@@ -644,61 +645,112 @@ function SessionListSearch(props: {
|
||||
customEnd: string
|
||||
sessionActivityDates: ReadonlySet<string>
|
||||
onDateRangeChange: (start: string, end: string) => void
|
||||
expanded: boolean
|
||||
onExpandedChange: (expanded: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const hasDateRange = Boolean(props.customStart && props.customEnd)
|
||||
const hasActiveFilters = props.value.length > 0 || hasDateRange
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expanded) {
|
||||
inputRef.current?.focus()
|
||||
} else {
|
||||
setDatePickerOpen(false)
|
||||
}
|
||||
}, [props.expanded])
|
||||
|
||||
if (!props.expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onExpandedChange(true)}
|
||||
className="relative shrink-0 rounded-full p-1.5 text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.open')}
|
||||
aria-label={t('sessions.search.open')}
|
||||
>
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
{hasActiveFilters ? <span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-1">
|
||||
<div className="relative min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[var(--app-hint)]">
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
placeholder={t('sessions.search.placeholder')}
|
||||
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-16 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
/>
|
||||
{props.value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange('')}
|
||||
className="absolute inset-y-0 right-9 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.clear')}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
className="relative min-w-0 flex-1"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
props.onExpandedChange(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[var(--app-hint)]">
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
placeholder={t('sessions.search.placeholder')}
|
||||
className="w-full appearance-none rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] py-1.5 pl-8 pr-16 text-sm text-[var(--app-fg)] outline-none transition-colors placeholder:text-[var(--app-hint)] focus:border-[var(--app-link)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
/>
|
||||
{props.value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onChange('')
|
||||
// The clear button unmounts with the query; keep focus off <body>
|
||||
// so a later outside click still routes blur through the wrapper.
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="absolute inset-y-0 right-9 flex items-center rounded p-0.5 text-[var(--app-hint)] hover:text-[var(--app-fg)]"
|
||||
title={t('sessions.search.clear')}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="absolute inset-y-0 right-0 flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDatePickerOpen(open => !open)}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-r-lg rounded-l-md px-2 transition-colors hover:bg-[var(--app-subtle-bg)]',
|
||||
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
|
||||
)}
|
||||
title={hasDateRange ? `${props.customStart} – ${props.customEnd}` : t('sessions.timeFilter.label')}
|
||||
aria-label={t('sessions.timeFilter.label')}
|
||||
aria-expanded={datePickerOpen}
|
||||
>
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{hasDateRange ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
|
||||
</button>
|
||||
{datePickerOpen ? (
|
||||
<>
|
||||
<button type="button" aria-label={t('sessions.timeFilter.close')} className="fixed inset-0 z-20 cursor-default" onClick={() => {
|
||||
setDatePickerOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}} />
|
||||
<SessionDateRangePicker
|
||||
start={props.customStart}
|
||||
end={props.customEnd}
|
||||
sessionActivityDates={props.sessionActivityDates}
|
||||
onChange={props.onDateRangeChange}
|
||||
onClear={() => {
|
||||
props.onDateRangeChange('', '')
|
||||
// The footer Clear button unmounts once the range is
|
||||
// empty; return focus so the wrapper blur still works.
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
onClose={() => {
|
||||
setDatePickerOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<div className="absolute inset-y-0 right-0 flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDatePickerOpen(open => !open)}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-r-lg rounded-l-md px-2 transition-colors hover:bg-[var(--app-subtle-bg)]',
|
||||
hasDateRange ? 'text-[var(--app-link)]' : 'text-[var(--app-hint)]'
|
||||
)}
|
||||
title={hasDateRange ? `${props.customStart} – ${props.customEnd}` : t('sessions.timeFilter.label')}
|
||||
aria-label={t('sessions.timeFilter.label')}
|
||||
aria-expanded={datePickerOpen}
|
||||
>
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{hasDateRange ? <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--app-link)]" /> : null}
|
||||
</button>
|
||||
{datePickerOpen ? (
|
||||
<>
|
||||
<button type="button" aria-label={t('sessions.timeFilter.close')} className="fixed inset-0 z-20 cursor-default" onClick={() => setDatePickerOpen(false)} />
|
||||
<SessionDateRangePicker
|
||||
start={props.customStart}
|
||||
end={props.customEnd}
|
||||
sessionActivityDates={props.sessionActivityDates}
|
||||
onChange={props.onDateRangeChange}
|
||||
onClose={() => setDatePickerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -896,6 +948,7 @@ export function SessionList(props: {
|
||||
onRefresh: () => void
|
||||
isLoading: boolean
|
||||
renderHeader?: boolean
|
||||
headerActions?: React.ReactNode
|
||||
api: ApiClient | null
|
||||
machineLabelsById?: Record<string, string>
|
||||
machinesById?: Record<string, Machine>
|
||||
@@ -909,6 +962,7 @@ export function SessionList(props: {
|
||||
const { machineFilter, setMachineFilter } = useSessionListMachineFilter()
|
||||
const showDetailedStatus = sessionListStatusMode === 'detailed'
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchExpanded, setSearchExpanded] = useState(false)
|
||||
const [customStart, setCustomStart] = useState('')
|
||||
const [customEnd, setCustomEnd] = useState('')
|
||||
const [, setCodexImportedSessionsVersion] = useState(0)
|
||||
@@ -1121,36 +1175,55 @@ export function SessionList(props: {
|
||||
})
|
||||
}, [allGroups])
|
||||
|
||||
// The search control unmounts when the list empties; reset the expansion so
|
||||
// it cannot suppress header actions (or re-expand on its own when sessions
|
||||
// return) while no search control is rendered.
|
||||
const showSearch = props.sessions.length > 0
|
||||
useEffect(() => {
|
||||
if (!showSearch) setSearchExpanded(false)
|
||||
}, [showSearch])
|
||||
|
||||
const showHeaderRow = showSearch || renderHeader || Boolean(props.headerActions)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="session-list-scrollbar-offset mx-auto w-full max-w-content shrink-0">
|
||||
{renderHeader ? (
|
||||
<div className="flex items-center justify-end px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewSession}
|
||||
className="session-list-new-button flex h-9 w-9 items-center justify-center rounded-full text-[var(--app-link)] transition-colors"
|
||||
title={t('sessions.new')}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{showHeaderRow ? (
|
||||
<div className="flex items-center gap-1 px-2 py-1">
|
||||
{showSearch ? (
|
||||
<SessionListSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
sessionActivityDates={sessionActivityDates}
|
||||
onDateRangeChange={(start, end) => {
|
||||
setCustomStart(start)
|
||||
setCustomEnd(end)
|
||||
}}
|
||||
expanded={searchExpanded}
|
||||
onExpandedChange={setSearchExpanded}
|
||||
/>
|
||||
) : null}
|
||||
{!(showSearch && searchExpanded) ? (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
{renderHeader ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewSession}
|
||||
className="session-list-new-button flex h-9 w-9 items-center justify-center rounded-full text-[var(--app-link)] transition-colors"
|
||||
title={t('sessions.new')}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
{props.headerActions}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length > 0 ? (
|
||||
<SessionListSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
sessionActivityDates={sessionActivityDates}
|
||||
onDateRangeChange={(start, end) => {
|
||||
setCustomStart(start)
|
||||
setCustomEnd(end)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.sessions.length === 0 && (
|
||||
<SessionsEmptyState
|
||||
onNewSession={props.onNewSession}
|
||||
|
||||
@@ -61,6 +61,7 @@ export default {
|
||||
'sessions.empty.startSession': 'Start a session',
|
||||
'sessions.empty.browse': 'Browse workspace',
|
||||
'sessions.search.placeholder': 'Search sessions…',
|
||||
'sessions.search.open': 'Search sessions',
|
||||
'sessions.search.clear': 'Clear search',
|
||||
'sessions.search.noResults': 'No sessions match your filters.',
|
||||
'sessions.timeFilter.label': 'Filter sessions by last activity',
|
||||
|
||||
@@ -61,6 +61,7 @@ export default {
|
||||
'sessions.empty.startSession': '启动会话',
|
||||
'sessions.empty.browse': '浏览 workspace',
|
||||
'sessions.search.placeholder': '搜索会话…',
|
||||
'sessions.search.open': '搜索会话',
|
||||
'sessions.search.clear': '清除搜索',
|
||||
'sessions.search.noResults': '没有符合筛选条件的会话。',
|
||||
'sessions.timeFilter.label': '按最后活动时间筛选会话',
|
||||
|
||||
+40
-43
@@ -231,49 +231,7 @@ function SessionsPage() {
|
||||
className={`${isSessionsIndex ? 'flex' : 'hidden split:flex'} w-full shrink-0 flex-col bg-[var(--app-bg)]`}
|
||||
style={{ '--sidebar-w': `${sidebar.width}px` } as React.CSSProperties}
|
||||
>
|
||||
<div className="session-list-scrollbar-offset shrink-0 bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
|
||||
<div className="mx-auto flex w-full max-w-content items-center justify-end px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-label={t('button.refresh')}
|
||||
aria-busy={isLoading}
|
||||
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors disabled:opacity-60 disabled:cursor-wait"
|
||||
title={t('button.refresh')}
|
||||
>
|
||||
<RefreshIcon className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/browse' })}
|
||||
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('browse.nav')}
|
||||
>
|
||||
<FolderOpenIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<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 flex h-9 w-9 items-center justify-center 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 min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[env(safe-area-inset-top)]">
|
||||
{error ? (
|
||||
<div className="mx-auto w-full max-w-content px-3 py-2">
|
||||
<div className="text-sm text-red-600">{error}</div>
|
||||
@@ -292,6 +250,45 @@ function SessionsPage() {
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={isLoading}
|
||||
renderHeader={false}
|
||||
headerActions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-label={t('button.refresh')}
|
||||
aria-busy={isLoading}
|
||||
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors disabled:opacity-60 disabled:cursor-wait"
|
||||
title={t('button.refresh')}
|
||||
>
|
||||
<RefreshIcon className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/browse' })}
|
||||
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('browse.nav')}
|
||||
>
|
||||
<FolderOpenIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<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 flex h-9 w-9 items-center justify-center rounded-full text-[var(--app-link)] transition-colors"
|
||||
title={t('sessions.new')}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
api={api}
|
||||
machineLabelsById={machineLabelsById}
|
||||
machinesById={machinesById}
|
||||
|
||||
Reference in New Issue
Block a user