fix: restore canvas runtime and panel toolbar behavior (#3827)

This commit is contained in:
Carlos Florêncio
2026-09-20 11:33:30 +01:00
committed by GitHub
parent 02ac02cd66
commit 93a7cba937
86 changed files with 6295 additions and 1640 deletions
@@ -2,7 +2,6 @@
import type { ReactNode } from "react";
import {
IconDots,
IconEdit,
IconExternalLink,
IconLayoutGrid,
@@ -13,11 +12,19 @@ import {
import { useTranslation } from "react-i18next";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
import { CanvasPage } from "@/components/plugins/canvas-page";
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
import { CanvasMobileActionsButton } from "./canvas-host-actions";
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
export {
CanvasDesktopActions,
CanvasDesktopOverflowActions,
CanvasDesktopOverflowMenuItems,
CanvasDesktopPrimaryAction,
} from "./canvas-host-desktop-actions";
export type CanvasHostState =
| "loading_metadata"
@@ -57,84 +64,6 @@ const STATE_COPY: Record<CanvasHostState, { title: string; description: string }
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
};
export function CanvasHostDialogs({
canvas,
promotionOpen,
onPromotionOpenChange,
releasesOpen,
onReleasesOpenChange,
onPromotionCompleted,
onChanged,
}: {
canvas: Canvas | null;
promotionOpen: boolean;
onPromotionOpenChange: (open: boolean) => void;
releasesOpen: boolean;
onReleasesOpenChange: (open: boolean) => void;
onPromotionCompleted: () => void;
onChanged: () => void;
}) {
return (
<>
<CanvasPromotionDialog
canvas={canvas?.scope_kind === "task" ? canvas : null}
open={promotionOpen}
onOpenChange={onPromotionOpenChange}
onCompleted={onPromotionCompleted}
/>
<CanvasReleaseDialog
canvas={canvas}
open={releasesOpen}
onOpenChange={onReleasesOpenChange}
onChanged={onChanged}
/>
</>
);
}
function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string {
return canvas.status === "archived"
? t("canvases:archivedCanvasActionHelp")
: t("canvases:disabledCanvasActionHelp");
}
function canvasPromotionHelp(canvas: Canvas, t: (key: string) => string): string {
if (canvas.status === "archived" || canvas.status === "disabled") {
return canvasLockHelp(canvas, t);
}
if (canvas.scope_kind === "task" && canvas.active_release_status === "valid") {
return t("canvases:promoteCanvasHelp");
}
return t("canvases:promoteCanvasUnavailable");
}
function CanvasDesktopActionTooltip({
description,
disabled,
testId,
children,
}: {
description: string;
disabled: boolean;
testId: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={disabled ? "cursor-not-allowed" : undefined}
data-testid={testId}
tabIndex={disabled ? 0 : undefined}
>
{children}
</span>
</TooltipTrigger>
<TooltipContent>{description}</TooltipContent>
</Tooltip>
);
}
function MobileCanvasAction({
description,
children,
@@ -158,11 +87,8 @@ export function CanvasHostBody({
canvasId,
title,
state,
isMobile,
menuOpen,
runtimeUrl,
error,
onOpenActions,
onRuntimeReady,
onRuntimeError,
onRetry,
@@ -170,29 +96,30 @@ export function CanvasHostBody({
canvasId: string;
title: string;
state: CanvasHostState;
isMobile: boolean;
menuOpen: boolean;
runtimeUrl: string | null;
error: string | null;
onOpenActions: () => void;
onRuntimeReady: () => void;
onRuntimeError: () => void;
onRetry: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex h-dvh min-h-0 min-w-0 max-h-[calc(100dvh-2.75rem)] flex-1 flex-col overflow-hidden md:h-auto md:max-h-none"
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid="canvas-host-route"
>
<CanvasHostHeader
title={title}
state={state}
isMobile={isMobile}
menuOpen={menuOpen}
onOpenActions={onOpenActions}
/>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{(state === "loading_runtime" || state === "ready") && runtimeUrl ? (
{(state === "loading_runtime" || state === "ready") && runtimeUrl ? (
<>
{state === "ready" && (
<span
className="sr-only"
role="status"
aria-live="polite"
data-testid="canvas-host-state"
>
<span data-testid="canvas-host-ready-announcement">{t(STATE_COPY.ready.title)}</span>
</span>
)}
<CanvasPage
key={`${canvasId}:${runtimeUrl}`}
runtimeUrl={runtimeUrl}
@@ -200,90 +127,9 @@ export function CanvasHostBody({
onLoad={onRuntimeReady}
onError={onRuntimeError}
/>
) : (
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
)}
</div>
</div>
);
}
export function CanvasDesktopActions({
canvas,
editing,
onEdit,
onPromote,
onReleases,
onShare,
}: {
canvas: Canvas;
editing: boolean;
onEdit: () => void;
onPromote: () => void;
onReleases: () => void;
onShare: () => void;
}) {
const { t } = useTranslation();
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
const promoteAvailable = canvas.scope_kind === "task" && canvas.active_release_status === "valid";
const promoteDisabled = lifecycleLocked || !promoteAvailable;
const promoteDescription = canvasPromotionHelp(canvas, t);
return (
<div className="flex items-center gap-2">
{canvas.scope_kind === "workspace" && (
<CanvasDesktopActionTooltip
description={lifecycleLocked ? canvasLockHelp(canvas, t) : t("canvases:editCanvasHelp")}
disabled={editing || lifecycleLocked}
testId="canvas-action-edit-tooltip-trigger"
>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={editing || lifecycleLocked}
onClick={onEdit}
>
<IconEdit className="mr-1.5 h-3.5 w-3.5" />
{t("canvases:editCanvas")}
</Button>
</CanvasDesktopActionTooltip>
)}
<CanvasDesktopActionTooltip
description={t("canvases:releasesAndPermissionsHelp")}
disabled={false}
testId="canvas-action-releases-tooltip-trigger"
>
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onReleases}>
<IconListDetails className="mr-1.5 h-3.5 w-3.5" />
{t("canvases:releasesAndPermissions")}
</Button>
</CanvasDesktopActionTooltip>
<CanvasDesktopActionTooltip
description={t("canvases:shareCanvasDescription")}
disabled={false}
testId="canvas-action-share-tooltip-trigger"
>
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onShare}>
<IconShare3 className="mr-1.5 h-3.5 w-3.5" />
{t("canvases:shareCanvas")}
</Button>
</CanvasDesktopActionTooltip>
{canvas.scope_kind === "task" && (
<CanvasDesktopActionTooltip
description={promoteDescription}
disabled={promoteDisabled}
testId="canvas-action-promote-tooltip-trigger"
>
<Button
size="sm"
className="cursor-pointer"
disabled={promoteDisabled}
onClick={onPromote}
>
<IconSparkles className="mr-1.5 h-3.5 w-3.5" />
{t("canvases:promoteCanvas")}
</Button>
</CanvasDesktopActionTooltip>
</>
) : (
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
)}
</div>
);
@@ -291,47 +137,34 @@ export function CanvasDesktopActions({
export function CanvasHostHeader({
title,
state,
isMobile,
menuOpen,
onOpenActions,
actions,
overflowActions,
}: {
title: string;
state: CanvasHostState;
isMobile: boolean;
menuOpen: boolean;
onOpenActions: () => void;
actions?: ReactNode;
overflowActions?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div
className="flex min-h-11 shrink-0 items-center gap-2 border-b px-3 py-1.5"
<PanelHeaderBarSplit
data-testid="canvas-host-header"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium md:hidden">{title}</p>
<p
className="text-xs text-muted-foreground"
data-testid="canvas-host-state"
aria-live="polite"
>
{t(STATE_COPY[state].title)}
</p>
</div>
{isMobile && (
<Button
variant="outline"
size="icon"
className="h-11 w-11 shrink-0 cursor-pointer"
aria-label={t("canvases:canvasActions")}
aria-expanded={menuOpen}
onClick={onOpenActions}
data-testid="canvas-mobile-actions"
>
<IconDots className="h-4 w-4" />
</Button>
)}
</div>
left={<span className="truncate text-sm font-medium">{title}</span>}
right={
<>
{actions}
{isMobile && (
<CanvasMobileActionsButton menuOpen={menuOpen} onOpenActions={onOpenActions} />
)}
</>
}
overflow={!isMobile ? overflowActions : undefined}
overflowAt={520}
/>
);
}
@@ -347,9 +180,14 @@ export function CanvasHostStatePanel({
const { t } = useTranslation();
const copy = STATE_COPY[state];
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6 text-center">
<div
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
data-testid="canvas-host-state-panel"
>
<div className="max-w-md space-y-3">
<h2 className="text-lg font-semibold">{t(copy.title)}</h2>
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
{t(copy.title)}
</h2>
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
{state !== "loading_metadata" && state !== "loading_runtime" && (
<Button