- We could not load this task. It may have been deleted or you may not have access.
-
+
{t("common:taskUnavailable")}
+
{t("common:taskUnavailableDescription")}
+
);
diff --git a/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts b/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts
index 2133a9927a..b859bdea51 100644
--- a/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts
@@ -117,4 +117,43 @@ test.describe("Mobile task loading state", () => {
await testPage.unroute(sessionPattern, sessionRoute);
}
});
+
+ test("returns to task overview from a missing task route", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const title = "Mobile missing route sibling";
+ await apiClient.createTask(seedData.workspaceId, title, {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+
+ await testPage.goto(`/t/missing-task-route-mobile?workspaceId=${seedData.workspaceId}`);
+ await expect(testPage.getByTestId("task-load-error-state")).toBeVisible({ timeout: 10_000 });
+
+ const overviewLink = testPage.getByTestId("task-unavailable-overview-link");
+ await expect(overviewLink).toBeVisible();
+ const linkBox = await overviewLink.boundingBox();
+ const viewport = testPage.viewportSize();
+ expect(linkBox).not.toBeNull();
+ expect(viewport).not.toBeNull();
+ if (linkBox && viewport) {
+ expect(linkBox.height).toBeGreaterThanOrEqual(44);
+ expect(linkBox.x).toBeGreaterThanOrEqual(0);
+ expect(linkBox.x + linkBox.width).toBeLessThanOrEqual(viewport.width + 1);
+ }
+ expect(
+ await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
+
+ await overviewLink.tap();
+ await expect(testPage).toHaveURL(
+ new RegExp(`/\\?home=overview&workspaceId=${seedData.workspaceId}`),
+ );
+ const mobile = new MobileKanbanPage(testPage);
+ await mobile.board.waitFor({ state: "visible", timeout: 10_000 });
+ await expect(mobile.taskCardByTitle(title)).toBeVisible({ timeout: 10_000 });
+ });
});
diff --git a/apps/web/e2e/tests/task/task-loading-state.spec.ts b/apps/web/e2e/tests/task/task-loading-state.spec.ts
index ff1529916b..b4ac723d64 100644
--- a/apps/web/e2e/tests/task/task-loading-state.spec.ts
+++ b/apps/web/e2e/tests/task/task-loading-state.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
+import { SidebarTasksPage } from "../../pages/sidebar-tasks-page";
import { openBlockedTaskLoadingState } from "./task-loading-state-helpers";
test.describe("Task loading state", () => {
@@ -22,4 +23,28 @@ test.describe("Task loading state", () => {
await unblockTaskDetailRequest();
}
});
+
+ test("keeps sibling tasks available after a missing task route", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const sibling = await apiClient.createTask(seedData.workspaceId, "Missing route sibling", {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+
+ await testPage.goto(`/t/missing-task-route-desktop?workspaceId=${seedData.workspaceId}`);
+ await expect(testPage.getByTestId("task-load-error-state")).toBeVisible({ timeout: 10_000 });
+ await expect(testPage.getByTestId("task-unavailable-overview-link")).toBeVisible();
+
+ const sidebar = new SidebarTasksPage(testPage);
+ await expect(sidebar.row(sibling.id)).toBeVisible({ timeout: 10_000 });
+ await expect(testPage.getByText("No tasks yet.", { exact: true })).toHaveCount(0);
+
+ await sidebar.plainClick(sibling.id);
+ await expect(testPage).toHaveURL(new RegExp(`/t/${sibling.id}$`));
+ await expect(testPage.getByTestId("task-load-error-state")).toBeHidden({ timeout: 10_000 });
+ });
});
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index c9905f5191..659c522d9e 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -2,6 +2,7 @@
"aboutContextCompactionCount": "About inferred compaction count",
"agents": "Agents",
"automations": "Automations",
+ "backToTaskOverview": "Back to task overview",
"cancel": "Cancel",
"changelog": "Changelog",
"commandAgentsSettings": "Agents Settings",
@@ -72,6 +73,9 @@
"status": "Status",
"subtaskTitle": "Subtask title",
"system": "System",
+ "taskLoading": "Loading task...",
+ "taskUnavailable": "Task unavailable",
+ "taskUnavailableDescription": "We could not load this task. It may have been deleted or you may not have access.",
"title": "Title",
"unsavedChanges": "Unsaved changes",
"workspace": "Workspace",
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 7e6899cd26..594f304abd 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -2,6 +2,7 @@
"aboutContextCompactionCount": "Àƀōũţ ĩńƒēŕŕēď ćōḿƥàćţĩōń ćōũńţ",
"agents": "Àĝēńţś",
"automations": "Àũţōḿàţĩōńś",
+ "backToTaskOverview": "Ɓàćķ ţō ţàśķ ōvēŕvĩēŵ",
"cancel": "Ćàńćēĺ",
"changelog": "Ćĥàńĝēĺōĝ",
"commandAgentsSettings": "Àĝēńţś Śēţţĩńĝś",
@@ -72,6 +73,9 @@
"status": "Śţàţũś",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"system": "Śŷśţēḿ",
+ "taskLoading": "Ĺōàďĩńĝ ţàśķ...",
+ "taskUnavailable": "Ţàśķ ũńàvàĩĺàƀĺē",
+ "taskUnavailableDescription": "Ŵē ćōũĺď ńōţ ĺōàď ţĥĩś ţàśķ. Ĩţ ḿàŷ ĥàvē ƀēēń ďēĺēţēď ōŕ ŷōũ ḿàŷ ńōţ ĥàvē àććēśś.",
"title": "Ţĩţĺē",
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"workspace": "Ŵōŕķśƥàćē",
diff --git a/apps/web/src/task-detail-route.test.tsx b/apps/web/src/task-detail-route.test.tsx
index 3aa28ddb7f..b9541fb8bd 100644
--- a/apps/web/src/task-detail-route.test.tsx
+++ b/apps/web/src/task-detail-route.test.tsx
@@ -8,6 +8,7 @@ import type { Task } from "@/lib/types/http";
const mocks = vi.hoisted(() => ({
fetchSessionDataForTask: vi.fn(),
}));
+const KANBAN_TASK_SHELL_TEST_ID = "kanban-task-shell";
vi.mock("@/components/state-hydrator", () => ({
StateHydrator: () => ,
@@ -16,7 +17,7 @@ vi.mock("@/components/state-hydrator", () => ({
vi.mock("@/app/tasks/[id]/kanban-task-shell", () => ({
KanbanTaskShell: ({ task, taskId }: { task: Task | null; taskId: string }) => (
@@ -75,7 +76,7 @@ describe("TaskDetailRoute", () => {
render();
- expect(screen.queryByTestId("kanban-task-shell")).toBeNull();
+ expect(screen.queryByTestId(KANBAN_TASK_SHELL_TEST_ID)).toBeNull();
expect(screen.getByRole("status").textContent).toContain("Loading task");
expect(screen.getByRole("status").parentElement?.className).toContain("h-full");
expect(screen.getByRole("status").parentElement?.className).toContain("min-h-0");
@@ -85,7 +86,9 @@ describe("TaskDetailRoute", () => {
routeData.resolve(makeFetchedData());
await waitFor(() => {
- expect(screen.getByTestId("kanban-task-shell").getAttribute("data-task-id")).toBe("task-1");
+ expect(screen.getByTestId(KANBAN_TASK_SHELL_TEST_ID).getAttribute("data-task-id")).toBe(
+ "task-1",
+ );
});
});
@@ -93,7 +96,21 @@ describe("TaskDetailRoute", () => {
render();
expect(mocks.fetchSessionDataForTask).not.toHaveBeenCalled();
- expect(screen.getByTestId("kanban-task-shell").getAttribute("data-task-id")).toBe("task-1");
+ expect(screen.getByTestId(KANBAN_TASK_SHELL_TEST_ID).getAttribute("data-task-id")).toBe(
+ "task-1",
+ );
expect(screen.getByTestId("state-hydrator")).toBeTruthy();
});
+
+ it("reaches the unavailable task shell when route data fails", async () => {
+ mocks.fetchSessionDataForTask.mockRejectedValueOnce(new Error("task not found"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId(KANBAN_TASK_SHELL_TEST_ID)).toBeTruthy();
+ });
+ expect(screen.getByTestId(KANBAN_TASK_SHELL_TEST_ID).getAttribute("data-task-id")).toBe("");
+ expect(screen.queryByTestId("state-hydrator")).toBeNull();
+ });
});
diff --git a/docs/plans/missing-task-route-recovery/plan.md b/docs/plans/missing-task-route-recovery/plan.md
new file mode 100644
index 0000000000..c6959ee204
--- /dev/null
+++ b/docs/plans/missing-task-route-recovery/plan.md
@@ -0,0 +1,163 @@
+---
+spec: docs/specs/missing-task-route-recovery/spec.md
+created: 2026-08-03
+status: completed
+---
+
+# Implementation Plan: Missing task route recovery
+
+## Overview
+
+Make task-detail boot construction aware of whether route-specific data was
+actually produced. When the requested task cannot be loaded, reuse the existing
+home Kanban boot builder to hydrate only the selected authorized workspace and
+its sidebar snapshots; keep successful task routes on their current lean,
+task-owned hydration path. Then add a workspace-preserving overview action to
+the unavailable state and prove the full cold-load behavior on desktop and
+phone.
+
+## Root cause
+
+`bootInitialState` deliberately adds the full workspace/workflow/Kanban state
+for Home and unknown routes, but not for `RouteTaskDetail`. A valid task route
+receives that context inside `routeData.taskDetail.initialState`. When
+`taskDetailRouteData` cannot load the requested task, it returns `nil`, leaving
+the top-level state without `workspaces.activeId`, workflows, or snapshots.
+The client retry then renders the unavailable state, while
+`useWorkspaceSidebarTasks(null)` intentionally scopes the sidebar to an empty
+list to avoid leaking stale workspace data.
+
+## Backend
+
+### Conditional task-detail fallback boot state
+
+- In `apps/backend/internal/backendapp/helpers.go`, update `bootPayload` to
+ construct `initialState` and `routeData` before assembling the final payload.
+- When the classified route is `webapp.RouteTaskDetail` and route data is
+ unavailable, call the existing
+ `bootStateBuilder.addHomeKanbanRouteState(ctx, req, initialState)` path. This
+ preserves URL/cookie/settings/default workspace precedence, Office-workflow
+ exclusion, repository hydration, and all-workflow snapshots without adding a
+ second data-loading implementation.
+- Only run that fallback for auth-disabled instances or requests carrying an
+ authenticated identity. Auth-enabled anonymous bootstrap requests must keep
+ the existing auth-only payload and must never invoke unscoped workspace or
+ task loaders.
+- Leave successful task-detail boot unchanged: its top-level initial state
+ remains lean and `routeData.taskDetail.initialState` remains authoritative for
+ the task's actual workspace.
+- Preserve the existing not-found/access-denied ambiguity and request identity
+ when listing fallback workspaces and tasks. No unscoped service call or new
+ endpoint is introduced.
+- Route-data errors remain intentionally represented by the existing generic
+ unavailable state; the client-side task fetch still retries and determines
+ whether the task can be rendered after the shell loads.
+
+## Frontend
+
+### Unavailable task recovery action
+
+- In `apps/web/components/task/task-page-content.tsx`, give
+ `TaskLoadErrorState` the resolved `workspaces.activeId` and render an existing
+ `Button`/routing `Link` to
+ `linkToTaskOverview({ workspaceId: activeWorkspaceId ?? undefined })`.
+- Keep the error copy generic and the main error surface unchanged. Add a stable
+ test ID to the overview action for rendered regression coverage.
+- Externalize the loading label, unavailable title/description, and new overview
+ action through `react-i18next`; add matching English and pseudo-locale keys in
+ `apps/web/src/locales/en/common.json` and
+ `apps/web/src/locales/pseudo/common.json`.
+
+### Mobile design contract
+
+- **Desktop outcome:** the global task sidebar remains populated from the
+ fallback workspace, and a valid row can replace the unavailable route.
+- **Mobile entry point:** the unavailable-state task-overview action is the
+ direct recovery path because `AppSidebar` is intentionally hidden below the
+ desktop breakpoint.
+- **Nearest shipped exemplar:** the task-overview back link in
+ `apps/web/components/task/mobile/session-mobile-top-bar.tsx`; reuse its
+ `linkToTaskOverview` destination and active-workspace context. The unavailable
+ action uses a normal button/link rather than mounting the full task workbench
+ or stacking a drawer onto an error state.
+- **Hierarchy and presentation:** the error remains the single focal surface;
+ the recovery action follows its explanation and navigates directly to Home.
+ No overlay, new scroll owner, or persisted responsive state is introduced.
+- **Geometry and input:** the error container remains viewport-contained, and
+ the action has at least a 44px active height on phone while retaining semantic
+ link and keyboard behavior.
+- **Shared logic:** both viewports use the same resolved workspace ID and link
+ builder. Only responsive sizing differs.
+
+## Tests
+
+- **Missing task fallback boot:** add a focused backend test in
+ `apps/backend/internal/backendapp/helpers_test.go` that creates a visible
+ sibling task, cold-builds `/t/` with an active-workspace cookie,
+ and asserts that top-level boot state contains the selected workspace,
+ workflows, and a snapshot containing the sibling while route-specific task
+ data remains absent.
+- **Anonymous bootstrap guard:** assert that an auth-enabled request without a
+ request identity keeps only auth/features state and does not load workspace,
+ workflow, repository, or Kanban snapshots.
+- **Successful task boot remains lean:** extend the backend coverage to assert
+ that a valid task route still provides `routeData.taskDetail` and does not
+ add fallback workspace/Kanban data to the top-level initial state.
+- **Client route failure:** extend
+ `apps/web/src/task-detail-route.test.tsx` so a rejected task-data request
+ reaches the null-task shell without a state hydrator; this preserves the
+ boundary between route failure and the unavailable surface.
+- The new overview action is component markup with no new branching helper; its
+ destination, accessibility, and responsive behavior are covered by the
+ desktop and mobile Playwright scenarios below.
+
+## E2E Tests
+
+- **Desktop scenario:** extend
+ `apps/web/e2e/tests/task/task-loading-state.spec.ts`. Seed a sibling task,
+ cold-load a missing `/t/:id`, assert `task-load-error-state`, assert the global
+ sidebar shows the sibling and not **No tasks yet**, then select the sibling
+ and verify its valid task route opens.
+- **Mobile scenario:** extend
+ `apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts`. Cold-load a
+ missing task route, assert the unavailable state and overview action fit the
+ Pixel 5 viewport with a touch-sized target and no document horizontal
+ overflow, tap the action, and verify the seeded task appears on mobile Home.
+- Both scenarios use the production Go-served Vite build through the managed
+ E2E runner so the route-aware boot payload—not only client navigation—is
+ exercised.
+
+## Verification Results
+
+- Backend contracts: `cd apps/backend && go test ./internal/backendapp -run '^(TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState|TestBootPayloadMissingTaskFallsBackToHomeKanbanState|TestBootPayloadValidTaskKeepsRouteSpecificState)$' -count=1` — 3 passed.
+- Anonymous regression: `cd apps/backend && go test ./internal/backendapp -run '^TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState$' -count=1` — 1 passed.
+- Client route boundary: `cd apps && pnpm --filter @kandev/web exec vitest run src/task-detail-route.test.tsx` — 3 passed.
+- Recovery-link component contract: `cd apps && pnpm --filter @kandev/web exec vitest run components/task/task-page-content.test.tsx` — 2 passed.
+- Frontend: `cd apps/web && pnpm run typecheck` — passed; `pnpm run i18n:check && pnpm run i18n:ratchet` — passed.
+- Desktop E2E: `cd apps/web && pnpm e2e:run tests/task/task-loading-state.spec.ts -- --grep 'keeps sibling tasks available'` — 1 passed.
+- Mobile E2E: `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-task-loading-state.spec.ts -- --grep 'returns to task overview'` — 1 passed.
+- The managed runner rebuilt the production Go-served Vite bundle and cleaned temporary reports.
+
+## Implementation Waves And Parallel Candidates
+
+Wave 1:
+
+- [x] [Task 01: Restore fallback boot context](task-01-fallback-boot-context.md)
+
+Wave 2 (depends on Task 01):
+
+- [x] [Task 02: Add unavailable-route recovery UI and E2E](task-02-recovery-ui-and-e2e.md)
+
+Both tasks are sequential. The rendered regression depends on the backend boot
+payload change, and no subagent execution is authorized by this plan.
+
+## Risks and out of scope
+
+- The fallback intentionally loads the same snapshots as Home only after task
+ route-data failure; accidentally running it for successful routes would add
+ duplicate work and could briefly select the wrong workspace.
+- Authorization must remain request-scoped. Tests should assert selected
+ authorized state, not weaken task `NotFound` behavior to distinguish missing
+ from inaccessible IDs.
+- Office task routes, task API semantics, sidebar presentation, and retry policy
+ remain out of scope.
diff --git a/docs/plans/missing-task-route-recovery/task-01-fallback-boot-context.md b/docs/plans/missing-task-route-recovery/task-01-fallback-boot-context.md
new file mode 100644
index 0000000000..29baebec39
--- /dev/null
+++ b/docs/plans/missing-task-route-recovery/task-01-fallback-boot-context.md
@@ -0,0 +1,97 @@
+---
+id: "01-fallback-boot-context"
+title: "Restore fallback boot context"
+status: completed
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/missing-task-route-recovery/spec.md"
+---
+
+# Task 01: Restore fallback boot context
+
+Use the existing Home Kanban boot builder only when a task-detail route cannot
+produce task-specific route data, so the app shell retains its authorized
+workspace and sidebar snapshots without changing successful task boot.
+
+## Acceptance
+
+- A missing or inaccessible `/t/:id` boot payload contains the selected
+ authorized Kanban workspace, workflows, repositories, and workflow snapshots,
+ including visible sibling tasks.
+- The invalid task ID is not synthesized into boot state and
+ `routeData.taskDetail` remains absent.
+- A valid task route still uses only its route-specific task initial state and
+ does not receive duplicate fallback Home state.
+- An auth-enabled anonymous request keeps its auth-only bootstrap and does not
+ invoke the fallback loaders.
+
+## Files likely touched
+
+- `apps/backend/internal/backendapp/helpers.go`
+- `apps/backend/internal/backendapp/helpers_test.go`
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+`sequential`. Task 02's production-build E2E assertions depend on this boot
+payload behavior.
+
+## Inputs
+
+- Spec sections **Desired behavior**, **Regression scenarios**, and
+ **Constraints**.
+- `apps/backend/internal/backendapp/boot_state.go`:
+ `taskDetailRouteData` and `addHomeKanbanRouteState`.
+- `apps/backend/internal/backendapp/helpers.go`: `bootPayload`.
+- ADR 0021 (Go-served SPA boot state) and ADR 0023 (active-workspace cookie
+ precedence).
+
+## TDD sequence
+
+1. Add a failing `helpers_test.go` regression for a missing task route with a
+ real sibling task and selected workspace.
+2. Add the valid-route non-duplication and anonymous-bootstrap assertions.
+3. Implement the minimal route-data-aware fallback in `bootPayload`.
+4. Refactor only if needed to keep `bootPayload` small and explicit.
+
+## Verification
+
+```bash
+cd apps/backend && go test ./internal/backendapp -run '^(TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState|TestBootPayloadMissingTaskFallsBackToHomeKanbanState|TestBootPayloadValidTaskKeepsRouteSpecificState)$' -count=1
+```
+
+## Risks
+
+- Running the fallback for successful task routes would duplicate boot work and
+ could expose a briefly incorrect active workspace before task hydration.
+- Fallback workspace listing must keep the request identity; bypassing the
+ scoped task service would leak cross-user workspace metadata.
+- Auth-enabled anonymous requests must not reach any fallback loader because
+ identity-free task-service calls are intentionally unscoped internal calls.
+
+## Output contract
+
+Report the RED failure, GREEN result, exact files changed, authorization and
+workspace-precedence evidence, remaining risks, and synchronized task/plan
+status. Record every exact command and outcome in `## Results`.
+
+## Results
+
+Implemented the conditional fallback in
+`apps/backend/internal/backendapp/helpers.go` and added focused coverage in
+`apps/backend/internal/backendapp/helpers_test.go`.
+
+- RED: `cd apps/backend && go test ./internal/backendapp -run 'TestBootPayload(MissingTaskFallsBackToHomeKanbanState|ValidTaskKeepsRouteSpecificState)$' -count=1` — 1 passed, 1 failed; the missing-task assertion reported absent fallback `workspaces` state.
+- GREEN: the same command after the production change — 2 passed.
+- Regression check: `cd apps/backend && go test ./internal/backendapp -run '^TestBoot(RouteDataTaskDetailIncludesTaskPageData|Payload(MissingTaskFallsBackToHomeKanbanState|ValidTaskKeepsRouteSpecificState))$' -count=1` — 3 passed.
+
+The fallback reuses the existing request-scoped Home Kanban builder, so the
+active-workspace cookie/query/settings/default precedence and authorization
+boundary remain unchanged. Successful task routes retain their lean
+route-specific initial state.
+
+- Security regression: `cd apps/backend && go test ./internal/backendapp -run '^TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState$' -count=1` — 1 passed after the initial RED failure; the auth-enabled anonymous payload contains no workspace, workflow, repository, or Kanban state.
diff --git a/docs/plans/missing-task-route-recovery/task-02-recovery-ui-and-e2e.md b/docs/plans/missing-task-route-recovery/task-02-recovery-ui-and-e2e.md
new file mode 100644
index 0000000000..5dfaede96e
--- /dev/null
+++ b/docs/plans/missing-task-route-recovery/task-02-recovery-ui-and-e2e.md
@@ -0,0 +1,117 @@
+---
+id: "02-recovery-ui-and-e2e"
+title: "Add unavailable-route recovery UI and E2E"
+status: completed
+wave: 2
+depends_on: ["01-fallback-boot-context"]
+plan: "plan.md"
+spec: "../../specs/missing-task-route-recovery/spec.md"
+---
+
+# Task 02: Add unavailable-route recovery UI and E2E
+
+Give the unavailable task surface a workspace-preserving overview action and
+prove that a cold missing-task route keeps desktop sibling navigation and a
+phone-safe recovery path.
+
+## Acceptance
+
+- The unavailable state keeps its generic error semantics and exposes an
+ accessible overview link scoped to the resolved workspace when one exists.
+- A desktop cold load lists and can navigate to a visible sibling task in the
+ global sidebar instead of showing **No tasks yet**.
+- On Pixel 5, the overview action is touch-sized and viewport-contained, does
+ not introduce horizontal document overflow, and opens Home where the sibling
+ task is visible.
+
+## Files likely touched
+
+- `apps/web/components/task/task-page-content.tsx`
+- `apps/web/src/locales/en/common.json`
+- `apps/web/src/locales/pseudo/common.json`
+- `apps/web/src/task-detail-route.test.tsx`
+- `apps/web/components/task/task-page-content.test.tsx`
+- `apps/web/e2e/tests/task/task-loading-state.spec.ts`
+- `apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts`
+
+## Dependencies
+
+- Task 01 must be complete so production-build E2E receives fallback workspace
+ snapshots from the Go boot payload.
+
+## Parallelism
+
+`sequential`. This task consumes Task 01 and its desktop/mobile E2E files share
+the same production build and task-route behavior.
+
+## Inputs
+
+- Spec sections **Desired behavior** and **Regression scenarios**.
+- Plan sections **Unavailable task recovery action**, **Mobile design
+ contract**, and **E2E Tests**.
+- `apps/web/components/task/mobile/session-mobile-top-bar.tsx` as the existing
+ task-overview navigation exemplar.
+- `apps/web/e2e/tests/task/task-loading-state-helpers.ts` and current desktop/
+ mobile loading specs as fixture and routing patterns.
+
+## TDD sequence
+
+1. Extend the client route unit test and desktop/mobile E2E specs, then run the
+ focused tests against the current implementation to capture the expected
+ failures.
+2. Add the localized overview action and workspace-scoped destination.
+3. Run focused frontend checks.
+4. Rebuild through the managed E2E runner, pass the desktop regression, then
+ reuse the unchanged build for the mobile regression.
+
+## Verification
+
+Run the workspace install once first if `apps/node_modules` is absent:
+
+```bash
+cd apps && pnpm install --frozen-lockfile
+cd apps && pnpm --filter @kandev/web exec vitest run src/task-detail-route.test.tsx
+cd apps/web && pnpm run typecheck
+cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
+cd apps/web && pnpm e2e:run tests/task/task-loading-state.spec.ts -- --grep 'keeps sibling tasks available'
+cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-task-loading-state.spec.ts -- --grep 'returns to task overview'
+```
+
+Confirm Playwright discovers and runs one intended test for each focused E2E
+command; `No tests found` is not passing evidence.
+
+## Risks
+
+- The overview action must use the fallback store workspace rather than the
+ missing task ID, or it can navigate Home into a different workspace.
+- The mobile assertion must measure the actual link hitbox and document
+ overflow after the unavailable state settles; visibility alone would miss
+ the touch and containment contract.
+
+## Output contract
+
+Report RED and GREEN evidence, exact files changed, desktop and Pixel 5 user
+outcomes, i18n/typecheck results, generated artifact paths, cleanup, remaining
+risks, and synchronized task/plan status. Record every exact command and outcome
+in `## Results`.
+
+## Results
+
+Implemented the workspace-aware unavailable-task recovery action and localized
+the loading/error copy in
+`apps/web/components/task/task-page-content.tsx` plus the English and pseudo
+common catalogs. Added route-boundary, desktop, and Pixel 5 E2E coverage.
+
+- RED: `cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-loading-state.spec.ts -- --grep 'returns to task overview'` — 1 failed as expected because `task-unavailable-overview-link` did not exist.
+- Client contract: `cd apps && pnpm --filter @kandev/web exec vitest run src/task-detail-route.test.tsx` — 3 passed.
+- Frontend checks: `cd apps/web && pnpm run typecheck` — passed; `pnpm run i18n:check` — passed (2423 referenced keys, pseudo in sync); `pnpm run i18n:ratchet` — passed.
+- Desktop GREEN: `cd apps/web && pnpm e2e:run tests/task/task-loading-state.spec.ts -- --grep 'keeps sibling tasks available'` — 1 passed.
+- Mobile GREEN: `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-task-loading-state.spec.ts -- --grep 'returns to task overview'` — 1 passed.
+- Targeted Prettier and ESLint checks passed with no errors or warnings; typecheck and the i18n ratchets are clean.
+- `cd apps && pnpm exec prettier --check web/components/task/task-page-content.tsx web/components/task/task-page-content.test.tsx web/src/task-detail-route.test.tsx web/e2e/tests/task/task-loading-state.spec.ts web/e2e/tests/task/mobile-task-loading-state.spec.ts web/src/locales/en/common.json web/src/locales/pseudo/common.json` — passed.
+- `cd apps && pnpm --filter @kandev/web exec eslint components/task/task-page-content.tsx components/task/task-page-content.test.tsx src/task-detail-route.test.tsx e2e/tests/task/task-loading-state.spec.ts e2e/tests/task/mobile-task-loading-state.spec.ts` — passed with no errors or warnings.
+- The managed E2E runner rebuilt the Go backend and Vite bundle, and cleaned its temporary test-results/blob-report directories after each run; no generated artifacts are part of the change.
+
+The desktop flow keeps sibling rows usable after a cold missing-task route. The
+Pixel 5 flow keeps the recovery link in the viewport at a 44px hitbox with no
+horizontal overflow and returns to Home scoped to the resolved workspace.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 10176bac56..cdc345073b 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -202,6 +202,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [workflow-settings-autosave](workflow-settings-autosave/spec.md) | archived; superseded by settings-manual-save |
| [mobile-quick-chat-topbar](mobile-quick-chat-topbar/spec.md) | building |
| [native-code-review](native-code-review/spec.md) | building |
+| [missing-task-route-recovery](missing-task-route-recovery/spec.md) | draft |
---
diff --git a/docs/specs/missing-task-route-recovery/spec.md b/docs/specs/missing-task-route-recovery/spec.md
new file mode 100644
index 0000000000..52b3503fdd
--- /dev/null
+++ b/docs/specs/missing-task-route-recovery/spec.md
@@ -0,0 +1,86 @@
+---
+title: Missing task route recovery
+status: draft
+created: 2026-08-03
+owner: kandev
+---
+
+# Missing task route recovery
+
+## Problem
+
+A cold navigation to a Kanban task-detail URL whose task is missing, deleted,
+or inaccessible renders the intended **Task unavailable** state, but the same
+failed task lookup also prevents the app shell from hydrating its authorized
+workspace and workflow context. The desktop sidebar therefore reports that
+there are no tasks even when the selected workspace contains other tasks, and
+the unavailable route offers no direct recovery action on phone viewports where
+the desktop sidebar is hidden.
+
+## Desired behavior
+
+- A failed cold load of `/t/:taskId` or the compatibility `/tasks/:taskId`
+ renders the existing generic unavailable state without revealing whether the
+ task was deleted, never existed, or is outside the current user's access.
+- Failure to load the requested task does not prevent the app shell from
+ resolving the user's authorized Kanban workspace with the existing explicit
+ URL parameter, active-workspace cookie, user-setting, and default-workspace
+ precedence.
+- When the resolved workspace contains other visible tasks, the desktop task
+ sidebar shows them and its rows remain usable navigation targets. The invalid
+ route ID is not synthesized as a sidebar task.
+- The unavailable state includes a visible action back to the task overview for
+ the resolved workspace. The action is keyboard accessible, touch reachable,
+ and usable when no workspace can be resolved.
+- A valid task-detail boot continues to hydrate from that task's workspace and
+ does not additionally load or briefly expose fallback workspace data.
+- Workspace and task authorization remain unchanged. Fallback state contains
+ only workspaces, workflows, repositories, and task snapshots already visible
+ to the current request identity.
+- Loading feedback remains visible while the task result is unresolved; the
+ unavailable state and recovery controls appear only after failure is known.
+
+## Regression scenarios
+
+- **GIVEN** an authorized workspace containing another visible task, **WHEN**
+ the user cold-loads `/t/`, **THEN** the unavailable state appears,
+ the desktop sidebar lists the other task instead of **No tasks yet**, and
+ selecting that row opens the valid task.
+- **GIVEN** the active-workspace cookie and user settings identify different
+ authorized workspaces, **WHEN** a missing task route is cold-loaded, **THEN**
+ fallback task data comes from the cookie-selected workspace according to the
+ existing boot precedence.
+- **GIVEN** a valid task-detail URL, **WHEN** it is cold-loaded, **THEN** its
+ route-specific boot data remains authoritative and no fallback workspace
+ bootstrap is added to the top-level initial state.
+- **GIVEN** a user cannot access the requested task but can access another
+ workspace, **WHEN** the task route loads, **THEN** the generic unavailable
+ state and only that user's authorized fallback workspace tasks are shown.
+- **GIVEN** no Kanban workspace is available, **WHEN** a missing task route
+ loads, **THEN** the unavailable state remains visible, the sidebar stays
+ empty, and the task-overview action remains safe to activate.
+- **GIVEN** a phone viewport on a missing task route, **WHEN** the unavailable
+ state renders, **THEN** the task-overview action fits the viewport, has a
+ touch-sized target, and opens the mobile task overview without relying on the
+ desktop sidebar.
+
+## Constraints
+
+- The Go-served SPA boot payload remains the first-paint source of workspace
+ context, consistent with ADRs 0021 and 0023.
+- The repair reuses the existing task, workspace, workflow, and snapshot
+ contracts; it adds no API, persistence, WebSocket, or permission contract.
+- Fallback hydration is conditional on unavailable task-detail route data so a
+ successful task boot does not duplicate its route-specific resource loading.
+
+## Out of scope
+
+- Changing task lookup status codes or disclosing why a task is unavailable.
+- Creating a placeholder task for the invalid route ID.
+- Redesigning the desktop task sidebar, task switcher, or Kanban overview.
+- Changing Office task-detail not-found behavior under `/office/tasks/:id`.
+- Retrying backend failures beyond the existing task-detail client retry.
+
+## Implementation plan
+
+- [Missing task route recovery](../../plans/missing-task-route-recovery/plan.md)