mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
fix: preserve in-app navigation context
This commit is contained in:
parent
26eb23f784
commit
0dd8d57ac5
22 changed files with 534 additions and 41 deletions
|
|
@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Established one bounded, keyboard-focusable scroll owner for task drawers and
|
||||
shared overlays, kept drawer chrome fixed, and made task description editors
|
||||
taller and vertically resizable (#935).
|
||||
- Preserved in-app route origins, scroll positions, and task return paths across
|
||||
full-page navigation, added browser Back and `Cmd+[` support, and made direct
|
||||
links fall back safely to Board (#937).
|
||||
|
||||
## [6.0.0] - 2026-07-24
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ function runSessionShareIdFromLocation(): string | null {
|
|||
|
||||
/** Renders the current view (board, activity feed, or backlog). */
|
||||
function MainContent() {
|
||||
const { view, setView, navigateToTask } = useView();
|
||||
const { view, goBack, navigateToTask } = useView();
|
||||
const runSessionShareId = runSessionShareIdFromLocation();
|
||||
|
||||
if (runSessionShareId) return <RunSessionShareView shareId={runSessionShareId} />;
|
||||
|
|
@ -100,7 +100,7 @@ function MainContent() {
|
|||
const ViewComponent = LAZY_VIEW_COMPONENTS[view];
|
||||
return (
|
||||
<Suspense fallback={<ViewLoading view={view} />}>
|
||||
<ViewComponent onBack={() => setView('board')} onTaskClick={navigateToTask} />
|
||||
<ViewComponent onBack={goBack} onTaskClick={navigateToTask} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { screen, cleanup, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { KanbanBoard } from '@/components/board/KanbanBoard';
|
||||
import { createMockTask, renderWithProviders } from './test-utils';
|
||||
import { DEFAULT_FEATURE_SETTINGS, type FeatureSettings, type Task } from '@veritas-kanban/shared';
|
||||
import { DesktopShellProvider } from '@/components/layout/DesktopShellContext';
|
||||
import { ViewProvider } from '@/contexts/ViewContext';
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -124,18 +126,25 @@ vi.mock('@/components/board/KanbanColumn', () => ({
|
|||
title,
|
||||
tasks,
|
||||
canChangeStatus,
|
||||
onTaskClick,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
canChangeStatus?: boolean;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}) => (
|
||||
<div data-testid={`column-${id}`} data-can-change-status={String(canChangeStatus)}>
|
||||
<h2>{title}</h2>
|
||||
{tasks.map((t: Task) => (
|
||||
<div key={t.id} data-testid={`task-${t.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
data-testid={`task-${t.id}`}
|
||||
onClick={() => onTaskClick?.(t)}
|
||||
>
|
||||
{t.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -146,7 +155,22 @@ vi.mock('@/components/board/BoardLoadingSkeleton', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@/components/task/TaskDetailPanel', () => ({
|
||||
TaskDetailPanel: () => null,
|
||||
TaskDetailPanel: ({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
task: Task | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open && task ? (
|
||||
<div role="dialog" aria-label={`Task details: ${task.title}`}>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close task details
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/board/FilterBar', async () => {
|
||||
|
|
@ -196,6 +220,18 @@ function renderBoard() {
|
|||
return renderWithProviders(<KanbanBoard />, { queryClient });
|
||||
}
|
||||
|
||||
function renderBoardWithView() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderWithProviders(
|
||||
<ViewProvider>
|
||||
<KanbanBoard />
|
||||
</ViewProvider>,
|
||||
{ queryClient }
|
||||
);
|
||||
}
|
||||
|
||||
function renderDesktopBoard() {
|
||||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
|
|
@ -274,6 +310,29 @@ describe('KanbanBoard', () => {
|
|||
expect(screen.getByTestId('task-k3')).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses browser history to close a task back to the same Board state', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseTasks = () => ({ data: mockTasks, isLoading: false, error: null });
|
||||
renderBoardWithView();
|
||||
const boardState = window.history.state;
|
||||
|
||||
await user.click(screen.getByTestId('task-k1'));
|
||||
|
||||
expect(window.history.state.veritasTaskDetail).toBe('k1');
|
||||
expect(await screen.findByRole('dialog', { name: 'Task details: Todo Task' })).toBeDefined();
|
||||
|
||||
vi.spyOn(window.history, 'back').mockImplementation(() => {
|
||||
window.history.replaceState(boardState, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: boardState }));
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Close task details' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Task details: Todo Task' })).toBeNull();
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders column titles', () => {
|
||||
mockUseTasks = () => ({ data: mockTasks, isLoading: false, error: null });
|
||||
renderBoard();
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ function createJsonResponse(data: unknown, ok = true) {
|
|||
describe('task detail Git and workflow Mantine migration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
mocks.useConfig.mockReturnValue({
|
||||
data: {
|
||||
|
|
@ -147,6 +148,7 @@ describe('task detail Git and workflow Mantine migration', () => {
|
|||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('renders Git selection through direct Mantine controls and keeps task Git updates wired', async () => {
|
||||
|
|
@ -446,6 +448,27 @@ describe('task detail Git and workflow Mantine migration', () => {
|
|||
expect(screen.queryByText('This section encountered an error')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses browser Back to close Workflow and preserve the originating task route', async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const task = createMockTask({ id: 'task-workflow-history' });
|
||||
const taskState = { veritasTaskDetail: task.id };
|
||||
window.history.replaceState(taskState, '', '/?q=release');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createJsonResponse([])));
|
||||
|
||||
renderWithProviders(<WorkflowSection task={task} open onOpenChange={onOpenChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.history.state.veritasTaskWorkflow).toBe(`${task.id}:workflow`);
|
||||
});
|
||||
window.history.replaceState(taskState, '', '/?q=release');
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: taskState }));
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(window.location.pathname).toBe('/');
|
||||
expect(window.location.search).toBe('?q=release');
|
||||
expect(window.history.state.veritasTaskDetail).toBe(task.id);
|
||||
});
|
||||
|
||||
it('renders run mode and QA gate controls through direct Mantine primitives', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ vi.mock('@/components/task/TaskMetricsPanel', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@/components/task/WorkflowSection', () => ({
|
||||
WorkflowSection: () => null,
|
||||
WorkflowSection: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog" aria-label="Run Workflow" /> : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/evidence/EvidenceTimelinePanel', () => ({
|
||||
|
|
@ -268,6 +269,19 @@ describe('task detail Mantine migration', () => {
|
|||
expect((textarea as HTMLTextAreaElement).style.resize).toBe('vertical');
|
||||
});
|
||||
|
||||
it('keeps the task drawer open when Escape belongs to a nested Workflow dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTaskDetail();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Workflow' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Run Workflow' })).toBeDefined();
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('task-detail-panel'), { key: 'Escape' });
|
||||
|
||||
expect(mocks.onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('task-detail-panel')).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps title editing and progress tab behavior wired after the migration', async () => {
|
||||
const { baseElement } = renderTaskDetail();
|
||||
const detailsTab = screen.getByRole('tab', { name: 'Details' });
|
||||
|
|
|
|||
109
web/src/__tests__/view-context-navigation.test.tsx
Normal file
109
web/src/__tests__/view-context-navigation.test.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ViewProvider, useView } from '@/contexts/ViewContext';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function NavigationHarness() {
|
||||
const { view, setView, navigateToTask, pendingTaskId, goBack, returnFromTask } = useView();
|
||||
|
||||
return (
|
||||
<>
|
||||
<output data-testid="current-view">{view}</output>
|
||||
<output data-testid="pending-task">{pendingTaskId ?? 'none'}</output>
|
||||
<button type="button" onClick={() => setView('activity')}>
|
||||
Open Activity
|
||||
</button>
|
||||
<button type="button" onClick={() => setView('workflows')}>
|
||||
Open Workflows
|
||||
</button>
|
||||
<button type="button" onClick={() => navigateToTask('task-route-context')}>
|
||||
Open Task
|
||||
</button>
|
||||
<button type="button" onClick={returnFromTask}>
|
||||
Close Task
|
||||
</button>
|
||||
<button type="button" onClick={goBack}>
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNavigationHarness() {
|
||||
return renderWithProviders(
|
||||
<div id="main-content">
|
||||
<ViewProvider>
|
||||
<NavigationHarness />
|
||||
</ViewProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ViewContext route history', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns Activity task navigation to its route and scroll position', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavigationHarness();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open Activity' }));
|
||||
expect(screen.getByTestId('current-view').textContent).toBe('activity');
|
||||
expect(window.location.pathname).toBe('/activity');
|
||||
|
||||
const main = document.getElementById('main-content') as HTMLDivElement;
|
||||
main.scrollTop = 420;
|
||||
fireEvent.scroll(main);
|
||||
await waitFor(() => {
|
||||
expect(window.history.state.veritasKanbanNavigation.scrollTop).toBe(420);
|
||||
});
|
||||
const activityState = window.history.state;
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open Task' }));
|
||||
expect(screen.getByTestId('current-view').textContent).toBe('board');
|
||||
expect(screen.getByTestId('pending-task').textContent).toBe('task-route-context');
|
||||
expect(window.location.pathname).toBe('/');
|
||||
|
||||
vi.spyOn(window.history, 'back').mockImplementation(() => {
|
||||
window.history.replaceState(activityState, '', '/activity');
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: activityState }));
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Close Task' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-view').textContent).toBe('activity');
|
||||
expect(main.scrollTop).toBe(420);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back from a direct feature link to Board without a history origin', async () => {
|
||||
window.history.replaceState({}, '', '/workflows');
|
||||
const user = userEvent.setup();
|
||||
renderNavigationHarness();
|
||||
|
||||
expect(screen.getByTestId('current-view').textContent).toBe('workflows');
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(screen.getByTestId('current-view').textContent).toBe('board');
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('maps Cmd+[ to in-app browser Back semantics', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavigationHarness();
|
||||
await user.click(screen.getByRole('button', { name: 'Open Workflows' }));
|
||||
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
fireEvent.keyDown(window, { key: '[', metaKey: true });
|
||||
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -345,12 +345,7 @@ export function ActivityFeed({ onBack, onTaskClick }: ActivityFeedProps) {
|
|||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={onBack}
|
||||
title="Back to board"
|
||||
aria-label="Back to board"
|
||||
>
|
||||
<ActionIcon variant="subtle" onClick={onBack} title="Back" aria-label="Back">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</ActionIcon>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export function ArchivePage({ onBack }: ArchivePageProps) {
|
|||
<Group gap="md" wrap="wrap">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<Text component="h1" size="xl" fw={700} lh={1.1} m={0}>
|
||||
Archive
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function BacklogPage({ onBack }: BacklogPageProps) {
|
|||
<Group gap="md" wrap="wrap">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<Text component="h1" size="xl" fw={700} lh={1.1} m={0}>
|
||||
Backlog
|
||||
|
|
|
|||
|
|
@ -47,6 +47,39 @@ import type { TaskDetailNavigationTarget } from '@/components/task/TaskDetailPan
|
|||
import { useDesktopShell } from '@/components/layout/DesktopShellContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TASK_DETAIL_HISTORY_KEY = 'veritasTaskDetail';
|
||||
|
||||
function taskIdFromHistory(): string | null {
|
||||
const state = window.history.state;
|
||||
if (!state || typeof state !== 'object') return null;
|
||||
const taskId = (state as Record<string, unknown>)[TASK_DETAIL_HISTORY_KEY];
|
||||
return typeof taskId === 'string' ? taskId : null;
|
||||
}
|
||||
|
||||
function currentViewHasOrigin(): boolean {
|
||||
const navigation = window.history.state?.veritasKanbanNavigation;
|
||||
return Boolean(
|
||||
navigation &&
|
||||
typeof navigation === 'object' &&
|
||||
typeof (navigation as Record<string, unknown>).originView === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function markTaskInHistory(taskId: string, replaceCurrent = false) {
|
||||
const nextState = {
|
||||
...(window.history.state && typeof window.history.state === 'object'
|
||||
? window.history.state
|
||||
: {}),
|
||||
[TASK_DETAIL_HISTORY_KEY]: taskId,
|
||||
};
|
||||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (replaceCurrent || taskIdFromHistory()) {
|
||||
window.history.replaceState(nextState, '', currentUrl);
|
||||
} else {
|
||||
window.history.pushState(nextState, '', currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load Dashboard so board startup does not include dashboard-heavy code paths.
|
||||
const Dashboard = lazy(() =>
|
||||
import('@/components/dashboard/Dashboard').then((mod) => ({
|
||||
|
|
@ -109,7 +142,7 @@ export function KanbanBoard() {
|
|||
|
||||
const { selectedTaskId, setTasks, setOnOpenTask, setOnMoveTask } = useKeyboard();
|
||||
const { isSelecting, toggleSelecting } = useBulkActions();
|
||||
const { pendingTaskId, pendingTaskTarget, clearPendingTask } = useView();
|
||||
const { pendingTaskId, pendingTaskTarget, clearPendingTask, returnFromTask } = useView();
|
||||
|
||||
const matchingSavedView = useMemo(
|
||||
() => findBoardSavedViewByFilters(savedViews, filters),
|
||||
|
|
@ -223,6 +256,7 @@ export function KanbanBoard() {
|
|||
// Try local task list first
|
||||
const localTask = tasks?.find((t) => t.id === pendingTaskId);
|
||||
if (localTask) {
|
||||
markTaskInHistory(localTask.id, currentViewHasOrigin());
|
||||
setDetailPanelMounted(true);
|
||||
setDetailNavigationTarget(pendingTaskTarget);
|
||||
setSelectedTask(localTask);
|
||||
|
|
@ -236,19 +270,33 @@ export function KanbanBoard() {
|
|||
const { api } = await import('@/lib/api');
|
||||
const fetchedTask = await api.tasks.get(pendingTaskId);
|
||||
if (fetchedTask) {
|
||||
markTaskInHistory(fetchedTask.id, currentViewHasOrigin());
|
||||
setDetailPanelMounted(true);
|
||||
setDetailNavigationTarget(pendingTaskTarget);
|
||||
setSelectedTask(fetchedTask);
|
||||
setDetailOpen(true);
|
||||
clearPendingTask();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Task no longer exists — ignore silently
|
||||
// Return to the originating view when the task no longer exists.
|
||||
}
|
||||
clearPendingTask();
|
||||
returnFromTask();
|
||||
};
|
||||
|
||||
openPendingTask();
|
||||
}, [pendingTaskId, pendingTaskTarget, tasks, clearPendingTask]);
|
||||
}, [pendingTaskId, pendingTaskTarget, tasks, clearPendingTask, returnFromTask]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (!detailOpen || taskIdFromHistory() === selectedTask?.id) return;
|
||||
setDetailOpen(false);
|
||||
setTimeout(() => setSelectedTask(null), 200);
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [detailOpen, selectedTask?.id]);
|
||||
|
||||
// Apply the configured default saved view only when the current URL has no board filters.
|
||||
useEffect(() => {
|
||||
|
|
@ -319,6 +367,7 @@ export function KanbanBoard() {
|
|||
|
||||
// Handler for opening a task
|
||||
const handleTaskClick = useCallback((task: Task, target?: TaskDetailNavigationTarget) => {
|
||||
markTaskInHistory(task.id);
|
||||
setDetailPanelMounted(true);
|
||||
setDetailNavigationTarget(target ?? null);
|
||||
setSelectedTask(task);
|
||||
|
|
@ -440,10 +489,15 @@ export function KanbanBoard() {
|
|||
});
|
||||
|
||||
const handleDetailClose = (open: boolean) => {
|
||||
if (!open && selectedTask?.id && taskIdFromHistory() === selectedTask.id) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
setDetailOpen(open);
|
||||
if (!open) {
|
||||
// Small delay to allow animation to complete
|
||||
setTimeout(() => setSelectedTask(null), 200);
|
||||
returnFromTask();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
|
|||
<div className="flex items-center gap-4">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Decision Audit Trail</h1>
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export function OperationsDigestPage({ onBack, onTaskClick }: OperationsDigestPa
|
|||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ export function DriftMonitor({ onBack }: DriftMonitorProps) {
|
|||
<div className="flex items-center gap-3">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Behavioral Drift Monitor</h1>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function EvidenceTimelinePage({ onBack, onTaskClick }: EvidenceTimelinePa
|
|||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ export function PolicyManager({ onBack }: PolicyManagerProps) {
|
|||
<div className="border-b bg-card px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionIcon variant="subtle" onClick={onBack} aria-label="Back to board">
|
||||
<ActionIcon variant="subtle" onClick={onBack} aria-label="Back">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
|
|||
miw={48}
|
||||
variant="subtle"
|
||||
onClick={handleBackToBoard}
|
||||
aria-label="Back to board"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</ActionIcon>
|
||||
|
|
|
|||
|
|
@ -72,16 +72,17 @@ export function TaskDetailPanel({
|
|||
const lastDefaultedTaskIdRef = useRef<string | undefined>(undefined);
|
||||
const addObservation = useAddObservation();
|
||||
const deleteObservation = useDeleteObservation();
|
||||
const nestedOverlayOpen = previewOpen || applyTemplateOpen || taskChatOpen || workflowOpen;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
if (e.key === 'Escape' && open && !nestedOverlayOpen) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onOpenChange]);
|
||||
}, [nestedOverlayOpen, open, onOpenChange]);
|
||||
|
||||
const isCodeTask = localTask?.type === 'code';
|
||||
const hasWorktree = !!localTask?.git?.worktreePath;
|
||||
|
|
@ -187,7 +188,7 @@ export function TaskDetailPanel({
|
|||
return (
|
||||
<>
|
||||
<Drawer.Root
|
||||
closeOnEscape
|
||||
closeOnEscape={!nestedOverlayOpen}
|
||||
lockScroll
|
||||
onClose={() => onOpenChange(false)}
|
||||
opened={open}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* - Shows active runs for this task
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { Badge, Button, Group, Loader, Modal, Paper, ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
|
@ -40,6 +40,15 @@ interface WorkflowRun {
|
|||
startedAt: string;
|
||||
}
|
||||
|
||||
const TASK_WORKFLOW_HISTORY_KEY = 'veritasTaskWorkflow';
|
||||
|
||||
function workflowHistoryId(): string | null {
|
||||
const state = window.history.state;
|
||||
if (!state || typeof state !== 'object') return null;
|
||||
const workflowId = (state as Record<string, unknown>)[TASK_WORKFLOW_HISTORY_KEY];
|
||||
return typeof workflowId === 'string' ? workflowId : null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
|
@ -134,10 +143,46 @@ export function WorkflowSection({ task, open, onOpenChange }: WorkflowSectionPro
|
|||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loadRevision, setLoadRevision] = useState(0);
|
||||
const [isStarting, setIsStarting] = useState<string | null>(null);
|
||||
const ownsHistoryEntryRef = useRef(false);
|
||||
const { toast } = useToast();
|
||||
const { hasPermission } = useIdentity();
|
||||
const isMobile = useMediaQuery('(max-width: 767px)', false);
|
||||
const canExecuteWorkflows = hasPermission('workflow:execute');
|
||||
const historyId = `${task.id}:workflow`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (workflowHistoryId() !== historyId) {
|
||||
const nextState = {
|
||||
...(window.history.state && typeof window.history.state === 'object'
|
||||
? window.history.state
|
||||
: {}),
|
||||
[TASK_WORKFLOW_HISTORY_KEY]: historyId,
|
||||
};
|
||||
window.history.pushState(
|
||||
nextState,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
ownsHistoryEntryRef.current = true;
|
||||
|
||||
const handlePopState = () => {
|
||||
if (!ownsHistoryEntryRef.current || workflowHistoryId() === historyId) return;
|
||||
ownsHistoryEntryRef.current = false;
|
||||
onOpenChange(false);
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [historyId, onOpenChange, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (ownsHistoryEntryRef.current && workflowHistoryId() === historyId) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -239,7 +284,7 @@ export function WorkflowSection({ task, open, onOpenChange }: WorkflowSectionPro
|
|||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={handleClose}
|
||||
title="Run Workflow"
|
||||
centered
|
||||
size="xl"
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function TemplatesPage({ onBack }: TemplatesPageProps) {
|
|||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onBack}
|
||||
aria-label="Back to board"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</ActionIcon>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export function TimeBreakdownPage({ onBack, onTaskClick }: TimeBreakdownPageProp
|
|||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Button variant="subtle" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
|
|||
leftSection={<ArrowLeft className="h-4 w-4" />}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back to Board
|
||||
Back
|
||||
</Button>
|
||||
<Title order={1} className="text-2xl">
|
||||
Workflows
|
||||
|
|
|
|||
|
|
@ -11,6 +11,50 @@ import { VIEW_PATHS, type AppView } from '@/lib/views';
|
|||
import type { TaskDetailNavigationTarget } from '@/components/task/TaskDetailPanel';
|
||||
|
||||
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
const NAVIGATION_STATE_KEY = 'veritasKanbanNavigation';
|
||||
|
||||
interface NavigationHistoryState {
|
||||
view: AppView;
|
||||
originView: AppView | null;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
function getNavigationHistoryState(): NavigationHistoryState | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const state = window.history.state;
|
||||
if (!state || typeof state !== 'object') return null;
|
||||
const navigation = (state as Record<string, unknown>)[NAVIGATION_STATE_KEY];
|
||||
if (!navigation || typeof navigation !== 'object') return null;
|
||||
const candidate = navigation as Partial<NavigationHistoryState>;
|
||||
if (!candidate.view || !(candidate.view in VIEW_PATHS)) return null;
|
||||
return {
|
||||
view: candidate.view,
|
||||
originView:
|
||||
candidate.originView && candidate.originView in VIEW_PATHS ? candidate.originView : null,
|
||||
scrollTop: typeof candidate.scrollTop === 'number' ? candidate.scrollTop : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function historyStateWithNavigation(
|
||||
navigation: NavigationHistoryState,
|
||||
preserveTransientState = true
|
||||
): Record<string, unknown> {
|
||||
const currentState =
|
||||
window.history.state && typeof window.history.state === 'object' ? window.history.state : {};
|
||||
const nextState: Record<string, unknown> = {
|
||||
...currentState,
|
||||
[NAVIGATION_STATE_KEY]: navigation,
|
||||
};
|
||||
if (!preserveTransientState) {
|
||||
delete nextState.veritasTaskDetail;
|
||||
delete nextState.veritasTaskWorkflow;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function getMainScrollTop(): number {
|
||||
return document.getElementById('main-content')?.scrollTop ?? 0;
|
||||
}
|
||||
|
||||
function normalizeAppPath(pathname: string): string {
|
||||
const normalized = pathname.startsWith(basePath)
|
||||
|
|
@ -36,6 +80,10 @@ interface ViewContextValue {
|
|||
/** Optional tab/run target for the pending task navigation. */
|
||||
pendingTaskTarget: TaskDetailNavigationTarget | null;
|
||||
clearPendingTask: () => void;
|
||||
/** Return to the actual in-app history origin, or replace a direct link with Board. */
|
||||
goBack: () => void;
|
||||
/** Return from a task opened by another full-page view. No-op for Board-owned tasks. */
|
||||
returnFromTask: () => void;
|
||||
}
|
||||
|
||||
const ViewContext = createContext<ViewContextValue>({
|
||||
|
|
@ -45,6 +93,8 @@ const ViewContext = createContext<ViewContextValue>({
|
|||
pendingTaskId: null,
|
||||
pendingTaskTarget: null,
|
||||
clearPendingTask: () => {},
|
||||
goBack: () => {},
|
||||
returnFromTask: () => {},
|
||||
});
|
||||
|
||||
export function ViewProvider({ children }: { children: ReactNode }) {
|
||||
|
|
@ -53,27 +103,84 @@ export function ViewProvider({ children }: { children: ReactNode }) {
|
|||
const [pendingTaskTarget, setPendingTaskTarget] = useState<TaskDetailNavigationTarget | null>(
|
||||
null
|
||||
);
|
||||
const [taskOriginView, setTaskOriginView] = useState<AppView | null>(null);
|
||||
|
||||
const setView = useCallback((nextView: AppView) => {
|
||||
setViewState(nextView);
|
||||
const setView = useCallback(
|
||||
(nextView: AppView) => {
|
||||
setTaskOriginView(null);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const currentNavigation = getNavigationHistoryState();
|
||||
if (currentNavigation) {
|
||||
window.history.replaceState(
|
||||
historyStateWithNavigation({
|
||||
...currentNavigation,
|
||||
view,
|
||||
scrollTop: getMainScrollTop(),
|
||||
}),
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
|
||||
const nextPath = `${basePath}${VIEW_PATHS[nextView]}`.replace(/\/+/g, '/');
|
||||
const nextUrl = nextView === 'board' ? `${nextPath}${window.location.search}` : nextPath;
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
if (currentPath !== nextUrl) {
|
||||
window.history.pushState(
|
||||
historyStateWithNavigation(
|
||||
{
|
||||
view: nextView,
|
||||
originView: view,
|
||||
scrollTop: 0,
|
||||
},
|
||||
false
|
||||
),
|
||||
'',
|
||||
nextUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setViewState(nextView);
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const nextPath = `${basePath}${VIEW_PATHS[nextView]}`.replace(/\/+/g, '/');
|
||||
const nextUrl = nextView === 'board' ? `${nextPath}${window.location.search}` : nextPath;
|
||||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||||
if (currentPath !== nextUrl) {
|
||||
window.history.pushState({}, '', nextUrl);
|
||||
const navigation = getNavigationHistoryState();
|
||||
if (navigation?.originView) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const boardPath = `${basePath}${VIEW_PATHS.board}`.replace(/\/+/g, '/');
|
||||
window.history.replaceState(
|
||||
historyStateWithNavigation(
|
||||
{
|
||||
view: 'board',
|
||||
originView: null,
|
||||
scrollTop: 0,
|
||||
},
|
||||
false
|
||||
),
|
||||
'',
|
||||
boardPath
|
||||
);
|
||||
setViewState('board');
|
||||
setTaskOriginView(null);
|
||||
}, []);
|
||||
|
||||
const navigateToTask = useCallback(
|
||||
(taskId: string, target?: TaskDetailNavigationTarget) => {
|
||||
const originView = view === 'board' ? null : view;
|
||||
setPendingTaskId(taskId);
|
||||
setPendingTaskTarget(target ?? null);
|
||||
setView('board');
|
||||
setTaskOriginView(originView);
|
||||
},
|
||||
[setView]
|
||||
[setView, view]
|
||||
);
|
||||
|
||||
const clearPendingTask = useCallback(() => {
|
||||
|
|
@ -81,6 +188,12 @@ export function ViewProvider({ children }: { children: ReactNode }) {
|
|||
setPendingTaskTarget(null);
|
||||
}, []);
|
||||
|
||||
const returnFromTask = useCallback(() => {
|
||||
if (!taskOriginView) return;
|
||||
setTaskOriginView(null);
|
||||
goBack();
|
||||
}, [goBack, taskOriginView]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
view,
|
||||
|
|
@ -89,12 +202,43 @@ export function ViewProvider({ children }: { children: ReactNode }) {
|
|||
pendingTaskId,
|
||||
pendingTaskTarget,
|
||||
clearPendingTask,
|
||||
goBack,
|
||||
returnFromTask,
|
||||
}),
|
||||
[view, setView, navigateToTask, pendingTaskId, pendingTaskTarget, clearPendingTask]
|
||||
[
|
||||
view,
|
||||
setView,
|
||||
navigateToTask,
|
||||
pendingTaskId,
|
||||
pendingTaskTarget,
|
||||
clearPendingTask,
|
||||
goBack,
|
||||
returnFromTask,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getNavigationHistoryState()) {
|
||||
window.history.replaceState(
|
||||
historyStateWithNavigation(
|
||||
{
|
||||
view: getViewFromLocation(),
|
||||
originView: null,
|
||||
scrollTop: getMainScrollTop(),
|
||||
},
|
||||
false
|
||||
),
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setPendingTaskId(null);
|
||||
setPendingTaskTarget(null);
|
||||
setTaskOriginView(null);
|
||||
setViewState(getViewFromLocation());
|
||||
};
|
||||
|
||||
|
|
@ -102,6 +246,52 @@ export function ViewProvider({ children }: { children: ReactNode }) {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const main = document.getElementById('main-content');
|
||||
if (!main) return;
|
||||
const navigation = getNavigationHistoryState();
|
||||
let restoreFrame = window.requestAnimationFrame(() => {
|
||||
restoreFrame = window.requestAnimationFrame(() => {
|
||||
if (navigation?.view === view) {
|
||||
main.scrollTop = navigation.scrollTop;
|
||||
}
|
||||
});
|
||||
});
|
||||
let persistFrame: number | null = null;
|
||||
const persistScroll = () => {
|
||||
if (persistFrame !== null) window.cancelAnimationFrame(persistFrame);
|
||||
persistFrame = window.requestAnimationFrame(() => {
|
||||
const currentNavigation = getNavigationHistoryState();
|
||||
if (!currentNavigation || currentNavigation.view !== view) return;
|
||||
window.history.replaceState(
|
||||
historyStateWithNavigation({
|
||||
...currentNavigation,
|
||||
scrollTop: main.scrollTop,
|
||||
}),
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
);
|
||||
});
|
||||
};
|
||||
main.addEventListener('scroll', persistScroll, { passive: true });
|
||||
return () => {
|
||||
main.removeEventListener('scroll', persistScroll);
|
||||
window.cancelAnimationFrame(restoreFrame);
|
||||
if (persistFrame !== null) window.cancelAnimationFrame(persistFrame);
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHistoryShortcut = (event: KeyboardEvent) => {
|
||||
if (event.metaKey && !event.altKey && !event.ctrlKey && event.key === '[') {
|
||||
event.preventDefault();
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleHistoryShortcut);
|
||||
return () => window.removeEventListener('keydown', handleHistoryShortcut);
|
||||
}, [goBack]);
|
||||
|
||||
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue