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:
Zamil Majdy
2026-04-08 02:47:47 +07:00
parent 6ed257225f
commit 8f855e5ea7
5 changed files with 71 additions and 36 deletions

View File

@@ -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"
>

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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;