fix: restore sidebar context for missing task routes (#2229)
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ŵōŕķśƥàćē",
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user