Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
51 changes: 46 additions & 5 deletions components/workspace/intent-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export function IntentBar({
cancelDraft,
draftChangeset,
executionError,
infoResponse,
dismissInfo,
} = useWorkspace();
const { isDemo } = useLayout();
const [input, setInput] = useState("");
Expand All @@ -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 [];
Expand All @@ -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";
Expand All @@ -125,14 +129,51 @@ export function IntentBar({
</div>
)}

{/* Completion indicator */}
{phase === "complete" && (
{/* Completion indicator (changeset executed) */}
{phase === "complete" && !infoResponse && (
<div className="flex items-center gap-2 px-4 pt-2 text-xs text-emerald-600 dark:text-emerald-400 sm:px-6 lg:px-8">
<CheckCircleIcon className="size-3" />
<span>Changes applied successfully</span>
</div>
)}

{/* Informational response (read-only query / no operations) */}
{phase === "complete" && infoResponse && (
<div className="mx-4 mt-2 rounded-lg border bg-muted/50 p-3 text-sm sm:mx-6 lg:mx-8">
<p className="text-foreground">{infoResponse.reasoning}</p>
{infoResponse.readerText && (
<pre className="mt-2 max-h-48 overflow-y-auto whitespace-pre-wrap text-xs text-muted-foreground">
{infoResponse.readerText}
</pre>
)}
{infoResponse.suggestions && infoResponse.suggestions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{infoResponse.suggestions.map((s) => (
<button
key={s}
type="button"
className="intent-suggestion shrink-0 min-h-[32px]"
onClick={() => {
setInput(s);
dismissInfo();
inputRef.current?.focus();
}}
>
{s}
</button>
))}
</div>
)}
<button
type="button"
className="mt-1 text-xs text-muted-foreground underline hover:no-underline"
onClick={dismissInfo}
>
Dismiss
</button>
</div>
)}

{/* Error indicator */}
{phase === "error" && (
<div className="flex items-center gap-2 px-4 pt-2 text-xs text-destructive sm:px-6 lg:px-8">
Expand Down Expand Up @@ -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"
/>
<VoiceControls
Expand Down
94 changes: 65 additions & 29 deletions components/workspace/workspace-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ interface WorkspaceContextValue {
loading: boolean;
fetchError: string | null;
executionError: string | null;
/** Informational response when orchestrator returns no operations (read-only query). */
infoResponse: { reasoning: string; readerText?: string; suggestions?: string[] } | null;
dismissInfo: () => void;
selectedIds: Set<string>;
draftChangeset: ChangeSet | null;
phase: WorkspacePhase;
Expand Down Expand Up @@ -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<typeof OrchestratorResponseSchema>,
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();
Expand Down Expand Up @@ -453,6 +484,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
const [chatActivityPhase, setChatActivityPhase] = useState<ChatActivityPhase>("idle");
const [chatActivityChangeset, setChatActivityChangeset] = useState<ChangeSet | null>(null);
const [executionError, setExecutionError] = useState<string | null>(null);
const [infoResponse, setInfoResponse] = useState<{
reasoning: string;
readerText?: string;
suggestions?: string[];
} | null>(null);
const [fetchAttempt, setFetchAttempt] = useState(0);
const executeInFlightRef = useRef(false);

Expand Down Expand Up @@ -645,6 +681,7 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
async (text: string): Promise<ChangeSet | null> => {
setPhase("preview");
setExecutionError(null);
setInfoResponse(null);
setDraftChangeset(null); // clear stale draft immediately
try {
const selectedProducts = products.filter((p) =>
Expand All @@ -655,9 +692,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
? `\n\nSelected products: ${selectedProducts.map((p) => `${p.name} (${p.sku})`).join(", ")}`
: "";

const fetchHeaders: Record<string, string> = { "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 }),
});

Expand All @@ -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<ChangeSet | null> => {
setPhase("preview");
setExecutionError(null);
setInfoResponse(null);
setDraftChangeset(null); // clear stale draft immediately
try {
const targetProduct = products.find((p) => p.id === productId);
Expand All @@ -711,9 +741,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
}
const context = `\n\nSelected products: ${targetProduct.name} (${targetProduct.sku})`;

const fetchHeaders: Record<string, string> = { "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 }),
});

Expand All @@ -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 }> => {
Expand All @@ -761,9 +785,11 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
setPhase("executing");
setExecutionError(null);
try {
const execHeaders: Record<string, string> = { "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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -863,6 +895,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
loading,
fetchError,
executionError,
infoResponse,
dismissInfo,
selectedIds,
draftChangeset,
phase,
Expand Down Expand Up @@ -890,6 +924,8 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
loading,
fetchError,
executionError,
infoResponse,
dismissInfo,
selectedIds,
draftChangeset,
phase,
Expand Down
Loading