From 90da2d66f2fdc41faeba09cd8f3539a053a7d025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 3 Aug 2026 22:24:39 +0100 Subject: [PATCH] fix: restore sidebar context for missing task routes (#2229) --- apps/backend/internal/backendapp/helpers.go | 20 ++- .../internal/backendapp/helpers_test.go | 155 +++++++++++++++++ .../internal/orchestrator/task_operations.go | 4 + .../task/task-page-content.test.tsx | 32 ++++ .../web/components/task/task-page-content.tsx | 29 +++- .../task/mobile-task-loading-state.spec.ts | 39 +++++ .../e2e/tests/task/task-loading-state.spec.ts | 25 +++ apps/web/src/locales/en/common.json | 4 + apps/web/src/locales/pseudo/common.json | 4 + apps/web/src/task-detail-route.test.tsx | 25 ++- .../plans/missing-task-route-recovery/plan.md | 163 ++++++++++++++++++ .../task-01-fallback-boot-context.md | 97 +++++++++++ .../task-02-recovery-ui-and-e2e.md | 117 +++++++++++++ docs/specs/INDEX.md | 1 + .../specs/missing-task-route-recovery/spec.md | 86 +++++++++ 15 files changed, 789 insertions(+), 12 deletions(-) create mode 100644 apps/web/components/task/task-page-content.test.tsx create mode 100644 docs/plans/missing-task-route-recovery/plan.md create mode 100644 docs/plans/missing-task-route-recovery/task-01-fallback-boot-context.md create mode 100644 docs/plans/missing-task-route-recovery/task-02-recovery-ui-and-e2e.md create mode 100644 docs/specs/missing-task-route-recovery/spec.md diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 4e72fc6b97..b9b78291f1 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -783,17 +783,33 @@ func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig { } func bootPayload(ctx context.Context, req *http.Request, p routeParams, route webapp.RouteClassification) webapp.BootPayload { + initialState := bootInitialState(ctx, req, p, route) + routeData := bootRouteData(ctx, req, p, route) + if route.Route == webapp.RouteTaskDetail && routeData == nil && canLoadTaskDetailFallback(req, p.authSvc) { + bootStateBuilder{p: p}.addHomeKanbanRouteState(ctx, req, initialState) + } payload := webapp.NewBootPayload( route, webRuntimeConfig(p.devMode, req), - bootInitialState(ctx, req, p, route), + initialState, ) - payload.RouteData = bootRouteData(ctx, req, p, route) + payload.RouteData = routeData payload.Plugins = bootActivePlugins(p) payload.InterimSettingsInterlockToken = p.interimSettingsInterlockToken return payload } +func canLoadTaskDetailFallback(req *http.Request, authSvc *auth.Service) bool { + if authSvc == nil || authSvc.Mode() == auth.ModeDisabled { + return true + } + if req == nil { + return false + } + _, ok := authn.IdentityFromContext(req.Context()) + return ok +} + // bootActivePlugins populates the boot payload's Plugins list from every // active, UI-bundle-declaring plugin, per // docs/plans/plugins/PLUGIN-API.md ("Loading model"). diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 7992d823c8..1bcf67ee83 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -796,6 +796,161 @@ func TestBootRouteDataTaskDetailIncludesTaskPageData(t *testing.T) { } } +func TestBootPayloadMissingTaskFallsBackToHomeKanbanState(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil || len(workspaces) == 0 { + t.Fatalf("ListWorkspaces: count=%d err=%v", len(workspaces), err) + } + workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil || len(workflows) == 0 { + t.Fatalf("ListWorkflows: count=%d err=%v", len(workflows), err) + } + steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil || len(steps) == 0 { + t.Fatalf("ListStepsByWorkflow: count=%d err=%v", len(steps), err) + } + task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + WorkflowStepID: steps[0].ID, + Title: "Visible sibling task", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + request := httptest.NewRequest( + http.MethodGet, + "/t/missing-task", + nil, + ) + request.AddCookie(&http.Cookie{Name: activeWorkspaceCookie, Value: workspaces[0].ID}) + route := webapp.ClassifyRoute("/t/missing-task") + payload := bootPayload(ctx, request, routeParams{ + taskSvc: harness.taskSvc, + userCtrl: harness.userCtrl, + services: &Services{Workflow: harness.workflowSvc}, + }, route) + + if payload.RouteData != nil { + t.Fatalf("missing task route data = %#v, want nil", payload.RouteData) + } + workspacesState, ok := payload.InitialState["workspaces"].(map[string]any) + if !ok { + t.Fatalf("missing fallback workspaces state: %#v", payload.InitialState["workspaces"]) + } + if got := workspacesState["activeId"]; got != workspaces[0].ID { + t.Fatalf("fallback active workspace = %v, want %q", got, workspaces[0].ID) + } + if _, ok := payload.InitialState["workflows"].(map[string]any); !ok { + t.Fatalf("missing fallback workflows state: %#v", payload.InitialState["workflows"]) + } + if _, ok := payload.InitialState["repositories"].(map[string]any); !ok { + t.Fatalf("missing fallback repositories state: %#v", payload.InitialState["repositories"]) + } + kanbanMulti, ok := payload.InitialState["kanbanMulti"].(map[string]any) + if !ok { + t.Fatalf("missing fallback kanban state: %#v", payload.InitialState["kanbanMulti"]) + } + snapshots, ok := kanbanMulti["snapshots"].(map[string]any) + if !ok { + t.Fatalf("fallback kanban snapshots = %#v", kanbanMulti["snapshots"]) + } + snapshot, ok := snapshots[workflows[0].ID].(map[string]any) + if !ok { + t.Fatalf("missing fallback workflow snapshot %q: %#v", workflows[0].ID, snapshots) + } + tasks, ok := snapshot["tasks"].([]map[string]any) + if !ok { + // JSON-safe boot state is assembled with []map[string]any, so this + // assertion protects the sidebar's task source without depending on + // JSON round-tripping in the test. + t.Fatalf("fallback workflow tasks = %#v", snapshot["tasks"]) + } + if len(tasks) != 1 || tasks[0]["id"] != task.ID { + t.Fatalf("fallback workflow tasks = %#v, want task %q", tasks, task.ID) + } +} + +func TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + request := httptest.NewRequest(http.MethodGet, "/t/missing-task", nil) + route := webapp.ClassifyRoute("/t/missing-task") + payload := bootPayload(ctx, request, routeParams{ + taskSvc: harness.taskSvc, + userCtrl: harness.userCtrl, + authSvc: newSSOTestAuthService(t), + services: &Services{Workflow: harness.workflowSvc}, + }, route) + + if payload.RouteData != nil { + t.Fatalf("anonymous missing task route data = %#v, want nil", payload.RouteData) + } + if _, ok := payload.InitialState["auth"]; !ok { + t.Fatal("anonymous auth-enabled bootstrap should include auth state") + } + for _, key := range []string{"workspaces", "workflows", "repositories", "kanbanMulti"} { + if _, ok := payload.InitialState[key]; ok { + t.Fatalf("anonymous auth-enabled bootstrap should not include %s state", key) + } + } +} + +func TestBootPayloadValidTaskKeepsRouteSpecificState(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil || len(workspaces) == 0 { + t.Fatalf("ListWorkspaces: count=%d err=%v", len(workspaces), err) + } + workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil || len(workflows) == 0 { + t.Fatalf("ListWorkflows: count=%d err=%v", len(workflows), err) + } + steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil || len(steps) == 0 { + t.Fatalf("ListStepsByWorkflow: count=%d err=%v", len(steps), err) + } + task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + WorkflowStepID: steps[0].ID, + Title: "Valid detail task", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/t/"+task.ID, nil) + route := webapp.ClassifyRoute("/t/" + task.ID) + payload := bootPayload(ctx, request, routeParams{ + taskSvc: harness.taskSvc, + userCtrl: harness.userCtrl, + services: &Services{Workflow: harness.workflowSvc}, + }, route) + + if payload.RouteData == nil { + t.Fatal("valid task route data is nil") + } + detail, ok := payload.RouteData["taskDetail"].(map[string]any) + if !ok { + t.Fatalf("task detail route data = %#v", payload.RouteData["taskDetail"]) + } + detailTask, ok := detail["task"].(taskdto.TaskDTO) + if !ok { + t.Fatalf("task detail task = %#v", detail["task"]) + } + if detailTask.ID != task.ID { + t.Fatalf("task detail id = %q, want %q", detailTask.ID, task.ID) + } + if _, ok := payload.InitialState["workspaces"]; ok { + t.Fatal("valid task route should keep the lean initial state without home workspace data") + } +} + func TestBootTaskDetailMessagesProjectShellOutput(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 969e96aab9..9d4250f0ed 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -2525,6 +2525,10 @@ func (s *Service) stopTaskSessionForCoordinator(ctx context.Context, taskID, ses if err != nil { return false, err } + // Detached teardown must not observe this operation as an in-flight + // cancellation. ScheduleTeardown starts a goroutine, so relying on the + // deferred release above makes the ordering scheduler-dependent. + endCancel() if result.Changed && teardownClaimed { result.ScheduleTeardown() } diff --git a/apps/web/components/task/task-page-content.test.tsx b/apps/web/components/task/task-page-content.test.tsx new file mode 100644 index 0000000000..8c09a78ff8 --- /dev/null +++ b/apps/web/components/task/task-page-content.test.tsx @@ -0,0 +1,32 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { TaskLoadErrorState } from "./task-page-content"; + +afterEach(cleanup); + +function renderErrorState(activeId: string | null) { + render( + + + , + ); +} + +describe("TaskLoadErrorState", () => { + it("preserves the active workspace in the overview destination", () => { + renderErrorState("ws-1"); + + const link = screen.getByTestId("task-unavailable-overview-link"); + expect(link.getAttribute("href")).toBe("/?home=overview&workspaceId=ws-1"); + expect(link.className).toContain("min-h-11"); + }); + + it("falls back to the unscoped overview when no workspace is active", () => { + renderErrorState(null); + + expect(screen.getByTestId("task-unavailable-overview-link").getAttribute("href")).toBe( + "/?home=overview", + ); + }); +}); diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 40be40b88e..1e30442736 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -2,6 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconAlertTriangle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import Link from "@/components/routing/app-link"; +import { useTranslation } from "react-i18next"; import type { Repository, RepositoryScript, Task } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import type { KanbanState } from "@/lib/state/slices"; @@ -14,6 +17,7 @@ import { useAppStore } from "@/components/state-provider"; import { useEnsureTaskSession } from "@/hooks/domains/session/use-ensure-task-session"; import { useExternalVcsFileLinkHydration } from "@/hooks/domains/workspace/use-external-vcs-file-link"; import { fetchTask } from "@/lib/api"; +import { linkToTaskOverview } from "@/lib/links"; import { useTasks } from "@/hooks/use-tasks"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useForegroundRefresh } from "@/hooks/use-foreground-refresh"; @@ -135,6 +139,8 @@ export function useMergedAgentState( } function TaskLoadingState() { + const { t } = useTranslation(); + return (
- Loading task... + {t("common:taskLoading")}
); } -function TaskLoadErrorState() { +export function TaskLoadErrorState() { + const { t } = useTranslation(); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + return (
); 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)