-
- Properties
- setPanelVisible(false)}
- aria-label="Close properties panel"
- >
-
-
-
-
+
);
}
diff --git a/ui/src/components/ResizableSidebarPane.test.tsx b/ui/src/components/ResizableSidebarPane.test.tsx
index deb904ec..cb635d81 100644
--- a/ui/src/components/ResizableSidebarPane.test.tsx
+++ b/ui/src/components/ResizableSidebarPane.test.tsx
@@ -17,10 +17,8 @@ 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);
@@ -33,8 +31,6 @@ describe("ResizableSidebarPane", () => {
});
container.remove();
window.localStorage.clear();
- document.documentElement.style.removeProperty("--test-sidebar-width");
- setInnerWidth(originalInnerWidth);
});
function pane() {
@@ -45,14 +41,6 @@ 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");
@@ -130,135 +118,4 @@ 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 8c66bca5..09f9c70a 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, min: number, max: number) {
- return Math.min(max, Math.max(min, width));
+function clampSidebarWidth(width: number) {
+ return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
}
-function readStoredSidebarWidth(storageKey: string, fallback: number, min: number, max: number) {
- if (typeof window === "undefined") return fallback;
+function readStoredSidebarWidth(storageKey: string) {
+ if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
try {
const stored = window.localStorage.getItem(storageKey);
- if (!stored) return fallback;
+ if (!stored) return DEFAULT_SIDEBAR_WIDTH;
const parsed = Number.parseInt(stored, 10);
- if (!Number.isFinite(parsed)) return fallback;
- return clampSidebarWidth(parsed, min, max);
+ if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
+ return clampSidebarWidth(parsed);
} catch {
- return fallback;
+ return DEFAULT_SIDEBAR_WIDTH;
}
}
-function writeStoredSidebarWidth(storageKey: string, width: number, min: number, max: number) {
+function writeStoredSidebarWidth(storageKey: string, width: number) {
if (typeof window === "undefined") return;
try {
- window.localStorage.setItem(storageKey, String(clampSidebarWidth(width, min, max)));
+ window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
} catch {
// Storage can be unavailable in private contexts; resizing should still work.
}
@@ -49,68 +49,25 @@ 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 [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 [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
useEffect(() => {
- 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);
+ const storedWidth = readStoredSidebarWidth(storageKey);
widthRef.current = storedWidth;
setWidth(storedWidth);
- }, [storageKey, fallbackWidth, minWidth, effectiveMaxWidth]);
+ }, [storageKey]);
const visibleWidth = open ? width : 0;
const paneStyle = useMemo(
@@ -118,25 +75,14 @@ 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, minWidth, effectiveMaxWidth);
+ const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
- if (!compactModeActive) {
- writeStoredSidebarWidth(storageKey, clamped, minWidth, maxWidth);
- }
+ writeStoredSidebarWidth(storageKey, clamped);
},
- [storageKey, minWidth, maxWidth, effectiveMaxWidth, compactModeActive],
+ [storageKey],
);
const handlePointerDown = useCallback(
@@ -155,15 +101,12 @@ export function ResizableSidebarPane({
(event: PointerEvent
) => {
if (!dragState.current) return;
- 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);
+ const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
+ const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
},
- [side, minWidth, effectiveMaxWidth],
+ [],
);
const endResize = useCallback(() => {
@@ -171,34 +114,28 @@ export function ResizableSidebarPane({
dragState.current = null;
setIsResizing(false);
- if (!compactModeActive) {
- writeStoredSidebarWidth(storageKey, widthRef.current, minWidth, maxWidth);
- }
- }, [storageKey, minWidth, maxWidth, compactModeActive]);
+ writeStoredSidebarWidth(storageKey, widthRef.current);
+ }, [storageKey]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
- if (!open || !resizable || !canResizeAtCurrentViewport) return;
+ if (!open || !resizable) return;
- // 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) {
+ if (event.key === "ArrowLeft") {
event.preventDefault();
commitWidth(width - SIDEBAR_WIDTH_STEP);
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ commitWidth(width + SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
- commitWidth(minWidth);
+ commitWidth(MIN_SIDEBAR_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
- commitWidth(effectiveMaxWidth);
+ commitWidth(MAX_SIDEBAR_WIDTH);
}
},
- [commitWidth, open, resizable, side, width, minWidth, effectiveMaxWidth, canResizeAtCurrentViewport],
+ [commitWidth, open, resizable, width],
);
return (
@@ -211,18 +148,17 @@ export function ResizableSidebarPane({
style={paneStyle}
>
{children}
- {resizable && open && canResizeAtCurrentViewport ? (
+ {resizable && open ? (
{
});
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 d30522e7..0bae2714 100644
--- a/ui/src/components/RoutineHistoryTab.tsx
+++ b/ui/src/components/RoutineHistoryTab.tsx
@@ -1,6 +1,6 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { History as HistoryIcon, RotateCcw } from "lucide-react";
+import { History as HistoryIcon, RotateCcw, Search } from "lucide-react";
import type {
Routine,
RoutineRevision,
@@ -13,6 +13,7 @@ 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";
@@ -64,11 +65,10 @@ 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,6 +86,12 @@ 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],
@@ -114,8 +120,6 @@ export function RoutineHistoryTab({
onRestoreSecretMaterials(data);
onRestored?.(data);
setConfirmOpen(false);
- setSnapshotOpen(false);
- setCompareOn(false);
setRestoreSummary("");
setSelectedRevisionId(data.revision.id);
setHighlightedRevisionId(data.revision.id);
@@ -146,15 +150,15 @@ export function RoutineHistoryTab({
const handleSelectRevision = (revisionId: string) => {
if (isEditDirty) return;
setSelectedRevisionId(revisionId);
- setCompareOn(false);
- setSnapshotOpen(true);
+ };
+
+ const handleReturnToCurrent = () => {
+ if (currentRevision) setSelectedRevisionId(currentRevision.id);
};
const openRestoreConfirm = () => {
if (!selectedRevision || !isHistoricalSelected) return;
setRestoreSummary("");
- setSnapshotOpen(false);
- setCompareOn(false);
setConfirmOpen(true);
};
@@ -168,7 +172,7 @@ export function RoutineHistoryTab({
if (revisionsQuery.isLoading) {
return (
-
+
{Array.from({ length: 5 }).map((_, idx) => (
@@ -200,55 +204,64 @@ export function RoutineHistoryTab({
const onlyBootstrapRevision = revisions.length <= 1;
return (
-
- {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}
- />
- )}
+
+
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}
+ />
+ >
+ )
+ )}
+
{selectedRevision && currentRevision && (
)}
+
+ {currentRevision && selectedRevision && (
+ {
+ setSelectedRevisionId(rev.id);
+ setDiffOpen(false);
+ setRestoreSummary("");
+ setConfirmOpen(true);
+ }}
+ />
+ )}
);
}
-function RevisionSnapshotDialog({
- open,
- onOpenChange,
- revision,
- currentRevision,
- isHistorical,
- compareOn,
- onCompareToggle,
- agents,
- projects,
+function HistoricalPreviewBanner({
+ revisionNumber,
+ nextRevisionNumber,
+ onReturn,
onRestore,
- restorePending,
- highlighted,
+ pending,
}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- revision: RoutineRevision;
- currentRevision: RoutineRevision | null;
- isHistorical: boolean;
- compareOn: boolean;
- onCompareToggle: (next: boolean) => void;
- agents: AgentLookup;
- projects: ProjectLookup;
+ revisionNumber: number;
+ nextRevisionNumber: number;
+ onReturn: () => void;
onRestore: () => void;
- restorePending: boolean;
- highlighted: boolean;
+ pending: boolean;
}) {
- const showCompare = compareOn && !!currentRevision && isHistorical;
return (
-
-
-
-
-
- {isHistorical
- ? `Viewing revision ${revision.revisionNumber} (read-only)`
- : `Revision ${revision.revisionNumber} (current)`}
-
- {isHistorical && currentRevision && (
- onCompareToggle(!compareOn)}
- >
- {compareOn ? "Hide current" : "Compare with current"}
-
- )}
-
- {isHistorical && currentRevision && (
-
- Restoring this revision creates a new revision {currentRevision.revisionNumber + 1}{" "}
- with the same content. History stays append-only.
-
- )}
-
-
- {showCompare && currentRevision ? (
-
- ) : (
-
- )}
+
+
+
+
+ Viewing revision {revisionNumber} (read-only)
+
+
+ Restoring this revision creates a new revision {nextRevisionNumber} with the same content.
+ History stays append-only.
+
-
- onOpenChange(false)} disabled={restorePending}>
- Close
+
+
+ Return to current
- {isHistorical && (
-
-
- Restore as new revision
-
- )}
-
-
-
- );
-}
-
-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}
+
+
+ Restore as new revision
+
+
+
);
}
-
function ConflictBanner({
dirtyFields,
onDiscard,
@@ -424,7 +355,7 @@ function ConflictBanner({
const fieldsText = formatDirtyFieldList(labels);
return (
-
+
Unsaved routine edits
@@ -541,30 +472,31 @@ 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 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 restoreLabel = isHistorical ? "Restore this revision" : "Restore this revision";
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 }> = [
{
@@ -611,29 +543,33 @@ 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}` : ""}
+
+
+
+
+
+ Compare with current
+
+
+
+ {restoreLabel}
+
+
@@ -641,13 +577,17 @@ function RevisionPreview({
Structured fields
-
+
{fieldRows.map((row) => (
{row.label}
{row.value || — }
- {row.differs && }
+ {row.differs && (
+
+ differs from current
+
+ )}
))}
@@ -655,12 +595,9 @@ function RevisionPreview({
-
-
- Description
-
- {descriptionDiffers &&
}
-
+
+ Description
+
{snapshot.description ? (
{snapshot.description}
@@ -678,26 +615,22 @@ function RevisionPreview({
No triggers in this revision.
) : (
- {triggers.map((trigger) => {
- const status = triggerStatus(trigger);
- return (
-
-
- {trigger.kind}
-
- {trigger.label ?? trigger.kind}
-
- {summarizeTriggerSnapshot(trigger)}
-
- {status !== "same" && }
-
- {trigger.enabled ? "enabled" : "disabled"}
-
-
- );
- })}
+ {triggers.map((trigger) => (
+
+
+ {trigger.kind}
+
+ {trigger.label ?? trigger.kind}
+
+ {summarizeTriggerSnapshot(trigger)}
+
+
+ {trigger.enabled ? "enabled" : "disabled"}
+
+
+ ))}
)}
@@ -712,18 +645,14 @@ function RevisionPreview({
Variables ({snapshot.variables.length})
- {snapshot.variables.map((variable) => {
- const status = variableStatus(variable);
- return (
-
- {variable.name}
-
- default: {formatVariableDefault(variable)}
-
- {status !== "same" && }
-
- );
- })}
+ {snapshot.variables.map((variable) => (
+
+ {variable.name}
+
+ default: {formatVariableDefault(variable)}
+
+
+ ))}
)}
@@ -806,6 +735,213 @@ 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.
+ ) : (
+
+
+
+ Field
+ Old value
+ New value
+
+
+
+ {fieldChanges.map((change) => (
+
+ {change.field}
+
+ {change.oldValue ?? "—"}
+
+
+ {change.newValue ?? "—"}
+
+
+ ))}
+
+
+ )}
+
+
+
+ Description diff
+
+
+
+
+
+ onOpenChange(false)}>
+ Close
+
+ {leftIsHistorical && left && (
+ onRestore(left)}>
+
+ Restore rev {left.revisionNumber} as new revision
+
+ )}
+
+
+
+ );
+}
+
+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}
+
+ onChange(event.target.value)}
+ className="h-8 min-w-[12rem] rounded-md border border-border/60 bg-background px-2 text-xs"
+ >
+ {revisions.map((revision) => (
+
+ rev {revision.revisionNumber} — {relativeTime(revision.createdAt)}
+ {revision.changeSummary ? ` • ${revision.changeSummary}` : ""}
+
+ ))}
+
+
+ );
+}
+
+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";
@@ -856,6 +992,104 @@ 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 45c06981..793e85a0 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(var(--properties-panel-width,320px)+1.5rem)]",
+ panelVisible && panelContent && "md:right-[calc(320px+1.5rem)]",
)}
aria-label="Scroll to bottom"
>
diff --git a/ui/src/context/PanelContext.tsx b/ui/src/context/PanelContext.tsx
index 38eca0e9..a29fbbb4 100644
--- a/ui/src/context/PanelContext.tsx
+++ b/ui/src/context/PanelContext.tsx
@@ -2,23 +2,10 @@ 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, layout?: PanelLayoutOptions) => void;
+ openPanel: (content: ReactNode) => void;
closePanel: () => void;
setPanelVisible: (visible: boolean) => void;
togglePanelVisible: () => void;
@@ -43,21 +30,16 @@ 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, layout?: PanelLayoutOptions) => {
+ const openPanel = useCallback((content: ReactNode) => {
setPanelContent(content);
- setPanelLayout(layout ?? EMPTY_LAYOUT);
}, []);
const closePanel = useCallback(() => {
setPanelContent(null);
- setPanelLayout(EMPTY_LAYOUT);
}, []);
const setPanelVisible = useCallback((visible: boolean) => {
@@ -75,7 +57,7 @@ export function PanelProvider({ children }: { children: ReactNode }) {
return (
{children}
diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx
index 2d809f63..076bf518 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,14 +815,7 @@ export function RoutineDetail() {
closePanel();
return;
}
- openPanel(activityTabsPanel, {
- storageKey: "paperclip.properties.width.routines",
- defaultWidth: 400,
- minWidth: 320,
- maxWidth: 640,
- compactBelowViewport: 1024,
- compactMaxWidth: 320,
- });
+ openPanel(activityTabsPanel);
return () => closePanel();
}, [activityTabsPanel, closePanel, openPanel]);
@@ -861,7 +854,7 @@ export function RoutineDetail() {
return (
{/* Header: editable title + actions */}
-
+
-
+
{
setRunVariablesOpen(true);
@@ -935,7 +928,6 @@ export function RoutineDetail() {
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
)}
onClick={() => setPanelVisible(true)}
- aria-label="Show triggers, runs and activity"
title="Show triggers, runs and activity"
>