feat(web): add directory quick session action (#624)

* fix(codex): support app-server plan mode

* fix(codex): broaden plan mode compatibility checks

* feat(web): add directory quick session action

* fix(web): hide quick session action for unknown directory
This commit is contained in:
NightWatcher314
2026-05-15 22:34:59 +08:00
committed by GitHub
parent 089ddad476
commit be0a41172a
5 changed files with 128 additions and 1 deletions
@@ -0,0 +1,97 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { SessionSummary } from '@/types/api'
import { I18nProvider } from '@/lib/i18n-context'
import { SessionList } from './SessionList'
afterEach(() => cleanup())
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return {
active: false,
thinking: false,
activeAt: 0,
updatedAt: 0,
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
model: null,
effort: null,
...overrides
}
}
function renderWithProviders(children: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
}
})
return render(
<QueryClientProvider client={queryClient}>
<I18nProvider>
{children}
</I18nProvider>
</QueryClientProvider>
)
}
describe('SessionList directory action', () => {
it('starts a new session with the project machine and directory', () => {
const onNewSessionInDirectory = vi.fn()
const session = makeSession({
id: 'session-1',
updatedAt: Date.now(),
metadata: {
path: '/home/ubuntu',
machineId: 'machine-1',
name: 'Greeting',
flavor: 'codex',
}
})
renderWithProviders(
<SessionList
sessions={[session]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onNewSessionInDirectory={onNewSessionInDirectory}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
machineLabelsById={{ 'machine-1': 'Mint' }}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'New session in this directory' }))
expect(onNewSessionInDirectory).toHaveBeenCalledWith({
machineId: 'machine-1',
directory: '/home/ubuntu',
})
})
it('hides the directory action for sessions without path metadata', () => {
renderWithProviders(
<SessionList
sessions={[makeSession({ id: 'session-without-path' })]}
selectedSessionId={null}
onSelect={vi.fn()}
onNewSession={vi.fn()}
onNewSessionInDirectory={vi.fn()}
onRefresh={vi.fn()}
isLoading={false}
renderHeader={false}
api={null}
/>
)
expect(screen.queryByRole('button', { name: 'New session in this directory' })).toBeNull()
})
})
+20 -1
View File
@@ -687,6 +687,7 @@ export function SessionList(props: {
sessions: SessionSummary[]
onSelect: (sessionId: string) => void
onNewSession: () => void
onNewSessionInDirectory?: (args: { machineId: string | null; directory: string }) => void
onBrowse?: () => void
onRefresh: () => void
isLoading: boolean
@@ -696,7 +697,7 @@ export function SessionList(props: {
selectedSessionId?: string | null
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props
const [searchQuery, setSearchQuery] = useState('')
const normalizedQuery = normalizeSearch(searchQuery)
const isSearching = normalizedQuery.length > 0
@@ -905,6 +906,7 @@ export function SessionList(props: {
const visibleGroupSessions = getVisibleGroupSessions(group)
const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length
const sessionGroupExpanded = isSessionGroupExpanded(group)
const canStartInGroupDirectory = group.directory !== 'Other'
return (
<div key={group.key}>
<div
@@ -917,6 +919,23 @@ export function SessionList(props: {
{group.displayName}
</span>
<CopyPathButton path={group.directory} className="opacity-0 group-hover/project:opacity-100 transition-opacity duration-150" />
{onNewSessionInDirectory && canStartInGroupDirectory ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onNewSessionInDirectory({
machineId: group.machineId,
directory: group.directory
})
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] opacity-70 transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-link)] hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
title={t('sessions.group.new')}
aria-label={t('sessions.group.new')}
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
) : null}
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">
({group.sessions.length})
</span>
+1
View File
@@ -52,6 +52,7 @@ export default {
'sessions.search.noResults': 'No sessions match your search.',
'sessions.group.showMore': 'Show {n} more',
'sessions.group.showLess': 'Show less',
'sessions.group.new': 'New session in this directory',
// Session list
'session.item.path': 'path',
+1
View File
@@ -52,6 +52,7 @@ export default {
'sessions.search.noResults': '没有匹配的会话。',
'sessions.group.showMore': '再显示 {n} 个',
'sessions.group.showLess': '收起',
'sessions.group.new': '在此目录新建会话',
// Session list
'session.item.path': '路径',
+9
View File
@@ -150,6 +150,14 @@ function SessionsPage() {
const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null
const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/'
const sidebar = useSidebarResize()
const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => {
navigate({
to: '/sessions/new',
search: args.machineId
? { directory: args.directory, machineId: args.machineId }
: { directory: args.directory }
})
}, [navigate])
return (
<div className="flex h-full min-h-0">
@@ -205,6 +213,7 @@ function SessionsPage() {
params: { sessionId },
})}
onNewSession={() => navigate({ to: '/sessions/new' })}
onNewSessionInDirectory={handleNewSessionInDirectory}
onBrowse={() => navigate({ to: '/browse' })}
onRefresh={handleRefresh}
isLoading={isLoading}