diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b7af003d..4bae4201 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { NewProjectDialog } from "./NewProjectDialog"; import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { OnboardingWizard } from "./OnboardingWizard"; +import { ToastViewport } from "./ToastViewport"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -88,6 +89,7 @@ export function Layout() { + ); } diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index cd519c94..8649c363 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { useToast } from "../context/ToastContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -83,6 +84,7 @@ const priorities = [ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); + const { pushToast } = useToast(); const queryClient = useQueryClient(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -117,12 +119,19 @@ export function NewIssueDialog() { const createIssue = useMutation({ mutationFn: (data: Record) => issuesApi.create(selectedCompanyId!, data), - onSuccess: () => { + onSuccess: (issue) => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); if (draftTimer.current) clearTimeout(draftTimer.current); clearDraft(); reset(); closeNewIssue(); + pushToast({ + dedupeKey: `issue-created-${issue.id}`, + title: `${issue.identifier ?? "Issue"} created`, + body: issue.title, + tone: "success", + action: { label: "View issue", href: `/issues/${issue.id}` }, + }); }, }); diff --git a/ui/src/components/ToastViewport.tsx b/ui/src/components/ToastViewport.tsx new file mode 100644 index 00000000..c68ceba1 --- /dev/null +++ b/ui/src/components/ToastViewport.tsx @@ -0,0 +1,73 @@ +import { Link } from "react-router-dom"; +import { X } from "lucide-react"; +import { useToast, type ToastTone } from "../context/ToastContext"; +import { cn } from "../lib/utils"; + +const toneClasses: Record = { + info: "border-border bg-card text-card-foreground", + success: "border-emerald-500/40 bg-emerald-50 text-emerald-950 dark:bg-emerald-900/30 dark:text-emerald-100", + warn: "border-amber-500/40 bg-amber-50 text-amber-950 dark:bg-amber-900/30 dark:text-amber-100", + error: "border-red-500/45 bg-red-50 text-red-950 dark:bg-red-900/35 dark:text-red-100", +}; + +const toneDotClasses: Record = { + info: "bg-sky-400", + success: "bg-emerald-400", + warn: "bg-amber-400", + error: "bg-red-400", +}; + +export function ToastViewport() { + const { toasts, dismissToast } = useToast(); + + if (toasts.length === 0) return null; + + return ( + + ); +} diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 18a00757..b6e06b0b 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -2,12 +2,121 @@ import { useEffect, type ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { LiveEvent } from "@paperclip/shared"; import { useCompany } from "./CompanyContext"; +import type { ToastInput } from "./ToastContext"; +import { useToast } from "./ToastContext"; import { queryKeys } from "../lib/queryKeys"; function readString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +function readRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function shortId(value: string) { + return value.slice(0, 8); +} + +const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); +const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]); +const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]); + +function buildActivityToast(payload: Record): ToastInput | null { + const entityType = readString(payload.entityType); + const entityId = readString(payload.entityId); + const action = readString(payload.action); + const details = readRecord(payload.details); + + if (entityType !== "issue" || !entityId || !action || !ISSUE_TOAST_ACTIONS.has(action)) { + return null; + } + + const issueHref = `/issues/${entityId}`; + const issueLabel = details?.title && typeof details.title === "string" + ? details.title + : `Issue ${shortId(entityId)}`; + + if (action === "issue.created") { + return { + title: "Issue created", + body: issueLabel, + tone: "success", + action: { label: "Open issue", href: issueHref }, + dedupeKey: `activity:${action}:${entityId}`, + }; + } + + if (action === "issue.updated") { + return { + title: "Issue updated", + body: issueLabel, + tone: "info", + action: { label: "Open issue", href: issueHref }, + dedupeKey: `activity:${action}:${entityId}`, + }; + } + + const commentId = readString(details?.commentId); + return { + title: "Issue comment added", + body: issueLabel, + tone: "info", + action: { label: "Open issue", href: issueHref }, + dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`, + }; +} + +function buildAgentStatusToast(payload: Record): ToastInput | null { + const agentId = readString(payload.agentId); + const status = readString(payload.status); + if (!agentId || !status || !AGENT_TOAST_STATUSES.has(status)) return null; + + const tone = status === "error" ? "error" : status === "idle" ? "success" : "info"; + const title = + status === "running" + ? "Agent started" + : status === "idle" + ? "Agent is idle" + : "Agent error"; + + return { + title, + body: `Agent ${shortId(agentId)}`, + tone, + action: { label: "View agent", href: `/agents/${agentId}` }, + dedupeKey: `agent-status:${agentId}:${status}`, + }; +} + +function buildRunStatusToast(payload: Record): ToastInput | null { + const runId = readString(payload.runId); + const agentId = readString(payload.agentId); + const status = readString(payload.status); + if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null; + + const error = readString(payload.error); + const tone = status === "succeeded" ? "success" : status === "cancelled" ? "warn" : "error"; + const title = + status === "succeeded" + ? "Run succeeded" + : status === "failed" + ? "Run failed" + : status === "timed_out" + ? "Run timed out" + : "Run cancelled"; + + return { + title, + body: error ?? `Agent ${shortId(agentId)} ยท Run ${shortId(runId)}`, + tone, + ttlMs: status === "succeeded" ? 5000 : 7000, + action: { label: "View run", href: `/agents/${agentId}/runs/${runId}` }, + dedupeKey: `run-status:${runId}:${status}`, + }; +} + function invalidateHeartbeatQueries( queryClient: ReturnType, companyId: string, @@ -93,6 +202,7 @@ function handleLiveEvent( queryClient: ReturnType, expectedCompanyId: string, event: LiveEvent, + pushToast: (toast: ToastInput) => string | null, ) { if (event.companyId !== expectedCompanyId) return; @@ -103,6 +213,10 @@ function handleLiveEvent( if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") { invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload); + if (event.type === "heartbeat.run.status") { + const toast = buildRunStatusToast(payload); + if (toast) pushToast(toast); + } return; } @@ -116,17 +230,22 @@ function handleLiveEvent( queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) }); const agentId = readString(payload.agentId); if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) }); + const toast = buildAgentStatusToast(payload); + if (toast) pushToast(toast); return; } if (event.type === "activity.logged") { invalidateActivityQueries(queryClient, expectedCompanyId, payload); + const toast = buildActivityToast(payload); + if (toast) pushToast(toast); } } export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); + const { pushToast } = useToast(); useEffect(() => { if (!selectedCompanyId) return; @@ -169,7 +288,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed); + handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast); } catch { // Ignore non-JSON payloads. } @@ -198,7 +317,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId]); + }, [queryClient, selectedCompanyId, pushToast]); return <>{children}>; } diff --git a/ui/src/context/ToastContext.tsx b/ui/src/context/ToastContext.tsx new file mode 100644 index 00000000..ce33bf1a --- /dev/null +++ b/ui/src/context/ToastContext.tsx @@ -0,0 +1,167 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type ToastTone = "info" | "success" | "warn" | "error"; + +export interface ToastAction { + label: string; + href: string; +} + +export interface ToastInput { + id?: string; + dedupeKey?: string; + title: string; + body?: string; + tone?: ToastTone; + ttlMs?: number; + action?: ToastAction; +} + +export interface ToastItem { + id: string; + title: string; + body?: string; + tone: ToastTone; + ttlMs: number; + action?: ToastAction; + createdAt: number; +} + +interface ToastContextValue { + toasts: ToastItem[]; + pushToast: (input: ToastInput) => string | null; + dismissToast: (id: string) => void; + clearToasts: () => void; +} + +const DEFAULT_TTL_MS = 5000; +const MIN_TTL_MS = 1500; +const MAX_TTL_MS = 15000; +const MAX_TOASTS = 5; +const DEDUPE_WINDOW_MS = 3500; +const DEDUPE_MAX_AGE_MS = 20000; + +const ToastContext = createContext(null); + +function normalizeTtl(value: number | undefined) { + const fallback = DEFAULT_TTL_MS; + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, Math.floor(value))); +} + +function generateToastId() { + return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef(new Map()); + const dedupeRef = useRef(new Map()); + + const clearTimer = useCallback((id: string) => { + const handle = timersRef.current.get(id); + if (handle !== undefined) { + window.clearTimeout(handle); + timersRef.current.delete(id); + } + }, []); + + const dismissToast = useCallback( + (id: string) => { + clearTimer(id); + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, + [clearTimer], + ); + + const clearToasts = useCallback(() => { + for (const handle of timersRef.current.values()) { + window.clearTimeout(handle); + } + timersRef.current.clear(); + setToasts([]); + }, []); + + const pushToast = useCallback( + (input: ToastInput) => { + const now = Date.now(); + const tone = input.tone ?? "info"; + const ttlMs = normalizeTtl(input.ttlMs); + const dedupeKey = + input.dedupeKey ?? input.id ?? `${tone}|${input.title}|${input.body ?? ""}|${input.action?.href ?? ""}`; + + for (const [key, ts] of dedupeRef.current.entries()) { + if (now - ts > DEDUPE_MAX_AGE_MS) { + dedupeRef.current.delete(key); + } + } + + const lastSeen = dedupeRef.current.get(dedupeKey); + if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) { + return null; + } + dedupeRef.current.set(dedupeKey, now); + + const id = input.id ?? generateToastId(); + clearTimer(id); + + setToasts((prev) => { + const nextToast: ToastItem = { + id, + title: input.title, + body: input.body, + tone, + ttlMs, + action: input.action, + createdAt: now, + }; + + const withoutCurrent = prev.filter((toast) => toast.id !== id); + return [nextToast, ...withoutCurrent].slice(0, MAX_TOASTS); + }); + + const timeout = window.setTimeout(() => { + dismissToast(id); + }, ttlMs); + timersRef.current.set(id, timeout); + return id; + }, + [clearTimer, dismissToast], + ); + + useEffect(() => () => { + for (const handle of timersRef.current.values()) { + window.clearTimeout(handle); + } + timersRef.current.clear(); + }, []); + + const value = useMemo( + () => ({ + toasts, + pushToast, + dismissToast, + clearToasts, + }), + [toasts, pushToast, dismissToast, clearToasts], + ); + + return {children}; +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} diff --git a/ui/src/index.css b/ui/src/index.css index 06f86a4d..0759c501 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -242,7 +242,11 @@ } .paperclip-mdxeditor--borderless .mdxeditor-root-contenteditable { - padding: 0.2rem 0; + padding: 0; +} + +.paperclip-mdxeditor--borderless [class*="_contentEditable_"] { + padding: 0 !important; } .paperclip-mdxeditor [class*="_placeholder_"] { @@ -254,7 +258,7 @@ } .paperclip-mdxeditor--borderless [class*="_placeholder_"] { - padding: 0.2rem 0; + padding: 0; } .paperclip-mdxeditor-content { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index fd3ff4b0..58aec541 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -9,6 +9,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; +import { ToastProvider } from "./context/ToastContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import "@mdxeditor/editor/style.css"; import "./index.css"; @@ -26,21 +27,23 @@ createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ad6e437b..49891db5 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -6,6 +6,7 @@ import { activityApi } from "../api/activity"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; +import { useToast } from "../context/ToastContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -110,6 +111,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map(); const { selectedCompanyId } = useCompany(); + const { pushToast } = useToast(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); @@ -253,7 +255,14 @@ export function IssueDetail() { const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), - onSuccess: invalidateIssue, + onSuccess: (updated) => { + invalidateIssue(); + pushToast({ + dedupeKey: `issue-updated-${updated.id}`, + title: "Issue updated", + tone: "success", + }); + }, }); const addComment = useMutation({ @@ -262,6 +271,11 @@ export function IssueDetail() { onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + pushToast({ + dedupeKey: `issue-comment-${issueId}`, + title: "Comment posted", + tone: "success", + }); }, });