diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 78c09562046..e37bc16659b 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -3,7 +3,7 @@ import type { ExecutionWorkspace } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Loader2 } from "lucide-react"; import { executionWorkspacesApi } from "../api/execution-workspaces"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { formatDateTime, issueUrl } from "../lib/utils"; import { Button } from "./ui/button"; @@ -44,7 +44,7 @@ export function ExecutionWorkspaceCloseDialog({ onClosed, }: ExecutionWorkspaceCloseDialogProps) { const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace"; const readinessQuery = useQuery({ diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b81b30e4327..6d62cdeead7 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -77,7 +77,7 @@ vi.mock("../context/CompanyContext", () => ({ })); vi.mock("../context/ToastContext", () => ({ - useToast: () => toastState, + useToastActions: () => toastState, })); vi.mock("../api/issues", () => ({ diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 7798c2348aa..6e3447bb3f0 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -14,7 +14,7 @@ import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { buildExecutionPolicy } from "../lib/issue-execution-policy"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { assigneeValueFromSelection, currentUserAssigneeOption, @@ -280,7 +280,7 @@ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { companies, selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("todo"); diff --git a/ui/src/components/ToastViewport.tsx b/ui/src/components/ToastViewport.tsx index 7caa532d4a2..c79a3b4b121 100644 --- a/ui/src/components/ToastViewport.tsx +++ b/ui/src/components/ToastViewport.tsx @@ -1,7 +1,12 @@ import { useEffect, useState } from "react"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; -import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext"; +import { + useToastActions, + useToastState, + type ToastItem, + type ToastTone, +} from "../context/ToastContext"; import { cn } from "../lib/utils"; const toneClasses: Record = { @@ -75,7 +80,8 @@ function AnimatedToast({ } export function ToastViewport() { - const { toasts, dismissToast } = useToast(); + const toasts = useToastState(); + const { dismissToast } = useToastActions(); if (toasts.length === 0) return null; diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index f5b0a4b9f69..81f0fcbac88 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -6,7 +6,8 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; -import { useToast } from "./ToastContext"; +import { useToastActions } from "./ToastContext"; +import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments"; import { queryKeys } from "../lib/queryKeys"; import { toCompanyRelativePath } from "../lib/company-routes"; import { useLocation } from "../lib/router"; @@ -722,7 +723,7 @@ export const __liveUpdatesTestUtils = { export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const location = useLocation(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); const pathnameRef = useRef(location.pathname); diff --git a/ui/src/context/ToastContext.test.tsx b/ui/src/context/ToastContext.test.tsx new file mode 100644 index 00000000000..09912a315cb --- /dev/null +++ b/ui/src/context/ToastContext.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ToastProvider, useToastActions, useToastState } from "./ToastContext"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ToastContext", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("does not rerender action-only consumers when toast state changes", () => { + const root = createRoot(container); + let actionOnlyRenderCount = 0; + let pushToastRef: ((input: { title: string }) => string | null) | null = null; + let clearToastsRef: (() => void) | null = null; + + function ActionOnlyConsumer() { + actionOnlyRenderCount += 1; + const { pushToast, clearToasts } = useToastActions(); + pushToastRef = pushToast; + clearToastsRef = clearToasts; + return null; + } + + function ToastCount() { + const toasts = useToastState(); + return
{String(toasts.length)}
; + } + + act(() => { + root.render( + + + + , + ); + }); + + expect(actionOnlyRenderCount).toBe(1); + expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0"); + + act(() => { + pushToastRef?.({ title: "Saved" }); + }); + + expect(actionOnlyRenderCount).toBe(1); + expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("1"); + + act(() => { + clearToastsRef?.(); + }); + + expect(actionOnlyRenderCount).toBe(1); + expect(container.querySelector('[data-testid="toast-count"]')?.textContent).toBe("0"); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/context/ToastContext.tsx b/ui/src/context/ToastContext.tsx index ec2c4a74457..77720d31776 100644 --- a/ui/src/context/ToastContext.tsx +++ b/ui/src/context/ToastContext.tsx @@ -36,13 +36,16 @@ export interface ToastItem { createdAt: number; } -interface ToastContextValue { - toasts: ToastItem[]; +interface ToastActionsContextValue { pushToast: (input: ToastInput) => string | null; dismissToast: (id: string) => void; clearToasts: () => void; } +interface ToastContextValue extends ToastActionsContextValue { + toasts: ToastItem[]; +} + const DEFAULT_TTL_BY_TONE: Record = { info: 4000, success: 3500, @@ -55,7 +58,8 @@ const MAX_TOASTS = 5; const DEDUPE_WINDOW_MS = 3500; const DEDUPE_MAX_AGE_MS = 20000; -const ToastContext = createContext(null); +const ToastStateContext = createContext(null); +const ToastActionsContext = createContext(null); function normalizeTtl(value: number | undefined, tone: ToastTone) { const fallback = DEFAULT_TTL_BY_TONE[tone]; @@ -150,23 +154,40 @@ export function ToastProvider({ children }: { children: ReactNode }) { timersRef.current.clear(); }, []); - const value = useMemo( + const actions = useMemo( () => ({ - toasts, pushToast, dismissToast, clearToasts, }), - [toasts, pushToast, dismissToast, clearToasts], + [pushToast, dismissToast, clearToasts], ); - return {children}; + return ( + + {children} + + ); } -export function useToast() { - const context = useContext(ToastContext); +export function useToastState() { + const context = useContext(ToastStateContext); if (!context) { - throw new Error("useToast must be used within a ToastProvider"); + throw new Error("useToastState must be used within a ToastProvider"); } return context; } + +export function useToastActions() { + const context = useContext(ToastActionsContext); + if (!context) { + throw new Error("useToastActions must be used within a ToastProvider"); + } + return context; +} + +export function useToast() { + const toasts = useToastState(); + const actions = useToastActions(); + return useMemo(() => ({ toasts, ...actions }), [toasts, actions]); +} diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 80c73834f5f..0f303bcc2e2 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -27,7 +27,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { useToast } from "@/context/ToastContext"; +import { useToastActions } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; import { ChoosePathButton } from "@/components/PathInstructionsModal"; import { invalidateDynamicParser } from "@/adapters/dynamic-loader"; @@ -255,7 +255,7 @@ export function AdapterManager() { const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const [installPackage, setInstallPackage] = useState(""); const [installVersion, setInstallVersion] = useState(""); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index caa9057812f..354e4320744 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -18,7 +18,7 @@ import { issuesApi } from "../api/issues"; import { usePanel } from "../context/PanelContext"; import { useSidebar } from "../context/SidebarContext"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -1540,7 +1540,7 @@ function ConfigurationTab({ hideInstructionsFile?: boolean; }) { const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 8c4f46c960a..8dc87d3efc9 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -11,7 +11,7 @@ import type { import { useNavigate, useLocation } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { companiesApi } from "../api/companies"; @@ -580,7 +580,7 @@ function expandAncestors(filePath: string): string[] { export function CompanyExport() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const navigate = useNavigate(); const location = useLocation(); const { data: session, isFetched: isSessionFetched } = useQuery({ diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index b2c6676ca02..34fb4e67e67 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -9,7 +9,7 @@ import type { } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { authApi } from "../api/auth"; import { companiesApi } from "../api/companies"; import { agentsApi } from "../api/agents"; @@ -651,7 +651,7 @@ export function CompanyImport() { setSelectedCompanyId, } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const queryClient = useQueryClient(); const packageInputRef = useRef(null); const { data: session } = useQuery({ diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 30b39627daa..bc27f879a7c 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; import { assetsApi } from "../api/assets"; @@ -34,7 +34,7 @@ export function CompanySettings() { setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const queryClient = useQueryClient(); // General settings local state const [companyName, setCompanyName] = useState(""); diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5a95337599c..87f6e811aa6 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -14,7 +14,7 @@ import type { import { companySkillsApi } from "../api/companySkills"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { MarkdownBody } from "../components/MarkdownBody"; @@ -530,7 +530,7 @@ function SkillPane({ onSave: () => void; savePending: boolean; }) { - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); if (!detail) { if (loading) { @@ -759,7 +759,7 @@ export function CompanySkills() { const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); const [createOpen, setCreateOpen] = useState(false); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 982d7aceb2f..4f6b478c367 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -13,7 +13,7 @@ import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; @@ -373,7 +373,8 @@ export function IssueDetail() { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); + const { isMobile } = useSidebar(); const [moreOpen, setMoreOpen] = useState(false); const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index 3d422f237fc..dd951185301 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -27,7 +27,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { useToast } from "@/context/ToastContext"; +import { useToastActions } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; function firstNonEmptyLine(value: string | null | undefined): string | null { @@ -64,7 +64,7 @@ export function PluginManager() { const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const [installPackage, setInstallPackage] = useState(""); const [installDialogOpen, setInstallDialogOpen] = useState(false); diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 1b16e52f46f..ae848f078c0 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -12,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; @@ -455,7 +455,7 @@ export function ProjectDetail() { const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 6356b4b9988..1ff17a2547e 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -22,7 +22,7 @@ import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { timeAgo } from "../lib/timeAgo"; @@ -268,7 +268,7 @@ export function RoutineDetail() { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const hydratedRoutineIdRef = useRef(null); const titleInputRef = useRef(null); const descriptionEditorRef = useRef(null); diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 1d591dd7484..aba2412cf53 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -31,7 +31,7 @@ vi.mock("../context/BreadcrumbContext", () => ({ })); vi.mock("../context/ToastContext", () => ({ - useToast: () => ({ pushToast: vi.fn() }), + useToastActions: () => ({ pushToast: vi.fn() }), })); vi.mock("../api/routines", () => ({ diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index c77faa5402c..f36adeba121 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -9,7 +9,7 @@ import { issuesApi } from "../api/issues"; import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; @@ -293,7 +293,7 @@ export function Routines() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); const assigneeSelectorRef = useRef(null); diff --git a/ui/src/plugins/bridge.ts b/ui/src/plugins/bridge.ts index 846df45b8c7..6e7e564374c 100644 --- a/ui/src/plugins/bridge.ts +++ b/ui/src/plugins/bridge.ts @@ -34,7 +34,7 @@ import type { } from "@paperclipai/shared"; import { pluginsApi } from "@/api/plugins"; import { ApiError } from "@/api/client"; -import { useToast, type ToastInput } from "@/context/ToastContext"; +import { useToastActions, type ToastInput } from "@/context/ToastContext"; // --------------------------------------------------------------------------- // Bridge error type (mirrors the SDK's PluginBridgeError) @@ -369,7 +369,7 @@ export function useHostContext(): PluginHostContext { // --------------------------------------------------------------------------- export function usePluginToast(): PluginToastFn { - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); return useCallback( (input: PluginToastInput) => pushToast(input), [pushToast],