CanvasHeader.tsx6.2 KBView on GitHub /**
* CanvasHeader — Colour dot + editable title + SaveConfigButton.
*
* Rendered by CanvasRenderer above every canvas type so the title/save UI
* is consistent regardless of canvas type (conversation, report, etc.).
*
* State resets automatically when `key=[redacted] is used at the call site.
*/
import { ColorPickerPopover } from '@/components/ui/SexyColourPicker';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import { useCallback, useEffect, useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { useCedarStore } from '@/modules/store';
import { api } from '@/modules/trpc/trpc';
import { RefreshCw, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface CanvasHeaderProps {
canvas: Canvas;
}
export function CanvasHeader({ canvas }: CanvasHeaderProps) {
const [isTitleFocused, setIsTitleFocused] = useState(false);
const [titleValue, setTitleValue] = useState(canvas.title ?? '');
const [isSaving, setIsSaving] = useState(false);
const [savedRecently, setSavedRecently] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const upsertCanvas = useCedarStore((state) => state.upsertCanvas);
const updateCanvasColour = useCedarStore((state) => state.updateCanvasColour);
// Auto-save whenever the canvas becomes dirty (e.g. agent writes viewConfig or user edits).
// Ephemeral canvases have no DB row yet so skip them.
const isDirty = useCedarStore((state) => state.dirtyCanvasIds.has(canvas.id));
const isEphemeral = useCedarStore((state) => state.ephemeralCanvasIds.has(canvas.id));
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
useEffect(() => {
if (!isDirty || isEphemeral) return;
setIsSaving(true);
setSavedRecently(false);
void saveCanvasViewConfig(canvas.id).then((saved) => {
setIsSaving(false);
// A failed save toasts on its own and leaves the canvas dirty. Flashing "Saved" over that
// is the exact lie that made a lost filter look like the app forgetting rather than failing.
if (!saved) return;
setSavedRecently(true);
setTimeout(() => setSavedRecently(false), 2000);
});
}, [isDirty, isEphemeral, canvas.id, saveCanvasViewConfig]);
// Sync when canvas.title changes externally (e.g. backend update)
useEffect(() => {
setTitleValue(canvas.title ?? '');
}, [canvas.title]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
const fresh = await api.canvas.getCanvas.query({ id: canvas.id });
// The wire row carries Date timestamps; Canvas declares ISO strings.
if (fresh) upsertCanvas(fresh as unknown as Canvas);
} finally {
setIsRefreshing(false);
}
}, [canvas.id, upsertCanvas]);
const handleSaveTitle = useCallback(async () => {
const currentCanvas = useCedarStore.getState().canvasesById[canvas.id];
if (!currentCanvas) return;
if (titleValue === currentCanvas.title) return;
upsertCanvas({ ...currentCanvas, title: titleValue });
await api.canvas.updateCanvas.mutate({ id: canvas.id, title: titleValue });
}, [canvas.id, titleValue, upsertCanvas]);
return (
<div className="mx-3 my-1.5 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Colour dot — opens picker popover */}
<ColorPickerPopover
value={canvas.colour}
onColorSelect={(colour) => void updateCanvasColour(canvas.id, colour)}
dotClassName="h-3.5 w-3.5 ring-1 ring-black/10 hover:scale-125 dark:ring-white/10"
align="start"
sideOffset={8}
/>
{isTitleFocused ? (
<Textarea
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={() => {
setIsTitleFocused(false);
void handleSaveTitle();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.currentTarget.blur();
}
if (e.key === 'Escape') {
setTitleValue(canvas.title ?? '');
e.currentTarget.blur();
}
}}
placeholder="Canvas title..."
className="min-h-0 min-w-0 flex-1 resize-none overflow-hidden rounded-none border-0 border-b border-primary bg-transparent p-0 text-2xl font-semibold leading-none shadow-none focus-visible:ring-0"
rows={1}
autoFocus
/>
) : (
<span
className="min-w-0 flex-1 cursor-text truncate text-2xl font-semibold leading-none"
onClick={() => setIsTitleFocused(true)}
>
{titleValue || 'Canvas title...'}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{/* Save status indicator — only for report canvases */}
{canvas.type === 'report' && (
<span className="text-muted-foreground flex items-center gap-1 text-xs transition-opacity">
{isSaving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>Saving…</span>
</>
) : savedRecently ? (
<>
<Check className="h-3 w-3 text-green-500" />
<span>Saved</span>
</>
) : null}
</span>
)}
{/* Refresh button — re-fetches viewConfig from backend */}
{canvas.type === 'report' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => void handleRefresh()}
size="sm"
variant="ghost"
disabled={isRefreshing}
className="text-muted-foreground h-8 w-8 rounded-lg p-0"
>
<RefreshCw className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh from server</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}