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>
  );
}