mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
fix automation run inputs, hitl routing, and detail UI polish
This commit is contained in:
parent
ed8d56aa16
commit
91962ba879
8 changed files with 258 additions and 86 deletions
|
|
@ -22,11 +22,14 @@ from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.agents.new_chat.tools.hitl import request_approval
|
from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import (
|
||||||
|
request_approval,
|
||||||
|
)
|
||||||
from app.automations.schemas.api import AutomationCreate
|
from app.automations.schemas.api import AutomationCreate
|
||||||
from app.automations.services.automation import AutomationService
|
from app.automations.services.automation import AutomationService
|
||||||
from app.db import User, async_session_maker
|
from app.db import User, async_session_maker
|
||||||
|
|
@ -56,7 +59,7 @@ def create_create_automation_tool(
|
||||||
uid = UUID(user_id) if isinstance(user_id, str) else user_id
|
uid = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def create_automation(intent: str) -> dict[str, Any]:
|
async def create_automation(intent: str, runtime: ToolRuntime) -> dict[str, Any]:
|
||||||
"""Draft + save an automation from a natural-language intent.
|
"""Draft + save an automation from a natural-language intent.
|
||||||
|
|
||||||
Use this when the user wants SurfSense to do something on its own
|
Use this when the user wants SurfSense to do something on its own
|
||||||
|
|
@ -137,6 +140,7 @@ def create_create_automation_tool(
|
||||||
tool_name="create_automation",
|
tool_name="create_automation",
|
||||||
params=card_params,
|
params=card_params,
|
||||||
context={"search_space_id": search_space_id},
|
context={"search_space_id": search_space_id},
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.rejected:
|
if result.rejected:
|
||||||
|
|
@ -200,6 +204,5 @@ def _extract_json(text: str) -> dict[str, Any] | None:
|
||||||
|
|
||||||
def _format_validation_issues(exc: ValidationError) -> list[str]:
|
def _format_validation_issues(exc: ValidationError) -> list[str]:
|
||||||
return [
|
return [
|
||||||
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
|
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
|
||||||
for err in exc.errors()
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ def request_approval(
|
||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
context: dict[str, Any] | None = None,
|
context: dict[str, Any] | None = None,
|
||||||
trusted_tools: list[str] | None = None,
|
trusted_tools: list[str] | None = None,
|
||||||
|
tool_call_id: str | None = None,
|
||||||
) -> HITLResult:
|
) -> HITLResult:
|
||||||
"""Pause the graph for user approval and return the user's decision.
|
"""Pause the graph for user approval and return the user's decision.
|
||||||
|
|
||||||
|
|
@ -64,6 +65,10 @@ def request_approval(
|
||||||
forwarded verbatim to the FE for richer card chrome.
|
forwarded verbatim to the FE for richer card chrome.
|
||||||
trusted_tools: Per-session allowlist; when ``tool_name`` is in it the
|
trusted_tools: Per-session allowlist; when ``tool_name`` is in it the
|
||||||
interrupt is skipped and the tool runs immediately.
|
interrupt is skipped and the tool runs immediately.
|
||||||
|
tool_call_id: Caller's LangChain tool-call id. Required for tools
|
||||||
|
running directly on the main agent; subagent-mounted tools omit
|
||||||
|
it (the ``task`` chokepoint stamps it on re-raise — see
|
||||||
|
:mod:`...checkpointed_subagent_middleware.propagation`).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`HITLResult` with ``rejected=True`` if the user declined or
|
:class:`HITLResult` with ``rejected=True`` if the user declined or
|
||||||
|
|
@ -90,6 +95,8 @@ def request_approval(
|
||||||
interrupt_type=action_type,
|
interrupt_type=action_type,
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
if tool_call_id:
|
||||||
|
payload["tool_call_id"] = tool_call_id
|
||||||
approval = interrupt(payload)
|
approval = interrupt(payload)
|
||||||
|
|
||||||
parsed = parse_lc_envelope(approval)
|
parsed = parse_lc_envelope(approval)
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,15 @@ async def dispatch_run(
|
||||||
def _validate_inputs(
|
def _validate_inputs(
|
||||||
definition: AutomationDefinition, inputs: dict[str, Any]
|
definition: AutomationDefinition, inputs: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Validate merged inputs against the optional declared schema.
|
||||||
|
|
||||||
|
No declared schema → pass through (runtime inputs like ``fired_at`` /
|
||||||
|
``last_fired_at`` and trigger ``static_inputs`` must still reach the
|
||||||
|
template context). Returning ``{}`` here strips them and makes Jinja
|
||||||
|
blow up on any ``{{ inputs.* }}`` reference.
|
||||||
|
"""
|
||||||
if definition.inputs is None or not definition.inputs.schema_:
|
if definition.inputs is None or not definition.inputs.schema_:
|
||||||
return {}
|
return inputs
|
||||||
try:
|
try:
|
||||||
jsonschema.validate(instance=inputs, schema=definition.inputs.schema_)
|
jsonschema.validate(instance=inputs, schema=definition.inputs.schema_)
|
||||||
except jsonschema.ValidationError as exc:
|
except jsonschema.ValidationError as exc:
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,8 @@ export function AutomationDetailContent({
|
||||||
<AutomationTriggersSection
|
<AutomationTriggersSection
|
||||||
triggers={automation.triggers}
|
triggers={automation.triggers}
|
||||||
automationId={automation.id}
|
automationId={automation.id}
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
canUpdate={perms.canUpdate}
|
canUpdate={perms.canUpdate}
|
||||||
canDelete={perms.canDelete}
|
canDelete={perms.canDelete}
|
||||||
canCreate={perms.canCreate}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AutomationRunsSection automationId={automation.id} />
|
<AutomationRunsSection automationId={automation.id} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { CalendarClock, MessageSquarePlus } from "lucide-react";
|
import { CalendarClock } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { Trigger } from "@/contracts/types/automation.types";
|
import type { Trigger } from "@/contracts/types/automation.types";
|
||||||
import { TriggerCard } from "./trigger-card";
|
import { TriggerCard } from "./trigger-card";
|
||||||
|
|
@ -9,43 +7,28 @@ import { TriggerCard } from "./trigger-card";
|
||||||
interface AutomationTriggersSectionProps {
|
interface AutomationTriggersSectionProps {
|
||||||
triggers: Trigger[];
|
triggers: Trigger[];
|
||||||
automationId: number;
|
automationId: number;
|
||||||
searchSpaceId: number;
|
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canCreate: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Triggers card. Lists each attached trigger with its own enable
|
* The Triggers card. Lists each attached trigger with its own enable
|
||||||
* toggle and remove button. Adding a new trigger is intent-driven (via
|
* toggle and remove button. v1 attaches triggers at automation-creation
|
||||||
* chat) for v1 — same philosophy as creating an automation, so the
|
* time only; there is no in-place "add trigger" affordance here.
|
||||||
* empty/add CTA links to a new chat rather than opening a form.
|
|
||||||
*/
|
*/
|
||||||
export function AutomationTriggersSection({
|
export function AutomationTriggersSection({
|
||||||
triggers,
|
triggers,
|
||||||
automationId,
|
automationId,
|
||||||
searchSpaceId,
|
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canCreate,
|
|
||||||
}: AutomationTriggersSectionProps) {
|
}: AutomationTriggersSectionProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="space-y-1">
|
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
||||||
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
When this automation fires. v1 supports scheduled triggers only.
|
||||||
When this automation fires. v1 supports scheduled triggers only.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreate && (
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
|
||||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
|
||||||
Add via chat
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{triggers.length === 0 ? (
|
{triggers.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { Trigger } from "@/contracts/types/automation.types";
|
import type { Trigger } from "@/contracts/types/automation.types";
|
||||||
import { describeCron } from "@/lib/automations/describe-cron";
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
|
||||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||||
|
|
||||||
interface TriggerCardProps {
|
interface TriggerCardProps {
|
||||||
|
|
@ -91,11 +91,18 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
|
|
||||||
<div className="px-4 py-3 space-y-3 text-xs">
|
<div className="px-4 py-3 space-y-3 text-xs">
|
||||||
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
|
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
||||||
{trigger.next_fire_at && (
|
{trigger.next_fire_at && (
|
||||||
<TimeRow label="Next fire" iso={trigger.next_fire_at} highlight={trigger.enabled} />
|
<TimeRow
|
||||||
|
label="Next fire"
|
||||||
|
iso={trigger.next_fire_at}
|
||||||
|
tense="future"
|
||||||
|
highlight={trigger.enabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{trigger.last_fired_at && (
|
||||||
|
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
|
||||||
)}
|
)}
|
||||||
{trigger.last_fired_at && <TimeRow label="Last fired" iso={trigger.last_fired_at} />}
|
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -126,17 +133,20 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
function TimeRow({
|
function TimeRow({
|
||||||
label,
|
label,
|
||||||
iso,
|
iso,
|
||||||
|
tense,
|
||||||
highlight = false,
|
highlight = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
iso: string;
|
iso: string;
|
||||||
|
tense: "past" | "future";
|
||||||
highlight?: boolean;
|
highlight?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-2 min-w-0">
|
<>
|
||||||
<dt className="text-muted-foreground shrink-0 inline-flex items-center gap-1">
|
<dt className="text-muted-foreground inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||||
<Clock className="h-3 w-3" aria-hidden />
|
<Clock className="h-3 w-3" aria-hidden />
|
||||||
{label}:
|
{label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd
|
<dd
|
||||||
className={
|
className={
|
||||||
|
|
@ -144,9 +154,10 @@ function TimeRow({
|
||||||
? "text-foreground font-medium min-w-0 truncate"
|
? "text-foreground font-medium min-w-0 truncate"
|
||||||
: "text-muted-foreground min-w-0 truncate"
|
: "text-muted-foreground min-w-0 truncate"
|
||||||
}
|
}
|
||||||
|
title={new Date(iso).toLocaleString()}
|
||||||
>
|
>
|
||||||
{formatRelativeDate(iso)}
|
{formatted}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,26 @@
|
||||||
|
|
||||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CornerDownLeftIcon, ExternalLink, Workflow } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Code,
|
||||||
|
CornerDownLeftIcon,
|
||||||
|
ExternalLink,
|
||||||
|
Pencil,
|
||||||
|
Workflow,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { automationCreateRequest } from "@/contracts/types/automation.types";
|
||||||
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
|
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
|
||||||
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
|
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
|
||||||
import { AutomationDraftPreview } from "./automation-draft-preview";
|
import { AutomationDraftPreview } from "./automation-draft-preview";
|
||||||
|
|
||||||
|
const editArgsSchema = automationCreateRequest.omit({ search_space_id: true });
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Result discrimination — mirrors the backend return shapes in
|
// Result discrimination — mirrors the backend return shapes in
|
||||||
// app/agents/multi_agent_chat/main_agent/tools/automation/create.py.
|
// app/agents/multi_agent_chat/main_agent/tools/automation/create.py.
|
||||||
|
|
@ -62,12 +72,11 @@ function hasStatus(value: unknown, status: string): boolean {
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Approval card — pending → processing → complete / rejected.
|
// Approval card — pending → processing → complete / rejected.
|
||||||
//
|
//
|
||||||
// v1 deliberately supports only approve/reject. The drafted JSON is complex
|
// Edit toggle reuses the same primitives as the Create-via-JSON page: raw
|
||||||
// (full plan + triggers) and we already have a multi-turn refinement path via
|
// textarea, Format, Zod validation against ``AutomationCreate`` (minus the
|
||||||
// chat ("make it run at 10am instead" → the agent re-calls the tool with a
|
// ``search_space_id`` field, which the backend injects). Approve dispatches
|
||||||
// refined intent). An in-card edit form would duplicate that flow and add UX
|
// an ``edit`` decision with the parsed args when edits are pending, otherwise
|
||||||
// surface area we don't need yet — leave it for the raw-JSON path on the
|
// a plain ``approve``. Multi-turn chat refinement still works as a fallback.
|
||||||
// detail page.
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ApprovalCardProps {
|
interface ApprovalCardProps {
|
||||||
|
|
@ -83,28 +92,34 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
|
||||||
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||||
const canApprove = allowedDecisions.includes("approve");
|
const canApprove = allowedDecisions.includes("approve");
|
||||||
const canReject = allowedDecisions.includes("reject");
|
const canReject = allowedDecisions.includes("reject");
|
||||||
|
const canEdit = allowedDecisions.includes("edit");
|
||||||
|
|
||||||
const draft = useMemo(() => extractDraft(args), [args]);
|
const [pendingEdits, setPendingEdits] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const effectiveArgs = pendingEdits ?? args;
|
||||||
|
const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]);
|
||||||
|
|
||||||
const handleApprove = useCallback(() => {
|
const handleApprove = useCallback(() => {
|
||||||
if (phase !== "pending" || !canApprove) return;
|
if (phase !== "pending" || !canApprove || isEditing) return;
|
||||||
setProcessing();
|
setProcessing();
|
||||||
onDecision({
|
onDecision({
|
||||||
type: "approve",
|
type: pendingEdits ? "edit" : "approve",
|
||||||
edited_action: {
|
edited_action: {
|
||||||
name: interruptData.action_requests[0]?.name ?? "create_automation",
|
name: interruptData.action_requests[0]?.name ?? "create_automation",
|
||||||
args,
|
args: pendingEdits ?? args,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [phase, canApprove, setProcessing, onDecision, interruptData, args]);
|
}, [phase, canApprove, isEditing, setProcessing, onDecision, interruptData, args, pendingEdits]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
const handleReject = useCallback(() => {
|
||||||
if (phase !== "pending" || !canReject) return;
|
if (phase !== "pending" || !canReject || isEditing) return;
|
||||||
setRejected();
|
setRejected();
|
||||||
onDecision({ type: "reject", message: "User rejected the automation draft." });
|
onDecision({ type: "reject", message: "User rejected the automation draft." });
|
||||||
}, [phase, canReject, setRejected, onDecision]);
|
}, [phase, canReject, isEditing, setRejected, onDecision]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEditing) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
handleApprove();
|
handleApprove();
|
||||||
|
|
@ -112,46 +127,77 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [handleApprove]);
|
}, [handleApprove, isEditing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
||||||
<div className="flex items-start gap-3 px-5 pt-5 pb-4 select-none">
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-4 select-none">
|
||||||
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<div className="min-w-0">
|
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<div className="min-w-0">
|
||||||
{phase === "rejected"
|
<p className="text-sm font-semibold text-foreground">
|
||||||
? "Automation cancelled"
|
{phase === "rejected"
|
||||||
: phase === "processing"
|
? "Automation cancelled"
|
||||||
? "Saving automation"
|
: phase === "processing"
|
||||||
: phase === "complete"
|
? "Saving automation"
|
||||||
? "Automation saved"
|
: phase === "complete"
|
||||||
: "Create automation"}
|
? "Automation saved"
|
||||||
</p>
|
: "Create automation"}
|
||||||
{phase === "processing" ? (
|
|
||||||
<TextShimmerLoader text="Saving automation" size="sm" />
|
|
||||||
) : phase === "complete" ? (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Automation created from this draft
|
|
||||||
</p>
|
</p>
|
||||||
) : phase === "rejected" ? (
|
{phase === "processing" ? (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<TextShimmerLoader
|
||||||
No automation was saved — ask in chat to refine and try again.
|
text={pendingEdits ? "Saving with your edits" : "Saving automation"}
|
||||||
</p>
|
size="sm"
|
||||||
) : (
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
) : phase === "complete" ? (
|
||||||
Review and approve to save. To change anything, reply in chat — I'll redraft.
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</p>
|
{pendingEdits
|
||||||
)}
|
? "Automation saved with your edits"
|
||||||
|
: "Automation created from this draft"}
|
||||||
|
</p>
|
||||||
|
) : phase === "rejected" ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
No automation was saved — ask in chat to refine and try again.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{pendingEdits
|
||||||
|
? "Showing your edits. Approve to save, or edit again."
|
||||||
|
: "Review and approve to save. Edit for fine-tuning, or reply in chat for a redraft."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{phase === "pending" && canEdit && !isEditing && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg text-muted-foreground -mt-1 -mr-2 shrink-0"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<AutomationDraftPreview draft={draft} raw={args} />
|
{isEditing ? (
|
||||||
|
<JsonEditor
|
||||||
|
initialValue={effectiveArgs}
|
||||||
|
onSave={(parsed) => {
|
||||||
|
setPendingEdits(parsed);
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AutomationDraftPreview draft={draft} raw={effectiveArgs} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase === "pending" && (
|
{phase === "pending" && !isEditing && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-5 h-px bg-border/50" />
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||||
|
|
@ -178,6 +224,85 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JsonEditorProps {
|
||||||
|
initialValue: Record<string, unknown>;
|
||||||
|
onSave: (parsed: Record<string, unknown>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
|
||||||
|
const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2));
|
||||||
|
const [issues, setIssues] = useState<string[]>([]);
|
||||||
|
|
||||||
|
function handleFormat() {
|
||||||
|
try {
|
||||||
|
setText(JSON.stringify(JSON.parse(text), null, 2));
|
||||||
|
setIssues([]);
|
||||||
|
} catch (err) {
|
||||||
|
setIssues([`Cannot format — not valid JSON: ${(err as Error).message}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
setIssues([]);
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
setIssues([`Invalid JSON: ${(err as Error).message}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = editArgsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
setIssues(
|
||||||
|
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(result.data as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
rows={16}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y min-h-[12rem]"
|
||||||
|
aria-label="Automation JSON"
|
||||||
|
/>
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{issues.length} issue{issues.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-0.5 text-xs text-destructive/90 list-disc list-inside">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li key={issue} className="font-mono">
|
||||||
|
{issue}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleFormat}>
|
||||||
|
<Code className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Format
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={handleSave}>
|
||||||
|
Save edits
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Terminal result cards.
|
// Terminal result cards.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
import {
|
||||||
|
differenceInDays,
|
||||||
|
differenceInMinutes,
|
||||||
|
format,
|
||||||
|
isThisYear,
|
||||||
|
isToday,
|
||||||
|
isTomorrow,
|
||||||
|
isYesterday,
|
||||||
|
} from "date-fns";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date string as a human-readable relative time
|
* Format a date string as a human-readable relative time
|
||||||
|
|
@ -23,6 +31,36 @@ export function formatRelativeDate(dateString: string): string {
|
||||||
return format(date, "MMM d, yyyy");
|
return format(date, "MMM d, yyyy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a future date string as a human-readable countdown.
|
||||||
|
* - < 1 min: "Any moment"
|
||||||
|
* - < 60 min: "in 15m"
|
||||||
|
* - Today: "Today, 2:30 PM"
|
||||||
|
* - Tomorrow: "Tomorrow, 2:30 PM"
|
||||||
|
* - < 7 days: "in 3d"
|
||||||
|
* - This year: "May 30, 2:30 PM"
|
||||||
|
* - Older: "Jan 15, 2027"
|
||||||
|
*
|
||||||
|
* Mirrors {@link formatRelativeDate} but for moments strictly after now.
|
||||||
|
* Falls back to the past-relative formatter if the timestamp is not in
|
||||||
|
* the future (defensive — guards against stale "next_fire_at" values).
|
||||||
|
*/
|
||||||
|
export function formatRelativeFutureDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const minutesAhead = differenceInMinutes(date, now);
|
||||||
|
const daysAhead = differenceInDays(date, now);
|
||||||
|
|
||||||
|
if (minutesAhead <= 0) return formatRelativeDate(dateString);
|
||||||
|
if (minutesAhead < 1) return "Any moment";
|
||||||
|
if (minutesAhead < 60) return `in ${minutesAhead}m`;
|
||||||
|
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
|
||||||
|
if (isTomorrow(date)) return `Tomorrow, ${format(date, "h:mm a")}`;
|
||||||
|
if (daysAhead < 7) return `in ${daysAhead}d`;
|
||||||
|
if (isThisYear(date)) return format(date, "MMM d, h:mm a");
|
||||||
|
return format(date, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a thread's last-updated timestamp for the chats sidebars.
|
* Format a thread's last-updated timestamp for the chats sidebars.
|
||||||
* Example: "Mar 23, 2026 at 4:30 PM"
|
* Example: "Mar 23, 2026 at 4:30 PM"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue