fix: preserve in-app navigation context

This commit is contained in:
Brad Groux 2026-07-24 13:06:28 -05:00
parent 26eb23f784
commit 0dd8d57ac5
22 changed files with 534 additions and 41 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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' });

View 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();
});
});

View file

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

View file

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

View file

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

View file

@ -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();
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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