diff --git a/web/src/__tests__/task-detail-mantine.test.tsx b/web/src/__tests__/task-detail-mantine.test.tsx index aa329a13..ebf7502d 100644 --- a/web/src/__tests__/task-detail-mantine.test.tsx +++ b/web/src/__tests__/task-detail-mantine.test.tsx @@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({ updateField: vi.fn(), updateProgress: vi.fn(), onOpenChange: vi.fn(), + taskFeatureSettings: { + enableAttachments: true, + enableComments: false, + enableDependencies: false, + enableTimeTracking: false, + }, + progressContent: { value: '## Learnings\n- Mantine task detail renders' }, })); vi.mock('@/hooks/useDebouncedSave', () => ({ @@ -48,10 +55,7 @@ vi.mock('@/hooks/useFeatureSettings', () => ({ useFeatureSettings: () => ({ settings: { tasks: { - enableAttachments: true, - enableComments: false, - enableDependencies: false, - enableTimeTracking: false, + ...mocks.taskFeatureSettings, }, agents: { enablePreview: true, @@ -97,7 +101,7 @@ vi.mock('@/hooks/useTasks', () => ({ vi.mock('@/hooks/useTaskProgress', () => ({ useTaskProgress: () => ({ - data: '## Learnings\n- Mantine task detail renders', + data: mocks.progressContent.value, isLoading: false, }), useUpdateProgress: () => ({ mutateAsync: mocks.updateProgress, isPending: false }), @@ -131,6 +135,10 @@ vi.mock('@/components/task/AttachmentsSection', () => ({ AttachmentsSection: () =>
Attachments section
, })); +vi.mock('@/components/task/DependenciesSection', () => ({ + DependenciesSection: () =>
Dependencies section
, +})); + vi.mock('@/components/task/ObservationsSection', () => ({ ObservationsSection: () =>
Observations section
, })); @@ -195,6 +203,13 @@ describe('task detail Mantine migration', () => { mocks.archiveTask.mockResolvedValue(undefined); mocks.deleteTask.mockResolvedValue(undefined); mocks.updateProgress.mockResolvedValue(undefined); + Object.assign(mocks.taskFeatureSettings, { + enableAttachments: true, + enableComments: false, + enableDependencies: false, + enableTimeTracking: false, + }); + mocks.progressContent.value = '## Learnings\n- Mantine task detail renders'; }); afterEach(() => { @@ -272,6 +287,30 @@ describe('task detail Mantine migration', () => { ); }); + it('groups every enabled preparation destination under Plan with stable wayfinding', async () => { + mocks.taskFeatureSettings.enableDependencies = true; + const user = userEvent.setup(); + renderTaskDetail(); + + const planSections = screen.getByRole('tablist', { name: 'Plan sections' }); + expect( + within(planSections) + .getAllByRole('tab') + .map((tab) => tab.textContent) + ).toEqual(['Details', 'Progress', 'Observations', 'Dependencies', 'Attachments']); + + await user.click(within(planSections).getByRole('tab', { name: 'Dependencies' })); + + const scrollRegion = screen.getByTestId('task-detail-scroll-region'); + expect(scrollRegion.getAttribute('aria-labelledby')).toBe( + 'task-workspace-mode-heading task-workspace-section-heading' + ); + expect(within(scrollRegion).getByRole('heading', { level: 3 }).textContent).toBe( + 'Dependencies' + ); + expect(await within(scrollRegion).findByText('Dependencies section')).toBeDefined(); + }); + it('applies description height and vertical resizing to the textarea input', () => { renderWithProviders( { expect(baseElement.querySelector('[data-slot="textarea"]')).toBeNull(); }); + it('keeps empty progress notes compact until editing starts', async () => { + mocks.progressContent.value = ''; + const user = userEvent.setup(); + renderTaskDetail(); + + await user.click(screen.getByRole('tab', { name: 'Progress' })); + + expect(await screen.findByText('No progress notes yet')).toBeDefined(); + expect(screen.queryByText('Progress Notes Best Practices:')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Add notes' })); + + expect(screen.getByPlaceholderText(/Document insights discovered during work/)).toBeDefined(); + }); + it('uses a direct Mantine modal for destructive delete confirmation', async () => { const user = userEvent.setup(); const { baseElement } = renderTaskDetail(); @@ -426,6 +480,41 @@ describe('task detail Mantine migration', () => { ); }); + it('preserves legacy Plan deep links for optional preparation sections', async () => { + mocks.taskFeatureSettings.enableDependencies = true; + const task = createMockTask({ id: 'task-plan-link', title: 'Prepare a task' }); + + const { rerender } = renderWithProviders( + + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: 'Dependencies' }).getAttribute('aria-selected')).toBe( + 'true' + ) + ); + + rerender( + + ); + + await waitFor(() => + expect(screen.getByRole('tab', { name: 'Attachments' }).getAttribute('aria-selected')).toBe( + 'true' + ) + ); + }); + it('falls back when the active tab becomes unavailable for the task data', async () => { const user = userEvent.setup(); const codeTask = createMockTask({ diff --git a/web/src/__tests__/task-detail-support-sections-mantine.test.tsx b/web/src/__tests__/task-detail-support-sections-mantine.test.tsx index 2e8b72df..ada6bfe9 100644 --- a/web/src/__tests__/task-detail-support-sections-mantine.test.tsx +++ b/web/src/__tests__/task-detail-support-sections-mantine.test.tsx @@ -198,6 +198,27 @@ describe('task detail support sections Mantine migration', () => { }); }); + it('keeps an empty attachment section compact while preserving upload access', () => { + const task = createMockTask({ id: 'task-empty-attachments', attachments: [] }); + const { container } = renderWithProviders(); + + const uploadTarget = screen.getByRole('button', { name: 'Upload attachments' }); + expect(uploadTarget.className).toContain('p-4'); + expect(uploadTarget.className).not.toContain('p-8'); + expect(screen.getByText('Add supporting files')).toBeDefined(); + expect(screen.queryByText('No attachments yet')).toBeNull(); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { + target: { files: [new File(['plan'], 'plan.md', { type: 'text/markdown' })] }, + }); + + expect(mocks.uploadAttachment).toHaveBeenCalledWith({ + taskId: task.id, + formData: expect.any(FormData), + }); + }); + it('renders observations through direct Mantine select, slider, textarea, badges, and modal', async () => { const user = userEvent.setup(); const onAddObservation = vi.fn().mockResolvedValue(undefined); diff --git a/web/src/components/task/AttachmentsSection.tsx b/web/src/components/task/AttachmentsSection.tsx index 4b95eab9..8b442d6e 100644 --- a/web/src/components/task/AttachmentsSection.tsx +++ b/web/src/components/task/AttachmentsSection.tsx @@ -264,6 +264,14 @@ export function AttachmentsSection({ task }: AttachmentsSectionProps) { )} + handleFileSelect(e.target.files)} + className="hidden" + /> + {/* Upload zone */} - handleFileSelect(e.target.files)} - className="hidden" - /> - - - - - {uploadAttachment.isPending ? 'Uploading...' : 'Drop files here or click to browse'} - - - Max 10MB per file, 20 files total - + {attachments.length === 0 ? ( + + + + +
+ + {uploadAttachment.isPending ? 'Uploading...' : 'Add supporting files'} + + + Drop files here or browse. Max 10MB per file. + +
+
+ ) : ( + <> + + + + + {uploadAttachment.isPending ? 'Uploading...' : 'Drop files here or click to browse'} + + + Max 10MB per file, 20 files total + + + )}
{/* Attachments list */} - {attachments.length === 0 ? ( - - - No attachments yet - - - ) : ( + {attachments.length > 0 && ( {attachments.map((attachment) => ( diff --git a/web/src/components/task/ObservationsSection.tsx b/web/src/components/task/ObservationsSection.tsx index 905b45a5..1a799cda 100644 --- a/web/src/components/task/ObservationsSection.tsx +++ b/web/src/components/task/ObservationsSection.tsx @@ -276,7 +276,7 @@ export function ObservationsSection({ {observations.length === 0 && !isAdding && ( - + No observations yet. Add context, decisions, insights, or blockers as you work on this task. diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index 0b5aa738..36240c86 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -133,8 +133,9 @@ export function TaskDetailPanel({ isCodeTask, hasWorktree, attachmentsEnabled: taskSettings.enableAttachments, + dependenciesEnabled: taskSettings.enableDependencies, }), - [hasWorktree, isCodeTask, taskSettings.enableAttachments] + [hasWorktree, isCodeTask, taskSettings.enableAttachments, taskSettings.enableDependencies] ); const visibleTabs = useMemo( () => getAvailableTaskDetailTabs(tabAvailabilityContext), @@ -151,6 +152,7 @@ export function TaskDetailPanel({ () => visibleTabs.filter((tab) => getTaskWorkspaceDestination(tab.id).mode === activeMode), [activeMode, visibleTabs] ); + const activeTabMetadata = visibleTabs.find((tab) => tab.id === activeTab); const addObservationForTask = useMemo( () => async (data: TaskDetailObservationInput) => { @@ -401,7 +403,7 @@ export function TaskDetailPanel({
- + {activeModeMetadata?.label ?? 'Workspace'} @@ -490,9 +492,12 @@ export function TaskDetailPanel({
+ + {activeTabMetadata?.label ?? 'Task details'} + {visibleTabs.map((tab) => { const tabContent = ( } > - Edit + {isEmpty ? 'Add notes' : 'Edit'} )} @@ -118,17 +118,22 @@ export function ProgressTab({ task }: ProgressTabProps) { {/* View Mode */} {!isEditing && ( - + {isEmpty ? ( - - - + + + - No progress notes yet - - Click Edit to add learnings, issues, and next steps for future sessions - - +
+ + No progress notes yet + + Add learnings, issues, and next steps when they are useful. +
+ ) : ( {progress} )} @@ -136,17 +141,19 @@ export function ProgressTab({ task }: ProgressTabProps) { )} {/* Help Text */} - - - Progress Notes Best Practices: - -
    -
  • Document key learnings and insights discovered during work
  • -
  • Track issues encountered and their solutions
  • -
  • List next steps for future sessions to pick up where you left off
  • -
  • Use markdown sections (##) to organize by category
  • -
-
+ {!isEmpty && !isEditing && ( + + + Progress Notes Best Practices: + +
    +
  • Document key learnings and insights discovered during work
  • +
  • Track issues encountered and their solutions
  • +
  • List next steps for future sessions to pick up where you left off
  • +
  • Use markdown sections (##) to organize by category
  • +
+
+ )} ); } diff --git a/web/src/components/task/detail/TaskDetailsTab.tsx b/web/src/components/task/detail/TaskDetailsTab.tsx index e10cfe3d..77f68796 100644 --- a/web/src/components/task/detail/TaskDetailsTab.tsx +++ b/web/src/components/task/detail/TaskDetailsTab.tsx @@ -6,7 +6,6 @@ import { MarkdownRenderer } from '@/components/ui/MarkdownRenderer'; import { TaskMetadataSection } from './TaskMetadataSection'; import { SubtasksSection } from '../SubtasksSection'; import { VerificationSection } from '../VerificationSection'; -import { DependenciesSection } from '../DependenciesSection'; import { TimeTrackingSection } from '../TimeTrackingSection'; import { CommentsSection } from '../CommentsSection'; import { DeliverablesSection } from '../DeliverablesSection'; @@ -200,16 +199,6 @@ export function TaskDetailsTab({ - {/* Dependencies */} - {taskSettings.enableDependencies && ( - - onUpdate('blockedBy', blockedBy)} - /> - - )} - {/* Time Tracking */} {taskSettings.enableTimeTracking && ( diff --git a/web/src/components/task/task-detail-tabs.tsx b/web/src/components/task/task-detail-tabs.tsx index ea3bf7af..d741a099 100644 --- a/web/src/components/task/task-detail-tabs.tsx +++ b/web/src/components/task/task-detail-tabs.tsx @@ -9,6 +9,7 @@ import { Files, GitBranch, History, + Network, NotebookPen, Paperclip, type LucideIcon, @@ -42,6 +43,9 @@ const AgentRunTimelinePanel = lazy(() => const AttachmentsSection = lazy(() => import('./AttachmentsSection').then((mod) => ({ default: mod.AttachmentsSection })) ); +const DependenciesSection = lazy(() => + import('./DependenciesSection').then((mod) => ({ default: mod.DependenciesSection })) +); const DiffViewer = lazy(() => import('./DiffViewer').then((mod) => ({ default: mod.DiffViewer }))); const EvidenceTimelinePanel = lazy(() => import('@/components/evidence/EvidenceTimelinePanel').then((mod) => ({ @@ -124,6 +128,7 @@ const TAB_ICONS: Record = { Files, GitBranch, History, + Network, NotebookPen, Paperclip, }; @@ -157,6 +162,12 @@ const TAB_RENDERERS: Record ), + dependencies: ({ task, updateField }) => ( + updateField('blockedBy', blockedBy)} + /> + ), attachments: ({ task }) => , git: ({ task, updateField }) => ( updateField('git', git as Task['git'])} /> diff --git a/web/src/lib/__tests__/task-detail-workspace.test.ts b/web/src/lib/__tests__/task-detail-workspace.test.ts index fb80134f..832fda6f 100644 --- a/web/src/lib/__tests__/task-detail-workspace.test.ts +++ b/web/src/lib/__tests__/task-detail-workspace.test.ts @@ -16,6 +16,7 @@ describe('task workspace navigation', () => { work: 'overview', details: 'plan', progress: 'plan', + dependencies: 'plan', 'work-products': 'results', observations: 'plan', attachments: 'plan', @@ -49,9 +50,12 @@ describe('task workspace navigation', () => { isCodeTask: false, hasWorktree: false, attachmentsEnabled: false, + dependenciesEnabled: false, }); const modes = getAvailableTaskWorkspaceModeMetadata(tabs); + expect(tabs.some((tab) => tab.id === 'dependencies')).toBe(false); + expect(tabs.some((tab) => tab.id === 'attachments')).toBe(false); expect(modes.find((mode) => mode.id === 'run')?.disabled).toBe(true); expect(modes.filter((mode) => !mode.disabled).map((mode) => mode.id)).toEqual([ 'overview', @@ -66,9 +70,22 @@ describe('task workspace navigation', () => { isCodeTask: true, hasWorktree: false, attachmentsEnabled: true, + dependenciesEnabled: true, }); expect(resolveTaskDetailNavigationTab({ tab: 'timeline' }, tabs)).toBe('timeline'); + for (const section of [ + 'details', + 'progress', + 'observations', + 'dependencies', + 'attachments', + ] as const) { + expect(resolveTaskDetailNavigationTab({ tab: section }, tabs)).toBe(section); + expect( + resolveTaskDetailNavigationTab({ workspace: { version: 1, mode: 'plan', section } }, tabs) + ).toBe(section); + } expect( resolveTaskDetailNavigationTab( { workspace: { version: 1, mode: 'results', section: 'evidence' } }, diff --git a/web/src/lib/task-detail-tabs.ts b/web/src/lib/task-detail-tabs.ts index 206609f2..1ee53064 100644 --- a/web/src/lib/task-detail-tabs.ts +++ b/web/src/lib/task-detail-tabs.ts @@ -2,6 +2,7 @@ export type TaskDetailTabId = | 'work' | 'details' | 'progress' + | 'dependencies' | 'work-products' | 'observations' | 'attachments' @@ -32,6 +33,7 @@ export interface TaskDetailAvailabilityContext { isCodeTask: boolean; hasWorktree: boolean; attachmentsEnabled: boolean; + dependenciesEnabled: boolean; } export type TaskDetailTabIcon = @@ -44,6 +46,7 @@ export type TaskDetailTabIcon = | 'Files' | 'GitBranch' | 'History' + | 'Network' | 'NotebookPen' | 'Paperclip'; @@ -84,8 +87,8 @@ export const TASK_WORKSPACE_MODE_METADATA: readonly TaskWorkspaceModeMetadata[] { id: 'plan', label: 'Plan', - description: 'Task details, progress, observations, and supporting context.', - sections: ['details', 'progress', 'observations', 'attachments'], + description: 'Task details, progress, observations, dependencies, and supporting context.', + sections: ['details', 'progress', 'observations', 'dependencies', 'attachments'], }, { id: 'run', @@ -143,6 +146,13 @@ export const TASK_DETAIL_TAB_METADATA: readonly TaskDetailTabMetadata[] = [ icon: 'Eye', fallbackTitle: 'Observations section failed to load', }, + { + id: 'dependencies', + label: 'Dependencies', + icon: 'Network', + fallbackTitle: 'Dependencies section failed to load', + isVisible: ({ dependenciesEnabled }) => dependenciesEnabled, + }, { id: 'attachments', label: 'Attachments',