Files
kandev/apps/web/components/task/dockview-canvas-activation.ts
T
2026-09-21 23:20:52 +01:00

536 lines
17 KiB
TypeScript

"use client";
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 type { Canvas } from "@/lib/api/domains/canvas-api";
import {
getCanvasLifecycleHints,
type CanvasLifecycleHint,
useCanvasLifecycleRevision,
} from "@/lib/canvas-lifecycle";
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;
const MAX_CANVAS_ACTIVATION_RETRIES = 5;
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,
taskId: string,
workspaceId: string,
): boolean {
return (
CANVAS_RELEASE_ACTIONS.has(hint.action) &&
hint.payload.canvas_id.length > 0 &&
hint.payload.task_id === taskId &&
(!hint.payload.workspace_id || hint.payload.workspace_id === workspaceId)
);
}
function canvasPanelId(canvasId: string): string {
return `${CANVAS_PANEL_ID_PREFIX}${canvasId}`;
}
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 {
return !(
(hint.payload.workspace_id && canvas.workspace_id !== hint.payload.workspace_id) ||
canvas.task_id !== hint.payload.task_id ||
canvas.scope_kind !== "task"
);
}
function activatedCanvasDecision(
hint: CanvasLifecycleHint,
canvas: Canvas,
): CanvasLifecycleActivationDecision {
if (canvas.status !== "active") return "stale";
if (!canvas.active_release_id || !canvas.active_release_status) return "retry";
if (canvas.active_release_status !== "valid") return "stale";
if (
hint.payload.active_release_id &&
hint.payload.active_release_id !== canvas.active_release_id
) {
return "stale";
}
return "eligible";
}
function permissionCanvasDecision(canvas: Canvas): CanvasLifecycleActivationDecision {
// A first release leaves the instance pending until its permission review
// is approved. Existing canvases stay active while a replacement release
// waits for review. Both states are host-eligible; archived, disabled,
// error, and removed instances must never be opened from a retained hint.
if (canvas.status !== "pending" && canvas.status !== "active") return "stale";
if (canvas.pending_release?.validation_status === "pending_permission") {
return "eligible";
}
if (canvas.pending_release) return "stale";
if (canvas.active_release_status === "pending_permission") return "eligible";
if (canvas.active_release_status === "valid") return "stale";
return "retry";
}
/**
* Lifecycle events are retained as hints and can outlive a rejection, archive,
* or task switch. The HTTP projection is the authority for deciding if an
* iframe may be opened. Keep this decision pure so every host surface tests
* the same release/lifecycle rules.
*/
export function canvasLifecycleActivationDecision(
hint: CanvasLifecycleHint,
canvas: Canvas,
): CanvasLifecycleActivationDecision {
if (!canvasMatchesLifecycleHint(hint, canvas)) return "stale";
if (hint.action === "canvas.release.activated") {
return activatedCanvasDecision(hint, canvas);
}
if (hint.action === "canvas.release.permission_required") {
return permissionCanvasDecision(canvas);
}
return "stale";
}
/** Add a canvas panel exactly once. Existing panels are deliberately left in
* their current focus state when a repeated lifecycle event arrives. */
export function activateCanvasPanel(
api: DockviewApi,
canvas: Pick<Canvas, "id" | "title">,
groupId?: string,
options: CanvasPanelPresentationOptions = {},
): boolean {
const id = canvasPanelId(canvas.id);
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 } } : {}),
},
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;
function hintKey(hint: CanvasLifecycleHint, taskId: string): string {
return `${hint.revision}:${taskId}:${hint.payload.canvas_id}`;
}
function isHandled(hint: CanvasLifecycleHint, taskId: string): boolean {
return handledHints.has(hintKey(hint, taskId));
}
function markHandled(hint: CanvasLifecycleHint, taskId: string): void {
const key = hintKey(hint, taskId);
if (handledHints.has(key)) return;
handledHints.add(key);
if (handledHints.size > MAX_HANDLED_HINTS) {
const oldest = handledHints.values().next().value;
if (oldest) handledHints.delete(oldest);
}
}
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(() => {
const snapshot = readCanvasInventory(inventoryRef, providedInventoryRef);
if (snapshot.status !== "success") return;
reconcileRef.current?.(
getCanvasLifecycleHints().filter((hint) =>
taskId && workspaceId ? shouldActivateCanvasForTask(hint, taskId, workspaceId) : false,
),
0,
);
}, [inventory.canvases, inventory.status, taskCanvases, taskCanvasesStatus, taskId, workspaceId]);
}