fix: restore sidebar context for missing task routes (#2229)

This commit is contained in:
Carlos Florêncio
2026-08-03 22:24:39 +01:00
committed by GitHub
parent 5772fa86f0
commit 90da2d66f2
15 changed files with 789 additions and 12 deletions
+18 -2
View File
@@ -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").
@@ -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()
@@ -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()
}
@@ -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(
<StateProvider initialState={{ workspaces: { items: [], activeId } }}>
<TaskLoadErrorState />
</StateProvider>,
);
}
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",
);
});
});
+23 -6
View File
@@ -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 (
<div
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
@@ -142,13 +148,16 @@ function TaskLoadingState() {
>
<div className="flex min-h-24 min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<GridSpinner className="text-primary" />
<span>Loading task...</span>
<span>{t("common:taskLoading")}</span>
</div>
</div>
);
}
function TaskLoadErrorState() {
export function TaskLoadErrorState() {
const { t } = useTranslation();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
return (
<div
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
@@ -157,11 +166,19 @@ function TaskLoadErrorState() {
<div className="flex min-h-24 max-w-sm min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<IconAlertTriangle className="h-5 w-5 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<div className="font-medium text-foreground">Task unavailable</div>
<div>
We could not load this task. It may have been deleted or you may not have access.
</div>
<div className="font-medium text-foreground">{t("common:taskUnavailable")}</div>
<div>{t("common:taskUnavailableDescription")}</div>
</div>
<Button
asChild
size="lg"
className="min-h-11 px-4 sm:min-h-9"
data-testid="task-unavailable-overview-link"
>
<Link href={linkToTaskOverview({ workspaceId: activeWorkspaceId ?? undefined })}>
{t("common:backToTaskOverview")}
</Link>
</Button>
</div>
</div>
);
@@ -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 });
});
});
@@ -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 });
});
});
+4
View File
@@ -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",
+4
View File
@@ -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": "Ŵōŕķśƥàćē",
+21 -4
View File
@@ -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: () => <div data-testid="state-hydrator" />,
@@ -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 }) => (
<div
data-testid="kanban-task-shell"
data-testid={KANBAN_TASK_SHELL_TEST_ID}
data-route-task-id={taskId}
data-task-id={task?.id ?? ""}
/>
@@ -75,7 +76,7 @@ describe("TaskDetailRoute", () => {
render(<TaskDetailRoute taskId="task-1" />);
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(<TaskDetailRoute taskId="task-1" initialData={makeFetchedData()} />);
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(<TaskDetailRoute taskId="missing-task" />);
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();
});
});
@@ -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/<missing-id>` 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.
@@ -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.
@@ -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.
+1
View File
@@ -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 |
---
@@ -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/<missing-id>`, **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)