diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 06cf01d4..103a3cb4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { @@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } /** Build accumulated patch and send to parent */ - function handleSave() { + const handleCancel = useCallback(() => { + setOverlay({ ...emptyOverlay }); + }, []); + + const handleSave = useCallback(() => { if (isCreate || !isDirty) return; const agent = props.agent; const patch: Record = {}; @@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } props.onSave(patch); - } + }, [isCreate, isDirty, overlay, props]); useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); - props.onSaveActionChange?.(() => handleSave()); - props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay })); - return () => { - props.onSaveActionChange?.(null); - props.onCancelActionChange?.(null); - props.onDirtyChange?.(false); - }; + props.onSaveActionChange?.(handleSave); + props.onCancelActionChange?.(handleCancel); } - return; - }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); + + useEffect(() => { + if (isCreate) return; + return () => { + props.onSaveActionChange?.(null); + props.onCancelActionChange?.(null); + props.onDirtyChange?.(false); + }; + }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 10d0709b..481ce933 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -142,6 +142,7 @@ interface IssuesListProps { liveIssueIds?: Set; projectId?: string; viewStateKey: string; + issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; onSearchChange?: (search: string) => void; @@ -156,6 +157,7 @@ export function IssuesList({ liveIssueIds, projectId, viewStateKey, + issueLinkState, initialAssignees, initialSearch, onSearchChange, @@ -591,6 +593,7 @@ export function IssuesList({ {/* Status icon - left column on mobile, inline on desktop */} diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts new file mode 100644 index 00000000..ba330eb3 --- /dev/null +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -0,0 +1,24 @@ +type IssueDetailBreadcrumb = { + label: string; + href: string; +}; + +type IssueDetailLocationState = { + issueDetailBreadcrumb?: IssueDetailBreadcrumb; +}; + +function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return typeof candidate.label === "string" && typeof candidate.href === "string"; +} + +export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { + return { issueDetailBreadcrumb: { label, href } }; +} + +export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { + if (typeof state !== "object" || state === null) return null; + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + return isIssueDetailBreadcrumb(candidate) ? candidate : null; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 3cbb6394..fc97cbc7 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -437,7 +437,7 @@ export function AgentDetail() { return ; } const isPendingApproval = agent.status === "pending_approval"; - const showConfigActionBar = activeView === "configuration" && configDirty; + const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving); return (
@@ -1037,6 +1037,8 @@ function ConfigurationTab({ updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); + const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); + const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ queryKey: @@ -1049,16 +1051,31 @@ function ConfigurationTab({ const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), + onMutate: () => { + setAwaitingRefreshAfterSave(true); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, + onError: () => { + setAwaitingRefreshAfterSave(false); + }, }); useEffect(() => { - onSavingChange(updateAgent.isPending); - }, [onSavingChange, updateAgent.isPending]); + if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { + setAwaitingRefreshAfterSave(false); + } + lastAgentRef.current = agent; + }, [agent, awaitingRefreshAfterSave]); + + const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; + + useEffect(() => { + onSavingChange(isConfigSaving); + }, [onSavingChange, isConfigSaving]); return (
@@ -1066,7 +1083,7 @@ function ConfigurationTab({ mode="edit" agent={agent} onSave={(patch) => updateAgent.mutate(patch)} - isSaving={updateAgent.isPending} + isSaving={isConfigSaving} adapterModels={adapterModels} onDirtyChange={onDirtyChange} onSaveActionChange={onSaveActionChange} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index fce74c7a..5b601e48 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useCallback, useRef } from "react"; -import { useSearchParams } from "@/lib/router"; +import { useLocation, useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; @@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react"; export function Issues() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const location = useLocation(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); @@ -63,6 +65,15 @@ export function Issues() { return ids; }, [liveRuns]); + const issueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Issues", + `${location.pathname}${location.search}${location.hash}`, + ), + [location.pathname, location.search, location.hash], + ); + useEffect(() => { setBreadcrumbs([{ label: "Issues" }]); }, [setBreadcrumbs]); @@ -93,6 +104,7 @@ export function Issues() { agents={agents} liveIssueIds={liveIssueIds} viewStateKey="paperclip:issues-view" + issueLinkState={issueLinkState} initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange}