fix: restore canvas availability on task entry (#3846)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user