mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend/builder): address PR review comments on chat panel
- Feature-flag the BuilderChatPanel behind BUILDER_CHAT_PANEL flag (ntindle) - Reset sessionId/initializedRef on flowID navigation (sentry x2) - Block input until session is ready to prevent pre-seed messages (coderabbitai) - Reset sessionError on panel reopen so retry works (coderabbitai) - Gate canvas invalidation on actual graph mutations only (coderabbitai) - Add comment explaining ActionItem applied=true is intentional (sentry) - Rename test and assert disabled state directly (coderabbitai)
This commit is contained in:
@@ -29,6 +29,7 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) {
|
||||
status,
|
||||
isCreatingSession,
|
||||
sessionError,
|
||||
sessionId,
|
||||
nodes,
|
||||
parsedActions,
|
||||
handleApplyAction,
|
||||
@@ -37,6 +38,10 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
// Block input until the session is ready to prevent messages being sent
|
||||
// before the seed context has been delivered to the AI.
|
||||
const canSend =
|
||||
Boolean(sessionId) && !isCreatingSession && !sessionError && !isStreaming;
|
||||
|
||||
// Scroll to bottom whenever a new message lands (AI response or user send)
|
||||
useEffect(() => {
|
||||
@@ -45,7 +50,7 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) {
|
||||
|
||||
function handleSend() {
|
||||
const text = inputValue.trim();
|
||||
if (!text || isStreaming) return;
|
||||
if (!text || !canSend) return;
|
||||
setInputValue("");
|
||||
sendMessage({ text });
|
||||
setTimeout(() => {
|
||||
@@ -88,6 +93,7 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) {
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
isDisabled={!canSend}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -219,6 +225,9 @@ function ActionItem({
|
||||
nodes: CustomNode[];
|
||||
onApply: () => void;
|
||||
}) {
|
||||
// The AI applies changes server-side via edit_agent; the canvas refreshes
|
||||
// automatically via invalidateQueries. The button starts in the applied state
|
||||
// to reflect that changes are already live — not pending user confirmation.
|
||||
const [applied, setApplied] = useState(true);
|
||||
|
||||
function handleApply() {
|
||||
@@ -260,6 +269,7 @@ interface PanelInputProps {
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
function PanelInput({
|
||||
@@ -269,17 +279,19 @@ function PanelInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming,
|
||||
isDisabled,
|
||||
}: PanelInputProps) {
|
||||
return (
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={value}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask about your agent…"
|
||||
rows={2}
|
||||
className="flex-1 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-violet-400 focus:outline-none focus:ring-1 focus:ring-violet-200"
|
||||
className="flex-1 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-violet-400 focus:outline-none focus:ring-1 focus:ring-violet-200 disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
@@ -292,7 +304,7 @@ function PanelInput({
|
||||
) : (
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!value.trim()}
|
||||
disabled={isDisabled || !value.trim()}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-violet-600 text-white transition-colors hover:bg-violet-700 disabled:opacity-40"
|
||||
aria-label="Send"
|
||||
>
|
||||
|
||||
@@ -122,8 +122,7 @@ describe("BuilderChatPanel", () => {
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Applied state by default and calls handleApplyAction when clicked", () => {
|
||||
const handleApplyAction = vi.fn();
|
||||
it("shows pre-applied actions as disabled", () => {
|
||||
const action = {
|
||||
type: "update_node_input" as const,
|
||||
nodeId: "1",
|
||||
@@ -134,11 +133,13 @@ describe("BuilderChatPanel", () => {
|
||||
makeMockHook({
|
||||
isOpen: true,
|
||||
parsedActions: [action],
|
||||
handleApplyAction,
|
||||
}),
|
||||
);
|
||||
render(<BuilderChatPanel />);
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Applied",
|
||||
}) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls sendMessage when the user submits a message", () => {
|
||||
|
||||
@@ -41,6 +41,14 @@ export function useBuilderChatPanel({
|
||||
const updateNodeData = useNodeStore(useShallow((s) => s.updateNodeData));
|
||||
const addEdge = useEdgeStore(useShallow((s) => s.addEdge));
|
||||
|
||||
// Reset session and initialized state when the user navigates to a different
|
||||
// graph so the new graph's context is sent to the AI on next open.
|
||||
useEffect(() => {
|
||||
setSessionId(null);
|
||||
setSessionError(false);
|
||||
initializedRef.current = false;
|
||||
}, [flowID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || sessionId || isCreatingSession || sessionError) return;
|
||||
|
||||
@@ -104,22 +112,48 @@ export function useBuilderChatPanel({
|
||||
// without including it in the deps array (avoids re-triggering the effect)
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Refresh the builder canvas after the AI finishes responding. The AI uses
|
||||
// edit_agent to modify the graph server-side; invalidating the query causes
|
||||
// useFlow.ts to re-fetch and repopulate nodeStore/edgeStore automatically.
|
||||
// Parsed actions from the last assistant message. Placed before the
|
||||
// invalidation effect so the effect can check whether a turn mutated the graph.
|
||||
const parsedActions = useMemo(() => {
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
const last = assistantMessages[assistantMessages.length - 1];
|
||||
if (!last) return [];
|
||||
const text = last.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const parsed = parseGraphActions(text);
|
||||
const seen = new Set<string>();
|
||||
return parsed.filter((action) => {
|
||||
const key =
|
||||
action.type === "update_node_input"
|
||||
? `${action.nodeId}:${action.key}`
|
||||
: `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// Refresh the canvas only when the AI turn actually mutated the graph via
|
||||
// edit_agent. Gating on parsedActions.length > 0 avoids an unnecessary
|
||||
// refetch after read-only turns (e.g. the initial description response).
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current;
|
||||
prevStatusRef.current = status;
|
||||
if (
|
||||
status === "ready" &&
|
||||
(prev === "streaming" || prev === "submitted") &&
|
||||
flowID
|
||||
flowID &&
|
||||
parsedActions.length > 0
|
||||
) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1GetSpecificGraphQueryKey(flowID),
|
||||
});
|
||||
}
|
||||
}, [status, flowID, queryClient]);
|
||||
}, [status, flowID, queryClient, parsedActions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !transport || !isGraphLoaded || initializedRef.current)
|
||||
@@ -142,6 +176,10 @@ export function useBuilderChatPanel({
|
||||
}, [sessionId, transport, isGraphLoaded]);
|
||||
|
||||
function handleToggle() {
|
||||
// Reset session error when reopening so the panel can retry session creation
|
||||
if (!isOpen && !sessionId) {
|
||||
setSessionError(false);
|
||||
}
|
||||
setIsOpen((o) => !o);
|
||||
}
|
||||
|
||||
@@ -167,29 +205,6 @@ export function useBuilderChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const parsedActions = useMemo(() => {
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
const last = assistantMessages[assistantMessages.length - 1];
|
||||
if (!last) return [];
|
||||
const text = last.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const parsed = parseGraphActions(text);
|
||||
const seen = new Set<string>();
|
||||
return parsed.filter((action) => {
|
||||
const key =
|
||||
action.type === "update_node_input"
|
||||
? `${action.nodeId}:${action.key}`
|
||||
: `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
handleToggle,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/grap
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { BuilderChatPanel } from "../../BuilderChatPanel/BuilderChatPanel";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
@@ -91,6 +92,8 @@ export const Flow = () => {
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
const isBuilderChatEnabled = useGetFlag(Flag.BUILDER_CHAT_PANEL);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
<div className="relative flex-1">
|
||||
@@ -135,7 +138,9 @@ export const Flow = () => {
|
||||
executionId={flowExecutionID || undefined}
|
||||
graphId={flowID || undefined}
|
||||
/>
|
||||
<BuilderChatPanel isGraphLoaded={isInitialLoadComplete} />
|
||||
{isBuilderChatEnabled && (
|
||||
<BuilderChatPanel isGraphLoaded={isInitialLoadComplete} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum Flag {
|
||||
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
|
||||
ARTIFACTS = "artifacts",
|
||||
CHAT_MODE_OPTION = "chat-mode-option",
|
||||
BUILDER_CHAT_PANEL = "builder-chat-panel",
|
||||
}
|
||||
|
||||
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -20,6 +21,7 @@ const defaultFlags = {
|
||||
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
|
||||
[Flag.ARTIFACTS]: false,
|
||||
[Flag.CHAT_MODE_OPTION]: false,
|
||||
[Flag.BUILDER_CHAT_PANEL]: false,
|
||||
};
|
||||
|
||||
type FlagValues = typeof defaultFlags;
|
||||
|
||||
Reference in New Issue
Block a user