diff --git a/CHANGELOG.md b/CHANGELOG.md index b92590e..0e7ddd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file. ### Fixes +- Fix workspace intent bar erroring on simple questions / read-only queries instead of showing response text - Fix bottom bar button/input height mismatch on desktop (Send and Mic buttons now match input height at sm+ breakpoint) - Fix missing voice and visual feedback after changeset execution via voice approval (PR `#29`) - Add error handling and logging to Gemini Live tool response pipeline to prevent silent failures (PR `#29`) diff --git a/components/workspace/intent-bar.tsx b/components/workspace/intent-bar.tsx index 03fc5ea..e84e859 100644 --- a/components/workspace/intent-bar.tsx +++ b/components/workspace/intent-bar.tsx @@ -73,6 +73,8 @@ export function IntentBar({ cancelDraft, draftChangeset, executionError, + infoResponse, + dismissInfo, } = useWorkspace(); const { isDemo } = useLayout(); const [input, setInput] = useState(""); @@ -90,13 +92,14 @@ export function IntentBar({ if (draftChangeset && (phase === "preview" || phase === "executing")) { return "Review changeset above"; } + if (phase === "complete" && infoResponse) return "Ask another question or describe a change..."; if (phase === "complete") return "Changes applied"; if (selectedProducts.length === 0) return "Describe a commerce change..."; if (selectedProducts.length === 1) return `${selectedProducts[0].name} — what to change?`; return `${selectedProducts.length} products — what to change?`; - }, [selectedProducts, draftChangeset, phase, voiceActive, voiceConnecting]); + }, [selectedProducts, draftChangeset, phase, voiceActive, voiceConnecting, infoResponse]); const chips = useMemo(() => { if (selectedProducts.length === 0) return []; @@ -107,9 +110,10 @@ export function IntentBar({ const handleSubmit = useCallback(() => { const trimmed = input.trim(); if (!trimmed || phase === "executing" || phase === "preview") return; + if (phase === "complete" && !infoResponse) return; setInput(""); submitIntent(trimmed); - }, [input, phase, submitIntent]); + }, [input, phase, submitIntent, infoResponse]); const isBusy = phase === "executing" || phase === "preview"; const hasDraft = !!draftChangeset && phase === "preview"; @@ -125,14 +129,51 @@ export function IntentBar({ )} - {/* Completion indicator */} - {phase === "complete" && ( + {/* Completion indicator (changeset executed) */} + {phase === "complete" && !infoResponse && (
Changes applied successfully
)} + {/* Informational response (read-only query / no operations) */} + {phase === "complete" && infoResponse && ( +
+

{infoResponse.reasoning}

+ {infoResponse.readerText && ( +
+              {infoResponse.readerText}
+            
+ )} + {infoResponse.suggestions && infoResponse.suggestions.length > 0 && ( +
+ {infoResponse.suggestions.map((s) => ( + + ))} +
+ )} + +
+ )} + {/* Error indicator */} {phase === "error" && (
@@ -203,7 +244,7 @@ export function IntentBar({ value={input} onChange={(e) => setInput(e.target.value)} placeholder={placeholder} - disabled={isBusy || phase === "complete"} + disabled={isBusy || (phase === "complete" && !infoResponse)} className="flex-1" /> void; selectedIds: Set; draftChangeset: ChangeSet | null; phase: WorkspacePhase; @@ -136,11 +139,39 @@ const ChangeSetSchema = z.object({ }).passthrough(); const OrchestratorResponseSchema = z.object({ - changeSet: ChangeSetSchema, + changeSet: ChangeSetSchema.nullable(), reasoning: z.string(), readerText: z.string().optional(), + suggestions: z.array(z.string()).optional(), }); +/** + * Resolve an orchestrator response into either a ChangeSet (action) or an + * informational response (read-only query / no operations). + */ +function resolveOrchestratorResponse( + data: z.infer, + setDraftChangeset: (cs: ChangeSet | null) => void, + setInfoResponse: (info: { reasoning: string; readerText?: string; suggestions?: string[] } | null) => void, + setPhase: (phase: WorkspacePhase) => void, +): ChangeSet | null { + const { changeSet: rawCs, reasoning, readerText, suggestions } = data; + + // Read-only / informational response (no operations to draft) + if (!rawCs || !Array.isArray(rawCs.operations) || rawCs.operations.length === 0) { + setDraftChangeset(null); + setInfoResponse({ reasoning, readerText, suggestions }); + setPhase("complete"); + return null; + } + + // Schema validates structural shape; cast through unknown since Zod passthrough + // infers a wider type than the full ChangeSet with its deep nested types + const cs = rawCs as unknown as ChangeSet; + setDraftChangeset(cs); + return cs; +} + const ExecutorResponseSchema = z.object({ changeSet: ChangeSetSchema, }).passthrough(); @@ -453,6 +484,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { const [chatActivityPhase, setChatActivityPhase] = useState("idle"); const [chatActivityChangeset, setChatActivityChangeset] = useState(null); const [executionError, setExecutionError] = useState(null); + const [infoResponse, setInfoResponse] = useState<{ + reasoning: string; + readerText?: string; + suggestions?: string[]; + } | null>(null); const [fetchAttempt, setFetchAttempt] = useState(0); const executeInFlightRef = useRef(false); @@ -645,6 +681,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { async (text: string): Promise => { setPhase("preview"); setExecutionError(null); + setInfoResponse(null); setDraftChangeset(null); // clear stale draft immediately try { const selectedProducts = products.filter((p) => @@ -655,9 +692,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { ? `\n\nSelected products: ${selectedProducts.map((p) => `${p.name} (${p.sku})`).join(", ")}` : ""; + const fetchHeaders: Record = { "Content-Type": "application/json" }; + if (isDemo) fetchHeaders["x-demo-session"] = "1"; const res = await fetch("/api/orchestrator", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: fetchHeaders, body: JSON.stringify({ message: text + context }), }); @@ -675,31 +714,22 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { setPhase("error"); return null; } - // Schema validates structural shape; cast through unknown since Zod passthrough - // infers a wider type than the full ChangeSet with its deep nested types - const cs = validated.data.changeSet as unknown as ChangeSet; - if (!Array.isArray(cs.operations) || cs.operations.length === 0) { - console.error("[workspace] Orchestrator returned changeset with 0 operations"); - setExecutionError("No operations generated — try a more specific request (e.g. \"Change price to $79\")"); - setDraftChangeset(null); - setPhase("error"); - return null; - } - setDraftChangeset(cs); - return cs; + + return resolveOrchestratorResponse(validated.data, setDraftChangeset, setInfoResponse, setPhase); } catch { setDraftChangeset(null); setPhase("error"); return null; } }, - [products, selectedIds], + [products, selectedIds, isDemo], ); const submitIntentForProduct = useCallback( async (text: string, productId: string): Promise => { setPhase("preview"); setExecutionError(null); + setInfoResponse(null); setDraftChangeset(null); // clear stale draft immediately try { const targetProduct = products.find((p) => p.id === productId); @@ -711,9 +741,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { } const context = `\n\nSelected products: ${targetProduct.name} (${targetProduct.sku})`; + const fetchHeaders: Record = { "Content-Type": "application/json" }; + if (isDemo) fetchHeaders["x-demo-session"] = "1"; const res = await fetch("/api/orchestrator", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: fetchHeaders, body: JSON.stringify({ message: text + context }), }); @@ -731,23 +763,15 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { setPhase("error"); return null; } - const cs = validated.data.changeSet as unknown as ChangeSet; - if (!Array.isArray(cs.operations) || cs.operations.length === 0) { - console.error("[workspace] Orchestrator returned changeset with 0 operations"); - setExecutionError("No operations generated — try a more specific request (e.g. \"Change price to $79\")"); - setDraftChangeset(null); - setPhase("error"); - return null; - } - setDraftChangeset(cs); - return cs; + + return resolveOrchestratorResponse(validated.data, setDraftChangeset, setInfoResponse, setPhase); } catch { setDraftChangeset(null); setPhase("error"); return null; } }, - [products], + [products, isDemo], ); const executeChangeset = useCallback(async (overrideCs?: ChangeSet): Promise<{ success: boolean; status?: string; error?: string }> => { @@ -761,9 +785,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { setPhase("executing"); setExecutionError(null); try { + const execHeaders: Record = { "Content-Type": "application/json" }; + if (isDemo) execHeaders["x-demo-session"] = "1"; const res = await fetch("/api/orchestrator/execute", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: execHeaders, body: JSON.stringify({ changeSet: cs }), }); if (!res.ok) { @@ -824,15 +850,21 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { executeInFlightRef.current = false; return { success: false, status: "failed", error: msg }; } - }, [draftChangeset]); + }, [draftChangeset, isDemo]); const cancelDraft = useCallback(() => { setDraftChangeset(null); setExecutionError(null); + setInfoResponse(null); executeInFlightRef.current = false; setPhase("idle"); }, []); + const dismissInfo = useCallback(() => { + setInfoResponse(null); + setPhase("idle"); + }, []); + const applyExecutedChangeset = useCallback((cs: ChangeSet) => { const opsToApply = filterSuccessfulOps( cs.operations, @@ -863,6 +895,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { loading, fetchError, executionError, + infoResponse, + dismissInfo, selectedIds, draftChangeset, phase, @@ -890,6 +924,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { loading, fetchError, executionError, + infoResponse, + dismissInfo, selectedIds, draftChangeset, phase,