mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -52,6 +52,7 @@ export default {
|
||||
'sessions.search.noResults': '没有匹配的会话。',
|
||||
'sessions.group.showMore': '再显示 {n} 个',
|
||||
'sessions.group.showLess': '收起',
|
||||
'sessions.group.new': '在此目录新建会话',
|
||||
|
||||
// Session list
|
||||
'session.item.path': '路径',
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user