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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -381,6 +382,8 @@ export const CompactToolCallRenderer: React.FC<CompactToolCallRendererProps> = m
return <PageAgentConversationRenderer part={dispatch.part} />;
case 'question':
return <AskUserQuestionCard part={dispatch.part} />;
case 'image':
return <GeneratedImageRenderer part={dispatch.part} />;
case 'generic':
return (
<CompactToolCallRendererInternal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,110 @@

import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { ImageOff } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { ImageOff, Loader2 } from 'lucide-react';
import { usePageNavigation } from '@/hooks/usePageNavigation';

export interface GeneratedImageData {
viewUrl: string;
export interface GeneratedImageToolPart {
state?: 'input-streaming' | 'input-available' | 'output-available' | 'output-error' | 'done' | 'streaming';
input?: unknown;
output?: unknown;
errorText?: string;
}

interface GeneratedImageOutput {
success?: boolean;
error?: string;
viewUrl?: string;
title?: string;
prompt?: string;
pageId?: string;
driveId?: string;
}

const safeJsonParse = (value: unknown): Record<string, unknown> | null => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return null;
}
}
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
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';
const hasOutput = parsedOutput !== null;

if (hasError) {
// 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 (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-muted-foreground">
<ImageOff className="h-4 w-4" />
Generated image is unavailable.
<div className={cn(BOX_SIZE, 'flex items-center justify-center rounded-md border border-border/50 bg-muted/50 my-2 animate-pulse')}>
<Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
</div>
);
}

return (
<>
<button
type="button"
onClick={() => setLightboxOpen(true)}
className={cn(
'relative block rounded-md overflow-hidden border border-border/50 mb-1',
'hover:border-border hover:shadow-sm transition-all cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
title={data.prompt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.viewUrl}
alt={alt}
className="object-cover max-w-[260px] max-h-[260px]"
onError={() => setHasError(true)}
/>
</button>
if (failed) {
const message = part.errorText || parsedOutput?.error || 'Generated image is unavailable.';
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-muted-foreground my-2">
<ImageOff className="h-4 w-4 shrink-0" />
{message}
</div>
);
}

<Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] p-2">
<DialogTitle className="sr-only">{alt}</DialogTitle>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.viewUrl}
alt={alt}
className="max-w-full max-h-[85vh] object-contain mx-auto"
/>
</DialogContent>
</Dialog>
</>
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 (
<button
type="button"
onClick={() => pageId && navigateToPage(pageId, parsedOutput?.driveId)}
disabled={!pageId}
className={cn(
'relative block rounded-md overflow-hidden border border-border/50 my-2',
pageId && 'hover:border-border hover:shadow-sm transition-all cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
title={pageId ? 'Open in your drive' : prompt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewUrl}
alt={alt}
className="object-cover max-w-[260px] max-h-[260px]"
onError={() => setLoadError(true)}
/>
</button>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -268,6 +269,8 @@ export const ToolCallRenderer: React.FC<ToolCallRendererProps> = memo(function T
return <PageAgentConversationRenderer part={dispatch.part} />;
case 'question':
return <AskUserQuestionCard part={dispatch.part} />;
case 'image':
return <GeneratedImageRenderer part={dispatch.part} />;
case 'generic':
return <ToolCallRendererInternal part={dispatch.part} toolName={dispatch.toolName} open={open} onOpenChange={onOpenChange} />;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<GeneratedImageRenderer part={part} />);
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(<GeneratedImageRenderer part={part} />);
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(<GeneratedImageRenderer part={part} />);
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(<GeneratedImageRenderer part={part} />);
expect(container.querySelector('svg.animate-spin')).toBeNull();
const img = container.querySelector('img');
expect(img?.getAttribute('src')).toBe('/api/files/page-9/view');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
18 changes: 1 addition & 17 deletions apps/web/src/components/ai/shared/chat/tool-calls/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -217,6 +216,7 @@ export const SPECIAL_HANDLED_TOOLS: Set<string> = new Set<string>([
...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.
Expand Down Expand Up @@ -782,22 +782,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
);
},

// === IMAGE GENERATION ===
generate_image: ({ parsedInput, parsedOutput }) => {
if (parsedOutput.success === false) return null;
const viewUrl = parsedOutput.viewUrl as string | undefined;
if (!viewUrl) return null;
return (
<GeneratedImageRenderer
data={{
viewUrl,
title: parsedOutput.title as string | undefined,
prompt: (parsedOutput.prompt as string | undefined) ?? (parsedInput?.prompt as string | undefined),
}}
/>
);
},

// === ACTIVITY ===
get_activity: ({ parsedOutput }) => {
if (parsedOutput.activities) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +30,7 @@ export type ToolCallDispatchResult<TPart extends DispatchToolPart> =
| { 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<string, unknown> | null => {
Expand Down Expand Up @@ -71,6 +77,7 @@ export function dispatchToolCall<TPart extends DispatchToolPart>(
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 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai/tools/image-generation-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down