fix: restore canvas availability on task entry (#3846)

This commit is contained in:
Carlos Florêncio
2026-09-21 23:20:52 +01:00
committed by GitHub
parent 65f27ecbff
commit d324d4918e
35 changed files with 2666 additions and 165 deletions
@@ -255,7 +255,7 @@ func runtimeProtocolPath(requestPath string) (string, bool) {
func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) {
w.Header().Set("Content-Security-Policy", policy)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Cache-Control", "no-store, no-transform")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
@@ -269,7 +269,7 @@ func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) {
// responses. It is exported for the protocol adapter, while asset responses
// additionally receive the response CSP from setRuntimeHeaders.
func SetProtocolHeaders(w http.ResponseWriter, origin string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Cache-Control", "no-store, no-transform")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
@@ -63,7 +63,7 @@ func TestRuntimeServesEntryWithSecurityHeadersAndCookiesDoNotReplaceCapability(t
t.Fatalf("CSP = %q", got)
}
for key, want := range map[string]string{
"Cache-Control": "no-store",
"Cache-Control": "no-store, no-transform",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cross-Origin-Resource-Policy": "cross-origin",
@@ -78,6 +78,62 @@ func TestRuntimeServesEntryWithSecurityHeadersAndCookiesDoNotReplaceCapability(t
}
}
// @covers AC-PLUGINS-ISOLATED-WEB-APPS-013.8
func TestRuntimeNoTransformHeaders(t *testing.T) {
archive := canvasArchive(t, map[string]string{
"manifest.yaml": staticManifestYAML,
"ui/index.html": "<!doctype html><html><body>entry</body></html>",
"ui/app.js": "window.__asset = true;",
})
pkg, err := ValidatePackage(bytes.NewReader(archive))
if err != nil {
t.Fatalf("ValidatePackage: %v", err)
}
artifacts, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatalf("NewArtifactStore: %v", err)
}
artifact, err := artifacts.Put(pkg)
if err != nil {
t.Fatalf("Put: %v", err)
}
manager := NewTokenManager(nil)
token, err := manager.Issue(CapabilityBinding{
UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main",
Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html",
}, 0)
if err != nil {
t.Fatalf("Issue: %v", err)
}
runtime := NewRuntime(manager, artifacts, nil, nil)
runtime.SetProtocolHandler(func(w http.ResponseWriter, _ *http.Request, _ string, _ CapabilityBinding, _ string) {
SetProtocolHeaders(w, "")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
for _, test := range []struct {
name string
path string
}{
{name: "entry HTML", path: ""},
{name: "nested asset", path: "app.js"},
{name: "host bootstrap", path: "_kandev/host-runtime.js"},
{name: "protocol", path: "_kandev/v1/context"},
} {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, test.path)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", response.Code, response.Body.String())
}
if got := response.Header().Get("Cache-Control"); got != "no-store, no-transform" {
t.Errorf("Cache-Control = %q, want %q", got, "no-store, no-transform")
}
})
}
}
func TestRuntimeCookieDoesNotReplaceCapability(t *testing.T) {
runtime := NewRuntime(NewTokenManager(nil), nil, nil, nil)
request := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -99,6 +99,11 @@ vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ auth: { mode: "disabled", user: null } }),
}));
import { CanvasHostRoute } from "./canvas-host-route";
const canvas: Canvas = {
@@ -15,6 +15,11 @@ import {
} from "@/lib/api/domains/canvas-api";
import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy";
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
import { useAppStore } from "@/components/state-provider";
import {
canvasPresentationUserId,
recordCanvasPresentation,
} from "@/lib/canvas-presentation-storage";
import { useCanvasHostCanvases } from "./canvas-host-picker";
import { type CanvasHostState } from "./canvas-host-components";
import { CanvasHostRouteView } from "./canvas-host-route-view";
@@ -393,6 +398,7 @@ export function CanvasHostRoute({
const { t } = useTranslation();
const router = useRouter();
const { isMobile } = useResponsiveBreakpoint();
const presentationUserId = useAppStore((state) => canvasPresentationUserId(state.auth));
const {
canvas,
runtimeUrl,
@@ -410,6 +416,19 @@ export function CanvasHostRoute({
const [shareOpen, setShareOpen] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !presentationUserId) return;
recordCanvasPresentation(
{
userId: presentationUserId,
workspaceId: canvas.workspace_id,
taskId: canvas.task_id,
canvasId: canvas.id,
},
"manual",
);
}, [canvas, presentationUserId]);
const edit = () =>
editCanvasFromHost({
canvas,
@@ -32,6 +32,7 @@ const mockDockviewStore = vi.hoisted(() => ({
api: null as null | {
getPanel: (id: string) => { params?: Record<string, unknown> } | undefined;
addPanel: typeof mockAddCanvasPanel;
groups: Array<{ id: string }>;
},
centerGroupId: "group-center",
addBrowserPanel: vi.fn(),
@@ -56,6 +57,7 @@ const mockNormalizedReviews = vi.hoisted(() => ({
const mockCanvasFeature = vi.hoisted(() => ({ enabled: true }));
const mockAppState = vi.hoisted(() => ({
auth: { mode: "disabled", authenticated: false, user: null },
workspaceId: "workspace-1",
workspaces: { activeId: "workspace-1" },
tasks: { activeSessionId: "session-1", activeTaskId: null },
@@ -195,6 +197,7 @@ function setOpenPanels(panels: Record<string, Record<string, unknown>>) {
mockDockviewStore.api = {
getPanel: (id) => (Object.hasOwn(panels, id) ? { params: panels[id] } : undefined),
addPanel: mockAddCanvasPanel,
groups: [{ id: CENTER_GROUP_ID }, { id: SECONDARY_GROUP_ID }],
};
}
@@ -238,6 +241,7 @@ beforeEach(() => {
mockDockviewStore.api = {
getPanel: () => undefined,
addPanel: mockAddCanvasPanel,
groups: [{ id: CENTER_GROUP_ID }, { id: SECONDARY_GROUP_ID }],
};
mockNormalizedReviews.reviews = [];
mockListTaskCanvases.mockReset();
@@ -34,7 +34,8 @@ import type { TaskMR } from "@/lib/types/gitlab";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useTaskCanvases } from "@/hooks/domains/task/use-task-canvases";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import { activateCanvasPanel, isDiscoverableTaskCanvas } from "./dockview-canvas-activation";
import { canvasPresentationUserId } from "@/lib/canvas-presentation-storage";
import { mrTaskKey } from "@/components/gitlab/mr-detail-panel";
import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
@@ -67,14 +68,6 @@ export const MENU_ICON_CLASS = "h-3.5 w-3.5 mr-1.5 shrink-0";
export const MENU_ITEM_CLASS = "cursor-pointer text-xs";
const PR_SUBMENU_TEST_ID = "add-panel-pr-submenu";
// i18n-exempt: Dockview panel identity prefix, not user-facing copy.
const CANVAS_PANEL_ID_PREFIX = "canvas:";
const DISCOVERABLE_TASK_CANVAS_STATUSES = new Set(["active", "pending", "error"]);
export function isDiscoverableTaskCanvas(canvas: Pick<Canvas, "status">): boolean {
return DISCOVERABLE_TASK_CANVAS_STATUSES.has(canvas.status);
}
type ReviewMenuIdentity = Pick<ReviewItemSummary, "providerId" | "reviewKey"> &
Partial<Pick<ReviewItemSummary, "connectionScope" | "repositoryId" | "changeRequestNumber">>;
@@ -255,6 +248,7 @@ function PluginTaskPanelMenuItems({
function TaskCanvasMenuItems({ groupId, taskId }: { groupId: string; taskId: string | null }) {
const enabled = useFeature("canvases");
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const userId = useAppStore((state) => canvasPresentationUserId(state.auth));
const canvases = useTaskCanvases(taskId, workspaceId, enabled).filter(isDiscoverableTaskCanvas);
const api = useDockviewStore((s) => s.api);
@@ -266,12 +260,21 @@ function TaskCanvasMenuItems({ groupId, taskId }: { groupId: string; taskId: str
<DropdownMenuItem
key={canvas.id}
onClick={() =>
api?.addPanel({
id: `${CANVAS_PANEL_ID_PREFIX}${canvas.id}`,
component: "canvas",
title: canvas.title,
params: { canvasId: canvas.id },
position: { referenceGroup: groupId },
api &&
activateCanvasPanel(api, canvas, groupId, {
focusExisting: true,
presentation:
userId && taskId
? {
identity: {
userId,
workspaceId: canvas.workspace_id,
taskId,
canvasId: canvas.id,
},
reason: "manual",
}
: undefined,
})
}
className={MENU_ITEM_CLASS}
@@ -1,21 +1,63 @@
import { renderHook } from "@testing-library/react";
import { cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import type { CanvasLifecycleHint } from "@/lib/canvas-lifecycle";
const { mockGetCanvas, mockRouterPush, mockGetHints, mockState, mockValues } = vi.hoisted(() => ({
mockGetCanvas: vi.fn(),
mockRouterPush: vi.fn(),
mockGetHints: vi.fn(),
mockState: { current: { api: null as unknown } },
mockValues: { revision: 1, featureEnabled: true },
}));
const {
mockGetCanvas,
mockListTaskCanvases,
mockRouterPush,
mockRouter,
mockGetHints,
mockState,
mockValues,
mockAppState,
mockDockviewStore,
mockAppStoreApi,
} = vi.hoisted(() => {
const mockRouterPush = vi.fn();
const mockState = {
current: {
api: null as unknown,
isRestoringLayout: false,
currentLayoutEnvId: null as string | null,
},
};
const mockAppState = {
auth: { mode: "disabled", user: null },
connection: { status: "connected" },
environmentIdBySessionId: { "session-1": "env-1", "session-2": "env-2" },
taskSessionsByTask: {
itemsByTaskId: {} as Record<string, Array<{ id: string; task_environment_id?: string }>>,
},
taskSessions: {
items: {} as Record<string, { task_environment_id?: string }>,
},
};
const mockDockviewStore = Object.assign(
(selector: (state: typeof mockState.current) => unknown) => selector(mockState.current),
{ getState: () => mockState.current },
);
const mockAppStoreApi = { getState: () => mockAppState };
return {
mockGetCanvas: vi.fn(),
mockListTaskCanvases: vi.fn().mockResolvedValue({ canvases: [] }),
mockRouterPush,
mockRouter: { push: mockRouterPush },
mockGetHints: vi.fn(),
mockState,
mockValues: { revision: 1, featureEnabled: true },
mockAppState,
mockDockviewStore,
mockAppStoreApi,
};
});
vi.mock("@/lib/api/domains/canvas-api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/domains/canvas-api")>(
"@/lib/api/domains/canvas-api",
);
return { ...actual, getCanvas: mockGetCanvas };
return { ...actual, getCanvas: mockGetCanvas, listTaskCanvases: mockListTaskCanvases };
});
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => mockValues.featureEnabled,
@@ -30,18 +72,25 @@ vi.mock("@/lib/canvas-lifecycle", async () => {
};
});
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mockRouterPush }),
useRouter: () => mockRouter,
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: { api: unknown }) => unknown) => selector(mockState.current),
useDockviewStore: mockDockviewStore,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState),
useAppStoreApi: () => mockAppStoreApi,
}));
import {
activateCanvasPanel,
canvasLifecycleActivationDecision,
isTaskCanvasPresentationEligible,
reconcileTaskCanvasPanels,
shouldActivateCanvasForTask,
useTaskCanvasLifecycleActivation,
} from "./dockview-canvas-activation";
import { wasCanvasPresented } from "@/lib/canvas-presentation-storage";
const TASK_ID = "task-1";
const WORKSPACE_ID = "workspace-1";
@@ -73,6 +122,15 @@ function hint(overrides: Partial<CanvasLifecycleHint> = {}): CanvasLifecycleHint
};
}
function presentedIdentity(canvasId: string, taskId = TASK_ID) {
return {
userId: "anonymous",
workspaceId: WORKSPACE_ID,
taskId,
canvasId,
};
}
function lifecycleApi() {
const addPanel = vi.fn();
const api = {
@@ -81,19 +139,209 @@ function lifecycleApi() {
groups: [{ id: "center" }],
panels: [],
};
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
return { api, addPanel };
}
afterEach(() => {
cleanup();
mockGetCanvas.mockReset();
mockListTaskCanvases.mockReset().mockResolvedValue({ canvases: [] });
mockRouterPush.mockReset();
mockGetHints.mockReset();
mockGetHints.mockReturnValue([]);
mockValues.revision += 1;
mockValues.featureEnabled = true;
mockState.current = { api: null };
mockState.current = { api: null, isRestoringLayout: false, currentLayoutEnvId: null };
mockAppState.environmentIdBySessionId = { "session-1": "env-1", "session-2": "env-2" };
mockAppState.taskSessionsByTask.itemsByTaskId = {};
mockAppState.taskSessions.items = {};
mockAppState.connection.status = "connected";
window.sessionStorage.clear();
vi.useRealTimers();
});
describe("task-entry inventory filtering", () => {
it("opens a published inventory canvas when lifecycle hints are empty", async () => {
const { addPanel } = lifecycleApi();
mockGetHints.mockReturnValue([]);
mockListTaskCanvases.mockResolvedValue({
canvases: [
canvas({
created_at: "2026-09-21T10:00:00Z",
active_release_id: "release-1",
active_release_status: "valid",
}),
],
});
renderHook(() =>
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
}),
);
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1));
expect(addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: "canvas:canvas-1",
component: "canvas",
params: { canvasId: "canvas-1" },
position: { referenceGroup: "center" },
}),
);
});
it("filters inventory to eligible canvases in the current task", async () => {
const { addPanel } = lifecycleApi();
const eligible = canvas({
id: "canvas-eligible",
active_release_id: "release-eligible",
active_release_status: "valid",
});
const permissionReview = canvas({
id: "canvas-permission",
status: "pending",
pending_release: { id: "release-pending", validation_status: "pending_permission" },
});
const wrongTask = canvas({
id: "canvas-other-task",
task_id: "task-other",
active_release_id: "release-other",
active_release_status: "valid",
});
const archived = canvas({
id: "canvas-archived",
status: "archived",
active_release_id: "release-archived",
active_release_status: "valid",
});
mockListTaskCanvases.mockResolvedValue({
canvases: [eligible, permissionReview, wrongTask, archived],
});
renderHook(() =>
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
}),
);
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(2));
expect(addPanel.mock.calls.map(([options]) => options.id)).toEqual([
"canvas:canvas-eligible",
"canvas:canvas-permission",
]);
expect(isTaskCanvasPresentationEligible(wrongTask, TASK_ID, WORKSPACE_ID)).toBe(false);
});
});
describe("task-entry layout ownership", () => {
it("uses a hydrated task session environment when no session is selected", async () => {
const { addPanel } = lifecycleApi();
const canvasWithoutSelectedSession = canvas({
active_release_id: "release-no-session",
active_release_status: "valid",
});
mockState.current.currentLayoutEnvId = "env-3";
mockAppState.taskSessionsByTask.itemsByTaskId[TASK_ID] = [
{ id: "session-3", task_environment_id: "env-3" },
];
renderHook(() =>
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: null,
isMobile: false,
taskCanvases: [canvasWithoutSelectedSession],
taskCanvasesStatus: "success",
}),
);
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1));
});
it("waits for the target task environment before recording a presentation", async () => {
const { api, addPanel } = lifecycleApi();
const taskB = "task-2";
const canvasB = canvas({
id: "canvas-b",
task_id: taskB,
active_release_id: "release-b",
active_release_status: "valid",
});
const props = {
taskId: taskB,
workspaceId: WORKSPACE_ID,
sessionId: "session-2",
isMobile: false,
taskCanvases: [canvasB],
taskCanvasesStatus: "success" as const,
};
const hook = renderHook(() => useTaskCanvasLifecycleActivation(props));
await Promise.resolve();
expect(addPanel).not.toHaveBeenCalled();
expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(false);
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-2" };
hook.rerender();
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1));
expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(true);
});
it("rechecks layout ownership after an authoritative hint lookup", async () => {
const { api, addPanel } = lifecycleApi();
const taskB = "task-2";
const canvasB = canvas({
id: "canvas-b",
task_id: taskB,
active_release_id: "release-b",
active_release_status: "valid",
});
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-2" };
mockGetHints.mockReturnValue([
hint({
revision: 70,
payload: { canvas_id: canvasB.id, task_id: taskB, workspace_id: WORKSPACE_ID },
}),
]);
let resolveCanvas!: (value: Canvas) => void;
mockGetCanvas.mockReturnValue(
new Promise<Canvas>((resolve) => {
resolveCanvas = resolve;
}),
);
const props = {
taskId: taskB,
workspaceId: WORKSPACE_ID,
sessionId: "session-2",
isMobile: false,
taskCanvases: [] as Canvas[],
taskCanvasesStatus: "success" as const,
};
const hook = renderHook(() => useTaskCanvasLifecycleActivation(props));
await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1));
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
hook.rerender();
resolveCanvas(canvasB);
await Promise.resolve();
await Promise.resolve();
expect(addPanel).not.toHaveBeenCalled();
expect(wasCanvasPresented(presentedIdentity(canvasB.id, taskB))).toBe(false);
});
});
describe("canvas lifecycle matching", () => {
it("matches only a release event for the active task and workspace", () => {
expect(shouldActivateCanvasForTask(hint(), TASK_ID, WORKSPACE_ID)).toBe(true);
@@ -153,6 +401,66 @@ describe("canvas lifecycle panels", () => {
expect(addPanel).not.toHaveBeenCalled();
expect(setActive).not.toHaveBeenCalled();
});
it("adds a batch in creation order and focuses only the final panel", () => {
const addedIds = new Set<string>();
const addPanel = vi.fn((options: { id: string }) => addedIds.add(options.id));
const api = {
addPanel,
getPanel: vi.fn((id: string) =>
addedIds.has(id) ? { api: { setActive: vi.fn() } } : undefined,
),
groups: [{ id: "center" }],
panels: [],
};
const older = canvas({
id: "canvas-older",
created_at: "2026-09-21T10:00:00Z",
active_release_id: "release-older",
active_release_status: "valid",
});
const newer = canvas({
id: "canvas-newer",
created_at: "2026-09-21T11:00:00Z",
active_release_id: "release-newer",
active_release_status: "valid",
});
reconcileTaskCanvasPanels(api as never, [newer, older], {
userId: "anonymous",
workspaceId: WORKSPACE_ID,
taskId: TASK_ID,
});
expect(addPanel).toHaveBeenCalledTimes(2);
expect(addPanel.mock.calls.map(([options]) => options.id)).toEqual([
"canvas:canvas-older",
"canvas:canvas-newer",
]);
expect(addPanel.mock.calls[0][0]).toMatchObject({ inactive: true });
expect(addPanel.mock.calls[1][0]).not.toHaveProperty("inactive");
expect(wasCanvasPresented(presentedIdentity(older.id))).toBe(true);
expect(wasCanvasPresented(presentedIdentity(newer.id))).toBe(true);
});
it("records a restored receipt for a canvas already open in dockview", () => {
const addPanel = vi.fn();
const api = {
addPanel,
getPanel: vi.fn().mockReturnValue({ api: { setActive: vi.fn() } }),
groups: [{ id: "center" }],
panels: [],
};
reconcileTaskCanvasPanels(api as never, [canvas({ id: "canvas-restored" })], {
userId: "anonymous",
workspaceId: WORKSPACE_ID,
taskId: TASK_ID,
});
expect(addPanel).not.toHaveBeenCalled();
expect(wasCanvasPresented(presentedIdentity("canvas-restored"))).toBe(true);
});
});
describe("canvas lifecycle decisions", () => {
@@ -194,7 +502,7 @@ describe("canvas lifecycle decisions", () => {
describe("canvas lifecycle asynchronous activation", () => {
it("does not replay an archived or rejected canvas from a retained hint", async () => {
const { api, addPanel } = lifecycleApi();
mockState.current = { api };
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
mockGetHints.mockReturnValue([
hint({ revision: 20 }),
hint({
@@ -223,6 +531,7 @@ describe("canvas lifecycle asynchronous activation", () => {
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
}),
);
@@ -233,20 +542,20 @@ describe("canvas lifecycle asynchronous activation", () => {
it("does not add a deferred result after the active task changes", async () => {
const { api, addPanel } = lifecycleApi();
mockState.current = { api };
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
mockGetHints.mockReturnValue([hint({ revision: 30 })]);
let resolveCanvas!: (value: Canvas) => void;
mockGetCanvas.mockReturnValue(
new Promise<Canvas>((resolve) => {
resolveCanvas = resolve;
}),
);
const deferredCanvas = new Promise<Canvas>((resolve) => {
resolveCanvas = resolve;
});
mockGetCanvas.mockReturnValue(deferredCanvas);
const hook = renderHook(
({ taskId }: { taskId: string }) =>
useTaskCanvasLifecycleActivation({
taskId,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
}),
{ initialProps: { taskId: TASK_ID } },
@@ -257,6 +566,36 @@ describe("canvas lifecycle asynchronous activation", () => {
await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1));
expect(addPanel).not.toHaveBeenCalled();
});
it("retries a hint lookup when a newer generation invalidates the pending one", async () => {
const { api, addPanel } = lifecycleApi();
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
mockGetHints.mockReturnValue([hint({ revision: 31 })]);
let resolveFirst!: (value: Canvas) => void, resolveSecond!: (value: Canvas) => void;
const first = new Promise<Canvas>((resolve) => (resolveFirst = resolve));
const second = new Promise<Canvas>((resolve) => (resolveSecond = resolve));
mockGetCanvas.mockReturnValueOnce(first).mockReturnValueOnce(second);
const hook = renderHook(() =>
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
taskCanvases: [],
taskCanvasesStatus: "success",
}),
);
await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(1));
mockValues.revision += 1;
hook.rerender();
await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(2));
resolveSecond(canvas({ active_release_id: "release-1", active_release_status: "valid" }));
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1));
resolveFirst(canvas({ active_release_id: "release-1", active_release_status: "valid" }));
});
});
describe("canvas lifecycle mobile and retry activation", () => {
@@ -270,18 +609,24 @@ describe("canvas lifecycle mobile and retry activation", () => {
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: true,
}),
);
expect(mockRouterPush).not.toHaveBeenCalled();
await vi.waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith("/canvases/canvas-1"));
await vi.waitFor(() =>
expect(mockRouterPush).toHaveBeenCalledWith(
"/canvases/canvas-1",
expect.objectContaining({ onNavigated: expect.any(Function) }),
),
);
});
it("retries a transient authoritative lookup", async () => {
vi.useFakeTimers();
const { api, addPanel } = lifecycleApi();
mockState.current = { api };
mockState.current = { api, isRestoringLayout: false, currentLayoutEnvId: "env-1" };
mockGetHints.mockReturnValue([hint({ revision: 50 })]);
mockGetCanvas
.mockRejectedValueOnce(new Error("temporary transport failure"))
@@ -293,6 +638,7 @@ describe("canvas lifecycle mobile and retry activation", () => {
useTaskCanvasLifecycleActivation({
taskId: TASK_ID,
workspaceId: WORKSPACE_ID,
sessionId: "session-1",
isMobile: false,
}),
);
@@ -302,6 +648,6 @@ describe("canvas lifecycle mobile and retry activation", () => {
vi.advanceTimersByTime(500);
await vi.waitFor(() => expect(mockGetCanvas).toHaveBeenCalledTimes(2));
expect(addPanel).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(addPanel).toHaveBeenCalledTimes(1));
});
});
@@ -1,17 +1,39 @@
"use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { DockviewApi } from "dockview-react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { canvasHref, getCanvas, type Canvas } from "@/lib/api/domains/canvas-api";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import {
getCanvasLifecycleHints,
type CanvasLifecycleHint,
useCanvasLifecycleRevision,
} from "@/lib/canvas-lifecycle";
import { useRouter } from "@/lib/routing/client-router";
import {
canvasPresentationUserId,
recordCanvasPresentation,
type CanvasPresentationIdentity,
type CanvasPresentationReason,
wasCanvasPresented,
} from "@/lib/canvas-presentation-storage";
import { useRouter, type AppRouter } from "@/lib/routing/client-router";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { fallbackGroupPosition, focusOrAddPanel } from "@/lib/state/dockview-layout-builders";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
import {
useTaskCanvasesState,
type TaskCanvasesLoadStatus,
} from "@/hooks/domains/task/use-task-canvases";
import type { AppState } from "@/lib/state/store";
import {
readCanvasInventory,
reconcileTaskCanvasCandidates,
type CanvasInventoryRef,
type DockviewLayoutSnapshot,
type ProvidedCanvasInventoryRef,
type ReconcileRef,
} from "./dockview-canvas-reconciliation";
const CANVAS_PANEL_ID_PREFIX = "canvas:";
const CANVAS_ACTIVATION_RETRY_DELAY_MS = 500;
@@ -20,6 +42,11 @@ const CANVAS_RELEASE_ACTIONS = new Set<CanvasLifecycleHint["action"]>([
"canvas.release.activated",
"canvas.release.permission_required",
]);
const DISCOVERABLE_TASK_CANVAS_STATUSES = new Set(["active", "pending", "error"]);
export function isDiscoverableTaskCanvas(canvas: Pick<Canvas, "status">): boolean {
return DISCOVERABLE_TASK_CANVAS_STATUSES.has(canvas.status);
}
export function shouldActivateCanvasForTask(
hint: CanvasLifecycleHint,
@@ -42,6 +69,82 @@ function canvasPanelGroupId(api: DockviewApi): string | undefined {
return fallbackGroupPosition(api)?.referenceGroup;
}
function readDockviewLayout(): DockviewLayoutSnapshot {
const state = useDockviewStore.getState();
return {
api: state.api,
isRestoringLayout: state.isRestoringLayout,
currentLayoutEnvId: state.currentLayoutEnvId,
};
}
function layoutOwnsTask(layout: DockviewLayoutSnapshot, taskEnvironmentId: string | null): boolean {
return (
layout.api !== null &&
!layout.isRestoringLayout &&
taskEnvironmentId !== null &&
layout.currentLayoutEnvId === taskEnvironmentId
);
}
export function isTaskCanvasPresentationEligible(
canvas: Canvas,
taskId: string,
workspaceId: string,
): boolean {
if (
!isDiscoverableTaskCanvas(canvas) ||
canvas.scope_kind !== "task" ||
canvas.task_id !== taskId ||
canvas.workspace_id !== workspaceId
) {
return false;
}
if (
canvas.status === "active" &&
canvas.active_release_id &&
canvas.active_release_status === "valid"
) {
return true;
}
return (
(canvas.status === "pending" || canvas.status === "active") &&
(canvas.pending_release?.validation_status === "pending_permission" ||
(!canvas.pending_release && canvas.active_release_status === "pending_permission"))
);
}
function compareCanvasCreation(a: Canvas, b: Canvas): number {
const aCreated = parseStrictRfc3339Timestamp(a.created_at);
const bCreated = parseStrictRfc3339Timestamp(b.created_at);
if (aCreated !== null && bCreated !== null && aCreated !== bCreated) {
return aCreated < bCreated ? -1 : 1;
}
if (aCreated !== null && bCreated === null) return -1;
if (aCreated === null && bCreated !== null) return 1;
return a.id.localeCompare(b.id);
}
export function sortTaskCanvasPresentationCandidates(canvases: readonly Canvas[]): Canvas[] {
return [...canvases].sort(compareCanvasCreation);
}
function canvasIdentity(
identity: Omit<CanvasPresentationIdentity, "canvasId">,
canvasId: string,
): CanvasPresentationIdentity {
return { ...identity, canvasId };
}
export type CanvasPanelPresentationOptions = {
focus?: boolean;
focusExisting?: boolean;
presentation?: {
identity: CanvasPresentationIdentity;
reason: CanvasPresentationReason;
};
};
type CanvasLifecycleActivationDecision = "eligible" | "retry" | "stale";
function canvasMatchesLifecycleHint(hint: CanvasLifecycleHint, canvas: Canvas): boolean {
@@ -111,20 +214,86 @@ export function activateCanvasPanel(
api: DockviewApi,
canvas: Pick<Canvas, "id" | "title">,
groupId?: string,
options: CanvasPanelPresentationOptions = {},
): boolean {
const id = canvasPanelId(canvas.id);
if (api.getPanel(id)) return false;
const existing = api.getPanel(id);
if (existing) {
if (options.focusExisting) existing.api.setActive();
if (options.presentation) {
recordCanvasPresentation(options.presentation.identity, options.presentation.reason);
}
return false;
}
focusOrAddPanel(api, {
id,
component: "canvas",
title: canvas.title,
params: { canvasId: canvas.id },
...(groupId ? { position: { referenceGroup: groupId } } : {}),
});
focusOrAddPanel(
api,
{
id,
component: "canvas",
title: canvas.title,
params: { canvasId: canvas.id },
...(groupId ? { position: { referenceGroup: groupId } } : {}),
},
options.focus === false,
);
if (options.presentation) {
recordCanvasPresentation(options.presentation.identity, options.presentation.reason);
}
return true;
}
export function reconcileTaskCanvasPanels(
api: DockviewApi,
canvases: readonly Canvas[],
identity: Omit<CanvasPresentationIdentity, "canvasId">,
): Canvas[] {
const candidates = sortTaskCanvasPresentationCandidates(canvases);
const groupId = canvasPanelGroupId(api);
const newCanvases = candidates.filter((canvas) => !api.getPanel(canvasPanelId(canvas.id)));
const added: Canvas[] = [];
newCanvases.forEach((canvas, index) => {
try {
if (
activateCanvasPanel(api, canvas, groupId, {
focus: index === newCanvases.length - 1,
presentation: {
identity: canvasIdentity(identity, canvas.id),
reason: "automatic",
},
})
) {
added.push(canvas);
}
} catch {
// A panel that fails to insert has not been presented and remains eligible.
}
});
const addedIds = new Set(added.map((canvas) => canvas.id));
candidates.forEach((canvas) => {
if (!addedIds.has(canvas.id) && api.getPanel(canvasPanelId(canvas.id))) {
recordCanvasPresentation(canvasIdentity(identity, canvas.id), "restored");
}
});
return added;
}
export type MobileCanvasPresentationDecision = {
canvas: Canvas;
offered: Canvas[];
};
export function selectMobileCanvasPresentation(
canvases: readonly Canvas[],
identity: Omit<CanvasPresentationIdentity, "canvasId">,
): MobileCanvasPresentationDecision | null {
const unseen = sortTaskCanvasPresentationCandidates(canvases).filter(
(canvas) => !wasCanvasPresented(canvasIdentity(identity, canvas.id)),
);
const canvas = unseen.at(-1);
return canvas ? { canvas, offered: unseen } : null;
}
const handledHints = new Set<string>();
const MAX_HANDLED_HINTS = 128;
@@ -149,84 +318,218 @@ function markHandled(hint: CanvasLifecycleHint, taskId: string): void {
type CanvasLifecycleActivationProps = {
taskId: string | null;
workspaceId: string | null;
sessionId?: string | null;
isMobile: boolean;
userId?: string | null;
taskCanvases?: readonly Canvas[];
taskCanvasesStatus?: TaskCanvasesLoadStatus;
};
type CanvasActivationEffectOptions = {
enabled: boolean;
taskId: string | null;
workspaceId: string | null;
sessionId?: string | null;
taskEnvironmentId: string | null;
isMobile: boolean;
userId?: string | null;
defaultUserId: string | null;
layoutApi: DockviewApi | null;
layoutRestoring: boolean;
layoutEnvId: string | null;
revision: number;
router: AppRouter;
inventoryRef: CanvasInventoryRef;
providedInventoryRef: ProvidedCanvasInventoryRef;
readTaskEnvironmentId: () => string | null;
inFlightRef: { current: Set<string> };
generationRef: { current: number };
mobileNavigationRef: { current: string | null };
reconcileRef: ReconcileRef;
};
function useCanvasActivationEffect(options: CanvasActivationEffectOptions): void {
useEffect(() => {
const { taskId, workspaceId } = options;
if (!options.enabled || !taskId || !workspaceId) return;
const presentationUserId =
options.userId === undefined ? options.defaultUserId : options.userId;
if (!presentationUserId) return;
const generation = ++options.generationRef.current;
let disposed = false;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
const isCurrent = () => !disposed && options.generationRef.current === generation;
const identity = { userId: presentationUserId, workspaceId, taskId };
const retryReconcile: ReconcileRef = { current: null };
const scheduleRetry = (hint: CanvasLifecycleHint, attempt: number) => {
if (!isCurrent() || attempt >= MAX_CANVAS_ACTIVATION_RETRIES) return;
const timer = setTimeout(() => {
retryTimers.delete(timer);
retryReconcile.current?.([hint], attempt + 1);
}, CANVAS_ACTIVATION_RETRY_DELAY_MS);
retryTimers.add(timer);
};
const runReconcile = (hints: CanvasLifecycleHint[], attempt: number) => {
void reconcileTaskCanvasCandidates(
{
hints,
attempt,
generation,
taskId,
workspaceId,
identity,
readTaskEnvironmentId: options.readTaskEnvironmentId,
readDockviewLayout,
isMobile: options.isMobile,
router: options.router,
mobileNavigationRef: options.mobileNavigationRef,
inventoryRef: options.inventoryRef,
providedInventoryRef: options.providedInventoryRef,
inFlight: options.inFlightRef.current,
isCurrent,
scheduleRetry,
},
{
hintKey,
isHandled,
markHandled,
layoutOwnsTask,
isTaskCanvasPresentationEligible,
sortTaskCanvasPresentationCandidates,
canvasIdentity,
canvasLifecycleActivationDecision,
selectMobileCanvasPresentation,
reconcileTaskCanvasPanels,
},
);
};
retryReconcile.current = runReconcile;
options.reconcileRef.current = runReconcile;
runReconcile(
getCanvasLifecycleHints().filter((hint) =>
shouldActivateCanvasForTask(hint, taskId, workspaceId),
),
0,
);
return () => {
disposed = true;
options.generationRef.current += 1;
if (options.reconcileRef.current === runReconcile) options.reconcileRef.current = null;
retryTimers.forEach((timer) => clearTimeout(timer));
retryTimers.clear();
};
}, [
options.defaultUserId,
options.enabled,
options.isMobile,
options.layoutApi,
options.layoutEnvId,
options.layoutRestoring,
options.readTaskEnvironmentId,
options.revision,
options.router,
options.sessionId,
options.taskEnvironmentId,
options.taskId,
options.userId,
options.workspaceId,
]);
}
function resolveTaskEnvironmentId(
state: Pick<AppState, "environmentIdBySessionId" | "taskSessions" | "taskSessionsByTask">,
taskId: string | null,
sessionId: string | null | undefined,
): string | null {
if (sessionId) {
const mapped = state.environmentIdBySessionId[sessionId];
if (mapped) return mapped;
const sessionEnvironment = state.taskSessions.items[sessionId]?.task_environment_id;
if (sessionEnvironment) return sessionEnvironment;
}
const sessions = taskId ? (state.taskSessionsByTask.itemsByTaskId[taskId] ?? []) : [];
const candidates = sessionId ? sessions.filter((session) => session.id === sessionId) : sessions;
return candidates.find((session) => session.task_environment_id)?.task_environment_id ?? null;
}
/** React to retained or newly-arrived release events for the active task.
* Metadata remains authoritative, so the event only identifies which canvas
* to refetch and open. */
export function useTaskCanvasLifecycleActivation({
taskId,
workspaceId,
sessionId,
isMobile,
userId,
taskCanvases,
taskCanvasesStatus,
}: CanvasLifecycleActivationProps): void {
const enabled = useFeature("canvases");
const revision = useCanvasLifecycleRevision();
const inventory = useTaskCanvasesState(
taskId,
workspaceId,
enabled && taskCanvases === undefined,
);
const api = useDockviewStore((state) => state.api);
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
const currentLayoutEnvId = useDockviewStore((state) => state.currentLayoutEnvId);
const defaultUserId = useAppStore((state) => canvasPresentationUserId(state.auth));
const appStoreApi = useAppStoreApi();
const taskEnvironmentId = useAppStore((state) =>
resolveTaskEnvironmentId(state, taskId, sessionId),
);
const readTaskEnvironmentId = useCallback(
() => resolveTaskEnvironmentId(appStoreApi.getState(), taskId, sessionId),
[appStoreApi, sessionId, taskId],
);
const router = useRouter();
const inFlightRef = useRef(new Set<string>());
const generationRef = useRef(0);
const mobileNavigationRef = useRef<string | null>(null);
const inventoryRef = useRef(inventory);
inventoryRef.current = inventory;
const providedInventoryRef = useRef<{
canvases?: readonly Canvas[];
status?: TaskCanvasesLoadStatus;
}>({ canvases: taskCanvases, status: taskCanvasesStatus });
providedInventoryRef.current = { canvases: taskCanvases, status: taskCanvasesStatus };
const reconcileRef = useRef<ReconcileRef["current"]>(null);
useCanvasActivationEffect({
enabled,
taskId,
workspaceId,
sessionId,
taskEnvironmentId,
isMobile,
userId,
defaultUserId,
layoutApi: api,
layoutRestoring: isRestoringLayout,
layoutEnvId: currentLayoutEnvId,
revision,
router,
inventoryRef,
providedInventoryRef,
readTaskEnvironmentId,
inFlightRef,
generationRef,
mobileNavigationRef,
reconcileRef,
});
useEffect(() => {
if (!enabled || !taskId || !workspaceId) return;
const generation = ++generationRef.current;
let disposed = false;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
const isCurrent = () => !disposed && generationRef.current === generation;
const matchingHints = getCanvasLifecycleHints().filter((hint) =>
shouldActivateCanvasForTask(hint, taskId, workspaceId),
const snapshot = readCanvasInventory(inventoryRef, providedInventoryRef);
if (snapshot.status !== "success") return;
reconcileRef.current?.(
getCanvasLifecycleHints().filter((hint) =>
taskId && workspaceId ? shouldActivateCanvasForTask(hint, taskId, workspaceId) : false,
),
0,
);
const scheduleRetry = (hint: CanvasLifecycleHint, attempt: number) => {
if (!isCurrent() || attempt >= MAX_CANVAS_ACTIVATION_RETRIES) return;
const timer = setTimeout(() => {
retryTimers.delete(timer);
void fetchAndActivate(hint, attempt + 1);
}, CANVAS_ACTIVATION_RETRY_DELAY_MS);
retryTimers.add(timer);
};
const fetchAndActivate = async (hint: CanvasLifecycleHint, attempt: number) => {
const key = hintKey(hint, taskId);
if (isHandled(hint, taskId) || inFlightRef.current.has(key)) return;
if (!isMobile && !api) return;
inFlightRef.current.add(key);
try {
const canvas = await getCanvas(hint.payload.canvas_id);
if (!isCurrent()) return;
const decision = canvasLifecycleActivationDecision(hint, canvas);
if (decision === "retry") {
scheduleRetry(hint, attempt);
return;
}
markHandled(hint, taskId);
if (decision !== "eligible") return;
if (isMobile) {
router.push(canvasHref(canvas.id));
return;
}
const panelId = canvasPanelId(canvas.id);
if (api?.getPanel(panelId)) return;
if (api) activateCanvasPanel(api, canvas, canvasPanelGroupId(api));
} catch {
if (isCurrent()) scheduleRetry(hint, attempt);
} finally {
inFlightRef.current.delete(key);
}
};
for (const hint of matchingHints) void fetchAndActivate(hint, 0);
return () => {
disposed = true;
generationRef.current += 1;
retryTimers.forEach((timer) => clearTimeout(timer));
retryTimers.clear();
};
}, [api, enabled, isMobile, revision, router, taskId, workspaceId]);
}, [inventory.canvases, inventory.status, taskCanvases, taskCanvasesStatus, taskId, workspaceId]);
}
@@ -0,0 +1,234 @@
import type { DockviewApi } from "dockview-react";
import { canvasHref, getCanvas, type Canvas } from "@/lib/api/domains/canvas-api";
import type { CanvasLifecycleHint } from "@/lib/canvas-lifecycle";
import {
recordCanvasPresentation,
type CanvasPresentationIdentity,
wasCanvasPresented,
} from "@/lib/canvas-presentation-storage";
import { type AppRouter } from "@/lib/routing/client-router";
import type {
TaskCanvasesLoadStatus,
TaskCanvasesState,
} from "@/hooks/domains/task/use-task-canvases";
export type DockviewLayoutSnapshot = {
api: DockviewApi | null;
isRestoringLayout: boolean;
currentLayoutEnvId: string | null;
};
export type CanvasInventoryRef = { current: TaskCanvasesState };
export type ProvidedCanvasInventoryRef = {
current: { canvases?: readonly Canvas[]; status?: TaskCanvasesLoadStatus };
};
export type ReconcileRef = {
current: ((hints: CanvasLifecycleHint[], attempt: number) => void) | null;
};
type CanvasInventorySnapshot = {
canvases: readonly Canvas[];
status: TaskCanvasesLoadStatus;
};
export function readCanvasInventory(
inventoryRef: CanvasInventoryRef,
providedRef: ProvidedCanvasInventoryRef,
): CanvasInventorySnapshot {
const provided = providedRef.current;
if (provided.canvases !== undefined) {
return { canvases: provided.canvases, status: provided.status ?? "success" };
}
return inventoryRef.current;
}
type ResolveHintOptions = {
hint: CanvasLifecycleHint;
attempt: number;
generation: number;
taskId: string;
workspaceId: string;
inventoryRef: CanvasInventoryRef;
providedInventoryRef: ProvidedCanvasInventoryRef;
inFlight: Set<string>;
isCurrent: () => boolean;
hintKey: (hint: CanvasLifecycleHint, taskId: string) => string;
canvasLifecycleActivationDecision: (
hint: CanvasLifecycleHint,
canvas: Canvas,
) => "eligible" | "retry" | "stale";
markHandled: (hint: CanvasLifecycleHint, taskId: string) => void;
isHandled: (hint: CanvasLifecycleHint, taskId: string) => boolean;
scheduleRetry: (hint: CanvasLifecycleHint, attempt: number) => void;
};
async function resolveTaskCanvasHint(options: ResolveHintOptions): Promise<Canvas | null> {
const { hint, taskId, workspaceId } = options;
const key = `${options.generation}\u0000${options.hintKey(hint, taskId)}`;
if (options.isHandled(hint, taskId) || options.inFlight.has(key)) return null;
options.inFlight.add(key);
try {
const snapshot = readCanvasInventory(options.inventoryRef, options.providedInventoryRef);
const listed =
snapshot.status === "success"
? snapshot.canvases.find((canvas) => canvas.id === hint.payload.canvas_id)
: undefined;
const canvas = listed ?? (await getCanvas(hint.payload.canvas_id));
if (!options.isCurrent()) return null;
if (canvas.workspace_id !== workspaceId) {
options.markHandled(hint, taskId);
return null;
}
const decision = options.canvasLifecycleActivationDecision(hint, canvas);
if (decision === "retry") {
options.scheduleRetry(hint, options.attempt);
return null;
}
options.markHandled(hint, taskId);
return decision === "eligible" ? canvas : null;
} catch {
if (options.isCurrent()) options.scheduleRetry(hint, options.attempt);
return null;
} finally {
options.inFlight.delete(key);
}
}
export type ReconcileTaskCanvasOptions = {
hints: CanvasLifecycleHint[];
attempt: number;
generation: number;
taskId: string;
workspaceId: string;
identity: Omit<CanvasPresentationIdentity, "canvasId">;
readTaskEnvironmentId: () => string | null;
readDockviewLayout: () => DockviewLayoutSnapshot;
isMobile: boolean;
router: AppRouter;
mobileNavigationRef: { current: string | null };
inventoryRef: CanvasInventoryRef;
providedInventoryRef: ProvidedCanvasInventoryRef;
inFlight: Set<string>;
isCurrent: () => boolean;
scheduleRetry: (hint: CanvasLifecycleHint, attempt: number) => void;
};
export type ReconcileTaskCanvasDependencies = {
hintKey: (hint: CanvasLifecycleHint, taskId: string) => string;
isHandled: (hint: CanvasLifecycleHint, taskId: string) => boolean;
markHandled: (hint: CanvasLifecycleHint, taskId: string) => void;
layoutOwnsTask: (layout: DockviewLayoutSnapshot, taskEnvironmentId: string | null) => boolean;
isTaskCanvasPresentationEligible: (
canvas: Canvas,
taskId: string,
workspaceId: string,
) => boolean;
sortTaskCanvasPresentationCandidates: (canvases: readonly Canvas[]) => Canvas[];
canvasIdentity: (
identity: Omit<CanvasPresentationIdentity, "canvasId">,
canvasId: string,
) => CanvasPresentationIdentity;
canvasLifecycleActivationDecision: (
hint: CanvasLifecycleHint,
canvas: Canvas,
) => "eligible" | "retry" | "stale";
selectMobileCanvasPresentation: (
canvases: readonly Canvas[],
identity: Omit<CanvasPresentationIdentity, "canvasId">,
) => { canvas: Canvas; offered: Canvas[] } | null;
reconcileTaskCanvasPanels: (
api: DockviewApi,
canvases: readonly Canvas[],
identity: Omit<CanvasPresentationIdentity, "canvasId">,
) => Canvas[];
};
export async function reconcileTaskCanvasCandidates(
options: ReconcileTaskCanvasOptions,
dependencies: ReconcileTaskCanvasDependencies,
): Promise<void> {
if (!options.isCurrent()) return;
if (
!options.isMobile &&
!dependencies.layoutOwnsTask(options.readDockviewLayout(), options.readTaskEnvironmentId())
) {
return;
}
const snapshot = readCanvasInventory(options.inventoryRef, options.providedInventoryRef);
const listedCandidates =
snapshot.status === "success"
? snapshot.canvases.filter((canvas) =>
dependencies.isTaskCanvasPresentationEligible(
canvas,
options.taskId,
options.workspaceId,
),
)
: [];
const hintedCandidates = (
await Promise.all(
options.hints.map((hint) =>
resolveTaskCanvasHint({
...options,
hint,
hintKey: dependencies.hintKey,
isHandled: dependencies.isHandled,
markHandled: dependencies.markHandled,
canvasLifecycleActivationDecision: dependencies.canvasLifecycleActivationDecision,
}),
),
)
).filter((canvas): canvas is Canvas => canvas !== null);
if (!options.isCurrent()) return;
const currentLayout = options.readDockviewLayout();
const currentTaskEnvironmentId = options.readTaskEnvironmentId();
if (!options.isMobile && !dependencies.layoutOwnsTask(currentLayout, currentTaskEnvironmentId)) {
return;
}
const uniqueCanvases = new Map<string, Canvas>();
[...listedCandidates, ...hintedCandidates].forEach((canvas) => {
if (
dependencies.isTaskCanvasPresentationEligible(canvas, options.taskId, options.workspaceId)
) {
uniqueCanvases.set(canvas.id, canvas);
}
});
const candidates = dependencies
.sortTaskCanvasPresentationCandidates([...uniqueCanvases.values()])
.filter(
(canvas) => !wasCanvasPresented(dependencies.canvasIdentity(options.identity, canvas.id)),
);
if (candidates.length === 0) return;
if (options.isMobile) {
const decision = dependencies.selectMobileCanvasPresentation(candidates, options.identity);
if (!decision || options.mobileNavigationRef.current) return;
options.mobileNavigationRef.current = decision.canvas.id;
try {
options.router.push(canvasHref(decision.canvas.id), {
onNavigated: () => {
if (!options.isCurrent()) return;
decision.offered.forEach((canvas) =>
recordCanvasPresentation(
dependencies.canvasIdentity(options.identity, canvas.id),
"automatic",
),
);
options.mobileNavigationRef.current = null;
},
});
} catch {
options.mobileNavigationRef.current = null;
}
return;
}
if (currentLayout.api) {
dependencies.reconcileTaskCanvasPanels(currentLayout.api, candidates, options.identity);
}
}
+13 -3
View File
@@ -14,6 +14,7 @@ import type { Layout } from "react-resizable-panels";
import { useTaskCanvasLifecycleActivation } from "./dockview-canvas-activation";
import { statusSummaryTaskError } from "@/lib/task-status-summary";
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
import type { TaskCanvasesLoadStatus } from "@/hooks/domains/task/use-task-canvases";
// Re-export for backwards compatibility
export type { SelectedDiff } from "@/hooks/use-session-layout-state";
@@ -49,6 +50,7 @@ type TaskLayoutProps = {
isArchived?: boolean;
onTaskUnarchived?: (taskId: string) => void;
taskCanvases?: Canvas[];
taskCanvasesStatus?: TaskCanvasesLoadStatus;
};
export const TaskLayout = memo(function TaskLayout(props: TaskLayoutProps) {
@@ -91,12 +93,20 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({
initialLayout,
isArchived,
onTaskUnarchived,
taskCanvases = [],
taskCanvases,
taskCanvasesStatus,
}: TaskLayoutProps) {
const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint();
const launchErrorContext = useTaskLaunchErrorContext();
const hasSharedTaskError = Boolean(statusSummaryTaskError(launchErrorContext?.statusSummary));
useTaskCanvasLifecycleActivation({ taskId, workspaceId, isMobile });
useTaskCanvasLifecycleActivation({
taskId,
workspaceId,
sessionId,
isMobile,
taskCanvases,
taskCanvasesStatus,
});
const router = useRouter();
const onOpenCanvas = useCallback(
(canvasId: string) => router.push(canvasHref(canvasId)),
@@ -122,7 +132,7 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({
remoteStatusError={remoteStatusError}
isArchived={isArchived}
onTaskUnarchived={onTaskUnarchived}
taskCanvases={taskCanvases}
taskCanvases={taskCanvases ?? []}
onOpenCanvas={onOpenCanvas}
hasSharedTaskError={hasSharedTaskError}
/>
@@ -21,7 +21,7 @@ import { linkToTaskOverview } from "@/lib/links";
import { useTasks } from "@/hooks/use-tasks";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useTaskCanvasesForTask } from "@/hooks/domains/task/use-task-canvases";
import { useTaskCanvasesStateForTask } from "@/hooks/domains/task/use-task-canvases";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import type { Layout } from "react-resizable-panels";
import {
@@ -360,7 +360,7 @@ function TaskPageContentLive({
onTaskUnarchived,
refreshTask,
} = useTaskPageData(initialTask, initialTaskId, sessionId, initialRepositories);
const taskCanvases = useTaskCanvasesForTask(task, isMobile, canvasesEnabled);
const taskCanvasesState = useTaskCanvasesStateForTask(task, canvasesEnabled);
useExternalVcsFileLinkHydration(task, repositories);
const workflowSteps = useWorkflowStepsMapped();
@@ -414,7 +414,8 @@ function TaskPageContentLive({
officeTaskHref={officeTaskHref}
ensureSession={ensureSession}
onTaskUnarchived={onTaskUnarchived}
taskCanvases={taskCanvases}
taskCanvases={taskCanvasesState.canvases}
taskCanvasesStatus={taskCanvasesState.status}
/>
);
}
+7 -1
View File
@@ -47,6 +47,7 @@ import type {
} from "./task-page-content";
import { useTranslation } from "react-i18next";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import type { TaskCanvasesLoadStatus } from "@/hooks/domains/task/use-task-canvases";
export type TaskPageInnerProps = {
task: Task | null;
@@ -70,6 +71,7 @@ export type TaskPageInnerProps = {
ensureSession: UseEnsureTaskSessionResult;
onTaskUnarchived: (taskId: string) => void;
taskCanvases?: Canvas[];
taskCanvasesStatus?: TaskCanvasesLoadStatus;
};
type RemoteExecutorStatus = {
@@ -168,6 +170,7 @@ function buildTaskLayoutProps(params: {
initialLayout?: string | null;
onTaskUnarchived: (taskId: string) => void;
taskCanvases?: Canvas[];
taskCanvasesStatus?: TaskCanvasesLoadStatus;
}) {
const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params;
return {
@@ -180,7 +183,8 @@ function buildTaskLayoutProps(params: {
initialTerminals,
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskCanvases: params.taskCanvases ?? [],
taskCanvases: params.taskCanvases,
taskCanvasesStatus: params.taskCanvasesStatus,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
baseBranch: taskProps.baseBranch,
@@ -307,6 +311,7 @@ function useTaskPageDerivedProps({
officeTaskHref,
onTaskUnarchived,
taskCanvases,
taskCanvasesStatus,
}: TaskPageInnerProps) {
const workspaceRepositories = useAppStore((state) =>
selectWorkspaceRepositories(state.repositories.itemsByWorkspaceId, task?.workspace_id),
@@ -355,6 +360,7 @@ function useTaskPageDerivedProps({
initialLayout,
onTaskUnarchived,
taskCanvases,
taskCanvasesStatus,
});
return { taskProps, debugEntries, topBarProps, layoutProps };
@@ -34,19 +34,29 @@ export type CanvasAuthenticatedProxy = {
count: (kind: RuntimeRequestKind, authenticated?: boolean) => number;
observations: () => CanvasProxyObservation[];
activeUpstreamRequests: () => number;
injectedRuntimeDocuments: () => number;
close: () => Promise<void>;
};
export type CanvasAuthenticatedProxyOptions = {
/** Ask the proxy to try an HTML transformation when the origin permits it. */
injectRuntimeHtml?: boolean;
/** Simulate a proxy that removes the origin's no-transform directive. */
stripNoTransform?: boolean;
};
const runtimePrefix = "/api/v1/plugins/web-apps/runtime/";
export async function startCanvasAuthenticatedProxy(
backendURL: string,
options: CanvasAuthenticatedProxyOptions = {},
): Promise<CanvasAuthenticatedProxy> {
const certificate = createCertificate();
const backend = new URL(backendURL);
const observations: CanvasProxyObservation[] = [];
const upstreamRequests = new Set<ClientRequest>();
const upstreamResponses = new Set<IncomingMessage>();
let injectedRuntimeDocumentCount = 0;
const server = createTLSServer(
{
key: fs.readFileSync(certificate.keyPath),
@@ -95,14 +105,44 @@ export async function startCanvasAuthenticatedProxy(
upstreamResponse = response;
upstreamResponses.add(response);
response.once("close", () => upstreamResponses.delete(response));
outgoing.writeHead(response.statusCode ?? 502, response.headers);
outgoing.flushHeaders();
const headers = proxyResponseHeaders(response.headers, options.stripNoTransform === true);
const shouldTransform =
options.injectRuntimeHtml === true &&
kind === "runtime-entry" &&
isHtmlContentType(headers["content-type"]) &&
!hasNoTransformDirective(headers["cache-control"]) &&
!headers["content-encoding"];
if (shouldTransform) {
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
response.once("end", () => {
if (outgoing.destroyed) return;
const original = Buffer.concat(chunks);
const transformed = injectRuntimeHtml(original);
if (transformed.equals(original)) {
outgoing.writeHead(response.statusCode ?? 502, headers);
outgoing.end(original);
return;
}
injectedRuntimeDocumentCount += 1;
const transformedHeaders = {
...headers,
"content-length": String(transformed.length),
};
delete transformedHeaders["transfer-encoding"];
outgoing.writeHead(response.statusCode ?? 502, transformedHeaders);
outgoing.end(transformed);
});
} else {
outgoing.writeHead(response.statusCode ?? 502, headers);
outgoing.flushHeaders();
response.pipe(outgoing);
}
response.once("error", (error) => {
if (!outgoing.destroyed) outgoing.destroy(error);
destroyUpstream(error);
});
response.once("aborted", () => destroyUpstream());
response.pipe(outgoing);
},
);
upstreamRequests.add(upstream);
@@ -176,6 +216,7 @@ export async function startCanvasAuthenticatedProxy(
).length,
observations: () => observations.map((observation) => ({ ...observation })),
activeUpstreamRequests: () => upstreamRequests.size,
injectedRuntimeDocuments: () => injectedRuntimeDocumentCount,
close: async () => {
await closeServer(server, sockets, upstreamRequests, upstreamResponses);
fs.rmSync(certificate.directory, { recursive: true, force: true });
@@ -183,6 +224,47 @@ export async function startCanvasAuthenticatedProxy(
};
}
function proxyResponseHeaders(
source: IncomingMessage["headers"],
stripNoTransform: boolean,
): IncomingMessage["headers"] {
const headers = { ...source };
if (!stripNoTransform) return headers;
const cacheControl = headerString(headers["cache-control"]);
if (!cacheControl) return headers;
const directives = cacheControl
.split(",")
.map((directive) => directive.trim())
.filter((directive) => directive.toLowerCase() !== "no-transform");
if (directives.length === 0) delete headers["cache-control"];
else headers["cache-control"] = directives.join(", ");
return headers;
}
function headerString(value: string | string[] | undefined): string {
return Array.isArray(value) ? value.join(", ") : (value ?? "");
}
function hasNoTransformDirective(value: string | string[] | undefined): boolean {
return headerString(value)
.split(",")
.some((directive) => directive.trim().toLowerCase() === "no-transform");
}
function isHtmlContentType(value: string | string[] | undefined): boolean {
return headerString(value).toLowerCase().startsWith("text/html");
}
function injectRuntimeHtml(document: Buffer): Buffer {
const source = document.toString("utf8");
const script = '<script src="https://static.cloudflareinsights.com/beacon.min.js"></script>';
const closingHead = /<\/head\s*>/i.exec(source);
const transformed = closingHead
? `${source.slice(0, closingHead.index)}${script}${source.slice(closingHead.index)}`
: `${source}${script}`;
return Buffer.from(transformed, "utf8");
}
function runtimeRequestKind(pathname: string, method: string): RuntimeRequestKind | null {
if (!pathname.startsWith(runtimePrefix)) return null;
const tokenAndPath = pathname.slice(runtimePrefix.length);
@@ -50,7 +50,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { injectRuntimeHtml: true });
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
const page = opened.page;
@@ -71,6 +71,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0);
expect(proxy?.injectedRuntimeDocuments()).toBe(0);
expect(proxy?.observations().every((observation) => observation.host === proxy?.host)).toBe(
true,
);
@@ -91,6 +92,44 @@ test.describe("authenticated same-origin canvas runtime", () => {
}
});
test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({
browser,
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let proxy: CanvasAuthenticatedProxy | undefined;
let context: BrowserContext | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, {
injectRuntimeHtml: true,
stripNoTransform: true,
});
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
timeout: 30_000,
});
await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0);
await expect(
opened.page.getByRole("button", { name: "Try again", exact: true }),
).toBeVisible();
expect(proxy.injectedRuntimeDocuments()).toBeGreaterThan(0);
} finally {
await context?.close();
await proxy?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
test("releases a disconnected event stream before reconnecting", async ({
browser,
testPage,
@@ -49,7 +49,7 @@ test.describe("authenticated same-origin canvas runtime on mobile", () => {
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { injectRuntimeHtml: true });
const opened = await openMobileCanvas(browser, proxy, canvasId);
context = opened.context;
const page = opened.page;
@@ -483,4 +483,48 @@ test.describe("Plugin-backed canvases on mobile", () => {
await releaseFeature();
}
});
test("opens one published task canvas after returning and keeps Back usable", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let taskId: string | undefined;
let authoringPage: Page | undefined;
try {
await testPage.goto("/");
authoringPage = await testPage.context().newPage();
const seeded = await seedTaskCanvas(authoringPage, apiClient, seedData, true);
canvasId = seeded.canvas.id;
taskId = seeded.taskId;
await authoringPage.close();
authoringPage = undefined;
await testPage.reload();
await testPage.goto(`/t/${encodeURIComponent(seeded.taskId)}`);
await expect(testPage).toHaveURL(new RegExp(`${canvasHref(seeded.canvas.id)}$`), {
timeout: 30_000,
});
await expect(testPage.getByTestId("canvas-host-route")).toBeVisible({ timeout: 20_000 });
await expect(testPage.getByTestId("web-app-frame")).toHaveAttribute(
"data-frame-state",
"ready",
{ timeout: 20_000 },
);
await testPage.goBack();
await expect(testPage).toHaveURL(new RegExp(`/t/${seeded.taskId}(?:[?]|$)`));
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible({ timeout: 20_000 });
await expect(testPage.getByTestId("canvas-host-route")).toHaveCount(0);
} finally {
await authoringPage?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined);
await releaseFeature();
}
});
});
@@ -1,4 +1,5 @@
import { expect, test } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import { waitForHttp } from "../../helpers/causal-waits";
import { waitForSessionDone } from "../../helpers/session";
import { resizeColumnViaSplitview } from "../../helpers/dockview-resize";
@@ -360,6 +361,110 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
}
});
test("reconciles a published task canvas after returning to the task", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let taskId: string | undefined;
let authoringPage: Page | undefined;
try {
await testPage.goto("/");
authoringPage = await testPage.context().newPage();
const seeded = await seedTaskCanvas(authoringPage, apiClient, seedData);
canvasId = seeded.canvas.id;
taskId = seeded.taskId;
await authoringPage.close();
authoringPage = undefined;
await testPage.reload();
await testPage.goto(`/t/${encodeURIComponent(seeded.taskId)}`);
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
await expect
.poll(
() =>
testPage.evaluate((id) => {
const api = (
window as unknown as {
__dockviewApi__?: {
getPanel: (panelId: string) => { group?: { id?: string } } | undefined;
panels?: Array<{ id: string }>;
};
}
).__dockviewApi__;
const panel = api?.getPanel(`canvas:${id}`);
return {
count:
api?.panels?.filter((candidate) => candidate.id === `canvas:${id}`).length ?? 0,
canvasGroup: panel?.group?.id ?? null,
};
}, seeded.canvas.id),
{ timeout: 30_000 },
)
.toEqual({ count: 1, canvasGroup: "group-center" });
await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", {
timeout: 20_000,
});
await testPage.evaluate((id) => {
const api = (
window as unknown as {
__dockviewApi__?: {
getPanel: (panelId: string) => { api: { close: () => void } } | undefined;
};
__persistDockviewLayout__?: () => void;
}
).__dockviewApi__;
api?.getPanel(`canvas:${id}`)?.api.close();
(
window as unknown as { __persistDockviewLayout__?: () => void }
).__persistDockviewLayout__?.();
}, seeded.canvas.id);
await expect
.poll(() =>
testPage.evaluate((id) => {
const api = (
window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } }
).__dockviewApi__;
return api?.getPanel(`canvas:${id}`) !== undefined;
}, seeded.canvas.id),
)
.toBe(false);
const taskInventory = waitForHttp(
testPage,
"GET",
new RegExp(`/api/v1/tasks/${encodeURIComponent(seeded.taskId)}/canvases$`),
);
await testPage.reload();
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
await taskInventory;
await expect(testPage.getByTestId("dockview-task-layout")).toHaveAttribute(
"aria-busy",
"false",
);
await expect
.poll(() =>
testPage.evaluate((id) => {
const api = (
window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } }
).__dockviewApi__;
return api?.getPanel(`canvas:${id}`) !== undefined;
}, seeded.canvas.id),
)
.toBe(false);
} finally {
await authoringPage?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined);
await releaseFeature();
}
});
test("shows recoverable startup failure and retries with a fresh runtime", async ({
testPage,
apiClient,
@@ -1,6 +1,8 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import { invalidateCanvasLifecycle } from "@/lib/canvas-lifecycle";
import type { Task } from "@/lib/types/http";
const listTaskCanvasesMock = vi.hoisted(() => vi.fn());
@@ -11,7 +13,7 @@ vi.mock("@/lib/api/domains/canvas-api", () => ({
listTaskCanvases: listTaskCanvasesMock,
}));
import { useTaskCanvases } from "./use-task-canvases";
import { useTaskCanvases, useTaskCanvasesForTask, useTaskCanvasesState } from "./use-task-canvases";
const canvas: Canvas = {
id: "canvas-1",
@@ -24,6 +26,15 @@ const canvas: Canvas = {
status: "active",
};
const connectionState = { status: "connected" };
const authState = { mode: "disabled", user: null as { id: string } | null };
vi.mock("@/components/state-provider", () => ({
useAppStore: (
selector: (state: { connection: typeof connectionState; auth: typeof authState }) => unknown,
) => selector({ connection: connectionState, auth: authState }),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
@@ -34,11 +45,14 @@ function deferred<T>() {
beforeEach(() => {
listTaskCanvasesMock.mockReset().mockResolvedValue({ canvases: [canvas] });
connectionState.status = "connected";
authState.mode = "disabled";
authState.user = null;
});
afterEach(cleanup);
describe("useTaskCanvases", () => {
describe("useTaskCanvases loading", () => {
it("loads canvases for the task and workspace", async () => {
const { result } = renderHook(() => useTaskCanvases(TASK_ID, WORKSPACE_ID));
@@ -56,6 +70,40 @@ describe("useTaskCanvases", () => {
expect(listTaskCanvasesMock).not.toHaveBeenCalled();
});
it("keeps an empty successful inventory distinct from a failed request", async () => {
listTaskCanvasesMock.mockResolvedValueOnce({ canvases: [] });
const successful = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
await waitFor(() => expect(successful.result.current.status).toBe("success"));
expect(successful.result.current.canvases).toEqual([]);
successful.unmount();
listTaskCanvasesMock.mockRejectedValueOnce(new Error("inventory unavailable"));
const failed = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
await waitFor(() => expect(failed.result.current.status).toBe("error"));
expect(failed.result.current.canvases).toEqual([]);
});
it("loads the task inventory for desktop task entry", async () => {
const { result } = renderHook(() =>
useTaskCanvasesForTask(
{
id: TASK_ID as Task["id"],
workspace_id: WORKSPACE_ID as Task["workspace_id"],
},
false,
true,
),
);
await waitFor(() => expect(result.current).toEqual([canvas]));
expect(listTaskCanvasesMock).toHaveBeenCalledWith(TASK_ID, {
workspaceId: WORKSPACE_ID,
cache: "no-store",
});
});
it("ignores a stale request after the task changes", async () => {
const first = deferred<{ canvases: Canvas[] }>();
const second = deferred<{ canvases: Canvas[] }>();
@@ -84,3 +132,76 @@ describe("useTaskCanvases", () => {
});
});
});
describe("useTaskCanvases request lifecycle", () => {
it("starts a fresh shared request after lifecycle invalidation", async () => {
const first = deferred<{ canvases: Canvas[] }>();
const second = deferred<{ canvases: Canvas[] }>();
listTaskCanvasesMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const firstHook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
const secondHook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1));
act(() => invalidateCanvasLifecycle());
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2));
second.resolve({ canvases: [canvas] });
await waitFor(() => expect(firstHook.result.current.canvases).toEqual([canvas]));
expect(secondHook.result.current.canvases).toEqual([canvas]);
first.resolve({ canvases: [] });
await first.promise;
expect(firstHook.result.current.canvases).toEqual([canvas]);
});
it("does not reuse a connected request after reconnect invalidates it", async () => {
const beforeDisconnect = deferred<{ canvases: Canvas[] }>();
const duringDisconnect = deferred<{ canvases: Canvas[] }>();
const afterReconnect = deferred<{ canvases: Canvas[] }>();
listTaskCanvasesMock
.mockReturnValueOnce(beforeDisconnect.promise)
.mockReturnValueOnce(duringDisconnect.promise)
.mockReturnValueOnce(afterReconnect.promise);
const hook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1));
connectionState.status = "disconnected";
hook.rerender();
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2));
duringDisconnect.resolve({ canvases: [] });
await duringDisconnect.promise;
connectionState.status = "connected";
hook.rerender();
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(3));
afterReconnect.resolve({ canvases: [canvas] });
await waitFor(() => expect(hook.result.current.canvases).toEqual([canvas]));
beforeDisconnect.resolve({ canvases: [] });
await beforeDisconnect.promise;
});
it("does not share a pending inventory across authenticated identities", async () => {
const firstIdentity = deferred<{ canvases: Canvas[] }>();
const secondIdentity = deferred<{ canvases: Canvas[] }>();
listTaskCanvasesMock
.mockReturnValueOnce(firstIdentity.promise)
.mockReturnValueOnce(secondIdentity.promise);
const hook = renderHook(() => useTaskCanvasesState(TASK_ID, WORKSPACE_ID));
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(1));
authState.mode = "enabled";
authState.user = { id: "user-2" };
hook.rerender();
await waitFor(() => expect(listTaskCanvasesMock).toHaveBeenCalledTimes(2));
secondIdentity.resolve({ canvases: [canvas] });
await waitFor(() => expect(hook.result.current.canvases).toEqual([canvas]));
firstIdentity.resolve({ canvases: [] });
await firstIdentity.promise;
expect(hook.result.current.canvases).toEqual([canvas]);
});
});
@@ -1,56 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { listTaskCanvases, type Canvas } from "@/lib/api/domains/canvas-api";
import type { Task } from "@/lib/types/http";
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
const EMPTY_CANVASES: Canvas[] = [];
export type TaskCanvasesLoadStatus = "idle" | "loading" | "success" | "error";
export type TaskCanvasesState = {
key: string | null;
canvases: Canvas[];
status: TaskCanvasesLoadStatus;
};
type InFlightTaskCanvasesRequest = {
fingerprint: string;
promise: Promise<Canvas[]>;
};
const inFlightRequests = new Map<string, InFlightTaskCanvasesRequest>();
function taskCanvasesKey(workspaceId: string, taskId: string, authIdentity: string): string {
return `${workspaceId}\u0000${taskId}\u0000${authIdentity}`;
}
function requestTaskCanvases(
taskId: string,
workspaceId: string,
authIdentity: string,
invalidationFingerprint: string,
): Promise<Canvas[]> {
const key = taskCanvasesKey(workspaceId, taskId, authIdentity);
const existing = inFlightRequests.get(key);
if (existing?.fingerprint === invalidationFingerprint) return existing.promise;
const request = listTaskCanvases(taskId, { workspaceId, cache: "no-store" }).then((response) => {
if (!response || !Array.isArray(response.canvases)) throw new TypeError();
return response.canvases;
});
const entry = { fingerprint: invalidationFingerprint, promise: request };
inFlightRequests.set(key, entry);
request.then(
() => {
if (inFlightRequests.get(key) === entry) inFlightRequests.delete(key);
},
() => {
if (inFlightRequests.get(key) === entry) inFlightRequests.delete(key);
},
);
return request;
}
const EMPTY_STATE: TaskCanvasesState = {
key: null,
canvases: EMPTY_CANVASES,
status: "idle",
};
/** Loads the canvases that can be opened from a task's mobile Panels picker. */
export function useTaskCanvases(
export function useTaskCanvasesState(
taskId: string | null | undefined,
workspaceId: string | null | undefined,
enabled = true,
): Canvas[] {
const [loaded, setLoaded] = useState<{ key: string | null; canvases: Canvas[] }>({
key: null,
canvases: EMPTY_CANVASES,
});
): TaskCanvasesState {
const connectionStatus = useAppStore((state) => state.connection.status);
const authIdentity = useAppStore(
(state) => `${state.auth.mode}\u0000${state.auth.user?.id ?? ""}`,
);
const [loaded, setLoaded] = useState<TaskCanvasesState>(EMPTY_STATE);
const requestRef = useRef(0);
const lifecycleRevision = useCanvasLifecycleRevision();
useEffect(() => {
const requestId = ++requestRef.current;
if (!enabled || !taskId || !workspaceId) {
setLoaded({ key: null, canvases: EMPTY_CANVASES });
setLoaded(EMPTY_STATE);
return;
}
const key = `${workspaceId}\u0000${taskId}`;
setLoaded((current) => (current.key === key ? current : { key, canvases: EMPTY_CANVASES }));
listTaskCanvases(taskId, { workspaceId, cache: "no-store" })
.then((response) => {
const key = taskCanvasesKey(workspaceId, taskId, authIdentity);
const invalidationFingerprint = `${connectionStatus}\u0000${lifecycleRevision}`;
setLoaded((current) =>
current.key === key
? { ...current, status: "loading" }
: { key, canvases: EMPTY_CANVASES, status: "loading" },
);
requestTaskCanvases(taskId, workspaceId, authIdentity, invalidationFingerprint)
.then((canvases) => {
if (requestRef.current !== requestId) return;
setLoaded({ key, canvases: response?.canvases ?? EMPTY_CANVASES });
setLoaded({ key, canvases, status: "success" });
})
.catch(() => {
if (requestRef.current === requestId) setLoaded({ key, canvases: EMPTY_CANVASES });
if (requestRef.current === requestId) {
setLoaded((current) => ({
key,
canvases: current.key === key ? current.canvases : EMPTY_CANVASES,
status: "error",
}));
}
});
return () => {
if (requestRef.current === requestId) requestRef.current += 1;
};
}, [enabled, lifecycleRevision, taskId, workspaceId]);
}, [authIdentity, connectionStatus, enabled, lifecycleRevision, taskId, workspaceId]);
const key = taskId && workspaceId ? `${workspaceId}\u0000${taskId}` : null;
return loaded.key === key ? loaded.canvases : EMPTY_CANVASES;
const key = taskId && workspaceId ? taskCanvasesKey(workspaceId, taskId, authIdentity) : null;
return loaded.key === key ? loaded : { key, canvases: EMPTY_CANVASES, status: "idle" };
}
export function useTaskCanvases(
taskId: string | null | undefined,
workspaceId: string | null | undefined,
enabled = true,
): Canvas[] {
return useTaskCanvasesState(taskId, workspaceId, enabled).canvases;
}
export function useTaskCanvasesForTask(
task: Pick<Task, "id" | "workspace_id"> | null | undefined,
isMobile: boolean,
_isMobile: boolean,
canvasesEnabled: boolean,
): Canvas[] {
return useTaskCanvases(task?.id ?? null, task?.workspace_id ?? null, isMobile && canvasesEnabled);
return useTaskCanvasesStateForTask(task, canvasesEnabled).canvases;
}
export function useTaskCanvasesStateForTask(
task: Pick<Task, "id" | "workspace_id"> | null | undefined,
canvasesEnabled: boolean,
): TaskCanvasesState {
return useTaskCanvasesState(task?.id ?? null, task?.workspace_id ?? null, canvasesEnabled);
}
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const identity = {
userId: "user-1",
workspaceId: "workspace-1",
taskId: "task-1",
canvasId: "canvas-1",
};
const uuidValues: string[] = [];
vi.doMock("@/lib/uuid", () => ({
generateUUID: () => uuidValues.shift() ?? "uuid-fallback",
}));
beforeEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
uuidValues.length = 0;
vi.resetModules();
});
describe("canvas presentation tab identity", () => {
it("rotates a copied session namespace while keeping the source receipt isolated", async () => {
uuidValues.push("source-page", "source-tab", "duplicate-page", "duplicate-tab");
const source = await import("./canvas-presentation-storage");
source.markCanvasPresented(identity);
const sourceKey = source.canvasPresentationKey(identity);
vi.resetModules();
const duplicate = await import("./canvas-presentation-storage");
expect(duplicate.wasCanvasPresented(identity)).toBe(false);
duplicate.markCanvasPresented(identity);
expect(duplicate.wasCanvasPresented(identity)).toBe(true);
expect(window.sessionStorage.getItem(sourceKey)).toBe("1");
});
it("reuses the tab identity after a normal reload releases its page owner", async () => {
uuidValues.push("source-page", "source-tab", "reloaded-page");
const source = await import("./canvas-presentation-storage");
source.markCanvasPresented(identity);
window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: false }));
vi.resetModules();
const reloaded = await import("./canvas-presentation-storage");
expect(reloaded.wasCanvasPresented(identity)).toBe(true);
});
});
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
canvasPresentationKey,
markCanvasPresented,
wasCanvasPresented,
type CanvasPresentationIdentity,
} from "./canvas-presentation-storage";
const identity: CanvasPresentationIdentity = {
userId: "user-1",
workspaceId: "workspace-1",
taskId: "task-1",
canvasId: "canvas-1",
};
beforeEach(() => {
window.sessionStorage.clear();
});
describe("canvas presentation receipts", () => {
it("keys receipts by the complete user, workspace, task, and canvas identity", () => {
expect(canvasPresentationKey(identity)).not.toBe(
canvasPresentationKey({ ...identity, userId: "user-2" }),
);
expect(canvasPresentationKey(identity)).not.toBe(
canvasPresentationKey({ ...identity, workspaceId: "workspace-2" }),
);
expect(canvasPresentationKey(identity)).not.toBe(
canvasPresentationKey({ ...identity, taskId: "task-2" }),
);
expect(canvasPresentationKey(identity)).not.toBe(
canvasPresentationKey({ ...identity, canvasId: "canvas-2" }),
);
});
it("persists a receipt for the browser tab", () => {
expect(wasCanvasPresented(identity)).toBe(false);
markCanvasPresented(identity);
expect(wasCanvasPresented(identity)).toBe(true);
expect(wasCanvasPresented({ ...identity, canvasId: "canvas-2" })).toBe(false);
});
it("falls back to tab memory when sessionStorage cannot be written", () => {
const blocked = { ...identity, canvasId: "canvas-blocked" };
const setItem = window.sessionStorage.setItem;
window.sessionStorage.setItem = () => {
throw new Error("storage blocked");
};
markCanvasPresented(blocked);
window.sessionStorage.setItem = setItem;
expect(wasCanvasPresented(blocked)).toBe(true);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { generateUUID } from "@/lib/uuid";
export type CanvasPresentationIdentity = {
userId: string;
workspaceId: string;
taskId: string;
canvasId: string;
};
export type CanvasPresentationReason = "existing" | "restored" | "manual" | "automatic";
const CANVAS_PRESENTATION_PREFIX = "kandev.canvas-presentation.v2";
const TAB_INSTANCE_KEY = `${CANVAS_PRESENTATION_PREFIX}.tab`;
const TAB_OWNER_PREFIX = `${CANVAS_PRESENTATION_PREFIX}.owner.`;
// i18n-exempt: stable browser-storage identity, not user-facing copy.
const ANONYMOUS_CANVAS_PRESENTATION_IDENTITY = "anonymous";
const SERVER_TAB_IDENTITY = "server";
const memoryReceipts = new Set<string>();
type TabOwnerRecord = {
pageId: string;
active: boolean;
};
type TabIdentity = {
tabId: string;
pageId: string;
};
let tabIdentity: TabIdentity | null = null;
export function canvasPresentationUserId(auth: {
mode: "disabled" | "setup" | "enabled";
user: { id: string } | null;
}): string | null {
if (auth.user?.id) return auth.user.id;
return auth.mode === "disabled" ? ANONYMOUS_CANVAS_PRESENTATION_IDENTITY : null;
}
function sessionStorageOrNull(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage;
} catch {
return null;
}
}
function localStorageOrNull(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function readTabOwner(storage: Storage, tabId: string): TabOwnerRecord | null {
try {
const raw = storage.getItem(`${TAB_OWNER_PREFIX}${tabId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<TabOwnerRecord>;
if (typeof parsed.pageId !== "string" || typeof parsed.active !== "boolean") return null;
return { pageId: parsed.pageId, active: parsed.active };
} catch {
return null;
}
}
function writeTabOwner(storage: Storage, tabId: string, record: TabOwnerRecord): void {
try {
storage.setItem(`${TAB_OWNER_PREFIX}${tabId}`, JSON.stringify(record));
} catch {
// Ignore private browsing, blocked storage, and quota failures.
}
}
function writeSessionTabId(storage: Storage | null, tabId: string): void {
if (!storage) return;
try {
storage.setItem(TAB_INSTANCE_KEY, tabId);
} catch {
// Receipts still use the in-memory fallback when session storage is blocked.
}
}
/**
* Return a stable identity for this tab while detecting a duplicated
* sessionStorage namespace. A reload releases the old page owner first, while
* a duplicated tab sees the live owner and receives a fresh namespace.
*/
function canvasPresentationTabId(): string {
if (typeof window === "undefined") return SERVER_TAB_IDENTITY;
const session = sessionStorageOrNull();
let storedTabId: string | null = null;
try {
storedTabId = session?.getItem(TAB_INSTANCE_KEY) ?? null;
} catch {
storedTabId = null;
}
if (tabIdentity && storedTabId === tabIdentity.tabId) return tabIdentity.tabId;
const pageId = generateUUID();
let tabId = storedTabId ?? generateUUID();
const local = localStorageOrNull();
if (local) {
const owner = readTabOwner(local, tabId);
if (owner?.active && owner.pageId !== pageId) {
tabId = generateUUID();
writeSessionTabId(session, tabId);
} else if (!storedTabId) {
writeSessionTabId(session, tabId);
}
writeTabOwner(local, tabId, { pageId, active: true });
window.addEventListener(
"pagehide",
(event) => {
if (event.persisted) return;
if (readTabOwner(local, tabId)?.pageId === pageId) {
writeTabOwner(local, tabId, { pageId, active: false });
}
},
{ once: true },
);
} else if (!storedTabId) {
writeSessionTabId(session, tabId);
}
tabIdentity = { tabId, pageId };
return tabId;
}
/** Build the tab-local identity for one presented task canvas. */
export function canvasPresentationKey(identity: CanvasPresentationIdentity): string {
return [
CANVAS_PRESENTATION_PREFIX,
canvasPresentationTabId(),
identity.userId,
identity.workspaceId,
identity.taskId,
identity.canvasId,
]
.map(encodeURIComponent)
.join(".");
}
/** Whether a task canvas was presented in this browser tab. */
export function wasCanvasPresented(identity: CanvasPresentationIdentity): boolean {
const key = canvasPresentationKey(identity);
const storage = sessionStorageOrNull();
if (!storage) return memoryReceipts.has(key);
try {
return storage.getItem(key) === "1" || memoryReceipts.has(key);
} catch {
return memoryReceipts.has(key);
}
}
/** Record a successful task canvas presentation in this browser tab. */
export function markCanvasPresented(identity: CanvasPresentationIdentity): void {
const key = canvasPresentationKey(identity);
const storage = sessionStorageOrNull();
if (!storage) {
memoryReceipts.add(key);
return;
}
try {
storage.setItem(key, "1");
} catch {
memoryReceipts.add(key);
}
}
/** Shared seam for manual, restored, and automatic host presentation paths. */
export function recordCanvasPresentation(
identity: CanvasPresentationIdentity,
reason: CanvasPresentationReason,
): void {
void reason;
markCanvasPresented(identity);
}