mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
feat: add progressive task workspace shell (#1343)
This commit is contained in:
parent
33a9512383
commit
88da23e117
5 changed files with 558 additions and 123 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
web/src/lib/__tests__/task-detail-workspace.test.ts
Normal file
86
web/src/lib/__tests__/task-detail-workspace.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue