diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index c289a312..75c09474 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -1,29 +1,59 @@ import { X } from "lucide-react"; import { usePanel } from "../context/PanelContext"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { ResizableSidebarPane } from "./ResizableSidebarPane"; + +const PROPERTIES_PANEL_DEFAULT = 320; +const PROPERTIES_PANEL_MIN = 320; +const PROPERTIES_PANEL_MAX = 640; +const PROPERTIES_PANEL_STORAGE_KEY = "paperclip.properties.width"; export function PropertiesPanel() { - const { panelContent, panelVisible, setPanelVisible } = usePanel(); + const { panelContent, panelLayout, panelVisible, setPanelVisible } = usePanel(); if (!panelContent) return null; + const storageKey = panelLayout.storageKey ?? PROPERTIES_PANEL_STORAGE_KEY; + const defaultWidth = panelLayout.defaultWidth ?? PROPERTIES_PANEL_DEFAULT; + const minWidth = panelLayout.minWidth ?? PROPERTIES_PANEL_MIN; + const maxWidth = panelLayout.maxWidth ?? PROPERTIES_PANEL_MAX; + const compactBelowViewport = panelLayout.compactBelowViewport; + const compactMaxWidth = panelLayout.compactMaxWidth; + return ( - ); } diff --git a/ui/src/components/ResizableSidebarPane.test.tsx b/ui/src/components/ResizableSidebarPane.test.tsx index cb635d81..deb904ec 100644 --- a/ui/src/components/ResizableSidebarPane.test.tsx +++ b/ui/src/components/ResizableSidebarPane.test.tsx @@ -17,8 +17,10 @@ function pointerEvent(type: string, clientX: number) { describe("ResizableSidebarPane", () => { let container: HTMLDivElement; let root: Root; + let originalInnerWidth: number; beforeEach(() => { + originalInnerWidth = window.innerWidth; window.localStorage.clear(); container = document.createElement("div"); document.body.appendChild(container); @@ -31,6 +33,8 @@ describe("ResizableSidebarPane", () => { }); container.remove(); window.localStorage.clear(); + document.documentElement.style.removeProperty("--test-sidebar-width"); + setInnerWidth(originalInnerWidth); }); function pane() { @@ -41,6 +45,14 @@ describe("ResizableSidebarPane", () => { return container.querySelector('[role="separator"]') as HTMLDivElement | null; } + function setInnerWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: width, + }); + } + it("uses a persisted width when open", () => { window.localStorage.setItem("test.sidebar.width", "320"); @@ -118,4 +130,135 @@ describe("ResizableSidebarPane", () => { expect(handle()).toBeNull(); expect(pane().style.width).toBe("240px"); }); + + it("supports custom defaults and bounds", () => { + act(() => { + root.render( + +
Properties
+
, + ); + }); + + expect(pane().style.width).toBe("400px"); + expect(handle()?.getAttribute("aria-valuemin")).toBe("320"); + expect(handle()?.getAttribute("aria-valuemax")).toBe("640"); + }); + + it("uses right-side drag and keyboard semantics", () => { + act(() => { + root.render( + +
Properties
+
, + ); + }); + + const separator = handle(); + expect(separator).not.toBeNull(); + separator!.setPointerCapture = vi.fn(); + + act(() => { + separator!.dispatchEvent(pointerEvent("pointerdown", 400)); + separator!.dispatchEvent(pointerEvent("pointermove", 360)); + separator!.dispatchEvent(pointerEvent("pointerup", 360)); + }); + + expect(pane().style.width).toBe("440px"); + expect(window.localStorage.getItem("test.properties.width")).toBe("440"); + + act(() => { + separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + }); + + expect(pane().style.width).toBe("456px"); + expect(window.localStorage.getItem("test.properties.width")).toBe("456"); + }); + + it("exposes the visible width as a CSS variable", () => { + act(() => { + root.render( + +
Properties
+
, + ); + }); + + expect(document.documentElement.style.getPropertyValue("--test-sidebar-width")).toBe("400px"); + + act(() => { + root.render( + +
Properties
+
, + ); + }); + + expect(document.documentElement.style.getPropertyValue("--test-sidebar-width")).toBe("0px"); + }); + + it("clamps to compact width below the configured viewport without overwriting the stored wide width", () => { + window.localStorage.setItem("test.properties.width", "520"); + setInnerWidth(900); + + act(() => { + root.render( + +
Properties
+
, + ); + }); + + expect(pane().style.width).toBe("320px"); + expect(handle()).toBeNull(); + expect(window.localStorage.getItem("test.properties.width")).toBe("520"); + + act(() => { + setInnerWidth(1200); + window.dispatchEvent(new Event("resize")); + }); + + expect(pane().style.width).toBe("520px"); + expect(handle()?.getAttribute("aria-valuemax")).toBe("640"); + }); }); diff --git a/ui/src/components/ResizableSidebarPane.tsx b/ui/src/components/ResizableSidebarPane.tsx index 09f9c70a..8c66bca5 100644 --- a/ui/src/components/ResizableSidebarPane.tsx +++ b/ui/src/components/ResizableSidebarPane.tsx @@ -15,29 +15,29 @@ const MIN_SIDEBAR_WIDTH = 208; const MAX_SIDEBAR_WIDTH = 420; const SIDEBAR_WIDTH_STEP = 16; -function clampSidebarWidth(width: number) { - return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); +function clampSidebarWidth(width: number, min: number, max: number) { + return Math.min(max, Math.max(min, width)); } -function readStoredSidebarWidth(storageKey: string) { - if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH; +function readStoredSidebarWidth(storageKey: string, fallback: number, min: number, max: number) { + if (typeof window === "undefined") return fallback; try { const stored = window.localStorage.getItem(storageKey); - if (!stored) return DEFAULT_SIDEBAR_WIDTH; + if (!stored) return fallback; const parsed = Number.parseInt(stored, 10); - if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH; - return clampSidebarWidth(parsed); + if (!Number.isFinite(parsed)) return fallback; + return clampSidebarWidth(parsed, min, max); } catch { - return DEFAULT_SIDEBAR_WIDTH; + return fallback; } } -function writeStoredSidebarWidth(storageKey: string, width: number) { +function writeStoredSidebarWidth(storageKey: string, width: number, min: number, max: number) { if (typeof window === "undefined") return; try { - window.localStorage.setItem(storageKey, String(clampSidebarWidth(width))); + window.localStorage.setItem(storageKey, String(clampSidebarWidth(width, min, max))); } catch { // Storage can be unavailable in private contexts; resizing should still work. } @@ -49,25 +49,68 @@ type ResizableSidebarPaneProps = { resizable?: boolean; storageKey?: string; className?: string; + /** Which side of the viewport this pane sits on. Determines handle position and drag direction. */ + side?: "left" | "right"; + defaultWidth?: number; + minWidth?: number; + maxWidth?: number; + /** Below this viewport width, clamp the pane to compactMaxWidth. */ + compactBelowViewport?: number; + compactMaxWidth?: number; + /** Optional CSS custom property name to expose the live pane width on :root (e.g. "--properties-panel-width"). */ + widthVariable?: string; }; +function readViewportWidth() { + if (typeof window === "undefined") return Number.POSITIVE_INFINITY; + return window.innerWidth; +} + export function ResizableSidebarPane({ children, open, resizable = false, storageKey = "paperclip.sidebar.width", className, + side = "left", + defaultWidth = DEFAULT_SIDEBAR_WIDTH, + minWidth = MIN_SIDEBAR_WIDTH, + maxWidth = MAX_SIDEBAR_WIDTH, + compactBelowViewport, + compactMaxWidth, + widthVariable, }: ResizableSidebarPaneProps) { - const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey)); + const [viewportWidth, setViewportWidth] = useState(readViewportWidth); + const compactModeActive = + compactBelowViewport !== undefined + && compactMaxWidth !== undefined + && viewportWidth < compactBelowViewport; + const effectiveMaxWidth = + compactModeActive + ? Math.max(minWidth, Math.min(maxWidth, compactMaxWidth)) + : maxWidth; + const canResizeAtCurrentViewport = effectiveMaxWidth > minWidth; + const fallbackWidth = clampSidebarWidth(defaultWidth, minWidth, effectiveMaxWidth); + const [width, setWidth] = useState(() => + readStoredSidebarWidth(storageKey, fallbackWidth, minWidth, effectiveMaxWidth), + ); const [isResizing, setIsResizing] = useState(false); const widthRef = useRef(width); const dragState = useRef<{ startX: number; startWidth: number } | null>(null); useEffect(() => { - const storedWidth = readStoredSidebarWidth(storageKey); + if (typeof window === "undefined") return; + + const handleResize = () => setViewportWidth(window.innerWidth); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + useEffect(() => { + const storedWidth = readStoredSidebarWidth(storageKey, fallbackWidth, minWidth, effectiveMaxWidth); widthRef.current = storedWidth; setWidth(storedWidth); - }, [storageKey]); + }, [storageKey, fallbackWidth, minWidth, effectiveMaxWidth]); const visibleWidth = open ? width : 0; const paneStyle = useMemo( @@ -75,14 +118,25 @@ export function ResizableSidebarPane({ [visibleWidth], ); + useEffect(() => { + if (!widthVariable || typeof document === "undefined") return; + const root = document.documentElement; + root.style.setProperty(widthVariable, `${visibleWidth}px`); + return () => { + root.style.removeProperty(widthVariable); + }; + }, [widthVariable, visibleWidth]); + const commitWidth = useCallback( (nextWidth: number) => { - const clamped = clampSidebarWidth(nextWidth); + const clamped = clampSidebarWidth(nextWidth, minWidth, effectiveMaxWidth); widthRef.current = clamped; setWidth(clamped); - writeStoredSidebarWidth(storageKey, clamped); + if (!compactModeActive) { + writeStoredSidebarWidth(storageKey, clamped, minWidth, maxWidth); + } }, - [storageKey], + [storageKey, minWidth, maxWidth, effectiveMaxWidth, compactModeActive], ); const handlePointerDown = useCallback( @@ -101,12 +155,15 @@ export function ResizableSidebarPane({ (event: PointerEvent) => { if (!dragState.current) return; - const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX; - const clamped = clampSidebarWidth(nextWidth); + const delta = event.clientX - dragState.current.startX; + // For a right-side pane the handle is on the left edge, so dragging left increases width. + const directional = side === "right" ? -delta : delta; + const nextWidth = dragState.current.startWidth + directional; + const clamped = clampSidebarWidth(nextWidth, minWidth, effectiveMaxWidth); widthRef.current = clamped; setWidth(clamped); }, - [], + [side, minWidth, effectiveMaxWidth], ); const endResize = useCallback(() => { @@ -114,28 +171,34 @@ export function ResizableSidebarPane({ dragState.current = null; setIsResizing(false); - writeStoredSidebarWidth(storageKey, widthRef.current); - }, [storageKey]); + if (!compactModeActive) { + writeStoredSidebarWidth(storageKey, widthRef.current, minWidth, maxWidth); + } + }, [storageKey, minWidth, maxWidth, compactModeActive]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (!open || !resizable) return; + if (!open || !resizable || !canResizeAtCurrentViewport) return; - if (event.key === "ArrowLeft") { - event.preventDefault(); - commitWidth(width - SIDEBAR_WIDTH_STEP); - } else if (event.key === "ArrowRight") { + // Match drag semantics: on a right-side pane, ArrowLeft grows the pane. + const growKey = side === "right" ? "ArrowLeft" : "ArrowRight"; + const shrinkKey = side === "right" ? "ArrowRight" : "ArrowLeft"; + + if (event.key === growKey) { event.preventDefault(); commitWidth(width + SIDEBAR_WIDTH_STEP); + } else if (event.key === shrinkKey) { + event.preventDefault(); + commitWidth(width - SIDEBAR_WIDTH_STEP); } else if (event.key === "Home") { event.preventDefault(); - commitWidth(MIN_SIDEBAR_WIDTH); + commitWidth(minWidth); } else if (event.key === "End") { event.preventDefault(); - commitWidth(MAX_SIDEBAR_WIDTH); + commitWidth(effectiveMaxWidth); } }, - [commitWidth, open, resizable, width], + [commitWidth, open, resizable, side, width, minWidth, effectiveMaxWidth, canResizeAtCurrentViewport], ); return ( @@ -148,17 +211,18 @@ export function ResizableSidebarPane({ style={paneStyle} > {children} - {resizable && open ? ( + {resizable && open && canResizeAtCurrentViewport ? (
{ }); await flush(); expect(container.querySelector("[data-testid='dialog']")).not.toBeNull(); + expect(container.textContent).not.toContain("Viewing revision 1 (read-only)"); + expect(container.textContent).toContain("Restore revision 1?"); const confirmButtons = Array.from(container.querySelectorAll("button")).filter((b) => (b.textContent ?? "").includes("Restore as revision 3"), ); diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx index 0bae2714..d30522e7 100644 --- a/ui/src/components/RoutineHistoryTab.tsx +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { History as HistoryIcon, RotateCcw, Search } from "lucide-react"; +import { History as HistoryIcon, RotateCcw } from "lucide-react"; import type { Routine, RoutineRevision, @@ -13,7 +13,6 @@ import { } from "../api/routines"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; -import { buildLineDiff, type DiffRow } from "../lib/line-diff"; import { relativeTime } from "../lib/utils"; import { useToastActions } from "../context/ToastContext"; import { Button } from "@/components/ui/button"; @@ -65,10 +64,11 @@ export function RoutineHistoryTab({ const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [selectedRevisionId, setSelectedRevisionId] = useState(null); + const [snapshotOpen, setSnapshotOpen] = useState(false); + const [compareOn, setCompareOn] = useState(false); const [highlightedRevisionId, setHighlightedRevisionId] = useState(null); const [showOlder, setShowOlder] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); - const [diffOpen, setDiffOpen] = useState(false); const [restoreSummary, setRestoreSummary] = useState(""); const revisionsQuery = useQuery({ @@ -86,12 +86,6 @@ export function RoutineHistoryTab({ [sortedRevisions, routine.latestRevisionId], ); - useEffect(() => { - if (selectedRevisionId === null && currentRevision) { - setSelectedRevisionId(currentRevision.id); - } - }, [currentRevision, selectedRevisionId]); - const selectedRevision = useMemo( () => sortedRevisions.find((r) => r.id === selectedRevisionId) ?? null, [sortedRevisions, selectedRevisionId], @@ -120,6 +114,8 @@ export function RoutineHistoryTab({ onRestoreSecretMaterials(data); onRestored?.(data); setConfirmOpen(false); + setSnapshotOpen(false); + setCompareOn(false); setRestoreSummary(""); setSelectedRevisionId(data.revision.id); setHighlightedRevisionId(data.revision.id); @@ -150,15 +146,15 @@ export function RoutineHistoryTab({ const handleSelectRevision = (revisionId: string) => { if (isEditDirty) return; setSelectedRevisionId(revisionId); - }; - - const handleReturnToCurrent = () => { - if (currentRevision) setSelectedRevisionId(currentRevision.id); + setCompareOn(false); + setSnapshotOpen(true); }; const openRestoreConfirm = () => { if (!selectedRevision || !isHistoricalSelected) return; setRestoreSummary(""); + setSnapshotOpen(false); + setCompareOn(false); setConfirmOpen(true); }; @@ -172,7 +168,7 @@ export function RoutineHistoryTab({ if (revisionsQuery.isLoading) { return ( -
+
{Array.from({ length: 5 }).map((_, idx) => ( @@ -204,64 +200,55 @@ export function RoutineHistoryTab({ const onlyBootstrapRevision = revisions.length <= 1; return ( -
- setShowOlder(true)} - showOlder={showOlder} - /> -
- {isEditDirty && ( - - )} - {!isEditDirty && onlyBootstrapRevision ? ( -
- -

- Revision 1 is the only history this routine has. Saving an edit creates the first - additional revision. -

-
- ) : ( - selectedRevision && ( - <> - {isHistoricalSelected && currentRevision && ( - - )} - setDiffOpen(true)} - onRestore={openRestoreConfirm} - restorePending={restoreMutation.isPending} - highlighted={highlightedRevisionId === selectedRevision.id} - /> - - ) - )} -
+
+ {isEditDirty && ( + + )} + {!isEditDirty && onlyBootstrapRevision ? ( +
+ +

+ Revision 1 is the only history this routine has. Saving an edit creates the first + additional revision. +

+
+ ) : ( + setShowOlder(true)} + showOlder={showOlder} + /> + )} + + {selectedRevision && ( + { + setSnapshotOpen(next); + if (!next) setCompareOn(false); + }} + revision={selectedRevision} + currentRevision={currentRevision} + isHistorical={isHistoricalSelected} + compareOn={compareOn} + onCompareToggle={setCompareOn} + agents={agents} + projects={projects} + onRestore={openRestoreConfirm} + restorePending={restoreMutation.isPending} + highlighted={highlightedRevisionId === selectedRevision.id} + /> + )} {selectedRevision && currentRevision && ( )} - - {currentRevision && selectedRevision && ( - { - setSelectedRevisionId(rev.id); - setDiffOpen(false); - setRestoreSummary(""); - setConfirmOpen(true); - }} - /> - )}
); } -function HistoricalPreviewBanner({ - revisionNumber, - nextRevisionNumber, - onReturn, +function RevisionSnapshotDialog({ + open, + onOpenChange, + revision, + currentRevision, + isHistorical, + compareOn, + onCompareToggle, + agents, + projects, onRestore, - pending, + restorePending, + highlighted, }: { - revisionNumber: number; - nextRevisionNumber: number; - onReturn: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; + revision: RoutineRevision; + currentRevision: RoutineRevision | null; + isHistorical: boolean; + compareOn: boolean; + onCompareToggle: (next: boolean) => void; + agents: AgentLookup; + projects: ProjectLookup; onRestore: () => void; - pending: boolean; + restorePending: boolean; + highlighted: boolean; }) { + const showCompare = compareOn && !!currentRevision && isHistorical; return ( -
-
-
-

- Viewing revision {revisionNumber} (read-only) -

-

- Restoring this revision creates a new revision {nextRevisionNumber} with the same content. - History stays append-only. -

+ + + +
+ + {isHistorical + ? `Viewing revision ${revision.revisionNumber} (read-only)` + : `Revision ${revision.revisionNumber} (current)`} + + {isHistorical && currentRevision && ( + + )} +
+ {isHistorical && currentRevision && ( + + Restoring this revision creates a new revision {currentRevision.revisionNumber + 1}{" "} + with the same content. History stays append-only. + + )} +
+
+ {showCompare && currentRevision ? ( +
+
+ + +
+
+ + +
+
+ ) : ( + + )}
-
- - -
-
+ {isHistorical && ( + + )} + + + + ); +} + +function DiffPill({ kind }: { kind: "differs" | "only-here" }) { + const label = kind === "differs" ? "differs" : "only here"; + return ( + + {label} + + ); +} + +function ColumnLabel({ tone, title }: { tone: "amber" | "emerald"; title: string }) { + const cls = + tone === "amber" + ? "border-amber-400 bg-amber-300 text-amber-950" + : "border-emerald-400 bg-emerald-300 text-emerald-950"; + return ( +
+ {title}
); } + function ConflictBanner({ dirtyFields, onDiscard, @@ -355,7 +424,7 @@ function ConflictBanner({ const fieldsText = formatDirtyFieldList(labels); return (
-
+

Unsaved routine edits

@@ -472,31 +541,30 @@ function RevisionList({ function RevisionPreview({ revision, currentRevision, - isHistorical, agents, projects, - onCompare, - onRestore, - restorePending, highlighted, }: { revision: RoutineRevision; currentRevision: RoutineRevision | null; - isHistorical: boolean; agents: AgentLookup; projects: ProjectLookup; - onCompare: () => void; - onRestore: () => void; - restorePending: boolean; highlighted: boolean; }) { const snapshot = revision.snapshot.routine; const triggers = revision.snapshot.triggers; const currentSnapshot = currentRevision?.snapshot.routine ?? null; - const restoreLabel = isHistorical ? "Restore this revision" : "Restore this revision"; + const otherTriggers = currentRevision?.snapshot.triggers ?? []; + const otherTriggerById = new Map(otherTriggers.map((t) => [t.id, t])); + const otherVariableByName = new Map( + (currentSnapshot?.variables ?? []).map((v) => [v.name, v]), + ); const cardWrapper = `rounded-md border transition-colors duration-1000 ${ highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border" }`; + const descriptionDiffers = + !!currentSnapshot && + (currentSnapshot.description ?? "") !== (snapshot.description ?? ""); const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [ { @@ -543,33 +611,29 @@ function RevisionPreview({ }, ]; + const triggerStatus = (trigger: RoutineRevisionSnapshotTriggerV1): "same" | "differs" | "only-here" => { + if (!currentRevision) return "same"; + const other = otherTriggerById.get(trigger.id); + if (!other) return "only-here"; + return JSON.stringify(other) === JSON.stringify(trigger) ? "same" : "differs"; + }; + + const variableStatus = (variable: RoutineVariable): "same" | "differs" | "only-here" => { + if (!currentRevision) return "same"; + const other = otherVariableByName.get(variable.name); + if (!other) return "only-here"; + return JSON.stringify(other) === JSON.stringify(variable) ? "same" : "differs"; + }; + return (

-
-
-

rev {revision.revisionNumber}

-

- Saved {relativeTime(revision.createdAt)} by {getActorLabel(revision)} - {revision.changeSummary ? ` · ${revision.changeSummary}` : ""} -

-
-
- - -
+
+

rev {revision.revisionNumber}

+

+ Saved {relativeTime(revision.createdAt)} by {getActorLabel(revision)} + {revision.changeSummary ? ` · ${revision.changeSummary}` : ""} +

@@ -577,17 +641,13 @@ function RevisionPreview({

Structured fields

-
+
{fieldRows.map((row) => (

{row.label}

{row.value || } - {row.differs && ( - - differs from current - - )} + {row.differs && }

))} @@ -595,9 +655,12 @@ function RevisionPreview({
-

- Description -

+
+

+ Description +

+ {descriptionDiffers && } +
{snapshot.description ? ( {snapshot.description} @@ -615,22 +678,26 @@ function RevisionPreview({

No triggers in this revision.

) : (
    - {triggers.map((trigger) => ( -
  • - - {trigger.kind} - - {trigger.label ?? trigger.kind} - - {summarizeTriggerSnapshot(trigger)} - - - {trigger.enabled ? "enabled" : "disabled"} - -
  • - ))} + {triggers.map((trigger) => { + const status = triggerStatus(trigger); + return ( +
  • + + {trigger.kind} + + {trigger.label ?? trigger.kind} + + {summarizeTriggerSnapshot(trigger)} + + {status !== "same" && } + + {trigger.enabled ? "enabled" : "disabled"} + +
  • + ); + })}
)}

@@ -645,14 +712,18 @@ function RevisionPreview({ Variables ({snapshot.variables.length})

    - {snapshot.variables.map((variable) => ( -
  • - {variable.name} - - default: {formatVariableDefault(variable)} - -
  • - ))} + {snapshot.variables.map((variable) => { + const status = variableStatus(variable); + return ( +
  • + {variable.name} + + default: {formatVariableDefault(variable)} + + {status !== "same" && } +
  • + ); + })}
)} @@ -735,213 +806,6 @@ function RestoreConfirmDialog({ ); } -function RoutineRevisionDiffModal({ - open, - onOpenChange, - revisions, - initialOldRevisionId, - initialNewRevisionId, - agents, - projects, - onRestore, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - revisions: RoutineRevision[]; - initialOldRevisionId: string; - initialNewRevisionId: string; - agents: AgentLookup; - projects: ProjectLookup; - onRestore: (revision: RoutineRevision) => void; -}) { - const [leftId, setLeftId] = useState(initialOldRevisionId); - const [rightId, setRightId] = useState(initialNewRevisionId); - - useEffect(() => { - if (open) { - setLeftId(initialOldRevisionId); - setRightId(initialNewRevisionId); - } - }, [open, initialOldRevisionId, initialNewRevisionId]); - - const left = revisions.find((r) => r.id === leftId) ?? null; - const right = revisions.find((r) => r.id === rightId) ?? null; - const fieldChanges = useMemo( - () => (left && right ? computeFieldChanges(left, right, agents, projects) : []), - [left, right, agents, projects], - ); - const descriptionDiff = useMemo( - () => (left && right - ? buildLineDiff(left.snapshot.routine.description ?? "", right.snapshot.routine.description ?? "") - : []), - [left, right], - ); - const newest = revisions[0] ?? null; - const leftIsHistorical = !!left && !!newest && left.id !== newest.id; - - return ( - - - - Compare routine revisions - -
- - -
-
-
-

- Field changes -

- {fieldChanges.length === 0 ? ( -

No structural field changes.

- ) : ( - - - - - - - - - - {fieldChanges.map((change) => ( - - - - - - ))} - -
FieldOld valueNew value
{change.field} - {change.oldValue ?? "—"} - - {change.newValue ?? "—"} -
- )} -
-
-

- Description diff -

- -
-
- - - {leftIsHistorical && left && ( - - )} - -
-
- ); -} - -function RevisionPicker({ - label, - value, - onChange, - revisions, - tone, -}: { - label: string; - value: string; - onChange: (id: string) => void; - revisions: RoutineRevision[]; - tone: "red" | "green"; -}) { - const toneClass = tone === "red" - ? "border-red-500/30 bg-red-500/10 text-red-300" - : "border-green-500/30 bg-green-500/10 text-green-300"; - return ( -
- - {label} - - -
- ); -} - -function DiffTable({ rows }: { rows: DiffRow[] }) { - if (rows.length === 0) { - return

No description on either revision.

; - } - if (rows.every((row) => row.kind === "context")) { - return

Descriptions are identical.

; - } - const lineClassesByKind: Record = { - context: "bg-transparent", - removed: "bg-red-500/10 text-red-100", - added: "bg-green-500/10 text-green-100", - }; - const markerByKind: Record = { - context: " ", - removed: "-", - added: "+", - }; - return ( -
-
- Old - New - - Content -
- {rows.map((row, index) => ( -
- - {row.oldLineNumber ?? ""} - - - {row.newLineNumber ?? ""} - - - {markerByKind[row.kind]} - -
-            {row.text.length > 0 ? row.text : " "}
-          
-
- ))} -
- ); -} function getActorLabel(revision: RoutineRevision): string { if (revision.createdByUserId) return "board"; @@ -992,104 +856,6 @@ function collectWebhookTriggerDifferences( .map((trigger) => trigger.label ?? "webhook"); } -function describeSnapshotField(value: unknown): string { - if (value == null) return "—"; - if (typeof value === "string") return value; - return JSON.stringify(value); -} - -function computeFieldChanges( - left: RoutineRevision, - right: RoutineRevision, - agents: AgentLookup, - projects: ProjectLookup, -): Array<{ field: string; oldValue: string | null; newValue: string | null }> { - const oldRoutine = left.snapshot.routine; - const newRoutine = right.snapshot.routine; - const changes: Array<{ field: string; oldValue: string | null; newValue: string | null }> = []; - const compareScalar = ( - _field: string, - label: string, - oldVal: unknown, - newVal: unknown, - transform: (value: unknown) => string = describeSnapshotField, - ) => { - if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { - changes.push({ field: label, oldValue: transform(oldVal), newValue: transform(newVal) }); - } - }; - compareScalar("title", "Title", oldRoutine.title, newRoutine.title); - compareScalar("priority", "Priority", oldRoutine.priority, newRoutine.priority); - compareScalar( - "assigneeAgentId", - "Default agent", - resolveAgentName(oldRoutine.assigneeAgentId, agents), - resolveAgentName(newRoutine.assigneeAgentId, agents), - ); - compareScalar( - "projectId", - "Project", - resolveProjectName(oldRoutine.projectId, projects), - resolveProjectName(newRoutine.projectId, projects), - ); - compareScalar("concurrencyPolicy", "Concurrency", oldRoutine.concurrencyPolicy, newRoutine.concurrencyPolicy); - compareScalar("catchUpPolicy", "Catch-up", oldRoutine.catchUpPolicy, newRoutine.catchUpPolicy); - compareScalar("status", "Status", oldRoutine.status, newRoutine.status); - if (JSON.stringify(oldRoutine.variables) !== JSON.stringify(newRoutine.variables)) { - changes.push({ - field: "Variables", - oldValue: summarizeVariables(oldRoutine.variables), - newValue: summarizeVariables(newRoutine.variables), - }); - } - compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes); - return changes; -} - -function summarizeVariables(variables: RoutineVariable[]): string { - if (variables.length === 0) return "(none)"; - return variables - .map((variable) => `${variable.name}=${formatVariableDefault(variable)}`) - .join(", "); -} - -function compareTriggers( - oldTriggers: RoutineRevisionSnapshotTriggerV1[], - newTriggers: RoutineRevisionSnapshotTriggerV1[], - changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>, -) { - const byId = new Map(); - for (const trigger of oldTriggers) byId.set(trigger.id, { old: trigger }); - for (const trigger of newTriggers) { - const existing = byId.get(trigger.id) ?? {}; - byId.set(trigger.id, { ...existing, next: trigger }); - } - for (const [, pair] of byId) { - if (pair.old && !pair.next) { - changes.push({ - field: `Trigger removed (${pair.old.label ?? pair.old.kind})`, - oldValue: summarizeTriggerSnapshot(pair.old), - newValue: null, - }); - } else if (!pair.old && pair.next) { - changes.push({ - field: `Trigger added (${pair.next.label ?? pair.next.kind})`, - oldValue: null, - newValue: summarizeTriggerSnapshot(pair.next), - }); - } else if (pair.old && pair.next) { - const oldSummary = summarizeTriggerSnapshot(pair.old); - const newSummary = summarizeTriggerSnapshot(pair.next); - if (oldSummary !== newSummary || pair.old.enabled !== pair.next.enabled) { - changes.push({ - field: `Trigger ${pair.next.label ?? pair.next.kind}`, - oldValue: `${oldSummary} (${pair.old.enabled ? "enabled" : "disabled"})`, - newValue: `${newSummary} (${pair.next.enabled ? "enabled" : "disabled"})`, - }); - } - } - } -} export function isUpdateConflictError(error: unknown): error is ApiError { return error instanceof ApiError && error.status === 409; diff --git a/ui/src/components/ScrollToBottom.tsx b/ui/src/components/ScrollToBottom.tsx index 793e85a0..45c06981 100644 --- a/ui/src/components/ScrollToBottom.tsx +++ b/ui/src/components/ScrollToBottom.tsx @@ -75,7 +75,7 @@ export function ScrollToBottom() { onClick={scroll} className={cn( "fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-[background-color,right] duration-200 md:bottom-6", - panelVisible && panelContent && "md:right-[calc(320px+1.5rem)]", + panelVisible && panelContent && "md:right-[calc(var(--properties-panel-width,320px)+1.5rem)]", )} aria-label="Scroll to bottom" > diff --git a/ui/src/context/PanelContext.tsx b/ui/src/context/PanelContext.tsx index a29fbbb4..38eca0e9 100644 --- a/ui/src/context/PanelContext.tsx +++ b/ui/src/context/PanelContext.tsx @@ -2,10 +2,23 @@ import { createContext, useCallback, useContext, useState, type ReactNode } from const STORAGE_KEY = "paperclip:panel-visible"; +export interface PanelLayoutOptions { + /** localStorage key under which the user's preferred panel width is saved. */ + storageKey?: string; + /** Width applied when no stored value exists. */ + defaultWidth?: number; + minWidth?: number; + maxWidth?: number; + /** Below this viewport width, clamp the panel to compactMaxWidth. */ + compactBelowViewport?: number; + compactMaxWidth?: number; +} + interface PanelContextValue { panelContent: ReactNode | null; + panelLayout: PanelLayoutOptions; panelVisible: boolean; - openPanel: (content: ReactNode) => void; + openPanel: (content: ReactNode, layout?: PanelLayoutOptions) => void; closePanel: () => void; setPanelVisible: (visible: boolean) => void; togglePanelVisible: () => void; @@ -30,16 +43,21 @@ function writePreference(visible: boolean) { } } +const EMPTY_LAYOUT: PanelLayoutOptions = {}; + export function PanelProvider({ children }: { children: ReactNode }) { const [panelContent, setPanelContent] = useState(null); + const [panelLayout, setPanelLayout] = useState(EMPTY_LAYOUT); const [panelVisible, setPanelVisibleState] = useState(readPreference); - const openPanel = useCallback((content: ReactNode) => { + const openPanel = useCallback((content: ReactNode, layout?: PanelLayoutOptions) => { setPanelContent(content); + setPanelLayout(layout ?? EMPTY_LAYOUT); }, []); const closePanel = useCallback(() => { setPanelContent(null); + setPanelLayout(EMPTY_LAYOUT); }, []); const setPanelVisible = useCallback((visible: boolean) => { @@ -57,7 +75,7 @@ export function PanelProvider({ children }: { children: ReactNode }) { return ( {children} diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 076bf518..2d809f63 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -591,22 +591,22 @@ export function RoutineDetail() { const activityTabsPanel = useMemo(() => { if (!routine) return null; return ( - - - + + + Triggers - + Runs {hasLiveRun && } - + Activity - + History @@ -815,7 +815,14 @@ export function RoutineDetail() { closePanel(); return; } - openPanel(activityTabsPanel); + openPanel(activityTabsPanel, { + storageKey: "paperclip.properties.width.routines", + defaultWidth: 400, + minWidth: 320, + maxWidth: 640, + compactBelowViewport: 1024, + compactMaxWidth: 320, + }); return () => closePanel(); }, [activityTabsPanel, closePanel, openPanel]); @@ -854,7 +861,7 @@ export function RoutineDetail() { return (
{/* Header: editable title + actions */} -
+