From dc8c5da8f8d9199d68bdf562d6dd48d9cac9955d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 11:25:36 -0500 Subject: [PATCH 1/2] feat(ai): render generate_image tool calls inline, no accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_image tool calls now bypass the generic accordion shell entirely (same dispatch path as task/agent/question tools) and always render the image inline — loading placeholder, inline error row, or the finished image. Clicking the image navigates to where it's saved in the user's Home drive instead of opening a lightbox. - tool-call-dispatch.ts gains an 'image' dispatch kind for generate_image, handled before the generic renderer/registry. - registry.tsx moves generate_image from toolRenderers into SPECIAL_HANDLED_TOOLS, which also makes it a standalone tool call (never folded into a batched ToolRunGroup). - GeneratedImageRenderer now owns all three tool-call states and navigates via usePageNavigation(pageId, driveId) on click. - image-generation-tools.ts returns driveId (already computed, was previously dropped) so the click can navigate without a lookup. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RoFf2J7TjkpBRif7rwHhdR --- .../tool-calls/CompactToolCallRenderer.tsx | 3 + .../tool-calls/GeneratedImageRenderer.tsx | 130 +++++++++++------- .../chat/tool-calls/ToolCallRenderer.tsx | 3 + .../__tests__/tool-call-dispatch.test.ts | 5 + .../ai/shared/chat/tool-calls/registry.tsx | 18 +-- .../chat/tool-calls/tool-call-dispatch.ts | 7 + .../__tests__/image-generation-tools.test.ts | 8 +- .../lib/ai/tools/image-generation-tools.ts | 1 + 8 files changed, 107 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx index e2d01a3b3d..c521a5c6fb 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/CompactToolCallRenderer.tsx @@ -23,6 +23,7 @@ import { import { type TreeItem } from './PageTreeRenderer'; import { TaskRenderer } from './TaskRenderer'; +import { GeneratedImageRenderer } from './GeneratedImageRenderer'; import { TASK_TOOL_NAMES } from '../useAggregatedTasks'; import { PageAgentConversationRenderer } from '@/components/ai/page-agents'; import { AskUserQuestionCard } from '../ask-user/AskUserQuestionCard'; @@ -381,6 +382,8 @@ export const CompactToolCallRenderer: React.FC = m return ; case 'question': return ; + case 'image': + return ; case 'generic': return ( | null => { + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return null; + } + } + if (typeof value === 'object' && value !== null) { + return value as Record; + } + return null; +}; + +const BOX_SIZE = 'w-[260px] h-[260px] max-w-[260px] max-h-[260px]'; + /** - * Renders the result of the generate_image tool: the generated image (served from - * the durable /api/files/[id]/view route, re-presigned on each load) with a - * click-to-expand lightbox. Mirrors ImageMessageContent's img + Dialog pattern. + * Renders a generate_image tool call inline — no accordion, always visible. + * Owns all three tool-call states (loading/error/success) since it fully + * bypasses the generic accordion shell (see tool-call-dispatch.ts's 'image' + * kind). Clicking the finished image navigates to where it's saved in the + * user's Home drive (usePageNavigation), rather than opening a lightbox. */ -export const GeneratedImageRenderer: React.FC<{ data: GeneratedImageData }> = ({ data }) => { - const [lightboxOpen, setLightboxOpen] = useState(false); - const [hasError, setHasError] = useState(false); - const alt = data.title || data.prompt || 'Generated image'; +export const GeneratedImageRenderer: React.FC<{ part: GeneratedImageToolPart }> = ({ part }) => { + const { navigateToPage } = usePageNavigation(); + const [loadError, setLoadError] = useState(false); + + const parsedInput = safeJsonParse(part.input); + const parsedOutput = safeJsonParse(part.output) as GeneratedImageOutput | null; + const state = part.state ?? 'input-available'; - if (hasError) { + const failed = state === 'output-error' || parsedOutput?.success === false || loadError; + const isLoading = !failed && !parsedOutput?.viewUrl; + + if (isLoading) { return ( -
- - Generated image is unavailable. +
+
); } - return ( - <> - + if (failed) { + const message = part.errorText || parsedOutput?.error || 'Generated image is unavailable.'; + return ( +
+ + {message} +
+ ); + } - - - {alt} - {/* eslint-disable-next-line @next/next/no-img-element */} - {alt} - - - + const viewUrl = parsedOutput!.viewUrl!; + const prompt = parsedOutput?.prompt ?? (parsedInput?.prompt as string | undefined); + const alt = parsedOutput?.title || prompt || 'Generated image'; + const pageId = parsedOutput?.pageId; + + return ( + ); }; diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx index 919112a3ef..ccd86bf29a 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx @@ -8,6 +8,7 @@ import { import { PageAgentConversationRenderer } from '@/components/ai/page-agents'; import { AskUserQuestionCard } from '../ask-user/AskUserQuestionCard'; import { TaskRenderer } from './TaskRenderer'; +import { GeneratedImageRenderer } from './GeneratedImageRenderer'; import { TASK_TOOL_NAMES } from '../useAggregatedTasks'; import { renderToolContent } from './registry'; import { dispatchToolCall, resolveIntegrationToolLabel } from './tool-call-dispatch'; @@ -268,6 +269,8 @@ export const ToolCallRenderer: React.FC = memo(function T return ; case 'question': return ; + case 'image': + return ; case 'generic': return ; } diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/tool-call-dispatch.test.ts b/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/tool-call-dispatch.test.ts index 1a1b1d3032..e7ffe65218 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/tool-call-dispatch.test.ts +++ b/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/tool-call-dispatch.test.ts @@ -23,6 +23,11 @@ describe('dispatchToolCall', () => { expect(result.kind).toBe('agent'); }); + it('routes generate_image to the image branch', () => { + const result = dispatchToolCall(part({ toolName: 'generate_image' }), TASK_TOOL_NAMES); + expect(result.kind).toBe('image'); + }); + it('routes an ordinary tool to the generic branch with its own toolName', () => { const result = dispatchToolCall(part({ toolName: 'read_page' }), TASK_TOOL_NAMES); expect(result).toEqual({ diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/registry.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/registry.tsx index fac24fb16b..a1b59c4bd3 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/registry.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/registry.tsx @@ -22,7 +22,6 @@ import { CalendarEventListRenderer } from './calendar/CalendarEventListRenderer' import { CalendarAvailabilityRenderer, type FreeSlot } from './calendar/CalendarAvailabilityRenderer'; import { WorkflowListRenderer } from './workflow/WorkflowListRenderer'; import { WorkflowCard, type WorkflowData } from './workflow/WorkflowCard'; -import { GeneratedImageRenderer } from './GeneratedImageRenderer'; /** * Tool-call renderer registry. @@ -217,6 +216,7 @@ export const SPECIAL_HANDLED_TOOLS: Set = new Set([ ...TASK_TOOL_NAMES, 'ask_agent', ASK_USER_TOOL_NAME, + 'generate_image', ]); // pi uses lowercase tool names — these must match exactly what the pi coding agent sends. @@ -782,22 +782,6 @@ export const toolRenderers: Record = { ); }, - // === IMAGE GENERATION === - generate_image: ({ parsedInput, parsedOutput }) => { - if (parsedOutput.success === false) return null; - const viewUrl = parsedOutput.viewUrl as string | undefined; - if (!viewUrl) return null; - return ( - - ); - }, - // === ACTIVITY === get_activity: ({ parsedOutput }) => { if (parsedOutput.activities) { diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/tool-call-dispatch.ts b/apps/web/src/components/ai/shared/chat/tool-calls/tool-call-dispatch.ts index 914e9d7745..189a65f002 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/tool-call-dispatch.ts +++ b/apps/web/src/components/ai/shared/chat/tool-calls/tool-call-dispatch.ts @@ -10,6 +10,11 @@ import { isIntegrationTool, parseIntegrationToolName } from '@pagespace/lib/inte import { getBuiltinProvider } from '@pagespace/lib/integrations/providers/builtin-providers'; import { ASK_USER_TOOL_NAME } from '@/lib/ai/tools/ask-user-tools'; +// Not imported from image-generation-tools.ts: that module pulls in the DB +// client and billing services (server-only) and must never reach the client +// bundle. Matches the existing 'ask_agent' literal below. +const GENERATE_IMAGE_TOOL_NAME = 'generate_image'; + export interface DispatchToolPart { type: string; toolName?: string; @@ -25,6 +30,7 @@ export type ToolCallDispatchResult = | { kind: 'task'; part: TPart } | { kind: 'agent'; part: TPart } | { kind: 'question'; part: TPart } + | { kind: 'image'; part: TPart } | { kind: 'generic'; part: TPart; toolName: string }; const safeJsonParse = (value: unknown): Record | null => { @@ -71,6 +77,7 @@ export function dispatchToolCall( if (taskToolNames.has(toolName)) return { kind: 'task', part: resolvedPart }; if (toolName === 'ask_agent') return { kind: 'agent', part: resolvedPart }; if (toolName === ASK_USER_TOOL_NAME) return { kind: 'question', part: resolvedPart }; + if (toolName === GENERATE_IMAGE_TOOL_NAME) return { kind: 'image', part: resolvedPart }; return { kind: 'generic', part: resolvedPart, toolName }; } diff --git a/apps/web/src/lib/ai/tools/__tests__/image-generation-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/image-generation-tools.test.ts index 7db40863d8..e1dca12d46 100644 --- a/apps/web/src/lib/ai/tools/__tests__/image-generation-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/image-generation-tools.test.ts @@ -85,13 +85,13 @@ describe('generate_image execute', () => { const res = (await run( { prompt: 'a red panda astronaut' }, { userId: 'u1', isAdmin: true, subscriptionTier: 'pro', imageGenerationModel: 'google/gemini-3.1-flash-image-preview' }, - )) as { success: boolean; pageId: string; viewUrl: string }; + )) as { success: boolean; pageId: string; driveId: string; viewUrl: string }; assert({ given: 'an admin user and a working model', - should: 'return success with the file view URL', - actual: { success: res.success, viewUrl: res.viewUrl }, - expected: { success: true, viewUrl: '/api/files/page-9/view' }, + should: 'return success with the file view URL and drive ID', + actual: { success: res.success, viewUrl: res.viewUrl, driveId: res.driveId }, + expected: { success: true, viewUrl: '/api/files/page-9/view', driveId: 'home-1' }, }); expect(trackUsage).toHaveBeenCalledOnce(); const usage = trackUsage.mock.calls[0][0] as { holdId: string; providerCostDollars: number; source: string }; diff --git a/apps/web/src/lib/ai/tools/image-generation-tools.ts b/apps/web/src/lib/ai/tools/image-generation-tools.ts index d3d9181e92..468b2a4cea 100644 --- a/apps/web/src/lib/ai/tools/image-generation-tools.ts +++ b/apps/web/src/lib/ai/tools/image-generation-tools.ts @@ -263,6 +263,7 @@ illustrate an image, logo, diagram, or picture. Currently restricted to app admi return { success: true, pageId: created.pageId, + driveId: created.driveId, viewUrl: `/api/files/${created.pageId}/view`, title, mediaType: image.mediaType, From ee5c6ebe7ce8574d42a75a8ad947a0a4cb48ec76 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sun, 12 Jul 2026 12:14:16 -0500 Subject: [PATCH 2/2] fix(ai): treat error-only generate_image outputs as failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generate_image call routed through the execute_tool wrapper (used by search-mode/Global Assistant agents) can complete with an error-shaped output — { error: string } — that carries no success: false field (see execute-tool.ts's safeParse-failure and permission-denied branches). GeneratedImageRenderer treated "no viewUrl yet" as the only loading signal, so a completed-but-errored call spun its loading placeholder forever instead of showing the error row. failed now also triggers when output has arrived but has no viewUrl (covers a bare `error` field, or any other unexpected completed shape), and isLoading is derived from "no output yet" instead of "no viewUrl yet". Adds regression coverage for the execute_tool-shaped error case, the explicit success:false case, and the success/loading cases. Addresses review feedback from PR #2019 (chatgpt-codex-connector). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RoFf2J7TjkpBRif7rwHhdR --- .../tool-calls/GeneratedImageRenderer.tsx | 12 ++++- .../__tests__/GeneratedImageRenderer.test.tsx | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ai/shared/chat/tool-calls/__tests__/GeneratedImageRenderer.test.tsx diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/GeneratedImageRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/GeneratedImageRenderer.tsx index 29c29bf33c..3359a0103e 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/GeneratedImageRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/GeneratedImageRenderer.tsx @@ -52,9 +52,17 @@ export const GeneratedImageRenderer: React.FC<{ part: GeneratedImageToolPart }> const parsedInput = safeJsonParse(part.input); const parsedOutput = safeJsonParse(part.output) as GeneratedImageOutput | null; const state = part.state ?? 'input-available'; + const hasOutput = parsedOutput !== null; - const failed = state === 'output-error' || parsedOutput?.success === false || loadError; - const isLoading = !failed && !parsedOutput?.viewUrl; + // A completed call whose output has no viewUrl is a failure, not "still + // loading" — this also covers callers (e.g. the execute_tool wrapper used + // by search-mode agents) whose error responses carry `error` without an + // explicit `success: false`. + const failed = + state === 'output-error' || + loadError || + (hasOutput && (parsedOutput?.success === false || Boolean(parsedOutput?.error) || !parsedOutput?.viewUrl)); + const isLoading = !hasOutput && !failed; if (isLoading) { return ( diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/GeneratedImageRenderer.test.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/GeneratedImageRenderer.test.tsx new file mode 100644 index 0000000000..a0b5665b62 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/tool-calls/__tests__/GeneratedImageRenderer.test.tsx @@ -0,0 +1,54 @@ +/** + * GeneratedImageRenderer tests. + * + * Regression coverage for a real bug caught in review (PR #2019): a + * generate_image call routed through the execute_tool wrapper (used by + * search-mode/Global Assistant agents) can complete with an error-shaped + * output — `{ error: string }` — that carries no `success: false` field + * (see execute-tool.ts's safeParse-failure and permission-denied branches). + * The renderer used to treat "no viewUrl yet" as the ONLY loading signal, + * so a completed-but-errored call spun forever instead of showing the + * error row already implemented below it. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { GeneratedImageRenderer, type GeneratedImageToolPart } from '../GeneratedImageRenderer'; + +describe('GeneratedImageRenderer', () => { + it('shows a loading placeholder while no output has arrived yet', () => { + const part: GeneratedImageToolPart = { state: 'input-available', input: { prompt: 'a red panda' } }; + const { container } = render(); + expect(container.querySelector('svg.animate-spin')).not.toBeNull(); + }); + + it('shows the error row (not a spinner) for an execute_tool-style error output with no success field', () => { + const part: GeneratedImageToolPart = { + state: 'output-available', + input: { prompt: 'a red panda' }, + output: { error: 'Invalid parameters for "generate_image". Call tool_search(...)' }, + }; + const { container, getByText } = render(); + expect(container.querySelector('svg.animate-spin')).toBeNull(); + expect(getByText(/Invalid parameters for "generate_image"/)).toBeTruthy(); + }); + + it('shows the error row for an explicit success: false output', () => { + const part: GeneratedImageToolPart = { + state: 'output-available', + output: { success: false, error: 'Insufficient credits to generate an image.' }, + }; + const { getByText } = render(); + expect(getByText('Insufficient credits to generate an image.')).toBeTruthy(); + }); + + it('renders the image once viewUrl is present, with no loading spinner or error row', () => { + const part: GeneratedImageToolPart = { + state: 'output-available', + output: { success: true, viewUrl: '/api/files/page-9/view', pageId: 'page-9', driveId: 'home-1' }, + }; + const { container } = render(); + expect(container.querySelector('svg.animate-spin')).toBeNull(); + const img = container.querySelector('img'); + expect(img?.getAttribute('src')).toBe('/api/files/page-9/view'); + }); +});