Smooth agent config save button state
This commit is contained in:
@@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||||
import type {
|
import type {
|
||||||
@@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build accumulated patch and send to parent */
|
/** Build accumulated patch and send to parent */
|
||||||
function handleSave() {
|
const handleCancel = useCallback(() => {
|
||||||
|
setOverlay({ ...emptyOverlay });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
if (isCreate || !isDirty) return;
|
if (isCreate || !isDirty) return;
|
||||||
const agent = props.agent;
|
const agent = props.agent;
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
@@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
props.onSave(patch);
|
props.onSave(patch);
|
||||||
}
|
}, [isCreate, isDirty, overlay, props]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreate) {
|
if (!isCreate) {
|
||||||
props.onDirtyChange?.(isDirty);
|
props.onDirtyChange?.(isDirty);
|
||||||
props.onSaveActionChange?.(() => handleSave());
|
props.onSaveActionChange?.(handleSave);
|
||||||
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
|
props.onCancelActionChange?.(handleCancel);
|
||||||
return () => {
|
|
||||||
props.onSaveActionChange?.(null);
|
|
||||||
props.onCancelActionChange?.(null);
|
|
||||||
props.onDirtyChange?.(false);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return;
|
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
|
||||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreate) return;
|
||||||
|
return () => {
|
||||||
|
props.onSaveActionChange?.(null);
|
||||||
|
props.onCancelActionChange?.(null);
|
||||||
|
props.onDirtyChange?.(false);
|
||||||
|
};
|
||||||
|
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
||||||
|
|
||||||
// ---- Resolve values ----
|
// ---- Resolve values ----
|
||||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ interface IssuesListProps {
|
|||||||
liveIssueIds?: Set<string>;
|
liveIssueIds?: Set<string>;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
viewStateKey: string;
|
viewStateKey: string;
|
||||||
|
issueLinkState?: unknown;
|
||||||
initialAssignees?: string[];
|
initialAssignees?: string[];
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
@@ -156,6 +157,7 @@ export function IssuesList({
|
|||||||
liveIssueIds,
|
liveIssueIds,
|
||||||
projectId,
|
projectId,
|
||||||
viewStateKey,
|
viewStateKey,
|
||||||
|
issueLinkState,
|
||||||
initialAssignees,
|
initialAssignees,
|
||||||
initialSearch,
|
initialSearch,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -591,6 +593,7 @@ export function IssuesList({
|
|||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
state={issueLinkState}
|
||||||
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
|
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
|
||||||
>
|
>
|
||||||
{/* Status icon - left column on mobile, inline on desktop */}
|
{/* Status icon - left column on mobile, inline on desktop */}
|
||||||
|
|||||||
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal file
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal file
@@ -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<IssueDetailBreadcrumb>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -437,7 +437,7 @@ export function AgentDetail() {
|
|||||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||||
}
|
}
|
||||||
const isPendingApproval = agent.status === "pending_approval";
|
const isPendingApproval = agent.status === "pending_approval";
|
||||||
const showConfigActionBar = activeView === "configuration" && configDirty;
|
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||||
@@ -1037,6 +1037,8 @@ function ConfigurationTab({
|
|||||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||||
|
const lastAgentRef = useRef(agent);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
queryKey:
|
queryKey:
|
||||||
@@ -1049,16 +1051,31 @@ function ConfigurationTab({
|
|||||||
|
|
||||||
const updateAgent = useMutation({
|
const updateAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||||
|
onMutate: () => {
|
||||||
|
setAwaitingRefreshAfterSave(true);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
setAwaitingRefreshAfterSave(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSavingChange(updateAgent.isPending);
|
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||||
}, [onSavingChange, updateAgent.isPending]);
|
setAwaitingRefreshAfterSave(false);
|
||||||
|
}
|
||||||
|
lastAgentRef.current = agent;
|
||||||
|
}, [agent, awaitingRefreshAfterSave]);
|
||||||
|
|
||||||
|
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSavingChange(isConfigSaving);
|
||||||
|
}, [onSavingChange, isConfigSaving]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1066,7 +1083,7 @@ function ConfigurationTab({
|
|||||||
mode="edit"
|
mode="edit"
|
||||||
agent={agent}
|
agent={agent}
|
||||||
onSave={(patch) => updateAgent.mutate(patch)}
|
onSave={(patch) => updateAgent.mutate(patch)}
|
||||||
isSaving={updateAgent.isPending}
|
isSaving={isConfigSaving}
|
||||||
adapterModels={adapterModels}
|
adapterModels={adapterModels}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
onSaveActionChange={onSaveActionChange}
|
onSaveActionChange={onSaveActionChange}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { CircleDot } from "lucide-react";
|
import { CircleDot } from "lucide-react";
|
||||||
@@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react";
|
|||||||
export function Issues() {
|
export function Issues() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -63,6 +65,15 @@ export function Issues() {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [liveRuns]);
|
}, [liveRuns]);
|
||||||
|
|
||||||
|
const issueLinkState = useMemo(
|
||||||
|
() =>
|
||||||
|
createIssueDetailLocationState(
|
||||||
|
"Issues",
|
||||||
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
),
|
||||||
|
[location.pathname, location.search, location.hash],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Issues" }]);
|
setBreadcrumbs([{ label: "Issues" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
@@ -93,6 +104,7 @@ export function Issues() {
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
liveIssueIds={liveIssueIds}
|
liveIssueIds={liveIssueIds}
|
||||||
viewStateKey="paperclip:issues-view"
|
viewStateKey="paperclip:issues-view"
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||||
initialSearch={initialSearch}
|
initialSearch={initialSearch}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|||||||
Reference in New Issue
Block a user