feat(web): migrate task preparation into Plan mode (#1345)

This commit is contained in:
Brad Groux 2026-09-02 22:20:04 -05:00 • committed by GitHub
parent c36fa5bb48
commit 1be8b9de38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 232 additions and 68 deletions

View file

@ -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: () => <div>Attachments section</div>,
}));
vi.mock('@/components/task/DependenciesSection', () => ({
DependenciesSection: () => <div>Dependencies section</div>,
}));
vi.mock('@/components/task/ObservationsSection', () => ({
ObservationsSection: () => <div>Observations section</div>,
}));
@ -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(
<MarkdownEditor
@ -332,6 +371,21 @@ describe('task detail Mantine migration', () => {
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(
<TaskDetailPanel
task={task}
open
onOpenChange={mocks.onOpenChange}
navigationTarget={{ tab: 'dependencies' }}
/>
);
await waitFor(() =>
expect(screen.getByRole('tab', { name: 'Dependencies' }).getAttribute('aria-selected')).toBe(
'true'
)
);
rerender(
<TaskDetailPanel
task={task}
open
onOpenChange={mocks.onOpenChange}
navigationTarget={{ workspace: { version: 1, mode: 'plan', section: 'attachments' } }}
/>
);
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({

View file

@ -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(<AttachmentsSection task={task} />);
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);

View file

@ -264,6 +264,14 @@ export function AttachmentsSection({ task }: AttachmentsSectionProps) {
</Alert>
)}
<input
ref={fileInputRef}
type="file"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{/* Upload zone */}
<Paper
role="button"
@ -279,39 +287,46 @@ export function AttachmentsSection({ task }: AttachmentsSectionProps) {
}
}}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
'cursor-pointer rounded-lg border-2 border-dashed transition-colors',
attachments.length === 0 ? 'p-4' : 'p-8 text-center',
isDragging && 'border-primary bg-primary/5',
!isDragging && 'border-muted-foreground/25 hover:border-muted-foreground/50',
uploadAttachment.isPending && 'opacity-50 pointer-events-none'
uploadAttachment.isPending && 'pointer-events-none opacity-50'
)}
radius="lg"
aria-label="Upload attachments"
>
<input
ref={fileInputRef}
type="file"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
<ThemeIcon variant="transparent" color="gray" size="xl" className="mx-auto mb-2">
<Upload className="h-8 w-8 text-muted-foreground" />
</ThemeIcon>
<Text size="sm" c="dimmed" className="mb-1">
{uploadAttachment.isPending ? 'Uploading...' : 'Drop files here or click to browse'}
</Text>
<Text size="xs" c="dimmed">
Max 10MB per file, 20 files total
</Text>
{attachments.length === 0 ? (
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="gray" size="md" className="flex-shrink-0">
<Upload className="h-4 w-4" />
</ThemeIcon>
<div className="min-w-0">
<Text size="sm" fw={500}>
{uploadAttachment.isPending ? 'Uploading...' : 'Add supporting files'}
</Text>
<Text size="xs" c="dimmed">
Drop files here or browse. Max 10MB per file.
</Text>
</div>
</Group>
) : (
<>
<ThemeIcon variant="transparent" color="gray" size="xl" className="mx-auto mb-2">
<Upload className="h-8 w-8 text-muted-foreground" />
</ThemeIcon>
<Text size="sm" c="dimmed" className="mb-1">
{uploadAttachment.isPending ? 'Uploading...' : 'Drop files here or click to browse'}
</Text>
<Text size="xs" c="dimmed">
Max 10MB per file, 20 files total
</Text>
</>
)}
</Paper>
{/* Attachments list */}
{attachments.length === 0 ? (
<Paper className="py-4 text-center" radius="md" withBorder>
<Text size="sm" c="dimmed" fs="italic">
No attachments yet
</Text>
</Paper>
) : (
{attachments.length > 0 && (
<Stack gap="xs">
{attachments.map((attachment) => (
<AttachmentItem key={attachment.id} taskId={task.id} attachment={attachment} />

View file

@ -276,7 +276,7 @@ export function ObservationsSection({
<Stack gap="xs">
{observations.length === 0 && !isAdding && (
<Text size="sm" c="dimmed" ta="center" className="py-8">
<Text size="sm" c="dimmed" ta="center" className="py-3">
No observations yet. Add context, decisions, insights, or blockers as you work on this
task.
</Text>

View file

@ -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({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div className="min-w-0">
<Text component="h2" fw={650} size="sm">
<Text id="task-workspace-mode-heading" component="h2" fw={650} size="sm">
{activeModeMetadata?.label ?? 'Workspace'}
</Text>
<Text size="xs" c="dimmed" className="mt-0.5 max-w-xl">
@ -490,9 +492,12 @@ export function TaskDetailPanel({
<div
className="veritas-overlay-scroll min-h-0 flex-1 overflow-y-scroll overscroll-contain px-4 py-4 sm:px-5 sm:py-5"
data-testid="task-detail-scroll-region"
aria-label={`${activeModeMetadata?.label ?? 'Task workspace'} content`}
aria-labelledby="task-workspace-mode-heading task-workspace-section-heading"
tabIndex={0}
>
<Text id="task-workspace-section-heading" component="h3" className="sr-only">
{activeTabMetadata?.label ?? 'Task details'}
</Text>
{visibleTabs.map((tab) => {
const tabContent = (
<Suspense

View file

@ -71,7 +71,7 @@ export function ProgressTab({ task }: ProgressTabProps) {
onClick={handleEdit}
leftSection={<Pencil className="h-3 w-3" />}
>
Edit
{isEmpty ? 'Add notes' : 'Edit'}
</Button>
)}
</Group>
@ -118,17 +118,22 @@ export function ProgressTab({ task }: ProgressTabProps) {
{/* View Mode */}
{!isEditing && (
<Paper className="min-h-[200px] border bg-card p-4" radius="lg">
<Paper
className={isEmpty ? 'border bg-card p-4' : 'min-h-[200px] border bg-card p-4'}
radius="lg"
>
{isEmpty ? (
<Stack align="center" gap={4} className="py-8 text-center text-muted-foreground">
<ThemeIcon color="gray" variant="subtle" size={48}>
<FileText className="h-8 w-8 opacity-50" />
<Group gap="sm" wrap="nowrap" className="text-muted-foreground">
<ThemeIcon color="gray" variant="light" size="md" className="flex-shrink-0">
<FileText className="h-4 w-4" />
</ThemeIcon>
<Text size="sm">No progress notes yet</Text>
<Text size="xs">
Click Edit to add learnings, issues, and next steps for future sessions
</Text>
</Stack>
<div className="min-w-0">
<Text size="sm" fw={500}>
No progress notes yet
</Text>
<Text size="xs">Add learnings, issues, and next steps when they are useful.</Text>
</div>
</Group>
) : (
<MarkdownText>{progress}</MarkdownText>
)}
@ -136,17 +141,19 @@ export function ProgressTab({ task }: ProgressTabProps) {
)}
{/* Help Text */}
<Stack gap={4} className="border-t pt-3 text-xs text-muted-foreground">
<Text size="xs" fw={500}>
Progress Notes Best Practices:
</Text>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Document key learnings and insights discovered during work</li>
<li>Track issues encountered and their solutions</li>
<li>List next steps for future sessions to pick up where you left off</li>
<li>Use markdown sections (##) to organize by category</li>
</ul>
</Stack>
{!isEmpty && !isEditing && (
<Stack gap={4} className="border-t pt-3 text-xs text-muted-foreground">
<Text size="xs" fw={500}>
Progress Notes Best Practices:
</Text>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Document key learnings and insights discovered during work</li>
<li>Track issues encountered and their solutions</li>
<li>List next steps for future sessions to pick up where you left off</li>
<li>Use markdown sections (##) to organize by category</li>
</ul>
</Stack>
)}
</Stack>
);
}

View file

@ -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({
<VerificationSection task={task} />
</Box>
{/* Dependencies */}
{taskSettings.enableDependencies && (
<Box className="border-t pt-4">
<DependenciesSection
task={task}
onBlockedByChange={(blockedBy) => onUpdate('blockedBy', blockedBy)}
/>
</Box>
)}
{/* Time Tracking */}
{taskSettings.enableTimeTracking && (
<Box className="border-t pt-4">

View file

@ -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<TaskDetailTabIcon, LucideIcon> = {
Files,
GitBranch,
History,
Network,
NotebookPen,
Paperclip,
};
@ -157,6 +162,12 @@ const TAB_RENDERERS: Record<TaskDetailTabId, (context: TaskDetailRenderContext)
onDeleteObservation={deleteObservation}
/>
),
dependencies: ({ task, updateField }) => (
<DependenciesSection
task={task}
onBlockedByChange={(blockedBy) => updateField('blockedBy', blockedBy)}
/>
),
attachments: ({ task }) => <AttachmentsSection task={task} />,
git: ({ task, updateField }) => (
<GitSection task={task} onGitChange={(git) => updateField('git', git as Task['git'])} />

View file

@ -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' } },

View file

@ -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',