Skip to content
Merged
Show file tree
Hide file tree
Changes from 94 commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
65b3380
chore: checkpoint KENT-356 implementation
Nek-12 Aug 4, 2026
a1fde69
refactor: replace sidebar stack bridge
Nek-12 Aug 4, 2026
6dcf40e
feat: add sidebar navigation admission and restoration
Nek-12 Aug 4, 2026
b9d347c
fix: coordinate board sidebar selector changes
Nek-12 Aug 4, 2026
6ca037d
chore: record sidebar verification boundary
Nek-12 Aug 4, 2026
2f438ba
fix: satisfy sidebar lint and verification checks
Nek-12 Aug 4, 2026
37d1f8e
docs: record final sidebar verification
Nek-12 Aug 4, 2026
b810afc
fix: remediate sidebar navigation review findings
Nek-12 Aug 4, 2026
8442b51
fix: harden sidebar route deletion remediation
Nek-12 Aug 4, 2026
a8a3ddf
fix: finalize sidebar restoration ownership
Nek-12 Aug 4, 2026
d5d3a54
fix: surface sidebar navigation outcomes
Nek-12 Aug 4, 2026
f6bb87f
fix: reconcile sidebar review outcomes
Nek-12 Aug 5, 2026
1e4d73d
fix: unify board deletion coordination
Nek-12 Aug 5, 2026
98e54fc
fix: defer deletion selector consumption
Nek-12 Aug 5, 2026
597830d
fix: centralize board selector ownership
Nek-12 Aug 5, 2026
73005d8
fix: remove board task sentinels
Nek-12 Aug 5, 2026
72756bf
fix: close sidebar on route changes
Nek-12 Aug 5, 2026
11dc5e0
docs: record route change verification
Nek-12 Aug 5, 2026
8f6de73
fix: centralize pathname close ownership
Nek-12 Aug 5, 2026
ef83352
docs: record passing desktop suite
Nek-12 Aug 5, 2026
d81222e
fix: subscribe to router history for sidebar close
Nek-12 Aug 5, 2026
1620b1f
refactor: trim sidebar scope
Nek-12 Aug 5, 2026
2cebcc2
docs: record production scope cap
Nek-12 Aug 5, 2026
e141855
refactor: fit production scope cap
Nek-12 Aug 5, 2026
237c1d1
refactor: meet complete scope cap
Nek-12 Aug 5, 2026
9ee2296
docs: record complete scope cap
Nek-12 Aug 5, 2026
200ac45
refactor: simplify sidebar scope remediation
Nek-12 Aug 5, 2026
cdf9a04
docs: record structural scope remediation
Nek-12 Aug 5, 2026
38719b8
fix: subscribe to router lifecycle navigation
Nek-12 Aug 5, 2026
5833f06
docs: record final route remediation scope
Nek-12 Aug 5, 2026
7c0721b
fix: close sidebar before route loading
Nek-12 Aug 5, 2026
9b16014
chore: use shared translation in route test
Nek-12 Aug 5, 2026
2cc0d4e
fix: observe router pathname for sidebar close
Nek-12 Aug 5, 2026
879a2d6
docs: record pathname observer scope
Nek-12 Aug 5, 2026
b2edf10
refactor: centralize sidebar route transition owner
Nek-12 Aug 5, 2026
03ca35e
refactor: centralize sidebar route transitions
Nek-12 Aug 5, 2026
400e960
docs: record centralized route contract verification
Nek-12 Aug 5, 2026
278d929
refactor: defer task deletion route reconciliation
Nek-12 Aug 5, 2026
8da2d8c
docs: record deferred deletion reconciliation
Nek-12 Aug 5, 2026
8e67687
fix: scope deferred deletion reconciliation
Nek-12 Aug 5, 2026
a64c1d6
refactor: reduce sidebar provider duplication
Nek-12 Aug 5, 2026
a196e8d
docs: record scoped deletion activation
Nek-12 Aug 5, 2026
a5d4b18
fix: preserve stale deletion scope through invalidation
Nek-12 Aug 5, 2026
5149bd5
docs: record invalidation scope guard
Nek-12 Aug 5, 2026
90a8b21
refactor: derive deletion harness contract
Nek-12 Aug 5, 2026
6d2fa5b
fix: close sidebar before pathname navigation
Nek-12 Aug 5, 2026
cfd9279
docs: record pathname lifecycle remediation
Nek-12 Aug 5, 2026
fbe43e8
refactor: centralize pathname route ownership
Nek-12 Aug 5, 2026
6bce95e
docs: record single pathname route authority
Nek-12 Aug 5, 2026
c40beb9
refactor: unify typed route transition dispatch
Nek-12 Aug 5, 2026
d85875f
refactor: reduce typed route dispatch scope
Nek-12 Aug 5, 2026
0a870c6
refactor: compact typed route model
Nek-12 Aug 5, 2026
99cb6ac
fix: preserve typed optional route search
Nek-12 Aug 5, 2026
970c7a2
refactor: narrow route search adapter
Nek-12 Aug 5, 2026
a1557e6
docs: record unified typed route dispatch
Nek-12 Aug 5, 2026
f2eecb8
docs: record final route dispatch verification
Nek-12 Aug 5, 2026
5b2060d
fix: close sidebar before route navigation
Nek-12 Aug 5, 2026
356ff60
test: guard sidebar route close ordering
Nek-12 Aug 5, 2026
84abf8b
refactor: compact synchronous route close
Nek-12 Aug 5, 2026
b983de6
docs: record route ordering guard
Nek-12 Aug 5, 2026
77cc09c
test: consolidate Home route guard
Nek-12 Aug 5, 2026
05f2894
fix: commit complete sidebar close state
Nek-12 Aug 5, 2026
f08a1cd
docs: record complete route close flush
Nek-12 Aug 5, 2026
8d002fd
refactor: unify sidebar presentation lifecycle
Nek-12 Aug 5, 2026
93a5d5a
docs: record single lifecycle scope
Nek-12 Aug 5, 2026
ded3ab7
test: guard active sidebar stack removal
Nek-12 Aug 5, 2026
377a148
test: consolidate sidebar fixture sequence
Nek-12 Aug 5, 2026
1508407
fix: narrow sidebar fixture sequence
Nek-12 Aug 5, 2026
4c45327
fix: close sidebar before route loading
Nek-12 Aug 5, 2026
161f259
fix: preserve typed router search contract
Nek-12 Aug 5, 2026
b01a61c
docs: record QA source mismatch
Nek-12 Aug 5, 2026
94d6824
fix: defer sidebar route close commit
Nek-12 Aug 5, 2026
fb0b0b0
refactor: remove superseded sidebar implementation
Nek-12 Aug 5, 2026
8234f01
docs: redesign sidebar stack plan
Nek-12 Aug 5, 2026
29082cc
docs: close sidebar plan lifecycle gaps
Nek-12 Aug 5, 2026
e75d50c
docs: revoke stale sidebar navigation synchronously
Nek-12 Aug 5, 2026
247fb40
docs: separate sidebar capture from navigation
Nek-12 Aug 6, 2026
5941da2
docs: make sidebar capability replay-safe
Nek-12 Aug 6, 2026
dc086cc
docs: scope all sidebar page settlement
Nek-12 Aug 6, 2026
d96ab05
docs: delete feature-typed sidebar results
Nek-12 Aug 6, 2026
85abae7
docs: own sidebar roots through teardown
Nek-12 Aug 6, 2026
291e219
docs: release sidebar roots synchronously
Nek-12 Aug 6, 2026
a6d3cfe
docs: keep sidebar internals out of product spec
Nek-12 Aug 6, 2026
58ea35a
docs: narrow sidebar plan scope
Nek-12 Aug 6, 2026
95786b0
docs: restore project sidebar ownership
Nek-12 Aug 6, 2026
c3200ad
docs: refetch project owners after deletion
Nek-12 Aug 6, 2026
8a0ec61
feat: add sidebar task navigation stack
Nek-12 Aug 6, 2026
23408e1
fix: correct sidebar project and dependency navigation
Nek-12 Aug 6, 2026
7bab83f
fix: centralize sidebar project error code
Nek-12 Aug 6, 2026
402f04e
fix: strengthen sidebar destination equality coverage
Nek-12 Aug 6, 2026
51d3393
fix: restore sidebar destination contracts
Nek-12 Aug 8, 2026
41537eb
chore: merge main into sidebar stack
Nek-12 Aug 8, 2026
f365d8f
fix: preserve sidebar destination completion
Nek-12 Aug 8, 2026
ca8baeb
chore: merge current main into sidebar stack
Nek-12 Aug 8, 2026
5ea3067
fix: centralize missing task classification
Nek-12 Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .kent/plans/KENT-356.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions apps/desktop/src/api/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import {
decodeWorkflowLabelError,
decodeWorkflowTaskDependencyError,
isProjectMissingError,
isTaskMissingError,
RpcError,
WorkflowLabelError,
WorkflowTaskDependencyError,
} from "./errors";
import { rpcErrorCodes } from "./rpcErrorCodes";

const labelID = "f74ce532-9e6e-4cf6-b3c1-d67d5a3eedcf";

describe("sidebar missing-entity errors", () => {
it("recognizes typed Task and Project missing errors without parsing messages", () => {
const error = (code: number, data?: Readonly<Record<string, string>>) =>
new RpcError({ code, data, message: "changed", method: "owner.operation" });
expect(isTaskMissingError(error(rpcErrorCodes.workflowTaskNotFound))).toBe(true);
expect(isProjectMissingError(error(-32000, { reason: "project_not_found" }))).toBe(true);
expect(isProjectMissingError(error(rpcErrorCodes.projectNotFound))).toBe(true);
expect(isProjectMissingError(new Error("project_not_found"))).toBe(false);
});
});

describe("workflow label RPC errors", () => {
it.each([
{
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export class RpcError extends Error {
}
}

export function isTaskMissingError(error: unknown): boolean {
return error instanceof RpcError && error.code === rpcErrorCodes.workflowTaskNotFound;
Comment thread
Nek-12 marked this conversation as resolved.
}

const projectMissingDataSchema = z.looseObject({ reason: z.literal("project_not_found") });
export function isProjectMissingError(error: unknown): boolean {
return (
error instanceof RpcError &&
(error.code === rpcErrorCodes.projectNotFound ||
projectMissingDataSchema.safeParse(error.data).success)
);
}

export type TaskSearchErrorReason = "normalized_too_short";

export class TaskSearchError extends RpcError {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export {
decodeTaskSearchError,
decodeWorkflowLabelError,
decodeWorkflowTaskDependencyError,
isProjectMissingError,
isTaskMissingError,
ServerRootMismatchError,
StartupConfigurationError,
TransportError,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/api/rpcErrorCodes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const rpcErrorCodes = {
projectNotFound: -32014,
workflowTaskNotFound: -32024,
workflowTaskDependency: -32049,
workflowTaskSearch: -32052,
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/app-facade/projectDeletionEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createBrowserNativeBridge } from "@app/native-bridge";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { createElement, type ReactNode } from "react";

import { isProjectMissingError, RpcError } from "@/api";
import { createTestServices, TestAppProviders } from "@/test-support/app-services";
import {
invalidateProjectDeleteQueries,
useProjectDeletedEvents,
} from "./projectDeletionEvents";
import { queryKeys } from "./queryKeys";

describe("Project deletion owner refresh", () => {
it.each([
["Project Edit", queryKeys.projectEdit("project-1")],
["New Task", queryKeys.workspaces("project-1")],
["Link Workflow", queryKeys.projectWorkflowLinks("project-1")],
["Project Workflow Editor", queryKeys.projectWorkflowLinks("project-1")],
])("drives the native deletion event through a second %s owner read", async (_name, queryKey) => {
const bridge = createBrowserNativeBridge();
const services = createTestServices([], bridge);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let requests = 0;
const observer = new QueryObserver(queryClient, {
queryFn: async () => {
requests += 1;
if (requests === 1) return { request: requests };
throw new RpcError({
code: -32000,
data: { reason: "project_not_found" },
message: "missing",
method: "owner.read",
});
},
queryKey,
});
const unsubscribe = observer.subscribe(() => undefined);
await observer.refetch();
let refresh: Promise<void> | undefined;
const handler = vi.fn(() => {
refresh = invalidateProjectDeleteQueries(queryClient, "project-1");
});
const wrapper = ({ children }: Readonly<{ children: ReactNode }>) =>
createElement(TestAppProviders, { children, services });
const view = renderHook(() => {
useProjectDeletedEvents(bridge, handler);
}, { wrapper });
await Promise.resolve();

await bridge.projectDeletion.notifyDeleted({ projectID: "project-1" });
await refresh;

expect(handler).toHaveBeenCalledOnce();
expect(requests).toBe(2);
expect(isProjectMissingError(observer.getCurrentResult().error)).toBe(true);
view.unmount();
unsubscribe();
queryClient.clear();
});
});
11 changes: 3 additions & 8 deletions apps/desktop/src/app-facade/projectDeletionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useEffect } from "react";
import { errorMessage } from "@/api";
import { clearLastProjectRoute } from "./projectRoutePersistence";
import { queryKeys } from "./queryKeys";
import type { SidebarController } from "./sidebarContext";
import { removeProjectTaskSearches } from "./taskSearchQueries";
import { useAppServices } from "./useAppServices";

Expand Down Expand Up @@ -43,31 +42,26 @@ export function useProjectDeletedEvents(
}

export async function completeProjectDeletion({
closeSidebar,
navigateHome,
projectID,
pushDeletedToast,
queryClient,
}: Readonly<{
closeSidebar: SidebarController["closeSidebar"];
navigateHome: () => Promise<void>;
navigateHome?: (() => Promise<void>) | undefined;
projectID: string;
pushDeletedToast: () => void;
queryClient: QueryClient;
}>): Promise<void> {
await invalidateProjectDeleteQueries(queryClient, projectID);
clearLastProjectRoute(projectID);
closeSidebar("closed");
await navigateHome();
if (navigateHome !== undefined) await navigateHome();
pushDeletedToast();
}

export async function invalidateProjectDeleteQueries(
queryClient: QueryClient,
projectID: string,
): Promise<void> {
queryClient.removeQueries({ queryKey: queryKeys.projectEdit(projectID) });
queryClient.removeQueries({ queryKey: queryKeys.workspaces(projectID) });
await removeProjectTaskSearches(queryClient, projectID);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.projects }),
Expand All @@ -76,5 +70,6 @@ export async function invalidateProjectDeleteQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.allBoards }),
queryClient.invalidateQueries({ queryKey: queryKeys.allAttention }),
queryClient.invalidateQueries({ queryKey: queryKeys.allTasks }),
queryClient.invalidateQueries({ queryKey: queryKeys.allProjectWorkflowLinks, refetchType: "active" }),
]);
}
126 changes: 88 additions & 38 deletions apps/desktop/src/app-facade/sidebarContext.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,12 @@
import { createContext, useContext, type ReactNode } from "react";
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";

import type { ResolvedSidebarWidth, SidebarSizePreference } from "./sidebarSizing";

export type SidebarMode = "overlay" | "shift";
export type SidebarPhase = "closing" | "open";

export type SidebarCancelReason = "closed" | "replaced" | "route_change";

export type SidebarCanceledResult = Readonly<{
status: "canceled";
reason: SidebarCancelReason;
}>;

export type SidebarNewTaskResult = Readonly<{
status: "submitted";
destination: "newTask";
}>;

export type SidebarTaskDetailResult = Readonly<{
status: "closed";
destination: "taskDetail";
}>;

export type SidebarWorkflowResult = Readonly<{
status: "completed";
destination: "workflow";
workflowID: string;
}>;
export type SidebarTransitionDirection = "back" | "push" | "replace";
export type SidebarRootOutcome = "closed" | "released" | "replaced";
export type SidebarNavigationOutcome = "accepted" | "stale" | "unavailable";

export type WorkflowInspectorSelection =
| Readonly<{ kind: "workflow" }>
Expand All @@ -42,9 +22,6 @@ export type TaskDetailInitialFocus =
| Readonly<{ kind: "interrupted_current_node" }>
| Readonly<{ kind: "dependencies" }>;

export type SidebarResult =
SidebarCanceledResult | SidebarNewTaskResult | SidebarTaskDetailResult | SidebarWorkflowResult;

export type SidebarDestination =
| Readonly<{
kind: "newTask";
Expand All @@ -66,8 +43,6 @@ export type SidebarDestination =
initialFocus?: TaskDetailInitialFocus | undefined;
taskID: string;
onMutated?: (() => void) | undefined;
// Set when opened from the Home inbox so the sidebar header exposes live
// Previous/Next navigation across the attention feed.
inboxNav?: boolean | undefined;
}>
| Readonly<{
Expand Down Expand Up @@ -108,23 +83,98 @@ export type SidebarDestination =
content: ReactNode;
}>;

export type SidebarController = Readonly<{
export type SidebarDestinationPolicy = Readonly<{
equals(left: SidebarDestination, right: SidebarDestination): boolean;
retainedState(destination: SidebarDestination, state: unknown): unknown;
}>;

export type SidebarPageNavigator = Readonly<{
back(): Exclude<SidebarNavigationOutcome, "unavailable">;
close(): Exclude<SidebarNavigationOutcome, "unavailable">;
push(destination: SidebarDestination): SidebarNavigationOutcome;
replace(destination: SidebarDestination): Exclude<SidebarNavigationOutcome, "unavailable">;
registerAvailability(availability: Readonly<{ back: boolean; close: boolean }>): () => void;
registerCapture(capture: () => unknown): () => void;
}>;

export type SidebarRootHandle = Readonly<{
lifecycle: Promise<SidebarRootOutcome>;
release(): void;
}>;

export type SidebarRootController = Readonly<{
open(destination: SidebarDestination): SidebarRootHandle;
}>;

export type SidebarShellController = Readonly<{
activeDestination: SidebarDestination | null;
closeSidebar(reason?: SidebarCancelReason): void;
openSidebar(destination: SidebarDestination): Promise<SidebarResult>;
replaceSidebar(destination: SidebarDestination): void;
back(): SidebarNavigationOutcome;
backAvailable: boolean;
canGoBack: boolean;
close(): SidebarNavigationOutcome;
closeAvailable: boolean;
phase: SidebarPhase;
resolveSidebar(result: Exclude<SidebarResult, SidebarCanceledResult>): void;
resizeSidebar(width: ResolvedSidebarWidth): void;
resize(width: ResolvedSidebarWidth): void;
sidebarWidthPx: number;
transitionDirection: SidebarTransitionDirection | null;
}>;

export const SidebarContext = createContext<SidebarController | null>(null);
export const SidebarRootContext = createContext<SidebarRootController | null>(null);
export const SidebarShellContext = createContext<SidebarShellController | null>(null);
const SidebarRootOwnerContext = createContext<SidebarRootController | null>(null);

export function useSidebarRoots(): SidebarRootController {
const value = useContext(SidebarRootContext);
if (value === null) {
throw new Error("SidebarProvider is required");
}
return value;
}

export function useSidebar(): SidebarController {
const value = useContext(SidebarContext);
export function useSidebarShell(): SidebarShellController {
const value = useContext(SidebarShellContext);
if (value === null) {
throw new Error("SidebarProvider is required");
}
return value;
}

export function SidebarRootOwner({ children }: Readonly<{ children: ReactNode }>) {
const roots = useSidebarRoots();
const [handles] = useState(() => new Set<SidebarRootHandle>());
const open = useCallback(
(destination: SidebarDestination) => {
const handle = roots.open(destination);
handles.add(handle);
void handle.lifecycle.finally(() => {
handles.delete(handle);
});
return handle;
},
[handles, roots],
);
useEffect(
() => () => {
for (const handle of handles) handle.release();
handles.clear();
},
[handles],
);
const value = useMemo(() => ({ open }), [open]);
return createElement(SidebarRootOwnerContext.Provider, { value }, children);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function useOwnedSidebarRoots(): SidebarRootController {
const value = useContext(SidebarRootOwnerContext);
if (value === null) throw new Error("SidebarRootOwner is required");
return value;
}

export function useSidebarBackWhen(
condition: boolean,
navigator: SidebarPageNavigator | undefined,
): void {
useEffect(() => {
if (condition) navigator?.back();
}, [condition, navigator]);
}
Loading