diff --git a/web/src/__tests__/task-detail-mantine.test.tsx b/web/src/__tests__/task-detail-mantine.test.tsx index 96e773ee..5cbfe10a 100644 --- a/web/src/__tests__/task-detail-mantine.test.tsx +++ b/web/src/__tests__/task-detail-mantine.test.tsx @@ -207,7 +207,9 @@ describe('task detail Mantine migration', () => { expect((screen.getByLabelText('Task title') as HTMLInputElement).value).toBe( 'Ship Mantine task detail' ); - expect(screen.getByRole('tab', { name: 'Work' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Overview' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Plan' }).getAttribute('aria-current')).toBe('page'); + expect(screen.getByRole('button', { name: 'Run' }).hasAttribute('disabled')).toBe(true); expect(screen.getByRole('tab', { name: 'Details' })).toBeDefined(); expect(screen.queryByRole('tab', { name: 'Git' })).toBeNull(); expect(screen.queryByRole('tab', { name: 'Timeline' })).toBeNull(); @@ -218,6 +220,8 @@ describe('task detail Mantine migration', () => { ); expect(screen.getByTestId('task-detail-panel').className).toContain('veritas-overlay-surface'); expect(screen.getByTestId('task-detail-panel').className).toContain('min-h-0'); + expect(screen.getByTestId('task-workspace-mode-navigation').className).toContain('sm:flex'); + expect(screen.getByRole('combobox', { name: 'Task workspace mode' })).toBeDefined(); const scrollRegion = screen.getByTestId('task-detail-scroll-region'); expect(scrollRegion.className).toContain('min-h-0'); expect(scrollRegion.className).toContain('overflow-y-scroll'); @@ -231,7 +235,7 @@ describe('task detail Mantine migration', () => { expect(container.querySelector('.mantine-Tabs-root')).toBeDefined(); expect(container.querySelector('.mantine-TextInput-root')).toBeDefined(); expect(container.querySelectorAll('.mantine-Select-root').length).toBeGreaterThanOrEqual(5); - expect(container.querySelectorAll('.mantine-Button-root').length).toBeGreaterThanOrEqual(5); + expect(container.querySelectorAll('.mantine-Button-root').length).toBeGreaterThanOrEqual(4); expect(container.querySelector('.mantine-ActionIcon-root')).toBeDefined(); expect(baseElement.querySelector('[data-slot="sheet-content"]')).toBeNull(); expect(baseElement.querySelector('[data-slot="tabs-trigger"]')).toBeNull(); @@ -247,12 +251,27 @@ describe('task detail Mantine migration', () => { const scrollRegion = screen.getByTestId('task-detail-scroll-region'); expect(within(scrollRegion).getByLabelText('Task description')).toBeDefined(); + await user.click(screen.getByRole('button', { name: 'Results' })); await user.click(screen.getByRole('tab', { name: 'Evidence' })); expect(await within(scrollRegion).findByTestId('long-evidence-content')).toBeDefined(); expect(screen.getByTestId('task-detail-scroll-region')).toBe(scrollRegion); }); + it('remembers the selected local section when moving between modes', async () => { + const user = userEvent.setup(); + renderTaskDetail(); + + await user.click(screen.getByRole('tab', { name: 'Progress' })); + await user.click(screen.getByRole('button', { name: 'Results' })); + await user.click(screen.getByRole('tab', { name: 'Evidence' })); + await user.click(screen.getByRole('button', { name: 'Plan' })); + + expect(screen.getByRole('tab', { name: 'Progress' }).getAttribute('aria-selected')).toBe( + 'true' + ); + }); + it('applies description height and vertical resizing to the textarea input', () => { renderWithProviders( { it('keeps the task drawer open when Escape belongs to a nested Workflow dialog', async () => { const user = userEvent.setup(); - renderTaskDetail(); + const task = createMockTask({ + id: 'task-code-workflow', + title: 'Run a code workflow', + type: 'code', + git: { + repo: 'BradGroux/veritas-kanban', + branch: 'task-workspace-shell', + baseBranch: 'main', + }, + }); + renderWithProviders(); + await user.click(screen.getByRole('button', { name: 'Run' })); await user.click(screen.getByRole('button', { name: 'Workflow' })); expect(screen.getByRole('dialog', { name: 'Run Workflow' })).toBeDefined(); @@ -319,7 +349,7 @@ describe('task detail Mantine migration', () => { expect(mocks.onOpenChange).toHaveBeenCalledWith(false); }); - it('defaults code tasks with execution context to the Work tab', () => { + it('defaults code tasks with execution context to Overview', () => { const task = createMockTask({ id: 'task-code-work', title: 'Ship task work view', @@ -336,11 +366,66 @@ describe('task detail Mantine migration', () => { renderWithProviders(); - expect(screen.getByRole('tab', { name: 'Work' }).getAttribute('aria-selected')).toBe('true'); - expect(screen.getByRole('tab', { name: 'Timeline' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Overview' }).getAttribute('aria-current')).toBe( + 'page' + ); + expect(screen.getByRole('button', { name: 'History' })).toBeDefined(); + expect(screen.queryByRole('tab', { name: 'Timeline' })).toBeNull(); expect(screen.getByText('Work View')).toBeDefined(); }); + it('translates legacy and versioned deep links into modes and local sections', async () => { + const codeTask = createMockTask({ + id: 'task-deep-link', + title: 'Inspect a run timeline', + type: 'code', + git: { + repo: 'BradGroux/veritas-kanban', + branch: 'task-workspace-shell', + baseBranch: 'main', + worktreePath: '/tmp/task-workspace-shell', + }, + }); + + const { rerender } = renderWithProviders( + + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'History' }).getAttribute('aria-current')).toBe( + 'page' + ) + ); + expect(screen.getByRole('tab', { name: 'Timeline' }).getAttribute('aria-selected')).toBe( + 'true' + ); + + rerender( + + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Results' }).getAttribute('aria-current')).toBe( + 'page' + ) + ); + expect(screen.getByRole('tab', { name: 'Evidence' }).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({ @@ -358,6 +443,7 @@ describe('task detail Mantine migration', () => { ); + await user.click(screen.getByRole('button', { name: 'History' })); await user.click(screen.getByRole('tab', { name: 'Timeline' })); expect(screen.getByRole('tab', { name: 'Timeline' }).getAttribute('aria-selected')).toBe( 'true' @@ -371,10 +457,9 @@ describe('task detail Mantine migration', () => { rerender(); await waitFor(() => - expect(screen.getByRole('tab', { name: 'Details' }).getAttribute('aria-selected')).toBe( - 'true' - ) + expect(screen.getByRole('button', { name: 'Plan' }).getAttribute('aria-current')).toBe('page') ); + expect(screen.getByRole('tab', { name: 'Details' }).getAttribute('aria-selected')).toBe('true'); expect(screen.queryByRole('tab', { name: 'Timeline' })).toBeNull(); }); }); diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index cdd408ce..0b5aa738 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -5,7 +5,7 @@ import { Button, Drawer, Group, - SimpleGrid, + Select, Stack, Tabs, Text, @@ -23,19 +23,38 @@ import { shouldDefaultTaskDetailToWork } from './TaskWorkView'; import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary'; import { useIdentity } from '@/hooks/useIdentity'; import { clientAllowsLocalAgentControls } from '@/lib/client-policy'; -import { Monitor, FileCode, Archive, MessageSquare, Workflow, X } from 'lucide-react'; +import { + Archive, + CheckCircle2, + ClipboardList, + FileCode, + History, + LayoutDashboard, + MessageSquare, + Monitor, + PlayCircle, + Workflow, + X, + type LucideIcon, +} from 'lucide-react'; import type { Task } from '@veritas-kanban/shared'; import { useAddObservation, useDeleteObservation } from '@/hooks/useTasks'; import { PreviewPanel } from './PreviewPanel'; import { getAvailableTaskDetailTabs, + getAvailableTaskWorkspaceModeMetadata, getFallbackTaskDetailTabId, + getTaskWorkspaceDestination, + getTaskWorkspaceModeTabId, isTaskDetailTabAvailable, isTaskDetailTabId, + isTaskWorkspaceModeId, + resolveTaskDetailNavigationTab, type TaskDetailObservationInput, type TaskDetailNavigationTarget, type TaskDetailRenderContext, type TaskDetailTabId, + type TaskWorkspaceModeId, } from './task-detail-tabs'; interface TaskDetailPanelProps { @@ -47,7 +66,19 @@ interface TaskDetailPanelProps { navigationTarget?: TaskDetailNavigationTarget | null; } -export type { TaskDetailNavigationTarget, TaskDetailTabId } from './task-detail-tabs'; +export type { + TaskDetailNavigationTarget, + TaskDetailTabId, + TaskWorkspaceModeId, +} from './task-detail-tabs'; + +const WORKSPACE_MODE_ICONS: Record = { + overview: LayoutDashboard, + plan: ClipboardList, + run: PlayCircle, + results: CheckCircle2, + history: History, +}; export function TaskDetailPanel({ task, @@ -73,6 +104,7 @@ export function TaskDetailPanel({ const [timelineAttemptId, setTimelineAttemptId] = useState(null); const [timelineEventId, setTimelineEventId] = useState(null); const lastDefaultedTaskIdRef = useRef(undefined); + const lastTabByModeRef = useRef>>({}); const addObservation = useAddObservation(); const deleteObservation = useDeleteObservation(); const nestedOverlayOpen = previewOpen || applyTemplateOpen || taskChatOpen || workflowOpen; @@ -109,6 +141,16 @@ export function TaskDetailPanel({ [tabAvailabilityContext] ); const fallbackTab = getFallbackTaskDetailTabId(visibleTabs, defaultTab); + const workspaceModes = useMemo( + () => getAvailableTaskWorkspaceModeMetadata(visibleTabs), + [visibleTabs] + ); + const activeMode = getTaskWorkspaceDestination(activeTab).mode; + const activeModeMetadata = workspaceModes.find((mode) => mode.id === activeMode); + const activeModeTabs = useMemo( + () => visibleTabs.filter((tab) => getTaskWorkspaceDestination(tab.id).mode === activeMode), + [activeMode, visibleTabs] + ); const addObservationForTask = useMemo( () => async (data: TaskDetailObservationInput) => { @@ -135,6 +177,10 @@ export function TaskDetailPanel({ } }, [activeTab, fallbackTab, visibleTabs]); + useEffect(() => { + lastTabByModeRef.current[activeMode] = activeTab; + }, [activeMode, activeTab]); + useEffect(() => { if (!activeTaskId) { lastDefaultedTaskIdRef.current = undefined; @@ -144,6 +190,7 @@ export function TaskDetailPanel({ } if (lastDefaultedTaskIdRef.current === activeTaskId) return; lastDefaultedTaskIdRef.current = activeTaskId; + lastTabByModeRef.current = {}; setTimelineAttemptId(null); setTimelineEventId(null); setActiveTab(fallbackTab); @@ -160,13 +207,17 @@ export function TaskDetailPanel({ if (navigationTarget.timelineEventId !== undefined) { setTimelineEventId(navigationTarget.timelineEventId ?? null); } - if (navigationTarget.tab && isTaskDetailTabAvailable(visibleTabs, navigationTarget.tab)) { - setActiveTab(navigationTarget.tab); - } + const targetTab = resolveTaskDetailNavigationTab(navigationTarget, visibleTabs); + if (targetTab) setActiveTab(targetTab); }, [activeTaskId, navigationTarget, visibleTabs]); if (!localTask) return null; + const selectWorkspaceMode = (mode: TaskWorkspaceModeId) => { + const nextTab = getTaskWorkspaceModeTabId(visibleTabs, mode, lastTabByModeRef.current[mode]); + if (nextTab) setActiveTab(nextTab); + }; + const setTimelineAttemptTarget = (attemptId: string | null) => { setTimelineAttemptId(attemptId); setTimelineEventId(null); @@ -207,9 +258,9 @@ export function TaskDetailPanel({ > @@ -241,8 +292,17 @@ export function TaskDetailPanel({ {isSaving ? 'Saving...' : 'Unsaved changes'} )} + onOpenChange(false)} @@ -281,119 +341,186 @@ export function TaskDetailPanel({ /> )} - {/* Action buttons above tabs */} - - - {!readOnly ? ( - <> - - - - ) : ( - <> -
-
- - )} - {!readOnly && - isCodeTask && - localTask.git?.repo && - agentSettings.enablePreview && - canUseLocalAgentControls && ( - - )} - - - { - if (isTaskDetailTabId(value)) setActiveTab(value); - }} - className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pt-3 pb-6 sm:px-6" - > - - {visibleTabs.map((tab) => { - const Icon = tab.Icon; + + Workspace + + {workspaceModes.map((mode, index) => { + const Icon = WORKSPACE_MODE_ICONS[mode.id]; + const active = mode.id === activeMode; return ( - : undefined} + ); })} - + -
{ + if (isTaskDetailTabId(value)) setActiveTab(value); + }} + className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" > - {visibleTabs.map((tab) => { - const tabContent = ( - - Loading {tab.label}... - - } - > - {tab.render(tabRenderContext)} - - ); +
+ ({ + value: tab.id, + label: tab.label, + disabled: tab.disabled, + }))} + onChange={(value) => { + if (isTaskDetailTabId(value)) setActiveTab(value); + }} + allowDeselect={false} + className="mt-3 sm:hidden" + /> + + )} +
+ +
+ {visibleTabs.map((tab) => { + const tabContent = ( + + Loading {tab.label}... + + } + > + {tab.render(tabRenderContext)} + + ); + + return ( + + {tab.fallbackTitle ? ( + + {tabContent} + + ) : ( + tabContent + )} + + ); + })} +
+ +
diff --git a/web/src/components/task/task-detail-tabs.tsx b/web/src/components/task/task-detail-tabs.tsx index dde8f39c..ea3bf7af 100644 --- a/web/src/components/task/task-detail-tabs.tsx +++ b/web/src/components/task/task-detail-tabs.tsx @@ -16,9 +16,15 @@ import { import type { ObservationType, ReviewComment, ReviewState, Task } from '@veritas-kanban/shared'; import { getAvailableTaskDetailTabMetadata, + getAvailableTaskWorkspaceModeMetadata, getFallbackTaskDetailTabId, + getTaskWorkspaceDestination, + getTaskWorkspaceModeTabId, isTaskDetailTabAvailable, isTaskDetailTabId, + isTaskWorkspaceModeId, + resolveTaskDetailNavigationTab, + TASK_WORKSPACE_MODE_METADATA, TASK_DETAIL_TAB_METADATA, type AvailableTaskDetailTabMetadata, type TaskDetailAvailabilityContext, @@ -63,8 +69,19 @@ export type { TaskDetailAvailabilityContext, TaskDetailNavigationTarget, TaskDetailTabId, + TaskWorkspaceModeId, } from '@/lib/task-detail-tabs'; -export { getFallbackTaskDetailTabId, isTaskDetailTabAvailable, isTaskDetailTabId }; +export { + getAvailableTaskWorkspaceModeMetadata, + getFallbackTaskDetailTabId, + getTaskWorkspaceDestination, + getTaskWorkspaceModeTabId, + isTaskDetailTabAvailable, + isTaskDetailTabId, + isTaskWorkspaceModeId, + resolveTaskDetailNavigationTab, + TASK_WORKSPACE_MODE_METADATA, +}; export interface TaskDetailObservationInput { type: ObservationType; diff --git a/web/src/lib/__tests__/task-detail-workspace.test.ts b/web/src/lib/__tests__/task-detail-workspace.test.ts new file mode 100644 index 00000000..fb80134f --- /dev/null +++ b/web/src/lib/__tests__/task-detail-workspace.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAvailableTaskDetailTabMetadata, + getAvailableTaskWorkspaceModeMetadata, + getTaskWorkspaceDestination, + resolveTaskDetailNavigationTab, + TASK_DETAIL_TAB_METADATA, + TASK_WORKSPACE_MODE_METADATA, + type TaskDetailTabId, +} from '@/lib/task-detail-tabs'; + +describe('task workspace navigation', () => { + it('maps every legacy task-detail tab to one reviewed mode and section', () => { + const expected: Record = { + work: 'overview', + details: 'plan', + progress: 'plan', + 'work-products': 'results', + observations: 'plan', + attachments: 'plan', + git: 'run', + agent: 'run', + timeline: 'history', + evidence: 'results', + changes: 'results', + review: 'results', + metrics: 'history', + }; + + for (const tab of TASK_DETAIL_TAB_METADATA) { + expect(getTaskWorkspaceDestination(tab.id)).toEqual({ + mode: expected[tab.id], + section: tab.id, + }); + } + + expect(TASK_WORKSPACE_MODE_METADATA.map((mode) => mode.id)).toEqual([ + 'overview', + 'plan', + 'run', + 'results', + 'history', + ]); + }); + + it('keeps unavailable task surfaces out of mode selection', () => { + const tabs = getAvailableTaskDetailTabMetadata({ + isCodeTask: false, + hasWorktree: false, + attachmentsEnabled: false, + }); + const modes = getAvailableTaskWorkspaceModeMetadata(tabs); + + expect(modes.find((mode) => mode.id === 'run')?.disabled).toBe(true); + expect(modes.filter((mode) => !mode.disabled).map((mode) => mode.id)).toEqual([ + 'overview', + 'plan', + 'results', + 'history', + ]); + }); + + it('resolves legacy and versioned deep links through the same availability rules', () => { + const tabs = getAvailableTaskDetailTabMetadata({ + isCodeTask: true, + hasWorktree: false, + attachmentsEnabled: true, + }); + + expect(resolveTaskDetailNavigationTab({ tab: 'timeline' }, tabs)).toBe('timeline'); + expect( + resolveTaskDetailNavigationTab( + { workspace: { version: 1, mode: 'results', section: 'evidence' } }, + tabs + ) + ).toBe('evidence'); + expect( + resolveTaskDetailNavigationTab( + { workspace: { version: 1, mode: 'results', section: 'changes' } }, + tabs + ) + ).toBe('work-products'); + expect(resolveTaskDetailNavigationTab({ tab: 'changes' }, tabs)).toBeNull(); + }); +}); diff --git a/web/src/lib/task-detail-tabs.ts b/web/src/lib/task-detail-tabs.ts index 1a646a0f..206609f2 100644 --- a/web/src/lib/task-detail-tabs.ts +++ b/web/src/lib/task-detail-tabs.ts @@ -13,8 +13,17 @@ export type TaskDetailTabId = | 'review' | 'metrics'; +export type TaskWorkspaceModeId = 'overview' | 'plan' | 'run' | 'results' | 'history'; + +export interface TaskWorkspaceNavigationTarget { + version: 1; + mode: TaskWorkspaceModeId; + section?: TaskDetailTabId; +} + export interface TaskDetailNavigationTarget { tab?: TaskDetailTabId; + workspace?: TaskWorkspaceNavigationTarget; timelineAttemptId?: string | null; timelineEventId?: string | null; } @@ -49,6 +58,65 @@ export interface TaskDetailTabMetadata { export type AvailableTaskDetailTabMetadata = TaskDetailTabMetadata & { disabled: boolean }; +export interface TaskWorkspaceModeMetadata { + id: TaskWorkspaceModeId; + label: string; + description: string; + sections: readonly TaskDetailTabId[]; +} + +export type AvailableTaskWorkspaceModeMetadata = TaskWorkspaceModeMetadata & { + disabled: boolean; +}; + +export interface TaskWorkspaceDestination { + mode: TaskWorkspaceModeId; + section: TaskDetailTabId; +} + +export const TASK_WORKSPACE_MODE_METADATA: readonly TaskWorkspaceModeMetadata[] = [ + { + id: 'overview', + label: 'Overview', + description: 'Current state, readiness, and the next useful action.', + sections: ['work'], + }, + { + id: 'plan', + label: 'Plan', + description: 'Task details, progress, observations, and supporting context.', + sections: ['details', 'progress', 'observations', 'attachments'], + }, + { + id: 'run', + label: 'Run', + description: 'Agent session, workflow controls, and source context.', + sections: ['git', 'agent'], + }, + { + id: 'results', + label: 'Results', + description: 'Work products, changes, review decisions, and evidence.', + sections: ['work-products', 'evidence', 'changes', 'review'], + }, + { + id: 'history', + label: 'History', + description: 'Attempt timeline and task-level metrics.', + sections: ['timeline', 'metrics'], + }, +]; + +const TASK_WORKSPACE_MODES_BY_ID = new Map( + TASK_WORKSPACE_MODE_METADATA.map((mode) => [mode.id, mode]) +); + +const TASK_WORKSPACE_DESTINATIONS = new Map( + TASK_WORKSPACE_MODE_METADATA.flatMap((mode) => + mode.sections.map((section) => [section, { mode: mode.id, section }] as const) + ) +); + export const TASK_DETAIL_TAB_METADATA: readonly TaskDetailTabMetadata[] = [ { id: 'work', @@ -138,6 +206,12 @@ export function isTaskDetailTabId(value: string | null | undefined): value is Ta return Boolean(value && TASK_DETAIL_TAB_IDS.has(value as TaskDetailTabId)); } +export function isTaskWorkspaceModeId( + value: string | null | undefined +): value is TaskWorkspaceModeId { + return Boolean(value && TASK_WORKSPACE_MODES_BY_ID.has(value as TaskWorkspaceModeId)); +} + export function getAvailableTaskDetailTabMetadata( context: TaskDetailAvailabilityContext ): AvailableTaskDetailTabMetadata[] { @@ -163,3 +237,49 @@ export function getFallbackTaskDetailTabId( if (detailsTab) return detailsTab.id; return tabs.find((tab) => !tab.disabled)?.id ?? 'details'; } + +export function getTaskWorkspaceDestination(tabId: TaskDetailTabId): TaskWorkspaceDestination { + return TASK_WORKSPACE_DESTINATIONS.get(tabId) ?? { mode: 'plan', section: 'details' }; +} + +export function getAvailableTaskWorkspaceModeMetadata( + tabs: readonly AvailableTaskDetailTabMetadata[] +): AvailableTaskWorkspaceModeMetadata[] { + return TASK_WORKSPACE_MODE_METADATA.map((mode) => ({ + ...mode, + disabled: !mode.sections.some((section) => isTaskDetailTabAvailable(tabs, section)), + })); +} + +export function getTaskWorkspaceModeTabId( + tabs: readonly AvailableTaskDetailTabMetadata[], + modeId: TaskWorkspaceModeId, + preferredTab?: TaskDetailTabId +): TaskDetailTabId | null { + const mode = TASK_WORKSPACE_MODES_BY_ID.get(modeId); + if (!mode) return null; + if ( + preferredTab && + mode.sections.includes(preferredTab) && + isTaskDetailTabAvailable(tabs, preferredTab) + ) { + return preferredTab; + } + return mode.sections.find((section) => isTaskDetailTabAvailable(tabs, section)) ?? null; +} + +export function resolveTaskDetailNavigationTab( + target: TaskDetailNavigationTarget, + tabs: readonly AvailableTaskDetailTabMetadata[] +): TaskDetailTabId | null { + if (target.workspace?.version === 1) { + const workspaceTab = getTaskWorkspaceModeTabId( + tabs, + target.workspace.mode, + target.workspace.section + ); + if (workspaceTab) return workspaceTab; + } + if (target.tab && isTaskDetailTabAvailable(tabs, target.tab)) return target.tab; + return null; +}