feat: add progressive task workspace shell (#1343)

This commit is contained in:
Brad Groux 2026-09-02 21:29:35 -05:00 • committed by GitHub
parent 33a9512383
commit 88da23e117
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 558 additions and 123 deletions

View file

@ -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(
<MarkdownEditor
@ -271,8 +290,19 @@ describe('task detail Mantine migration', () => {
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(<TaskDetailPanel task={task} open onOpenChange={mocks.onOpenChange} />);
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(<TaskDetailPanel task={task} open onOpenChange={mocks.onOpenChange} />);
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(
<TaskDetailPanel
task={codeTask}
open
onOpenChange={mocks.onOpenChange}
navigationTarget={{ tab: 'timeline', timelineAttemptId: 'attempt-1' }}
/>
);
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(
<TaskDetailPanel
task={codeTask}
open
onOpenChange={mocks.onOpenChange}
navigationTarget={{
workspace: { version: 1, mode: 'results', section: 'evidence' },
}}
/>
);
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', () => {
<TaskDetailPanel task={codeTask} open onOpenChange={mocks.onOpenChange} />
);
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(<TaskDetailPanel task={featureTask} open onOpenChange={mocks.onOpenChange} />);
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();
});
});

View file

@ -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<TaskWorkspaceModeId, LucideIcon> = {
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<string | null>(null);
const [timelineEventId, setTimelineEventId] = useState<string | null>(null);
const lastDefaultedTaskIdRef = useRef<string | undefined>(undefined);
const lastTabByModeRef = useRef<Partial<Record<TaskWorkspaceModeId, TaskDetailTabId>>>({});
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({
>
<Drawer.Overlay className="veritas-overlay fixed inset-0 z-50" />
<Drawer.Content
aria-label={`Task details: ${localTask.title}`}
aria-label={`Task workspace: ${localTask.title}`}
data-testid="task-detail-panel"
className="veritas-overlay-surface flex h-full min-h-0 max-h-[100dvh] w-[min(100vw,700px)] flex-col overflow-hidden border-l bg-background bg-clip-padding text-sm shadow-lg sm:max-w-[700px]"
className="veritas-overlay-surface flex h-full min-h-0 max-h-[100dvh] w-[min(100vw,960px)] flex-col overflow-hidden border-l bg-background bg-clip-padding text-sm shadow-lg sm:w-[min(92vw,960px)] lg:w-[min(62vw,960px)]"
>
<Drawer.Body className="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
<Stack gap={0} className="min-h-0 flex-1 overflow-hidden">
@ -241,8 +292,17 @@ export function TaskDetailPanel({
{isSaving ? 'Saving...' : 'Unsaved changes'}
</Text>
)}
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() => setTaskChatOpen(true)}
leftSection={<MessageSquare className="h-3.5 w-3.5" />}
>
Chat
</Button>
<ActionIcon
aria-label="Close task details"
aria-label="Close task workspace"
variant="subtle"
color="gray"
onClick={() => onOpenChange(false)}
@ -281,119 +341,186 @@ export function TaskDetailPanel({
/>
)}
{/* Action buttons above tabs */}
<SimpleGrid
cols={{ base: 2, sm: 3 }}
spacing="xs"
className="flex-shrink-0 px-4 pt-3 sm:px-6 sm:pt-4"
>
<Button
variant="outline"
size="sm"
onClick={() => setTaskChatOpen(true)}
leftSection={<MessageSquare className="h-3 w-3" />}
<div className="flex min-h-0 flex-1 overflow-hidden">
<nav
aria-label="Task workspace modes"
data-testid="task-workspace-mode-navigation"
className="veritas-overlay-scroll hidden w-40 flex-shrink-0 flex-col gap-1 overflow-y-auto border-r px-3 py-4 sm:flex"
>
Chat
</Button>
{!readOnly ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setApplyTemplateOpen(true)}
leftSection={<FileCode className="h-3 w-3" />}
>
Template
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setWorkflowOpen(true)}
leftSection={<Workflow className="h-3 w-3" />}
>
Workflow
</Button>
</>
) : (
<>
<div />
<div />
</>
)}
{!readOnly &&
isCodeTask &&
localTask.git?.repo &&
agentSettings.enablePreview &&
canUseLocalAgentControls && (
<Button
variant="outline"
size="sm"
onClick={() => setPreviewOpen(true)}
className="col-span-2"
leftSection={<Monitor className="h-3 w-3" />}
>
Preview
</Button>
)}
</SimpleGrid>
<Tabs
value={activeTab}
onChange={(value) => {
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"
>
<Tabs.List className="w-full flex-shrink-0 justify-start overflow-x-auto">
{visibleTabs.map((tab) => {
const Icon = tab.Icon;
<Text size="xs" tt="uppercase" c="dimmed" className="mb-1 px-2 tracking-wide">
Workspace
</Text>
{workspaceModes.map((mode, index) => {
const Icon = WORKSPACE_MODE_ICONS[mode.id];
const active = mode.id === activeMode;
return (
<Tabs.Tab
key={tab.id}
value={tab.id}
disabled={tab.disabled}
className="flex-none px-3"
leftSection={Icon ? <Icon className="h-3 w-3" /> : undefined}
<button
key={mode.id}
type="button"
disabled={mode.disabled}
aria-current={active ? 'page' : undefined}
onClick={() => selectWorkspaceMode(mode.id)}
className={`flex min-h-10 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500 disabled:cursor-not-allowed disabled:opacity-40 ${
active
? 'bg-violet-500/15 text-violet-700 dark:text-violet-300'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{tab.label}
</Tabs.Tab>
<span aria-hidden="true" className="w-4 font-mono text-[10px] opacity-70">
{String(index + 1).padStart(2, '0')}
</span>
<Icon className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
<span>{mode.label}</span>
</button>
);
})}
</Tabs.List>
</nav>
<div
className="veritas-overlay-scroll mt-4 min-h-0 flex-1 overflow-y-scroll overscroll-contain pr-1"
data-testid="task-detail-scroll-region"
aria-label="Task detail content"
tabIndex={0}
<Tabs
value={activeTab}
onChange={(value) => {
if (isTaskDetailTabId(value)) setActiveTab(value);
}}
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
{visibleTabs.map((tab) => {
const tabContent = (
<Suspense
fallback={
<Text size="sm" c="dimmed">
Loading {tab.label}...
</Text>
}
>
{tab.render(tabRenderContext)}
</Suspense>
);
<div className="flex-shrink-0 border-b px-4 py-3 sm:px-5">
<Select
label="Task workspace mode"
value={activeMode}
data={workspaceModes.map((mode) => ({
value: mode.id,
label: mode.label,
disabled: mode.disabled,
}))}
onChange={(value) => {
if (isTaskWorkspaceModeId(value)) selectWorkspaceMode(value);
}}
allowDeselect={false}
className="mb-3 sm:hidden"
/>
return (
<Tabs.Panel key={tab.id} value={tab.id} className="mt-0 min-h-full">
{tab.fallbackTitle ? (
<FeatureErrorBoundary fallbackTitle={tab.fallbackTitle}>
{tabContent}
</FeatureErrorBoundary>
) : (
tabContent
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div className="min-w-0">
<Text component="h2" fw={650} size="sm">
{activeModeMetadata?.label ?? 'Workspace'}
</Text>
<Text size="xs" c="dimmed" className="mt-0.5 max-w-xl">
{activeModeMetadata?.description}
</Text>
</div>
<Group gap="xs" wrap="wrap">
{!readOnly && activeMode === 'plan' && (
<Button
variant="outline"
size="compact-sm"
onClick={() => setApplyTemplateOpen(true)}
leftSection={<FileCode className="h-3 w-3" />}
>
Template
</Button>
)}
</Tabs.Panel>
);
})}
</div>
</Tabs>
{!readOnly && activeMode === 'run' && (
<Button
variant="outline"
size="compact-sm"
onClick={() => setWorkflowOpen(true)}
leftSection={<Workflow className="h-3 w-3" />}
>
Workflow
</Button>
)}
{!readOnly &&
activeMode === 'run' &&
activeTab === 'git' &&
isCodeTask &&
localTask.git?.repo &&
agentSettings.enablePreview &&
canUseLocalAgentControls && (
<Button
variant="outline"
size="compact-sm"
onClick={() => setPreviewOpen(true)}
leftSection={<Monitor className="h-3 w-3" />}
>
Preview
</Button>
)}
</Group>
</Group>
{activeModeTabs.length > 1 && (
<>
<Tabs.List
aria-label={`${activeModeMetadata?.label ?? 'Workspace'} sections`}
className="mt-3 !hidden w-full justify-start overflow-x-auto sm:!flex"
>
{activeModeTabs.map((tab) => {
const Icon = tab.Icon;
return (
<Tabs.Tab
key={tab.id}
value={tab.id}
disabled={tab.disabled}
className="flex-none px-3"
leftSection={Icon ? <Icon className="h-3 w-3" /> : undefined}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
<Select
label={`${activeModeMetadata?.label ?? 'Workspace'} section`}
value={activeTab}
data={activeModeTabs.map((tab) => ({
value: tab.id,
label: tab.label,
disabled: tab.disabled,
}))}
onChange={(value) => {
if (isTaskDetailTabId(value)) setActiveTab(value);
}}
allowDeselect={false}
className="mt-3 sm:hidden"
/>
</>
)}
</div>
<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`}
tabIndex={0}
>
{visibleTabs.map((tab) => {
const tabContent = (
<Suspense
fallback={
<Text size="sm" c="dimmed">
Loading {tab.label}...
</Text>
}
>
{tab.render(tabRenderContext)}
</Suspense>
);
return (
<Tabs.Panel key={tab.id} value={tab.id} className="mt-0 min-h-full">
{tab.fallbackTitle ? (
<FeatureErrorBoundary fallbackTitle={tab.fallbackTitle}>
{tabContent}
</FeatureErrorBoundary>
) : (
tabContent
)}
</Tabs.Panel>
);
})}
</div>
</Tabs>
</div>
</Stack>
</Drawer.Body>
</Drawer.Content>

View file

@ -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;

View file

@ -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<TaskDetailTabId, string> = {
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();
});
});

View file

@ -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<TaskDetailTabId, TaskWorkspaceDestination>(
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;
}