From 8367f1f2b46fab7e15b2935b15d04aaaa8830cb2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 19:08:26 +0000 Subject: [PATCH] loop: todo-header-mobile completed after 3 iterations --- .../message/SessionTodoDisplay.test.tsx | 103 ++++++ .../components/message/SessionTodoDisplay.tsx | 4 +- frontend/src/pages/SessionDetail.tsx | 3 +- .../SessionDetail.todo-header.test.tsx | 330 ++++++++++++++++++ 4 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/message/SessionTodoDisplay.test.tsx create mode 100644 frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx diff --git a/frontend/src/components/message/SessionTodoDisplay.test.tsx b/frontend/src/components/message/SessionTodoDisplay.test.tsx new file mode 100644 index 00000000..94960211 --- /dev/null +++ b/frontend/src/components/message/SessionTodoDisplay.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SessionTodoDisplay } from './SessionTodoDisplay' +import { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from './SessionTodoDisplay' + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, +] + +describe('SessionTodoDisplay', () => { + beforeEach(() => { + useSessionTodos.setState({ todos: new Map() }) + }) + + it('renders collapsed by default', () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + expect(screen.queryByText('Add regression tests')).not.toBeInTheDocument() + }) + + it('expands to show a small scrollable task preview when clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + expect(screen.getByText('Verify completed item grouping')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('collapses again when expanded header is clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render when all tasks are completed', () => { + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('dismisses current todo signature and reappears when todo status changes', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + const { rerender } = render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + const dismissButton = screen.getByLabelText('Dismiss tasks') + await user.click(dismissButton) + + expect(screen.queryByText('Tasks: 1/3 complete')).not.toBeInTheDocument() + + const updatedTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'completed', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, + ] + useSessionTodos.getState().setTodos('session-1', updatedTodos) + + rerender() + + expect(screen.getByText('Tasks: 2/3 complete')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/SessionTodoDisplay.tsx b/frontend/src/components/message/SessionTodoDisplay.tsx index af320298..f8894133 100644 --- a/frontend/src/components/message/SessionTodoDisplay.tsx +++ b/frontend/src/components/message/SessionTodoDisplay.tsx @@ -15,7 +15,7 @@ const todoSignature = (todos: Todo[]) => export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { const todos = useSessionTodosForSession(sessionID) - const [isCollapsed, setIsCollapsed] = useState(false) + const [isCollapsed, setIsCollapsed] = useState(true) const [isDismissed, setIsDismissed] = useState(false) const dismissedSignatureRef = useRef('') @@ -129,7 +129,7 @@ export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { -
+
{renderGroup('In Progress', inProgress)} {renderGroup('Pending', pending)} {renderGroup('Completed', completedTodos)} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 135b149f..7ade79e8 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -457,9 +457,10 @@ export function SessionDetail() { className="h-dvh max-h-dvh overflow-hidden bg-gradient-to-br from-background via-background to-background flex flex-col" >
diff --git a/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx new file mode 100644 index 00000000..73fcc4d3 --- /dev/null +++ b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from '@/components/message/SessionTodoDisplay' +import { SessionDetail } from '../SessionDetail' + +const mocks = vi.hoisted(() => ({ + useSession: vi.fn(), + useMessages: vi.fn(), + useSSE: vi.fn(), + useRepoActivity: vi.fn(), + usePermissions: vi.fn(), + useQuestions: vi.fn(), + useSSEHealth: vi.fn(), + useConfig: vi.fn(), + useOpenCodeClient: vi.fn(), + useSettings: vi.fn(), + useSettingsDialog: vi.fn(), + useMobile: vi.fn(), + useVisualViewport: vi.fn(), + useKeyboardShortcuts: vi.fn(), + useAutoScroll: vi.fn(), + useDialogParam: vi.fn(), + useSidebarAction: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSession: mocks.useSession, + useAbortSession: vi.fn(() => ({ mutate: vi.fn() })), + useUpdateSession: vi.fn(() => ({ mutate: vi.fn() })), + useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })), + useMessages: mocks.useMessages, + useConfig: mocks.useConfig, +})) + +vi.mock('@/hooks/useModelSelection', () => ({ + useModelSelection: vi.fn(() => ({ model: null, modelString: null })), +})) + +vi.mock('@/hooks/useOpenCodeClient', () => ({ + useOpenCodeClient: mocks.useOpenCodeClient, +})) + +vi.mock('@/hooks/useTTS', () => ({ + useTTS: vi.fn(() => ({ isEnabled: false })), +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + })), +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: vi.fn(() => ({ open: vi.fn() })), +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: vi.fn(() => false), + useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), +})) + +vi.mock('@/hooks/useVisualViewport', () => ({ + useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })), +})) + +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })), +})) + +vi.mock('@/hooks/useAutoScroll', () => ({ + useAutoScroll: vi.fn(() => ({ scrollToBottom: vi.fn() })), +})) + +vi.mock('@/hooks/useDialogParam', () => ({ + useDialogParam: vi.fn(() => [false, vi.fn()]), +})) + +vi.mock('@/hooks/useSidebarAction', () => ({ + useSidebarAction: vi.fn(() => {}), +})) + +vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ + getAssistantText: vi.fn(() => ''), + getLatestPlayableAssistantMessage: vi.fn(() => null), + useAutoPlayLastResponse: vi.fn(() => {}), +})) + +vi.mock('@/stores/uiStateStore', () => ({ + useUIState: vi.fn(() => vi.fn()), +})) + +vi.mock('@/stores/sessionStatusStore', () => ({ + useSessionStatus: vi.fn(() => ({ setStatus: vi.fn() })), + useSessionStatusForSession: vi.fn(() => ({ type: 'idle' })), +})) + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: mocks.useSSE, +})) + +vi.mock('@/hooks/useRepoActivity', () => ({ + useRepoActivity: mocks.useRepoActivity, +})) + +vi.mock('@/contexts/EventContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + usePermissions: mocks.usePermissions, + useQuestions: mocks.useQuestions, + useSSEHealth: mocks.useSSEHealth, + } +}) + +vi.mock('@/api/repos', () => ({ + getRepo: vi.fn(() => Promise.resolve({ + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: '/test/repo', + sourcePath: null, + fullPath: '/test/repo', + branch: 'main', + currentBranch: 'main', + fullSlug: 'test/repo', + repoType: 'github' as const, + })), + initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), +})) + +vi.mock('@/components/model/ModelSelectDialog', () => ({ + ModelSelectDialog: vi.fn(() => null), +})) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: vi.fn(() => null), +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoMcpDialog', () => ({ + RepoMcpDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ + ResetPermissionsDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoLspDialog', () => ({ + RepoLspDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ + RepoSkillsDialog: vi.fn(() => null), +})) + +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: vi.fn(() => null), +})) + +vi.mock('@/components/session/QuestionPrompt', () => ({ + QuestionPrompt: vi.fn(() => null), +})) + +vi.mock('@/components/session/MinimizedQuestionIndicator', () => ({ + MinimizedQuestionIndicator: vi.fn(() => null), +})) + +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ + PendingActionsGroup: vi.fn(() => null), +})) + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +describe('SessionDetail todo-header integration', () => { + beforeEach(() => { + vi.clearAllMocks() + useSessionTodos.setState({ todos: new Map() }) + + mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useMessages.mockReturnValue({ data: [], isLoading: false }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + mocks.useRepoActivity.mockReturnValue(undefined) + mocks.usePermissions.mockReturnValue({ + pendingCount: 0, + hasPermissionsForSession: vi.fn(() => false), + syncForSession: vi.fn(), + }) + mocks.useQuestions.mockReturnValue({ + current: null, + pendingCount: 0, + hasQuestionsForSession: vi.fn(() => false), + reply: vi.fn(), + reject: vi.fn(), + syncForSession: vi.fn(), + }) + mocks.useSSEHealth.mockReturnValue({ isHealthy: true }) + mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useOpenCodeClient.mockReturnValue({}) + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn() }) + mocks.useMobile.mockReturnValue(false) + mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) + mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false }) + mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() }) + mocks.useDialogParam.mockReturnValue([false, vi.fn()]) + mocks.useSidebarAction.mockReturnValue(undefined) + }) + + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + const renderSessionDetail = (sessionId: string, repoId: number) => { + return render( + + + + } /> + + + + ) + } + + it('renders SessionTodoDisplay collapsed by default inside SessionDetail header', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + }) + + it('expands todo list when clicked inside SessionDetail header', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('header wrapper uses max-h-72 sm:max-h-80 and overflow-hidden for proper containment', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByTestId('session-header-region')).toBeInTheDocument() + }) + + const headerRegion = screen.getByTestId('session-header-region') + + expect(headerRegion.className).toContain('max-h-72') + expect(headerRegion.className).toContain('sm:max-h-80') + expect(headerRegion.className).toContain('overflow-hidden') + expect(headerRegion.className).not.toContain('max-h-40') + }) + + it('collapses todo list when expanded header is clicked again', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render SessionTodoDisplay when all tasks are completed', async () => { + const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, + ] + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + const headerRegion = screen.getByTestId('session-header-region') + expect(headerRegion).toBeInTheDocument() + }) + + expect(screen.queryByText(/Tasks:/)).not.toBeInTheDocument() + }) +})