mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-12 09:56:40 +02:00
feat(ui): move routine triggers/runs/activity tabs into properties panel
Reuses the global PropertiesPanel (open/close persisted to localStorage) for routine detail, mirroring the IssueDetail pattern. Adds a header toggle button to reopen the panel, and keeps mobile parity by rendering the same tabs inline below the md breakpoint. Also restructures the trigger card and runs/activity rows into vertically stacked layouts so they fit the 320px panel without overflow, and replaces the last-result badge with a wrapping inline chip so long error strings no longer fill the card width.
This commit is contained in:
@@ -49,90 +49,90 @@ export function TriggerListCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border p-4 transition-colors ${trigger.enabled ? "bg-card" : "bg-muted/40"}`}
|
||||
className={`rounded-lg border border-border p-3 transition-colors ${trigger.enabled ? "bg-card" : "bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className={`text-sm font-medium truncate flex-1 min-w-0 ${trigger.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
disabled={togglePending}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-sm font-medium ${trigger.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{trigger.kind}
|
||||
</Badge>
|
||||
{!trigger.enabled && (
|
||||
<Badge variant="secondary" className="text-[11px] text-muted-foreground">
|
||||
paused
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{trigger.kind}
|
||||
</Badge>
|
||||
{!trigger.enabled && (
|
||||
<Badge variant="secondary" className="text-[11px] text-muted-foreground">
|
||||
paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm break-words">{summary}</div>
|
||||
{isSchedule && trigger.cronExpression && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono break-all">
|
||||
{trigger.cronExpression}
|
||||
{trigger.timezone ? ` · ${trigger.timezone}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Next run</dt>
|
||||
<dd className="break-words">{nextRun}</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Last fired</dt>
|
||||
<dd className="break-words">{lastFired}</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">Last result</dt>
|
||||
<dd className="min-w-0">
|
||||
{trigger.lastResult ? (
|
||||
<span
|
||||
className={`inline-block rounded px-1.5 py-0.5 text-[11px] break-words ${
|
||||
resultIsError
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
}`}
|
||||
title={trigger.lastResult}
|
||||
>
|
||||
{trigger.lastResult}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm mt-1.5">{summary}</div>
|
||||
{isSchedule && trigger.cronExpression && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
{trigger.cronExpression}
|
||||
{trigger.timezone ? ` · ${trigger.timezone}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 mt-4 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Next run</div>
|
||||
<div>{nextRun}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Last fired</div>
|
||||
<div>{lastFired}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-0.5">Last result</div>
|
||||
<div>
|
||||
{trigger.lastResult ? (
|
||||
<Badge
|
||||
variant={resultIsError ? "destructive" : "secondary"}
|
||||
className="text-[11px] max-w-full truncate"
|
||||
title={trigger.lastResult}
|
||||
>
|
||||
{trigger.lastResult}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||
<ToggleSwitch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
disabled={togglePending}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{isWebhook && onRotateSecret && (
|
||||
<Button variant="ghost" size="xs" onClick={onRotateSecret} title="Rotate secret">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onEdit} title="Edit">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-1 border-t border-border pt-2">
|
||||
{isWebhook && onRotateSecret && (
|
||||
<Button variant="ghost" size="xs" onClick={onRotateSecret} title="Rotate secret">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onEdit} title="Edit">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Plus,
|
||||
Repeat,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
||||
import { TriggerListCard } from "../components/TriggerListCard";
|
||||
@@ -22,7 +23,9 @@ import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
@@ -136,6 +139,7 @@ export function RoutineDetail() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { pushToast } = useToast();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const hydratedRoutineIdRef = useRef<string | null>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
@@ -519,6 +523,167 @@ export function RoutineDetail() {
|
||||
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
||||
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
||||
|
||||
const renderActivityTabs = () => {
|
||||
if (!routine) return null;
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="triggers" className="gap-1.5">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
Triggers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runs" className="gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="triggers" className="space-y-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add trigger
|
||||
</Button>
|
||||
|
||||
{routine.triggers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm font-medium">No triggers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Triggers fire this routine on a schedule or via webhook.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routine.triggers.map((trigger) => (
|
||||
<TriggerListCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onEdit={() => {
|
||||
setEditingTrigger(trigger);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
onDelete={() => setTriggerPendingDelete(trigger)}
|
||||
onToggleEnabled={(enabled) => {
|
||||
setTogglingTriggerId(trigger.id);
|
||||
updateTrigger.mutate({ id: trigger.id, patch: { enabled } });
|
||||
}}
|
||||
onRotateSecret={
|
||||
trigger.kind === "webhook"
|
||||
? () => rotateTrigger.mutate(trigger.id)
|
||||
: undefined
|
||||
}
|
||||
togglePending={togglingTriggerId === trigger.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs" className="space-y-4">
|
||||
{hasLiveRun && activeIssueId && routine && (
|
||||
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||
)}
|
||||
{(routineRuns ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(routineRuns ?? []).map((run) => (
|
||||
<div key={run.id} className="flex flex-col gap-1.5 px-3 py-2 text-sm min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge variant="outline" className="text-[11px]">{run.source}</Badge>
|
||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="text-[11px]">
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{(run.trigger || run.linkedIssue) && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-xs min-w-0">
|
||||
{run.trigger && (
|
||||
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||
)}
|
||||
{run.linkedIssue && (
|
||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{(activity ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(activity ?? []).map((event) => (
|
||||
<div key={event.id} className="flex flex-col gap-1 px-3 py-2 text-xs min-w-0">
|
||||
<span className="font-medium text-foreground/90">{event.action.replaceAll(".", " ")}</span>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<div className="text-muted-foreground break-words">
|
||||
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
||||
<span key={key}>
|
||||
{i > 0 && <span className="mx-1 text-border">·</span>}
|
||||
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
||||
{formatActivityDetailValue(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!routine) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
openPanel(renderActivityTabs());
|
||||
return () => closePanel();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
routine,
|
||||
routineRuns,
|
||||
activity,
|
||||
activeTab,
|
||||
hasLiveRun,
|
||||
activeIssueId,
|
||||
togglingTriggerId,
|
||||
openPanel,
|
||||
closePanel,
|
||||
]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||
}
|
||||
@@ -612,6 +777,18 @@ export function RoutineDetail() {
|
||||
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
||||
{automationLabel}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"hidden md:inline-flex shrink-0 transition-opacity duration-200",
|
||||
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
|
||||
)}
|
||||
onClick={() => setPanelVisible(true)}
|
||||
title="Show triggers, runs and activity"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -820,149 +997,12 @@ export function RoutineDetail() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="md:hidden" />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="triggers" className="gap-1.5">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
Triggers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runs" className="gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="triggers" className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Triggers</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Schedules and webhooks that fire this routine.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{routine.triggers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm font-medium">No triggers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Triggers fire this routine on a schedule or via webhook.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingTrigger(null);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add your first trigger
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{routine.triggers.map((trigger) => (
|
||||
<TriggerListCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
onEdit={() => {
|
||||
setEditingTrigger(trigger);
|
||||
setTriggerDialogOpen(true);
|
||||
}}
|
||||
onDelete={() => setTriggerPendingDelete(trigger)}
|
||||
onToggleEnabled={(enabled) => {
|
||||
setTogglingTriggerId(trigger.id);
|
||||
updateTrigger.mutate({ id: trigger.id, patch: { enabled } });
|
||||
}}
|
||||
onRotateSecret={
|
||||
trigger.kind === "webhook"
|
||||
? () => rotateTrigger.mutate(trigger.id)
|
||||
: undefined
|
||||
}
|
||||
togglePending={togglingTriggerId === trigger.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs" className="space-y-4">
|
||||
{hasLiveRun && activeIssueId && routine && (
|
||||
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||
)}
|
||||
{(routineRuns ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(routineRuns ?? []).map((run) => (
|
||||
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">{run.source}</Badge>
|
||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="shrink-0">
|
||||
{run.status.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
{run.trigger && (
|
||||
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||
)}
|
||||
{run.linkedIssue && (
|
||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{timeAgo(run.triggeredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{(activity ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{(activity ?? []).map((event) => (
|
||||
<div key={event.id} className="flex items-center justify-between px-3 py-2 text-xs gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground/90 shrink-0">{event.action.replaceAll(".", " ")}</span>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
||||
<span key={key}>
|
||||
{i > 0 && <span className="mx-1 text-border">·</span>}
|
||||
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
||||
{formatActivityDetailValue(value)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground/60 shrink-0">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Tabs (mobile only — desktop renders in the right properties panel) */}
|
||||
<div className="md:hidden">
|
||||
{renderActivityTabs()}
|
||||
</div>
|
||||
|
||||
<RoutineRunVariablesDialog
|
||||
open={runVariablesOpen}
|
||||
|
||||
Reference in New Issue
Block a user