diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 55e77514ad..ba9b7e1b6d 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -111,8 +111,8 @@ surface. `next-themes` directly. The routing/image/dynamic adapters now provide browser-native behavior for the Vite SPA while legacy Next entrypoints are phased out. -- Components: <200 lines, extract to domain components, composition over props. -- Hooks: domain-organized in `hooks/domains/`, encapsulate subscription + selection. +- Task links: `lib/links.ts::linkToTask` is the only `/t/:taskId` builder; pass raw IDs, use `TaskLink` or `AppLink`, and use `linkToTask` for router pushes. Keep compatibility `/tasks/:id`, Office/API paths, and route-recognition prefixes separate. +- Components stay under 200 lines; extract domain components. Hooks belong in `hooks/domains/` and encapsulate subscription plus selection. - **Code-host dashboards:** GitHub, GitLab, and plugin code-host pages must use the provider-neutral primitives in `components/integrations/` for change-request lists, rows, toolbars, scope controls, task preset menus, and diff --git a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx index b8f560b9fe..43660ceea9 100644 --- a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx +++ b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "@/lib/routing/client-dynamic"; -import Link from "@/components/routing/app-link"; +import TaskLink from "@/components/routing/task-link"; import { IconInfoCircle } from "@tabler/icons-react"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; @@ -47,13 +47,13 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps onToggleSimple()} /> - {t("office:openInAdvancedView")} - + ), }); diff --git a/apps/web/app/tasks/[id]/kanban-task-shell.tsx b/apps/web/app/tasks/[id]/kanban-task-shell.tsx index a5b6582cf5..3fa16ddd24 100644 --- a/apps/web/app/tasks/[id]/kanban-task-shell.tsx +++ b/apps/web/app/tasks/[id]/kanban-task-shell.tsx @@ -16,6 +16,7 @@ */ import Link from "@/components/routing/app-link"; +import TaskLink from "@/components/routing/task-link"; import { TaskPageContent } from "@/components/task/task-page-content"; import { TaskBody, resolveTaskBodyMode } from "@/components/task/TaskBody"; import { TaskHeader } from "@/components/task/TaskHeader"; @@ -153,11 +154,21 @@ function SimpleTaskHeaderRow({ task }: { task: Task | null }) { function CrossLinkRow({ taskId, target }: { taskId: string; target: "office" | "kanban" }) { const { t } = useTranslation(); - const href = target === "office" ? `/office/tasks/${taskId}` : `/t/${taskId}`; const label = target === "office" ? t("tasks:openInOfficeView") : t("tasks:openInAdvancedView"); + if (target === "kanban") { + return ( + + {label} + + ); + } return ( diff --git a/apps/web/app/tasks/[id]/page.tsx b/apps/web/app/tasks/[id]/page.tsx index 409af98114..761fed6c81 100644 --- a/apps/web/app/tasks/[id]/page.tsx +++ b/apps/web/app/tasks/[id]/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "@/lib/routing/server-navigation"; +import { linkToTask } from "@/lib/links"; /** * Compatibility alias for `/tasks/:id`. @@ -25,6 +26,5 @@ export default async function TasksDetailAliasPage({ query.set(key, value); } } - const queryString = query.toString(); - redirect(queryString ? `/t/${id}?${queryString}` : `/t/${id}`); + redirect(linkToTask(id, { searchParams: query })); } diff --git a/apps/web/components/automations/runs-section.test.tsx b/apps/web/components/automations/runs-section.test.tsx index e89946aaea..e36304d45e 100644 --- a/apps/web/components/automations/runs-section.test.tsx +++ b/apps/web/components/automations/runs-section.test.tsx @@ -116,7 +116,7 @@ describe("RunsSection run log", () => { fireEvent.click(screen.getByTestId("run-row-run-x")); - expect(mockPush).toHaveBeenCalledWith("/tasks/task-hidden"); + expect(mockPush).toHaveBeenCalledWith("/t/task-hidden"); }); it("does not link a run that never produced a task", () => { diff --git a/apps/web/components/automations/runs-section.tsx b/apps/web/components/automations/runs-section.tsx index 41b766a899..f9bedac314 100644 --- a/apps/web/components/automations/runs-section.tsx +++ b/apps/web/components/automations/runs-section.tsx @@ -21,6 +21,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { IconChevronDown, IconChevronUp, IconRefresh, IconTrash } from "@tabler/icons-react"; import { useAutomationRuns } from "@/hooks/domains/settings/use-automation-runs"; import { buildRunOutcomeReasonSuffix } from "@/lib/automation-run-reason"; +import { linkToTask } from "@/lib/links"; import type { AutomationRun, RunStatus } from "@/lib/types/automation"; import { formatRelativeTime } from "@/lib/utils"; @@ -326,7 +327,7 @@ export function RunsSection({ automationId, workspaceId }: RunsSectionProps) { run={run} deleting={deleting} onDelete={deleteRun} - onNavigate={(id) => router.push(`/tasks/${id}`)} + onNavigate={(id) => router.push(linkToTask(id))} /> )) )} diff --git a/apps/web/components/automations/trigger-configs/plugin-webhook-controls.tsx b/apps/web/components/automations/trigger-configs/plugin-webhook-controls.tsx index 9a613217a6..b56fe6b4a8 100644 --- a/apps/web/components/automations/trigger-configs/plugin-webhook-controls.tsx +++ b/apps/web/components/automations/trigger-configs/plugin-webhook-controls.tsx @@ -5,6 +5,7 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import type { AutomationTrigger } from "@/lib/types/automation"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import TaskLink from "@/components/routing/task-link"; export function PluginWebhookControls({ trigger, @@ -81,9 +82,9 @@ export function PluginWebhookControls({ {new Date(receipt.created_at * 1000).toLocaleString()}:{" "} {t(`automations:pluginWebhookState_${receipt.state}`)}{" "} {receipt.task_id ? ( - + {receipt.run_id} - + ) : ( receipt.run_id )} diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx index 13db32650b..47a5b46dea 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.test.tsx @@ -147,7 +147,7 @@ it("updates the cache and store after associating the created task", async () => await waitFor(() => expect(mocks.cache).toHaveBeenCalledWith(workspaceId, "task-1", linked)); expect(mocks.setTaskPullRequest).toHaveBeenCalledWith("task-1", linked); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); }); it("reports a pull request association failure", async () => { @@ -158,7 +158,7 @@ it("reports a pull request association failure", async () => { await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith("Azure association failed")); expect(mocks.close).toHaveBeenCalled(); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); }); it("associates and caches a created task for a work item", async () => { diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index fdfee890f3..88aaca2f97 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider"; import { cacheAzureDevOpsTaskPullRequest } from "@/hooks/domains/azure-devops/use-azure-devops-task-pull-requests"; import { cacheAzureDevOpsTaskWorkItem } from "@/hooks/domains/azure-devops/use-azure-devops-task-work-items"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import { associateAzureDevOpsPullRequest, associateAzureDevOpsWorkItem, @@ -186,7 +187,7 @@ export function AzureDevOpsTaskLauncher({ ) => { await linkCreatedTask(task); onClose(); - if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); }; if (!workspaceId || !payload || !launch?.workflow || !launch.workflowSteps[0]) return null; diff --git a/apps/web/components/github/my-github/quick-task-launcher.test.tsx b/apps/web/components/github/my-github/quick-task-launcher.test.tsx index 7e630df2cd..91599b189d 100644 --- a/apps/web/components/github/my-github/quick-task-launcher.test.tsx +++ b/apps/web/components/github/my-github/quick-task-launcher.test.tsx @@ -292,7 +292,7 @@ describe("QuickTaskLauncher issue linking", () => { expect(mocks.upsertTaskIssue).toHaveBeenCalledWith(WORKSPACE_ID, link); }); expect(mocks.push).toHaveBeenCalledTimes(autoFocus ? 1 : 0); - if (autoFocus) expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + if (autoFocus) expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); }, ); @@ -302,7 +302,7 @@ describe("QuickTaskLauncher issue linking", () => { mocks.dialogProps?.onSuccess?.({ id: "task-1" } as Task); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); await waitFor(() => expect(mocks.linkTaskIssue).toHaveBeenCalledTimes(1)); expect(mocks.upsertTaskIssue).not.toHaveBeenCalled(); }); diff --git a/apps/web/components/github/my-github/quick-task-launcher.tsx b/apps/web/components/github/my-github/quick-task-launcher.tsx index 98b96ac0f0..a9428db860 100644 --- a/apps/web/components/github/my-github/quick-task-launcher.tsx +++ b/apps/web/components/github/my-github/quick-task-launcher.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import type { Icon } from "@tabler/icons-react"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore } from "@/components/state-provider"; @@ -236,7 +237,7 @@ export function QuickTaskLauncher({ }); } onClose(); - if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/inbox-history/inbox-history-row.tsx b/apps/web/components/inbox-history/inbox-history-row.tsx index 339454b7c7..5392467751 100644 --- a/apps/web/components/inbox-history/inbox-history-row.tsx +++ b/apps/web/components/inbox-history/inbox-history-row.tsx @@ -17,6 +17,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { toast } from "@/lib/toast/sonner"; import { formatRelativeTime } from "@/lib/i18n/formats"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { linkToTask } from "@/lib/links"; import { inboxHistoryClarificationQuestions, inboxHistoryPermissionContent, @@ -32,8 +33,7 @@ const TOUCH_ICON_BUTTON = "max-md:min-h-11 max-md:min-w-11 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11"; function taskHrefForBundle(bundle: InboxHistoryBundle): string { - if (!bundle.session_id) return `/t/${bundle.task_id}`; - return `/t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)}`; + return linkToTask(bundle.task_id, { sessionId: bundle.session_id ?? undefined }); } function InboxHistoryQuestionDetail({ bundle }: { bundle: InboxHistoryBundle }) { diff --git a/apps/web/components/jira/my-jira/quick-task-launcher.tsx b/apps/web/components/jira/my-jira/quick-task-launcher.tsx index 0495cb4471..957ad52ddd 100644 --- a/apps/web/components/jira/my-jira/quick-task-launcher.tsx +++ b/apps/web/components/jira/my-jira/quick-task-launcher.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import type { Task, Workflow, WorkflowStep } from "@/lib/types/http"; import type { JiraTicket } from "@/lib/types/jira"; @@ -62,7 +63,7 @@ export function QuickTaskLauncher({ }; const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { onClose(); - if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/linear/linear-quick-task-launcher.tsx b/apps/web/components/linear/linear-quick-task-launcher.tsx index 73dadb6069..0ca39177cb 100644 --- a/apps/web/components/linear/linear-quick-task-launcher.tsx +++ b/apps/web/components/linear/linear-quick-task-launcher.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import type { Task, Workflow, WorkflowStep } from "@/lib/types/http"; import type { LinearIssue } from "@/lib/types/linear"; @@ -84,7 +85,7 @@ export function LinearQuickTaskLauncher({ }; const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { onClose(); - if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/needs-you-inbox/failed-inbox-row.tsx b/apps/web/components/needs-you-inbox/failed-inbox-row.tsx index 12d01a5860..a8096e75b8 100644 --- a/apps/web/components/needs-you-inbox/failed-inbox-row.tsx +++ b/apps/web/components/needs-you-inbox/failed-inbox-row.tsx @@ -7,6 +7,7 @@ import { formatRelativeTime } from "@/lib/i18n/formats"; import { getTaskStateIcon } from "@/lib/ui/state-icons"; import { FAILED_TASK_STATUS } from "@/lib/threads/thread-session-status"; import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp"; +import { linkToTask } from "@/lib/links"; import { failedInboxOriginMarkerKey, hasResolvableFailedInboxReason, @@ -16,7 +17,7 @@ import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-in // The row addresses a task, never a session (design-01#Session-resolution): // the open-task control needs only the task id. function taskHrefForRow(row: FailedInboxRowData): string { - return `/t/${row.task_id}`; + return linkToTask(row.task_id); } export function FailedInboxRow({ row }: { row: FailedInboxRowData }) { diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx index 2d76c93dbf..5ba709cc38 100644 --- a/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx @@ -41,6 +41,7 @@ import { rowSecondaryText, } from "@/lib/needs-you-inbox/row-presentation"; import { resolveThreadSessionStatus } from "@/lib/threads/thread-session-status"; +import { linkToTask } from "@/lib/links"; import type { TaskSessionState } from "@/lib/types/http"; import type { LateClarificationSnapshot, @@ -167,8 +168,7 @@ function RowActionsMenu({ } function taskHrefForBundle(bundle: ClarificationInboxBundle): string { - if (!bundle.session_id) return `/t/${bundle.task_id}`; - return `/t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)}`; + return linkToTask(bundle.task_id, { sessionId: bundle.session_id ?? undefined }); } // eslint-disable-next-line max-lines-per-function -- the row keeps its responsive actions and inline panel together. diff --git a/apps/web/components/routing/app-link.test.tsx b/apps/web/components/routing/app-link.test.tsx index 04fca24784..4ea9330f71 100644 --- a/apps/web/components/routing/app-link.test.tsx +++ b/apps/web/components/routing/app-link.test.tsx @@ -4,14 +4,18 @@ import { clearNavigationBlockerForTests, setNavigationBlocker, } from "@/lib/routing/navigation-guard"; +import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event"; import Link from "./app-link"; +const BLOCKED_PATH = "/settings/general/appearance"; + afterEach(() => { cleanup(); clearNavigationBlockerForTests(); vi.restoreAllMocks(); }); +// eslint-disable-next-line max-lines-per-function -- the suite keeps one AppLink contract together. describe("AppLink", () => { it("navigates same-origin links through browser history", () => { window.history.replaceState({}, "", "/"); @@ -37,13 +41,48 @@ describe("AppLink", () => { expect(onClick).toHaveBeenCalledOnce(); }); + it("calls onNavigated after the location event for a committed navigation", () => { + const events: string[] = []; + const onNavigated = vi.fn(() => events.push("callback")); + window.addEventListener(LOCATION_CHANGE_EVENT, () => events.push("location"), { once: true }); + render( + + Tasks + , + ); + + fireEvent.click(screen.getByText("Tasks")); + + expect(events).toEqual(["location", "callback"]); + expect(onNavigated).toHaveBeenCalledOnce(); + }); + it("does not intercept modified clicks", () => { const pushState = vi.spyOn(window.history, "pushState"); - render(Tasks); + const onNavigated = vi.fn(); + render( + + Tasks + , + ); fireEvent.click(screen.getByText("Tasks"), { metaKey: true }); expect(pushState).not.toHaveBeenCalled(); + expect(onNavigated).not.toHaveBeenCalled(); + }); + + it("does not call onNavigated when a caller prevents the click", () => { + const onNavigated = vi.fn(); + render( + event.preventDefault()}> + Tasks + , + ); + + fireEvent.click(screen.getByText("Tasks")); + + expect(onNavigated).not.toHaveBeenCalled(); }); it("does not intercept hash-only links", () => { @@ -70,7 +109,7 @@ describe("AppLink", () => { }); it("waits for a navigation blocker before changing routes", () => { - window.history.replaceState({}, "", "/settings/general/appearance"); + window.history.replaceState({}, "", BLOCKED_PATH); let proceed: () => void = () => undefined; const onLocationChange = vi.fn(); window.addEventListener("kandev:navigation", onLocationChange, { once: true }); @@ -81,9 +120,49 @@ describe("AppLink", () => { fireEvent.click(screen.getByText("Tasks")); - expect(window.location.pathname).toBe("/settings/general/appearance"); + expect(window.location.pathname).toBe(BLOCKED_PATH); proceed(); expect(window.location.pathname).toBe("/tasks"); expect(onLocationChange).toHaveBeenCalledOnce(); }); + + it("runs onNavigated only when a navigation blocker proceeds", () => { + window.history.replaceState({}, "", BLOCKED_PATH); + let proceed: () => void = () => undefined; + const onNavigated = vi.fn(); + setNavigationBlocker((intent) => { + proceed = intent.proceed; + }); + render( + + Tasks + , + ); + + fireEvent.click(screen.getByText("Tasks")); + + expect(onNavigated).not.toHaveBeenCalled(); + proceed(); + expect(onNavigated).toHaveBeenCalledOnce(); + }); + + it("does not call onNavigated when a navigation blocker cancels", () => { + window.history.replaceState({}, "", BLOCKED_PATH); + let cancel: () => void = () => undefined; + const onNavigated = vi.fn(); + setNavigationBlocker((intent) => { + cancel = intent.cancel; + }); + render( + + Tasks + , + ); + + fireEvent.click(screen.getByText("Tasks")); + cancel(); + + expect(window.location.pathname).toBe(BLOCKED_PATH); + expect(onNavigated).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/components/routing/app-link.tsx b/apps/web/components/routing/app-link.tsx index da04f8d776..ab99475883 100644 --- a/apps/web/components/routing/app-link.tsx +++ b/apps/web/components/routing/app-link.tsx @@ -11,10 +11,11 @@ type AppLinkHref = string | URL; export type AppLinkProps = Omit, "href"> & { href: AppLinkHref; children?: ReactNode; + onNavigated?: () => void; }; const Link = forwardRef(function Link( - { href, onClick, ...props }, + { href, onClick, onNavigated, ...props }, ref, ) { const resolvedHref = href.toString(); @@ -27,6 +28,7 @@ const Link = forwardRef(function Link( pushNavigationState({}, "", resolvedHref, () => { window.scrollTo({ top: 0, left: 0 }); window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); + onNavigated?.(); }); }; diff --git a/apps/web/components/routing/task-link.test.tsx b/apps/web/components/routing/task-link.test.tsx new file mode 100644 index 0000000000..e30f9e13bf --- /dev/null +++ b/apps/web/components/routing/task-link.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TaskLink from "./task-link"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("TaskLink", () => { + it("builds a canonical task href from raw task and query context", () => { + const searchParams = new URLSearchParams([["view", "focused"]]); + render( + + Open task + , + ); + + expect(screen.getByRole("link", { name: "Open task" }).getAttribute("href")).toBe( + "/t/task%2F123?view=focused&layout=plan&sessionId=session+123", + ); + }); + + it("forwards navigation callbacks and anchor refs through AppLink", () => { + const onNavigated = vi.fn(); + const ref = { current: null as HTMLAnchorElement | null }; + render( + + Open task + , + ); + + fireEvent.click(screen.getByRole("link", { name: "Open task" })); + + expect(ref.current).toBe(screen.getByRole("link", { name: "Open task" })); + expect(onNavigated).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/routing/task-link.tsx b/apps/web/components/routing/task-link.tsx new file mode 100644 index 0000000000..b3211c5571 --- /dev/null +++ b/apps/web/components/routing/task-link.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { forwardRef } from "react"; +import Link, { type AppLinkProps } from "./app-link"; +import { linkToTask, type TaskLinkOptions } from "@/lib/links"; + +export type TaskLinkProps = Omit & + TaskLinkOptions & { + taskId: string; + }; + +const TaskLink = forwardRef(function TaskLink( + { taskId, layout, sessionId, searchParams, ...props }, + ref, +) { + return ( + + ); +}); + +export default TaskLink; diff --git a/apps/web/components/runs/runs-list-page.test.tsx b/apps/web/components/runs/runs-list-page.test.tsx index a80bf47334..9edba83b06 100644 --- a/apps/web/components/runs/runs-list-page.test.tsx +++ b/apps/web/components/runs/runs-list-page.test.tsx @@ -171,7 +171,7 @@ describe("RunsListPage", () => { fireEvent.click(screen.getByTestId("run-entry-run-1")); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + expect(mocks.push).toHaveBeenCalledWith("/t/task-1"); }); it("states the scheduler constraint rather than leaving it to be discovered", async () => { diff --git a/apps/web/components/runs/runs-list-page.tsx b/apps/web/components/runs/runs-list-page.tsx index f15f429dd4..2794d91657 100644 --- a/apps/web/components/runs/runs-list-page.tsx +++ b/apps/web/components/runs/runs-list-page.tsx @@ -8,6 +8,7 @@ import Link from "@/components/routing/app-link"; import { PageTopbar } from "@/components/page-topbar"; import { useAppStore } from "@/components/state-provider"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import { cn } from "@/lib/utils"; import type { WorkspaceAutomationRun } from "@/lib/types/automation"; import { buildAgenda, STATE_DOT_CLASS } from "./automation-rows"; @@ -231,7 +232,7 @@ export function RunsListPage({ workspaceId }: { workspaceId?: string }) { router.push(`/tasks/${taskId}`)} + onOpen={(taskId) => router.push(linkToTask(taskId))} /> )} diff --git a/apps/web/components/runs/runs-page-client.test.tsx b/apps/web/components/runs/runs-page-client.test.tsx index 35829c5b7c..e8fabf094d 100644 --- a/apps/web/components/runs/runs-page-client.test.tsx +++ b/apps/web/components/runs/runs-page-client.test.tsx @@ -114,7 +114,7 @@ describe("RunsPageClient feed", () => { fireEvent.click(screen.getByTestId("run-entry-run-x")); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-hidden"); + expect(mocks.push).toHaveBeenCalledWith("/t/task-hidden"); }); it("does not navigate for a run that never produced a task", async () => { diff --git a/apps/web/components/runs/runs-page-client.tsx b/apps/web/components/runs/runs-page-client.tsx index 22ebd4289b..cdc82a1eb9 100644 --- a/apps/web/components/runs/runs-page-client.tsx +++ b/apps/web/components/runs/runs-page-client.tsx @@ -8,6 +8,7 @@ import { Button } from "@kandev/ui/button"; import Link from "@/components/routing/app-link"; import { PageTopbar } from "@/components/page-topbar"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import type { WorkspaceAutomationRun } from "@/lib/types/automation"; import { RunFeedItem } from "./run-feed-item"; import { ANY_AUTOMATION, RunFilters, isDefaultFilters } from "./run-filters"; @@ -191,7 +192,7 @@ export function RunsPageClient({ workspaceId }: RunsPageClientProps) { loading={loading} error={error} onClearFilters={clearFilters} - onOpen={(taskId) => router.push(`/tasks/${taskId}`)} + onOpen={(taskId) => router.push(linkToTask(taskId))} /> diff --git a/apps/web/components/settings/canvas-host-route.tsx b/apps/web/components/settings/canvas-host-route.tsx index e1b64c5004..b54f27acc6 100644 --- a/apps/web/components/settings/canvas-host-route.tsx +++ b/apps/web/components/settings/canvas-host-route.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask } from "@/lib/links"; import { canvasHref, getCanvas, @@ -372,10 +373,7 @@ async function editCanvasFromHost(options: CanvasHostEditOptions): Promise try { const response = await startCanvasEdit(canvas.id); if (response.task_id) { - const query = response.session_id - ? `?sessionId=${encodeURIComponent(response.session_id)}` - : ""; - router.push(`/t/${encodeURIComponent(response.task_id)}${query}`); + router.push(linkToTask(response.task_id, { sessionId: response.session_id ?? undefined })); } } catch (reason: unknown) { onError(reason); diff --git a/apps/web/components/settings/kubernetes-session-utils.ts b/apps/web/components/settings/kubernetes-session-utils.ts index 6d650f288a..7bea416354 100644 --- a/apps/web/components/settings/kubernetes-session-utils.ts +++ b/apps/web/components/settings/kubernetes-session-utils.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import { formatDateTime } from "@/lib/i18n/formats"; +import { linkToTask } from "@/lib/links"; import type { KubernetesSession } from "@/lib/types/http-kubernetes"; const POD_STATUS_LABEL_KEYS: Record = { @@ -86,7 +87,7 @@ export function translateTaskLink(session: KubernetesSession, t: TFunction): str } export function taskHref(taskId: string): string { - return `/t/${encodeURIComponent(taskId)}`; + return linkToTask(taskId); } export function shortId(value: string): string { diff --git a/apps/web/components/settings/system/bundle-customizer.tsx b/apps/web/components/settings/system/bundle-customizer.tsx index 0f0dba1593..81063cbc65 100644 --- a/apps/web/components/settings/system/bundle-customizer.tsx +++ b/apps/web/components/settings/system/bundle-customizer.tsx @@ -22,7 +22,7 @@ import { import { Spinner } from "@kandev/ui/spinner"; import { IconExternalLink } from "@tabler/icons-react"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; -import { linkToTask } from "@/lib/links"; +import TaskLink from "@/components/routing/task-link"; import type { DiagnosticBundleCapabilities, DiagnosticBundleSource, @@ -477,8 +477,8 @@ function SessionRow({ aria-label={session.session_id} />
- {taskTitle} - +
)} - {t("task:openInAdvancedView")} - + ), }); diff --git a/apps/web/components/task/task-dependency-chip.test.tsx b/apps/web/components/task/task-dependency-chip.test.tsx index 2e11bdc1d6..de8a324b35 100644 --- a/apps/web/components/task/task-dependency-chip.test.tsx +++ b/apps/web/components/task/task-dependency-chip.test.tsx @@ -1,22 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const getTaskDependenciesMock = vi.hoisted(() => vi.fn()); +const touchDrawerMock = vi.hoisted(() => ({ value: false })); vi.mock("@/lib/api/domains/task-dependencies-api", () => ({ getTaskDependencies: getTaskDependenciesMock, })); -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +vi.mock("@/hooks/use-compact-task-chrome", () => ({ + useTouchDrawer: () => touchDrawerMock.value, +})); +import { cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; +import { + clearNavigationBlockerForTests, + setNavigationBlocker, +} from "@/lib/routing/navigation-guard"; import { TaskDependencyChip } from "./task-dependency-chip"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; beforeEach(() => { getTaskDependenciesMock.mockReset(); + touchDrawerMock.value = false; // Default: the server also reports no edges, so the fetch fallback cannot be // what makes a "renders nothing" assertion pass. getTaskDependenciesMock.mockResolvedValue({ id: "unknown", depends_on: [], blocks: [] }); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + clearNavigationBlockerForTests(); +}); type KanbanTask = KanbanState["tasks"][number]; @@ -38,6 +50,7 @@ function renderChip(tasks: KanbanTask[], taskId: string) { ); } +// eslint-disable-next-line max-lines-per-function -- the suite covers both disclosure surfaces. describe("TaskDependencyChip", () => { it("renders nothing when the task has no edges in either direction", () => { renderChip([task({ id: "a" })], "a"); @@ -73,6 +86,52 @@ describe("TaskDependencyChip", () => { expect(chip.textContent).toMatch(/1/); }); + it("uses canonical guarded links for both dependency directions", () => { + renderChip( + [ + task({ + id: "b", + dependsOn: [{ id: "a", title: "A", status: "pending" }], + blocks: [{ id: "c", title: "C" }], + }), + ], + "b", + ); + const chip = screen.getByTestId(CHIP_TESTID); + fireEvent.click(chip); + const entries = screen.getAllByTestId("task-dependency-entry"); + + expect(entries.map((entry) => entry.getAttribute("href"))).toEqual(["/t/a", "/t/c"]); + + const click = createEvent.click(entries[0]); + fireEvent(entries[0], click); + expect(click.defaultPrevented).toBe(true); + }); + + it("keeps the disclosure open until guarded navigation commits", async () => { + let proceed: () => void = () => undefined; + setNavigationBlocker((intent) => { + proceed = intent.proceed; + }); + renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b"); + fireEvent.click(screen.getByTestId(CHIP_TESTID)); + fireEvent.click(screen.getByTestId("task-dependency-entry")); + + expect(screen.getByTestId("task-dependency-chip-popover")).toBeTruthy(); + proceed(); + await waitFor(() => expect(screen.queryByTestId("task-dependency-chip-popover")).toBeNull()); + }); + + it("keeps dependency rows touch-sized in the drawer", () => { + touchDrawerMock.value = true; + renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b"); + fireEvent.click(screen.getByTestId(CHIP_TESTID)); + + expect(screen.getByTestId("task-dependency-entry").className).toContain( + "[@media(pointer:coarse)]:min-h-11", + ); + }); + it("renders for a resolved dependency so the chain is still visible after it unblocks", () => { renderChip( [ diff --git a/apps/web/components/task/task-dependency-chip.tsx b/apps/web/components/task/task-dependency-chip.tsx index 5db6adb906..31801c82b4 100644 --- a/apps/web/components/task/task-dependency-chip.tsx +++ b/apps/web/components/task/task-dependency-chip.tsx @@ -28,6 +28,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { cn } from "@kandev/ui/lib/utils"; import { useAppStore } from "@/components/state-provider"; +import TaskLink from "@/components/routing/task-link"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { getTaskDependencies } from "@/lib/api/domains/task-dependencies-api"; import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types"; @@ -153,15 +154,23 @@ function statusIcon(status?: string) { function DependencyRow({ ref: entry, showStatus, + touchTarget, + onNavigated, }: { ref: TaskDependencyRef; showStatus: boolean; + touchTarget: boolean; + onNavigated: () => void; }) { const { t } = useTranslation(); return ( - {showStatus ? ( @@ -175,11 +184,19 @@ function DependencyRow({ {t(`task:state.${entry.state}`, { defaultValue: entry.state })} )} - + ); } -function DependencyLists({ data }: { data: DependencyChipData }) { +function DependencyLists({ + data, + touchTarget, + onNavigated, +}: { + data: DependencyChipData; + touchTarget: boolean; + onNavigated: () => void; +}) { const { t } = useTranslation(); return (
@@ -189,7 +206,13 @@ function DependencyLists({ data }: { data: DependencyChipData }) { {t("task:dependencyBlockedBy")} {data.dependsOn.map((entry) => ( - + ))} )} @@ -199,7 +222,13 @@ function DependencyLists({ data }: { data: DependencyChipData }) { {t("task:dependencyBlocks")} {data.blocks.map((entry) => ( - + ))} )} @@ -293,7 +322,7 @@ export function TaskDependencyChip({ taskId }: { taskId: string | null }) {
- + setOpen(false)} />
@@ -313,7 +342,7 @@ export function TaskDependencyChip({ taskId }: { taskId: string | null }) { - + setOpen(false)} /> ); diff --git a/apps/web/e2e/tests/integrations/azure-devops.spec.ts b/apps/web/e2e/tests/integrations/azure-devops.spec.ts index f7272fc0f2..d373e7e556 100644 --- a/apps/web/e2e/tests/integrations/azure-devops.spec.ts +++ b/apps/web/e2e/tests/integrations/azure-devops.spec.ts @@ -302,7 +302,7 @@ test("connects and browses Azure work items, PRs, and feedback", async ({ ); await taskDialog.getByTestId("submit-start-agent-chevron").click(); await testPage.getByTestId("submit-create-without-agent").click(); - await expect(testPage).toHaveURL(/\/tasks\//); + await expect(testPage).toHaveURL(/\/(?:t|tasks)\//); await testPage.goto("/azure-devops"); await testPage.getByTestId("azure-devops-work-items-mode").click(); await testPage.getByTestId("azure-devops-search-button").click(); diff --git a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts index 2d95757d07..d1a7d14cb4 100644 --- a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts @@ -271,7 +271,7 @@ test("mobile board opens a focused column editor without horizontal overflow", a "Implement: Handle token rotation", ); await testPage.getByRole("button", { name: "Create only" }).click(); - await expect(testPage).toHaveURL(/\/tasks\//); + await expect(testPage).toHaveURL(/\/(?:t|tasks)\//); await testPage.goto("/azure-devops"); await testPage.getByTestId("azure-devops-work-items-mode").click(); await testPage.getByTestId("azure-devops-mobile-filter-button").click(); diff --git a/apps/web/e2e/tests/task/mobile-task-dependency-navigation.spec.ts b/apps/web/e2e/tests/task/mobile-task-dependency-navigation.spec.ts new file mode 100644 index 0000000000..9b57a13aec --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-task-dependency-navigation.spec.ts @@ -0,0 +1,122 @@ +// Filename starts with "mobile-" so this runs on the mobile-chrome project. +import { expect, test } from "../../fixtures/test-base"; +import { + assertLocatorWithinViewportX, + assertNoDocumentHorizontalOverflow, +} from "../../helpers/layout-assertions"; + +test("mobile dependency links preserve the task workbench and touch layout", async ({ + testPage, + apiClient, + seedData, +}) => { + const predecessor = await apiClient.createTask( + seedData.workspaceId, + "Mobile navigation predecessor", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const target = await apiClient.createTask(seedData.workspaceId, "Mobile navigation target", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + blocked_by: [predecessor.id], + }); + const dependent = await apiClient.createTask( + seedData.workspaceId, + "Mobile navigation dependent", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + blocked_by: [target.id], + }, + ); + + await testPage.goto(`/t/${target.id}`); + const taskTitle = testPage.getByTestId("mobile-task-picker-trigger"); + await expect(taskTitle).toContainText(target.title); + + const documentRequests: string[] = []; + const onRequest = (request: import("@playwright/test").Request) => { + if ( + request.isNavigationRequest() && + request.frame() === testPage.mainFrame() && + request.resourceType() === "document" + ) { + documentRequests.push(request.url()); + } + }; + testPage.on("request", onRequest); + + try { + await testPage.evaluate(() => { + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel = + "mobile-dependency-links-stay-in-spa"; + }); + + const chip = testPage.getByTestId("task-dependency-chip"); + await chip.tap(); + const drawer = testPage.getByTestId("task-dependency-chip-drawer"); + await expect(drawer).toBeVisible(); + await expect(drawer.locator("[class*='overflow-y-auto']")).toHaveCount(1); + await assertLocatorWithinViewportX(drawer, "dependency drawer"); + await assertNoDocumentHorizontalOverflow(testPage, "dependency drawer"); + + const entries = drawer.getByTestId("task-dependency-entry"); + await expect(entries).toHaveCount(2); + for (const entry of await entries.all()) { + const box = await entry.boundingBox(); + expect(box, "dependency entry must have geometry").not.toBeNull(); + expect(box?.height ?? 0, "dependency entry touch target").toBeGreaterThanOrEqual(44); + await assertLocatorWithinViewportX(entry, "dependency entry"); + } + + const blockedBy = entries.filter({ hasText: predecessor.title }); + await expect(blockedBy).toHaveAttribute("href", `/t/${predecessor.id}`); + await blockedBy.tap(); + await expect(testPage).toHaveURL(new RegExp(`/t/${predecessor.id}$`)); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText( + predecessor.title, + ); + await expect(testPage.getByTestId("task-dependency-chip-drawer")).toHaveCount(0); + expect(documentRequests).toHaveLength(0); + expect( + await testPage.evaluate( + () => + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel, + ), + ).toBe("mobile-dependency-links-stay-in-spa"); + + await testPage.goBack(); + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`)); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText(target.title); + expect(documentRequests).toHaveLength(0); + + await chip.tap(); + const reopenedDrawer = testPage.getByTestId("task-dependency-chip-drawer"); + await expect(reopenedDrawer).toBeVisible(); + const blocks = reopenedDrawer + .getByTestId("task-dependency-entry") + .filter({ hasText: dependent.title }); + await expect(blocks).toHaveAttribute("href", `/t/${dependent.id}`); + await blocks.tap(); + + await expect(testPage).toHaveURL(new RegExp(`/t/${dependent.id}$`)); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText(dependent.title); + await expect(testPage.getByTestId("task-dependency-chip-drawer")).toHaveCount(0); + expect(documentRequests).toHaveLength(0); + expect( + await testPage.evaluate( + () => + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel, + ), + ).toBe("mobile-dependency-links-stay-in-spa"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency navigation"); + } finally { + testPage.off("request", onRequest); + } +}); diff --git a/apps/web/e2e/tests/task/task-dependencies.spec.ts b/apps/web/e2e/tests/task/task-dependencies.spec.ts index f2d835a3f6..09e9aec4c9 100644 --- a/apps/web/e2e/tests/task/task-dependencies.spec.ts +++ b/apps/web/e2e/tests/task/task-dependencies.spec.ts @@ -264,4 +264,95 @@ test.describe("Task dependencies", () => { await testPage.goto(`/t/${unrelated.id}`); await expect(testPage.getByTestId("task-dependency-chip")).toHaveCount(0); }); + + test("dependency links preserve the desktop task workbench", async ({ + testPage, + apiClient, + seedData, + }) => { + const predecessor = await apiClient.createTask(seedData.workspaceId, "Navigation predecessor", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const target = await apiClient.createTask(seedData.workspaceId, "Navigation target", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + blocked_by: [predecessor.id], + }); + const dependent = await apiClient.createTask(seedData.workspaceId, "Navigation dependent", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + blocked_by: [target.id], + }); + + await testPage.goto(`/t/${target.id}`); + const title = testPage.locator('[data-testid="task-topbar-title"]:visible'); + await expect(title).toHaveText(target.title); + + const documentRequests: string[] = []; + const onRequest = (request: import("@playwright/test").Request) => { + if ( + request.isNavigationRequest() && + request.frame() === testPage.mainFrame() && + request.resourceType() === "document" + ) { + documentRequests.push(request.url()); + } + }; + testPage.on("request", onRequest); + + try { + await testPage.evaluate(() => { + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel = + "dependency-links-stay-in-spa"; + }); + + const chip = testPage.getByTestId("task-dependency-chip"); + await chip.click(); + const blockedBy = testPage + .getByTestId("task-dependency-entry") + .filter({ hasText: predecessor.title }); + await expect(blockedBy).toHaveAttribute("href", `/t/${predecessor.id}`); + await blockedBy.click(); + + await expect(testPage).toHaveURL(new RegExp(`/t/${predecessor.id}$`)); + await expect(title).toHaveText(predecessor.title); + await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0); + expect(documentRequests).toHaveLength(0); + expect( + await testPage.evaluate( + () => + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel, + ), + ).toBe("dependency-links-stay-in-spa"); + + await testPage.goBack(); + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`)); + await expect(title).toHaveText(target.title); + expect(documentRequests).toHaveLength(0); + + await chip.click(); + const blocks = testPage + .getByTestId("task-dependency-entry") + .filter({ hasText: dependent.title }); + await expect(blocks).toHaveAttribute("href", `/t/${dependent.id}`); + await blocks.click(); + + await expect(testPage).toHaveURL(new RegExp(`/t/${dependent.id}$`)); + await expect(title).toHaveText(dependent.title); + await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0); + expect(documentRequests).toHaveLength(0); + expect( + await testPage.evaluate( + () => + (window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel, + ), + ).toBe("dependency-links-stay-in-spa"); + } finally { + testPage.off("request", onRequest); + } + }); }); diff --git a/apps/web/eslint-rules/no-task-link-bypass.mjs b/apps/web/eslint-rules/no-task-link-bypass.mjs new file mode 100644 index 0000000000..a5ce235d99 --- /dev/null +++ b/apps/web/eslint-rules/no-task-link-bypass.mjs @@ -0,0 +1,312 @@ +/** + * Keep first-party task workbench navigation on the shared URL and link + * authorities. This rule deliberately proves only the common static forms: + * literal/template/concatenated task routes, simple local aliases, imported + * linkToTask aliases, native task anchors, and window.location writes. + */ + +const TASK_ROUTE_PREFIX = /^\/(?:t|tasks)\//; +const TASK_ROUTE_WITH_ID = /^\/(?:t|tasks)\/.+/; +const LINKS_MODULE = /(?:^|\/)lib\/links(?:\.[cm]?[jt]s)?$/; + +function unwrap(node) { + if (!node) return null; + if ( + node.type === "TSAsExpression" || + node.type === "TSTypeAssertion" || + node.type === "TSNonNullExpression" || + node.type === "ChainExpression" + ) { + return unwrap(node.expression); + } + return node; +} + +function lookupVariable(scope, name) { + for (let current = scope; current; current = current.upper) { + const found = current.variables.find((variable) => variable.name === name); + if (found) return found; + } + return null; +} + +function variableForIdentifier(context, node) { + if (node.type !== "Identifier") return null; + return lookupVariable(context.sourceCode.getScope(node), node.name); +} + +function isLinksImport(def) { + return def.type === "ImportBinding" && LINKS_MODULE.test(String(def.parent?.source?.value)); +} + +function isLinkToTaskImport(def) { + if (!isLinksImport(def)) return false; + return def.node?.type === "ImportSpecifier" && def.node.imported?.name === "linkToTask"; +} + +function isLinksNamespaceImport(def) { + return isLinksImport(def) && def.node?.type === "ImportNamespaceSpecifier"; +} + +function resolvesToLinkToTask(context, node, seen = new Set()) { + const current = unwrap(node); + if (!current) return false; + + if (current.type === "MemberExpression") { + const property = current.computed ? current.property : current.property; + const propertyName = property.type === "Identifier" ? property.name : property.value; + if (propertyName !== "linkToTask") return false; + const namespace = current.object; + if (namespace.type !== "Identifier") return false; + const variable = variableForIdentifier(context, namespace); + return Boolean(variable?.defs.some(isLinksNamespaceImport)); + } + + if (current.type !== "Identifier") return false; + const variable = variableForIdentifier(context, current); + if (!variable || seen.has(variable)) return false; + seen.add(variable); + if (variable.defs.some(isLinkToTaskImport)) return true; + + return variable.defs.some((def) => { + if (def.type !== "Variable" || !def.node?.init) return false; + return resolvesToLinkToTask(context, def.node.init, seen); + }); +} + +function staticStringFromVariable(context, node, seen) { + const variable = variableForIdentifier(context, node); + if (!variable || seen.has(variable)) return null; + for (const def of variable.defs) { + if (def.type !== "Variable" || !def.node?.init) continue; + const branchSeen = new Set(seen); + branchSeen.add(variable); + const value = isStaticString(context, def.node.init, branchSeen); + if (value !== null) return value; + } + return null; +} + +function isStaticString(context, node, seen = new Set()) { + const current = unwrap(node); + if (!current) return null; + if (current.type === "Literal" && typeof current.value === "string") return current.value; + if (current.type === "TemplateLiteral" && current.expressions.length === 0) { + return current.quasis[0]?.value.cooked ?? ""; + } + if (current.type === "BinaryExpression" && current.operator === "+") { + const left = isStaticString(context, current.left, new Set(seen)); + const right = isStaticString(context, current.right, new Set(seen)); + return left !== null && right !== null ? left + right : null; + } + return current.type === "Identifier" ? staticStringFromVariable(context, current, seen) : null; +} + +function routeFromTemplate(node) { + const first = node.quasis[0]?.value.cooked ?? ""; + if (!TASK_ROUTE_PREFIX.test(first)) return null; + if (TASK_ROUTE_WITH_ID.test(first)) return "raw"; + return node.expressions.length > 0 ? "raw" : null; +} + +function routeFromBinary(context, node, seen) { + const staticValue = isStaticString(context, node, new Set(seen)); + if (staticValue !== null) return TASK_ROUTE_WITH_ID.test(staticValue) ? "raw" : null; + const left = isStaticString(context, node.left, new Set(seen)); + if (!left || !TASK_ROUTE_PREFIX.test(left)) return null; + if (TASK_ROUTE_WITH_ID.test(left)) return "raw"; + // A dynamic right-hand side supplies the task segment. A static query or + // hash after the bare prefix still has no task id and is not a detail route. + const right = isStaticString(context, node.right, new Set(seen)); + return right === null ? "raw" : null; +} + +function combineRouteKinds(left, right) { + if (left === "raw" || right === "raw") return "raw"; + if (left === "builder" || right === "builder") return "builder"; + return null; +} + +function routeKindFromVariable(context, node, seen) { + const variable = variableForIdentifier(context, node); + if (!variable || seen.has(variable)) return null; + if (variable.defs.some(isLinkToTaskImport)) return "builder"; + for (const def of variable.defs) { + if (def.type !== "Variable" || !def.node?.init) continue; + const branchSeen = new Set(seen); + branchSeen.add(variable); + const value = routeKind(context, def.node.init, branchSeen); + if (value) return value; + } + return null; +} + +function routeKindFromComposite(context, node, seen) { + const left = routeKind(context, node.left ?? node.consequent, new Set(seen)); + const right = routeKind(context, node.right ?? node.alternate, new Set(seen)); + return combineRouteKinds(left, right); +} + +function routeKind(context, node, seen = new Set()) { + const current = unwrap(node); + if (!current) return null; + switch (current.type) { + case "Literal": + return typeof current.value === "string" && TASK_ROUTE_WITH_ID.test(current.value) + ? "raw" + : null; + case "TemplateLiteral": + return routeFromTemplate(current); + case "BinaryExpression": + return current.operator === "+" ? routeFromBinary(context, current, seen) : null; + case "ConditionalExpression": + case "LogicalExpression": + return routeKindFromComposite(context, current, seen); + case "SequenceExpression": + return routeKind(context, current.expressions.at(-1), seen); + case "CallExpression": + return resolvesToLinkToTask(context, current.callee) ? "builder" : null; + case "Identifier": + return routeKindFromVariable(context, current, seen); + default: + return null; + } +} + +function isNativeAnchorHref(node) { + const opening = node.parent; + return ( + opening?.type === "JSXOpeningElement" && + opening.name?.type === "JSXIdentifier" && + opening.name.name === "a" + ); +} + +function hrefExpression(node) { + if (!node) return null; + if (node.type === "JSXExpressionContainer") return node.expression; + return node; +} + +function isLocationObject(node) { + const current = unwrap(node); + if (!current) return false; + if (current.type === "Identifier") return current.name === "location"; + return ( + current.type === "MemberExpression" && + !current.computed && + current.property.type === "Identifier" && + current.property.name === "location" && + current.object.type === "Identifier" && + (current.object.name === "window" || current.object.name === "globalThis") + ); +} + +function isLocationHref(node) { + const current = unwrap(node); + return ( + current?.type === "MemberExpression" && + ((current.computed && + current.property.type === "Literal" && + current.property.value === "href") || + (!current.computed && + current.property.type === "Identifier" && + current.property.name === "href")) && + isLocationObject(current.object) + ); +} + +function isLocationMethod(node) { + const current = unwrap(node); + if (current?.type !== "MemberExpression" || !isLocationObject(current.object)) return false; + const property = current.computed ? current.property : current.property; + const name = property.type === "Identifier" ? property.name : property.value; + return name === "assign" || name === "replace"; +} + +function isRouteContainer(parent, node) { + if (parent.type === "BinaryExpression" && parent.operator === "+") return true; + if (parent.type === "ConditionalExpression") { + return parent.consequent === node || parent.alternate === node; + } + if (parent.type === "LogicalExpression") return parent.right === node; + if (parent.type === "TSAsExpression") return parent.expression === node; + if (parent.type === "TSTypeAssertion") return parent.expression === node; + if (parent.type === "TSNonNullExpression") return parent.expression === node; + return parent.type === "ChainExpression" && parent.expression === node; +} + +function isNestedRoutePart(node, context) { + const parent = node.parent; + return Boolean(parent && isRouteContainer(parent, node) && routeKind(context, parent) === "raw"); +} + +/** @type {import("eslint").Rule.RuleModule} */ +export const noTaskLinkBypass = { + meta: { + type: "problem", + docs: { + description: "Require shared task URL and link primitives for task workbench navigation.", + }, + schema: [], + messages: { + taskLinkBypass: + "Use linkToTask() for task workbench URLs and TaskLink/AppLink for task anchors; do not hand-build or natively navigate task-detail links.", + }, + }, + create(context) { + const reported = new WeakSet(); + const report = (node) => { + if (!node || reported.has(node)) return; + reported.add(node); + context.report({ node, messageId: "taskLinkBypass" }); + }; + + return { + JSXAttribute(node) { + if (node.name?.type !== "JSXIdentifier" || node.name.name !== "href") return; + const expression = hrefExpression(node.value); + const kind = routeKind(context, expression); + if (!kind) return; + if (kind === "builder" && !isNativeAnchorHref(node)) return; + // Local raw aliases are reported at their defining expression. This + // avoids a second diagnostic on the consuming JSX attribute. + if (kind === "raw" && expression?.type === "Identifier") return; + report(expression ?? node); + }, + AssignmentExpression(node) { + if (isLocationHref(node.left) && routeKind(context, node.right)) report(node.right); + }, + CallExpression(node) { + if (isLocationMethod(node.callee) && routeKind(context, node.arguments[0])) { + report(node.arguments[0]); + } + }, + Literal(node) { + if (typeof node.value !== "string" || isNestedRoutePart(node, context)) return; + if (node.parent?.type === "JSXAttribute") return; + if (routeKind(context, node) === "raw") report(node); + }, + TemplateLiteral(node) { + if (isNestedRoutePart(node, context)) return; + if (routeKind(context, node) === "raw") report(node); + }, + BinaryExpression(node) { + if (node.operator !== "+" || isNestedRoutePart(node, context)) return; + if (routeKind(context, node) === "raw") report(node); + }, + ConditionalExpression(node) { + if (routeKind(context, node) === "raw") report(node); + }, + LogicalExpression(node) { + if (routeKind(context, node) === "raw") report(node); + }, + }; + }, +}; + +export const taskLinksPlugin = { + rules: { "no-task-link-bypass": noTaskLinkBypass }, +}; + +export const TASK_LINK_RULE_ID = "task-links/no-task-link-bypass"; diff --git a/apps/web/eslint-rules/no-task-link-bypass.test.ts b/apps/web/eslint-rules/no-task-link-bypass.test.ts new file mode 100644 index 0000000000..816c7e0499 --- /dev/null +++ b/apps/web/eslint-rules/no-task-link-bypass.test.ts @@ -0,0 +1,116 @@ +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import { describe, expect, it } from "vitest"; + +import { noTaskLinkBypass } from "./no-task-link-bypass.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tseslint.parser as never, + parserOptions: { ecmaVersion: 2022, sourceType: "module", ecmaFeatures: { jsx: true } }, + }, +}); + +ruleTester.run("no-task-link-bypass", noTaskLinkBypass, { + valid: [ + { code: 'const prefix = "/t/"; pathname.startsWith(prefix);' }, + { code: 'for (const prefix of ["/t/", "/tasks/"]) pathname.startsWith(prefix);' }, + { code: "const apiPath = `/api/tasks/${taskId}`;" }, + { code: "const officePath = `/office/tasks/${taskId}`;" }, + { code: "router.push(linkToTask(taskId));" }, + { + code: `import { linkToTask } from "@/lib/links"; + import Link from "@/components/routing/app-link"; + ;`, + }, + { + code: `import { linkToTask } from "@/lib/links"; + import AppLink from "@/components/routing/app-link"; + ;`, + }, + { code: ";" }, + { code: 'const tasksPath = "/tasks";' }, + ], + invalid: [ + { + code: 'const href = "/t/task-1";', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: "const href = `/tasks/${taskId}`;", + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: 'const href = "/t/" + taskId;', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: 'const href = condition ? `/t/${taskId}` : "/tasks/fallback";', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: "const href = condition && `/t/${taskId}`;", + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: 'Task;', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: 'Task;', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: 'Task;', + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: `import { linkToTask as taskHref } from "@/lib/links"; + Task;`, + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: `import { linkToTask } from "@/lib/links"; + const href = linkToTask(taskId); + Task;`, + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: `import * as Links from "@/lib/links"; + Task;`, + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: "window.location.assign(`/tasks/${taskId}`);", + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: "window.location.href = `/t/${taskId}`;", + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: `function openTask(taskId: string) { + const prefix = "/tasks/"; + const href = prefix + taskId; + return Task; + }`, + errors: [{ messageId: "taskLinkBypass" }], + }, + { + code: `function openTask(taskId: string) { + const prefix = "/tasks/"; + window.location.assign(prefix + taskId); + }`, + errors: [{ messageId: "taskLinkBypass" }], + }, + ], +}); + +describe("no-task-link-bypass rule", () => { + it("keeps the diagnostic focused on first-party task workbench routes", () => { + expect(noTaskLinkBypass.meta?.messages?.taskLinkBypass).toContain("TaskLink"); + }); +}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 366fc77b35..39048339a6 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -11,6 +11,7 @@ import { e2eSleepPlugin, SLEEP_EXEMPT_FILES, } from "./eslint-rules/no-unsanctioned-sleep.mjs"; +import { taskLinksPlugin } from "./eslint-rules/no-task-link-bypass.mjs"; const eslintConfig = defineConfig([ { @@ -86,6 +87,24 @@ const eslintConfig = defineConfig([ plugins: { i18next }, rules: { "i18next/no-literal-string": ["error", noLiteralStringOptions] }, }, + // First-party task workbench URLs and anchors have one URL authority and one + // click transport. Tests and the authority module itself are excluded so the + // rule checks callers rather than its own implementation and fixtures. + { + files: ["**/*.ts", "**/*.tsx"], + ignores: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-helpers.ts", + "**/*.test-helpers.tsx", + "**/*.test-utils.ts", + "**/*.test-utils.tsx", + "e2e/**", + "lib/links.ts", + ], + plugins: { "task-links": taskLinksPlugin }, + rules: { "task-links/no-task-link-bypass": "error" }, + }, // E2E tests (Playwright): disable React hooks rules since Playwright's `use()` and // `test.extend()` patterns are falsely flagged, and relax test-specific limits. { diff --git a/apps/web/lib/entity-references/message-references.ts b/apps/web/lib/entity-references/message-references.ts index 120cb61927..08fdfd4f9c 100644 --- a/apps/web/lib/entity-references/message-references.ts +++ b/apps/web/lib/entity-references/message-references.ts @@ -1,4 +1,5 @@ import type { EntityReference } from "@/lib/types/entity-reference"; +import { linkToTask } from "@/lib/links"; const IDENTITY_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/; const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u; @@ -57,7 +58,7 @@ function hasSafeURL(reference: EntityReference): boolean { return false; } if (reference.provider === "kandev" && reference.kind === "task") { - return reference.url === `/t/${encodeURIComponent(reference.id)}`; + return reference.url === linkToTask(reference.id); } try { const parsed = new URL(reference.url); diff --git a/apps/web/lib/links.test.ts b/apps/web/lib/links.test.ts index 34cc5714e6..bdd47bfc57 100644 --- a/apps/web/lib/links.test.ts +++ b/apps/web/lib/links.test.ts @@ -19,6 +19,23 @@ describe("task links", () => { expect(linkToTask("task-123", "plan")).toBe("/t/task-123?layout=plan"); }); + it("encodes the raw task id once and merges task query context", () => { + const searchParams = new URLSearchParams([ + ["layout", "old"], + ["tag", "first"], + ["tag", "second"], + ]); + + expect( + linkToTask("task/123%raw", { + searchParams, + layout: "plan", + sessionId: "session 123", + }), + ).toBe("/t/task%2F123%25raw?layout=plan&tag=first&tag=second&sessionId=session+123"); + expect(searchParams.toString()).toBe("layout=old&tag=first&tag=second"); + }); + it("keeps /tasks for the task list route", () => { expect(linkToTasks()).toBe("/tasks"); expect(linkToTasks("workspace-123")).toBe("/tasks?workspace=workspace-123"); diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts index 477b17ab5e..0bfd12e494 100644 --- a/apps/web/lib/links.ts +++ b/apps/web/lib/links.ts @@ -1,6 +1,22 @@ -export function linkToTask(taskId: string, layout?: string): string { - const base = `/t/${taskId}`; - return layout ? `${base}?layout=${encodeURIComponent(layout)}` : base; +export type TaskLinkOptions = { + layout?: string; + sessionId?: string; + searchParams?: URLSearchParams; +}; + +export function linkToTask(taskId: string, layout?: string): string; +export function linkToTask(taskId: string, options?: TaskLinkOptions): string; +export function linkToTask(taskId: string, layoutOrOptions?: string | TaskLinkOptions): string { + const base = `/t/${encodeURIComponent(taskId)}`; + const options = + typeof layoutOrOptions === "string" ? { layout: layoutOrOptions } : (layoutOrOptions ?? {}); + const searchParams = new URLSearchParams(options.searchParams); + + if (options.layout !== undefined) searchParams.set("layout", options.layout); + if (options.sessionId !== undefined) searchParams.set("sessionId", options.sessionId); + + const query = searchParams.toString(); + return query ? `${base}?${query}` : base; } export function linkToTaskOverview({ diff --git a/apps/web/scripts/lib/task-link-wiring.test.ts b/apps/web/scripts/lib/task-link-wiring.test.ts new file mode 100644 index 0000000000..3fc74f8a65 --- /dev/null +++ b/apps/web/scripts/lib/task-link-wiring.test.ts @@ -0,0 +1,26 @@ +import path from "node:path"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +const WEB_DIR = path.resolve(import.meta.dirname, "../.."); +const eslint = new ESLint({ cwd: WEB_DIR }); + +describe("task link eslint wiring", () => { + it("guards a new production component in an arbitrary directory", async () => { + const [result] = await eslint.lintText( + `export function NewTaskLink({ taskId }: { taskId: string }) { + return ; +} +`, + { filePath: path.join(WEB_DIR, "components/new-surface/task-link-fixture.tsx") }, + ); + + expect(result.messages).toEqual([ + expect.objectContaining({ + ruleId: "task-links/no-task-link-bypass", + severity: 2, + }), + ]); + }); +}); diff --git a/docs/plans/task-link-navigation/migration-inventory.md b/docs/plans/task-link-navigation/migration-inventory.md new file mode 100644 index 0000000000..632ce22f34 --- /dev/null +++ b/docs/plans/task-link-navigation/migration-inventory.md @@ -0,0 +1,122 @@ +# Task-link migration inventory + +Source snapshot: 2026-09-21. Re-run the rule over the whole production tree; +this literal search is evidence, not an exhaustive static analysis. +Classify bare route prefixes as recognizers, links.ts as authority, and the rest +as builders/consumers. API-relative `/tasks/...` paths require transport-aware +classification. Do not rewrite API contracts. + +Task 02 must record every migrated caller and its changed test. Before its +`vitest related` command, write `/tmp/kandev-task-link-changed-sources.txt` with +one web-package-relative changed source path per line (no test paths). These +repository paths have no spaces. Use the explicit resulting test list in the +work-order Results; run changed test files directly if related discovery misses +one. The temporary file is a command input, not a persistent artifact. + +- `apps/web/components/linear/linear-quick-task-launcher.tsx` + - `87: if (meta?.autoFocus !== false) router.push(/tasks/${task.id});` + +- `apps/web/components/automations/runs-section.tsx` + - `329: onNavigate={(id) => router.push(/tasks/${id})}` + +- `apps/web/components/runs/runs-list-page.tsx` + - `234: onOpen={(taskId) => router.push(/tasks/${taskId})}` + +- `apps/web/components/runs/runs-page-client.tsx` + - `194: onOpen={(taskId) => router.push(/tasks/${taskId})}` + +- `apps/web/components/task/task-session-sidebar.tsx` + - `314: !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));` + - `469: !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));` + +- `apps/web/components/task/task-dependency-chip.tsx` + - `163: href={/tasks/${entry.id}}` + +- `apps/web/components/app-sidebar/app-sidebar.tsx` + - `37: matches: (p) => p.startsWith("/t/"),` + +- `apps/web/components/inbox-history/inbox-history-row.tsx` + - `35: if (!bundle.session_id) return /t/${bundle.task_id};` + - `36: return /t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)};` + +- `apps/web/components/azure-devops/azure-devops-task-launcher.tsx` + - `189: if (meta?.autoFocus !== false) router.push(/tasks/${task.id});` + +- `apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx` + - `170: if (!bundle.session_id) return /t/${bundle.task_id};` + - `171: return /t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)};` + +- `apps/web/components/needs-you-inbox/failed-inbox-row.tsx` + - `19: return /t/${row.task_id};` + +- `apps/web/components/settings/workspace-canvases-page.tsx` + - `107: router.push(/t/${encodeURIComponent(response.task_id)}${query});` + +- `apps/web/components/settings/canvas-host-route.tsx` + - `378: router.push(/t/${encodeURIComponent(response.task_id)}${query});` + +- `apps/web/components/settings/kubernetes-session-utils.ts` + - `89: return /t/${encodeURIComponent(taskId)};` + +- `apps/web/components/jira/my-jira/quick-task-launcher.tsx` + - `65: if (meta?.autoFocus !== false) router.push(/tasks/${task.id});` + +- `apps/web/components/task/simple/OfficeSimplePane.tsx` + - `145: href={/t/${task.id}}` + +- `apps/web/components/automations/trigger-configs/plugin-webhook-controls.tsx` + - `84: ` + +- `apps/web/components/github/my-github/quick-task-launcher.tsx` + - `239: if (meta?.autoFocus !== false) router.push(/tasks/${task.id});` + +- `apps/web/lib/links.ts` + - `2: const base = /t/${taskId};` + - `30: const TASK_DETAIL_PREFIXES = ["/t/", "/tasks/"];` + +- `apps/web/lib/entity-references/message-references.ts` + - `60: return reference.url === /t/${encodeURIComponent(reference.id)};` + +- `apps/web/app/tasks/[id]/kanban-task-shell.tsx` + - `156: const href = target === "office" ? /office/tasks/${taskId} : /t/${taskId};` + +- `apps/web/app/tasks/[id]/page.tsx` + - `29: redirect(queryString ? /t/${id}?${queryString} : /t/${id});` + +- `apps/web/app/office/tasks/[id]/task-advanced-mode.tsx` + - `51: href={/t/${task.id}}` + +- `apps/web/src/spa-routes.tsx` + - `746: for (const prefix of ["/t/", "/tasks/"]) {` + +## Task 02 migration result + +All builders listed above now use `linkToTask` with raw task IDs. Direct task +anchors use `TaskLink`; Office, API, listing, and route-recognition paths keep +their separate contracts. The following callers changed: + +- `components/automations/runs-section.tsx` → `components/automations/runs-section.test.tsx` +- `components/runs/runs-page-client.tsx` → `components/runs/runs-page-client.test.tsx` +- `components/runs/runs-list-page.tsx` → `components/runs/runs-list-page.test.tsx` +- `components/azure-devops/azure-devops-task-launcher.tsx` → `components/azure-devops/azure-devops-task-launcher.test.tsx` +- `components/github/my-github/quick-task-launcher.tsx` → `components/github/my-github/quick-task-launcher.test.tsx` +- `components/linear/linear-quick-task-launcher.tsx` → existing launcher contract retained; no path-specific assertion needed +- `components/jira/my-jira/quick-task-launcher.tsx` → existing launcher contract retained; no path-specific assertion needed +- `components/automations/trigger-configs/plugin-webhook-controls.tsx` → existing rendered link contract retained +- `components/task/simple/OfficeSimplePane.tsx` → existing cross-link contract retained +- `app/office/tasks/[id]/task-advanced-mode.tsx` → existing cross-link contract retained +- `app/tasks/[id]/kanban-task-shell.tsx` → existing Office/Kanban cross-link contract retained +- `app/tasks/[id]/page.tsx` → compatibility redirect contract retained +- `components/inbox-history/inbox-history-row.tsx` → existing session query contract retained +- `components/needs-you-inbox/needs-you-inbox-row.tsx` → existing session query contract retained +- `components/needs-you-inbox/failed-inbox-row.tsx` → existing task href contract retained +- `components/settings/system/bundle-customizer.tsx` → existing alternate-target contract retained +- `components/settings/workspace-canvases-page.tsx` → existing canvas session context retained +- `components/settings/canvas-host-route.tsx` → existing canvas session context retained +- `components/settings/kubernetes-session-utils.ts` → existing task href helper contract retained +- `lib/entity-references/message-references.ts` → existing encoded entity-reference contract retained +- `components/task/task-dependency-chip.tsx` → `components/task/task-dependency-chip.test.tsx` + +The enforcement rule and real-config wiring are covered by +`eslint-rules/no-task-link-bypass.test.ts` and +`scripts/lib/task-link-wiring.test.ts`. diff --git a/docs/plans/task-link-navigation/plan.md b/docs/plans/task-link-navigation/plan.md new file mode 100644 index 0000000000..cf43fbbe12 --- /dev/null +++ b/docs/plans/task-link-navigation/plan.md @@ -0,0 +1,128 @@ +--- +created: 2026-09-21 +status: complete +requirements: + - REQ-UI-TASK-NAVIGATION-001 +system_design: + - ../../specs/ui/system-design/task-navigation.md +legacy_specs: [] +--- + +# Implementation Plan: Task link navigation + +## Overview + +Fix dependency navigation and prevent recurrence across first-party task links. +The shared primitives, first-party migration, enforcement, and desktop/mobile +proof are complete across the three sequential work orders. + +## Evidence and assumptions + +Confirmed: the user requests a fix plan and central task-link construction. +Verified: DependencyRow uses a native anchor to `/tasks/${entry.id}`. Both +Blocks and Blocked by share the row, on desktop and touch. Existing component +and E2E tests check visibility but never activate the dependency link. +Verified: linkToTask already builds `/t/:id`; AppLink already handles guarded +SPA navigation. Thus URL construction and click transport need separate guards. +Source tracing establishes the cause; a browser reproduction has not been run. +No material product choice remains unresolved. The reusable navigation contract +belongs to UI; dependency state remains owned by Tasks. + +## Scope + +### In scope + +- Shared TaskLink, compatible URL-builder extension, committed-navigation callback. +- Dependency popover/drawer fix, touch targets, regression coverage. +- Migration of first-party hand-built task-detail URLs and known raw task anchors. +- ESLint enforcement, wiring coverage, and scoped engineering guidance. + +### Out of scope + +Backend routes/APIs, Office destination semantics, external/plugin links, user +content rewriting, a generic route catalog, and layout redesign. + +## Technical approach + +Follow [the design](../../specs/ui/system-design/task-navigation.md). +Use linkToTask as URL authority and AppLink as click/guard authority; TaskLink +combines them without another routing implementation. Preserve session, layout, +canvas query context, explicit new-tab behavior, and compatibility routes. + +## ASCII UI preview + +UI-01: Dependency navigation, expanded state (AC .1, .4, .5, .6). + +```text +Desktop: [blocks 2] -> Popover: [Task B | REVIEW] -> Task B workbench +Phone: [blocks 2] -> Drawer: + Dependencies [Close] + [Task B | REVIEW] (44px+ tap row) + [Task C | CREATED] (list scrolls) + -> Task B workbench; drawer closes +Before: selecting a row loads a new document through /tasks/:id. +After: selecting a row commits SPA navigation to /t/:id. +``` + +Control order, touch targets, and successful-navigation dismissal are required. +Spacing is illustrative. Retain existing localized labels and status displays. +Cancelled navigation keeps the disclosure; modified clicks keep the current page. + +## Tests + +| Acceptance | Evidence | +| --- | --- | +| .1, .3, .4 | task-link.test.tsx, app-link.test.tsx, task-dependency-chip.test.tsx | +| .2 | links.test.ts and migrated caller tests | +| .1, .2 prevention | no-task-link-bypass.test.ts and task-link-wiring.test.ts | +| .1, .4, .5, .6 | Browser scenarios below | + +## E2E tests + +Add the desktop test `dependency links preserve the desktop task workbench` to +`e2e/tests/task/task-dependencies.spec.ts` (chromium) and the mobile test +`mobile dependency links preserve the task workbench and touch layout` in +`e2e/tests/task/mobile-task-dependency-navigation.spec.ts` (mobile-chrome). +They exercise Blocks and Blocked by, target identity, canonical href, disclosure +closure, document sentinel survival, no main-frame document request, and Back. +The phone test uses the real drawer and measures 44px targets and viewport +containment. + +## Work orders + +- [x] [Task 01: Shared task links and dependency fix](task-01-shared-task-links.md) +- [x] [Task 02: Migrate and enforce task URL construction](task-02-enforce-task-links.md) +- [x] [Task 03: Desktop and touch navigation proof](task-03-browser-regressions.md) + +## Verification results + +Implementation completed on 2026-09-21: + +- Task 01 added `TaskLink`, successful-navigation callbacks, canonical dependency + links, guarded disclosure dismissal, and touch-sized dependency rows. +- Task 02 migrated first-party task destinations, added bounded ESLint + enforcement, added real-config wiring coverage, and recorded the migration + inventory. +- Task 03 added desktop and mobile no-reload regressions. The mobile layout now + forwards the task status identity needed by sessionless dependency tasks. +- Focused Vitest checks passed: 6 files and 51 tests for the routing, dependency, + and rule contracts. +- Broad related Vitest checks passed: 437 files and 4,254 tests. +- `pnpm run typecheck` and `pnpm run lint` passed. +- Chromium dependency E2E passed: 6 tests. +- Mobile dependency E2E passed: 1 test. +- Specification validation, specification lint, and `git diff --check` passed. + +Planning validation passed on 2026-09-21: + +- `python3 scripts/list-docs.py validate`: 294 decisions and 1067 specifications validated. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `git diff --check -- docs/specs docs/plans/task-link-navigation`: passed. +- Catalog discovery includes the new requirement and design. Git status includes all three new work orders and the inventory. + +## Risks + +- Changing query construction must preserve duplicate parameters and avoid double encoding. +- Closing a disclosure before a dirty-navigation guard commits would lose context. +- Static rules cannot resolve arbitrary runtime strings; test bounded detection honestly. +- Compatibility route recognition and API URLs must not become false positives. diff --git a/docs/plans/task-link-navigation/task-01-shared-task-links.md b/docs/plans/task-link-navigation/task-01-shared-task-links.md new file mode 100644 index 0000000000..beefb7197c --- /dev/null +++ b/docs/plans/task-link-navigation/task-01-shared-task-links.md @@ -0,0 +1,113 @@ +--- +id: "01-shared-task-links" +title: "Shared task links and dependency fix" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-TASK-NAVIGATION-001 +acceptance_criteria: + - AC-UI-TASK-NAVIGATION-001.1 + - AC-UI-TASK-NAVIGATION-001.2 + - AC-UI-TASK-NAVIGATION-001.3 + - AC-UI-TASK-NAVIGATION-001.4 + - AC-UI-TASK-NAVIGATION-001.5 +system_design: + - ../../specs/ui/system-design/task-navigation.md +--- + +# Task 01: Shared task links and dependency fix + +## Summary + +Centralize task anchors and repair both dependency directions with the existing router. + +## In scope + +- Add TaskLink and compatible linkToTask options; preserve raw IDs and query context. +- Add AppLink onNavigated within the successful guard callback. +- Switch DependencyRow and pass a close callback; scope 44px minimum rows to touch. + +## Out of scope + +Backend behavior and unrelated navigation redesign. + +## Acceptance + +- Failing regression: clicking a dependency row is intercepted and opens `/t/b`; today its native click is not prevented. +- Cover both directions, canonical URL/encoding, ordinary and keyboard activation, modified/middle clicks, explicit targets, prevented clicks, guard rejection/acceptance, and callback timing. +- Desktop stays compact; phone drawer closes only on committed same-tab navigation. + +## ASCII UI preview + +UI-01: Dependency navigation, expanded state (AC .1, .4, .5, .6). + +```text +Desktop: [blocks 2] -> Popover: [Task B | REVIEW] -> Task B workbench +Phone: [blocks 2] -> Drawer: + Dependencies [Close] + [Task B | REVIEW] (44px+ tap row) + [Task C | CREATED] (list scrolls) + -> Task B workbench; drawer closes +Before: selecting a row loads a new document through /tasks/:id. +After: selecting a row commits SPA navigation to /t/:id. +``` + +Control order, touch targets, and successful-navigation dismissal are required. +Spacing is illustrative. Retain existing localized labels and status displays. +Cancelled navigation keeps the disclosure; modified clicks keep the current page. + +See [full preview](plan.md#ascii-ui-preview). + +## Verification + +Run from repository root. Install once with `(cd apps && pnpm install --frozen-lockfile)` if this worktree lacks dependencies. + +```bash +(cd apps/web && pnpm exec vitest run lib/links.test.ts components/routing/app-link.test.tsx components/routing/task-link.test.tsx components/task/task-dependency-chip.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint lib/links.ts components/routing/app-link.tsx components/routing/task-link.tsx components/task/task-dependency-chip.tsx --max-warnings 0) +``` + +## Files likely touched + +- `apps/web/lib/links.ts` +- `apps/web/lib/links.test.ts` +- `apps/web/components/routing/app-link.tsx` +- `apps/web/components/routing/app-link.test.tsx` +- `apps/web/components/routing/task-link.tsx (new)` +- `apps/web/components/routing/task-link.test.tsx (new)` +- `apps/web/components/task/task-dependency-chip.tsx` +- `apps/web/components/task/task-dependency-chip.test.tsx` + +## Dependencies + +None. + +## Risks + +Preserve existing route guards, query context, and browser-native alternate-tab behavior. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/task-navigation.md) +- [System design](../../specs/ui/system-design/task-navigation.md) +- Existing AppLink, linkToTask, local ESLint rules, and task dependency tests. + +## Results + +Completed on 2026-09-21. + +- The initial focused RED run failed on the six expected pre-fix behaviors: + canonical URL construction, AppLink callback timing, dependency interception, + disclosure dismissal, and touch-row sizing. +- The focused Vitest suite passed 4 files and 32 tests after implementation. +- Review follow-up added explicit blocker-cancellation coverage; the focused + suite now passes 4 files and 33 tests. +- `pnpm run typecheck` passed. +- Focused ESLint passed with zero warnings. diff --git a/docs/plans/task-link-navigation/task-02-enforce-task-links.md b/docs/plans/task-link-navigation/task-02-enforce-task-links.md new file mode 100644 index 0000000000..8e436d5079 --- /dev/null +++ b/docs/plans/task-link-navigation/task-02-enforce-task-links.md @@ -0,0 +1,97 @@ +--- +id: "02-enforce-task-links" +title: "Migrate and enforce task URL construction" +status: done +wave: 2 +depends_on: ['01-shared-task-links'] +plan: "plan.md" +requirements: + - REQ-UI-TASK-NAVIGATION-001 +acceptance_criteria: + - AC-UI-TASK-NAVIGATION-001.1 + - AC-UI-TASK-NAVIGATION-001.2 + - AC-UI-TASK-NAVIGATION-001.3 +system_design: + - ../../specs/ui/system-design/task-navigation.md +--- + +# Task 02: Migrate and enforce task URL construction + +## Summary + +Make the default lint command reject recurring URL and anchor bypasses, with zero current violations. + +## In scope + +- Add the bounded AST rule and actual-config wiring test described in the design. +- Inventory and migrate every production task-detail builder; classify route-recognition constants separately. +- Keep existing compliant Link/router consumers; migrate raw task anchors to TaskLink. +- Update apps/web/AGENTS.md with URL and anchor ownership. No new ADR is needed: this applies existing authorities, with alternatives documented in the design. + +## Out of scope + +Backend behavior and unrelated navigation redesign. + +## Acceptance + +- RED: rule fixtures reject original DependencyRow, raw anchors using aliased helper calls, and hand-built URLs; valid API/Office/matcher cases pass. +- Final production lint has no task-link violations or migration baseline; real config catches a new component in an arbitrary directory. +- Migrated caller tests preserve context and selection side effects; new workbench destinations use canonical URLs. + +## Verification + +Run from repository root. Install once with `(cd apps && pnpm install --frozen-lockfile)` if this worktree lacks dependencies. + +```bash +(cd apps/web && pnpm exec vitest run eslint-rules/no-task-link-bypass.test.ts scripts/lib/task-link-wiring.test.ts lib/links.test.ts) +(cd apps/web && pnpm exec vitest related --run $(cat /tmp/kandev-task-link-changed-sources.txt)) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +git diff --check +``` + +## Files likely touched + +- `apps/web/eslint-rules/no-task-link-bypass.mjs (new)` +- `apps/web/eslint-rules/no-task-link-bypass.test.ts (new)` +- `apps/web/scripts/lib/task-link-wiring.test.ts (new)` +- `apps/web/eslint.config.mjs` +- `apps/web/AGENTS.md` +- `First-party callers listed in migration-inventory.md and their tests` + +## Dependencies + +01-shared-task-links. + +## Risks + +Preserve existing route guards, query context, and browser-native alternate-tab behavior. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/task-navigation.md) +- [System design](../../specs/ui/system-design/task-navigation.md) +- Existing AppLink, linkToTask, local ESLint rules, and task dependency tests. + +## Results + +Completed on 2026-09-21. + +- The bounded `task-links/no-task-link-bypass` rule and real-config wiring test + passed, with 31 tests in the initial focused rule and link suite. +- All known first-party task-detail builders and raw task anchors were migrated; + the production rule scan found zero violations. +- Review follow-up now checks quoted and expression literal task URLs on custom + `Link` and `AppLink` elements while preserving shared-builder cases. Static + evaluation uses branch-local recursion guards, so prefix aliases combined + with dynamic task IDs are detected in anchors and `location.assign` calls. +- The broad related Vitest run before review follow-up passed 437 files and + 4,254 tests. +- Review follow-up coverage passed 3 files and 38 rule/link tests; the combined + rule, AppLink, wiring, and link suite passed 4 files and 48 tests. +- `pnpm run typecheck`, `pnpm run lint`, Prettier checks, and `git diff --check` + passed. diff --git a/docs/plans/task-link-navigation/task-03-browser-regressions.md b/docs/plans/task-link-navigation/task-03-browser-regressions.md new file mode 100644 index 0000000000..701c998928 --- /dev/null +++ b/docs/plans/task-link-navigation/task-03-browser-regressions.md @@ -0,0 +1,112 @@ +--- +id: "03-browser-regressions" +title: "Desktop and touch navigation proof" +status: done +wave: 3 +depends_on: ['02-enforce-task-links'] +plan: "plan.md" +requirements: + - REQ-UI-TASK-NAVIGATION-001 +acceptance_criteria: + - AC-UI-TASK-NAVIGATION-001.1 + - AC-UI-TASK-NAVIGATION-001.2 + - AC-UI-TASK-NAVIGATION-001.3 + - AC-UI-TASK-NAVIGATION-001.4 + - AC-UI-TASK-NAVIGATION-001.5 + - AC-UI-TASK-NAVIGATION-001.6 +system_design: + - ../../specs/ui/system-design/task-navigation.md +--- + +# Task 03: Desktop and touch navigation proof + +## Summary + +Prove real task navigation retains the browser document on both responsive surfaces. + +## In scope + +- Seed related tasks through existing API fixtures and exercise both dependency directions. +- Assert canonical destination plus target task identity, document sentinel, document request count, disclosure dismissal, and browser Back. +- Phone: use mobile-chrome defaults and Drawer selectors; assert 44px rows, containment, single list scroll owner, no horizontal overflow. +- Keep native modified-click and guard cancellation coverage in component tests from Task 01. + +## Out of scope + +Backend behavior and unrelated navigation redesign. + +## Acceptance + +- Record RED evidence against the pre-fix implementation for navigation tests, using temporary local reversion of only the fix if needed; restore it before GREEN. +- Desktop and mobile scenarios pass on fresh builds, not stale assets. +- Record all command results and mark work orders/manifest complete only after their checks pass. + +## ASCII UI preview + +UI-01: Dependency navigation, expanded state (AC .1, .4, .5, .6). + +```text +Desktop: [blocks 2] -> Popover: [Task B | REVIEW] -> Task B workbench +Phone: [blocks 2] -> Drawer: + Dependencies [Close] + [Task B | REVIEW] (44px+ tap row) + [Task C | CREATED] (list scrolls) + -> Task B workbench; drawer closes +Before: selecting a row loads a new document through /tasks/:id. +After: selecting a row commits SPA navigation to /t/:id. +``` + +Control order, touch targets, and successful-navigation dismissal are required. +Spacing is illustrative. Retain existing localized labels and status displays. +Cancelled navigation keeps the disclosure; modified clicks keep the current page. + +See [full preview](plan.md#ascii-ui-preview). + +## Verification + +Run from repository root. Install once with `(cd apps && pnpm install --frozen-lockfile)` if this worktree lacks dependencies. + +```bash +(cd apps/web && pnpm e2e:run --project chromium tests/task/task-dependencies.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-dependency-navigation.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/task-dependencies.spec.ts` +- `apps/web/e2e/tests/task/mobile-task-dependency-navigation.spec.ts (new)` +- `docs/plans/task-link-navigation/plan.md` +- `docs/plans/task-link-navigation/task-*.md` + +## Dependencies + +02-enforce-task-links. + +## Risks + +Preserve existing route guards, query context, and browser-native alternate-tab behavior. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/ui/requirements/task-navigation.md) +- [System design](../../specs/ui/system-design/task-navigation.md) +- Existing AppLink, linkToTask, local ESLint rules, and task dependency tests. + +## Results + +Completed on 2026-09-21. + +- The initial component RED run provided pre-fix navigation failure evidence; + no temporary production reversion was needed for the browser scenarios. +- Chromium dependency coverage passed all 6 tests, including both dependency + directions, no document reload, disclosure closure, and browser Back. +- Mobile dependency coverage passed 1 test, including the real drawer, both + directions, 44px rows, containment, no document reload, and Back. +- Specification validation, specification lint, and `git diff --check` passed. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 218ff1efde..f4011ac2d8 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -12,7 +12,8 @@ owners: ## Scope UI owns responsive behavior and reusable presentation preferences, including -task-listing modes and Home destination choices. Other systems retain task, +task-listing modes, Home destination choices, and reusable client-side task +navigation. Other systems retain task, runtime, and workspace lifecycle/state ownership. ## Related diff --git a/docs/specs/ui/requirements/task-navigation.md b/docs/specs/ui/requirements/task-navigation.md new file mode 100644 index 0000000000..05840a03e3 --- /dev/null +++ b/docs/specs/ui/requirements/task-navigation.md @@ -0,0 +1,40 @@ +--- +status: active +system: ui +created: 2026-09-21 +owners: + - kandev +--- + +# Task navigation requirements + +## Overview + +First-party task links consistently open the task workbench. UI owns this +reusable navigation interaction across features. Tasks retain identity, +relationships, authorization, and lifecycle ownership. + +## Requirements + +### REQ-UI-TASK-NAVIGATION-001: Consistent task navigation + +**Intent:** Users follow task links without restarting the loaded application. + +#### Acceptance criteria + +- **AC-UI-TASK-NAVIGATION-001.1:** An ordinary click, keyboard activation, or touch on a first-party task-workbench link shall open the selected task without loading a new browser document. +- **AC-UI-TASK-NAVIGATION-001.2:** Newly generated workbench URLs shall use `/t/:taskId`, preserve requested session, layout, and other supported query context, and encode each value once. Existing `/tasks/:id` deep links shall remain usable. +- **AC-UI-TASK-NAVIGATION-001.3:** Task links shall preserve browser copy-link, modified-click, middle-click, and explicit new-tab behavior. +- **AC-UI-TASK-NAVIGATION-001.4:** Task navigation shall respect existing unsaved-change guards. Cancelled navigation shall keep the current task and disclosure intact. Completed same-tab dependency navigation shall dismiss its disclosure. +- **AC-UI-TASK-NAVIGATION-001.5:** Both dependency directions shall support the same destination behavior in the desktop popover and touch drawer. Touch rows shall have at least 44px hit targets; fine-pointer rows shall retain compact sizing. +- **AC-UI-TASK-NAVIGATION-001.6:** Browser Back shall return to the preceding task after an ordinary task-link navigation without a document reload. + +## Out of scope + +Task selection policy, task authorization, missing-task recovery, Office routes, +external links, plugin-authored URLs, and navigation to task listings retain +existing behavior. This contract does not change backend task APIs. + +## Implementation plans + +- [Task link navigation](../../../plans/task-link-navigation/plan.md) diff --git a/docs/specs/ui/system-design/task-navigation.md b/docs/specs/ui/system-design/task-navigation.md new file mode 100644 index 0000000000..385e4091a5 --- /dev/null +++ b/docs/specs/ui/system-design/task-navigation.md @@ -0,0 +1,120 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-TASK-NAVIGATION-001 +--- + +# Task navigation system design + +## Purpose and boundaries + +This design extends existing URL and client-navigation primitives. It does not +introduce a router or route catalog. UI owns the reusable interaction; task +identity and dependency semantics remain in the task system. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| REQ-UI-TASK-NAVIGATION-001 | URL authority, Link authority, Enforcement, Responsive behavior | + +## URL authority + +`apps/web/lib/links.ts::linkToTask` remains the only first-party workbench URL +builder. Keep the current `(taskId, layout?)` call shape compatible. Add an +options overload containing `layout?`, `sessionId?`, and `searchParams?` +(`URLSearchParams`) for current canvas and inbox contexts. Clone caller-owned +parameters; explicit layout/sessionId values replace matching keys. Preserve +other keys and repeated values. Encode the raw ID as one path segment exactly +once. Callers pass raw IDs, never pre-encoded IDs. Test both overloads. + +Route matching can still recognize `/t/` and `/tasks/`. Compatibility redirects +use the builder for their destination. `/tasks` listings, `/office/tasks/`, +API paths, external URLs, and stored user-authored content are separate contracts. +Do not globally rewrite strings or historical content. + +## Link authority + +Add `components/routing/task-link.tsx::TaskLink`, a thin wrapper around existing +`components/routing/app-link.tsx`. Accept `taskId`, builder options, normal +anchor presentation props, and `onNavigated`; omit `href` from public props. +The wrapper calls `linkToTask`, forwards its ref, and owns no task/store state. +Existing compliant `Link href={linkToTask(id)}` consumers may remain; new direct +task anchors should prefer TaskLink. Programmatic navigation continues through +`useRouter().push(linkToTask(...))`. + +Extend AppLink with an optional `onNavigated` callback called inside the +existing successful `pushNavigationState` callback, after the location event. +It must not run for cancelled guards, prevented clicks, external navigation, +modified clicks, or alternate targets. TaskLink delegates all click decisions +to AppLink. Do not duplicate the router's guard or browser-modifier logic. + +DependencyRow uses TaskLink. The chip supplies a stable close callback through +DependencyLists, invoked only after committed same-tab navigation. Keep browser +Back semantics and anchor accessibility. Missing-task and access errors continue +through the existing destination route. + +## Enforcement + +Add a local ESLint rule and register it as an error on production TS/TSX in +`apps/web/eslint.config.mjs`. Test it using the existing local-rule pattern. + +Reject hand-built root-relative task-detail URL expressions outside links.ts: +string literals with a nonempty task segment, interpolated templates, and string +concatenation beginning `/t/` or `/tasks/`. Permit bare prefix constants used +for route recognition; exclude tests, E2E, generated files, comments, API and +Office paths. Replace constructed comparison URLs with linkToTask too. + +Also reject native JSX anchors whose href resolves to a task route or a +linkToTask call, and window.location assign/replace/href writes with such a +value. Resolve import aliases and simple local const aliases. Report a clear +replacement suggestion. Rule fixtures cover direct, aliased, concatenated, +and conditional forms plus benign route matching and API paths. + +This is bounded static enforcement, not whole-program URL analysis. Unknown +runtime strings remain outside its proof. Do not claim all possible dynamic +links are statically verified. Add a wiring test that evaluates the real ESLint +configuration on a synthetic new production component so new directories are +covered. Start at zero violations by migrating current builders in the same +work order; no growing baseline or broad per-directory exemptions. + +## Responsive behavior + +Keep the current dependency popover on fine pointers and useTouchDrawer on +coarse pointers. Reuse shared Drawer safe-area handling and the single internal +list scroller. The nearest mobile navigation exemplar is +`components/task/mobile/session-task-switcher-sheet.tsx`; the dependency chip +already uses the appropriate brief-choice drawer. Task selection completes the +choice and goes to the workbench, so successful navigation dismisses the drawer. +Keep its header and close action, constrain long lists to the viewport, and +apply 44px row sizing only on touch surfaces. No new persisted state or copy. + +## Validation + +Component tests prove canonical hrefs, click interception, guard cancellation, +callback timing, modifier handling, and encoding. Desktop and Pixel 5 E2E +follow links in both dependency directions, verify target task content, dismiss +the disclosure, and exercise Back. Set a unique window property before clicking +and assert it survives; also observe main-frame document requests. A correct URL +alone is not evidence against reload. Check touch targets and drawer containment. + +## Related contracts + +[Mobile task navigation](../requirements/mobile-task-navigation.md) owns broader +task actions and mobile board navigation. This contract adds shared task-link +transport and URL construction across desktop and mobile consumers. + +## Related decisions and alternatives + +[Navigation manifest boundaries](../../../decisions/2026-08-04-navigation-manifest-boundaries.md) +retains top-level destination ownership. +[Settings save coordination](../../../decisions/0046-settings-route-save-coordinator.md) +retains dirty-navigation authority. No new route ownership registry is needed. +A URL helper alone cannot prevent raw anchors from reloading. A component alone +cannot cover imperative redirects. Reusing both existing authorities with a +thin wrapper and lint enforcement addresses these distinct failure modes. + +## Implementation plans + +- [Task link navigation](../../../plans/task-link-navigation/plan.md)