From 8935db738127f91516a204c4313ad9bc2b9568e3 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 17 Feb 2026 14:05:10 +0100 Subject: [PATCH] App Builder: rewrite system prompt, add preview awareness, and clickable preview links - Rewrite system prompt to use non-technical language with vocabulary guide, preview awareness section, and good/bad examples - Send current preview path with each message so the LLM knows which page the user is viewing (prepended as context to the prompt) - Make relative links in assistant messages clickable to navigate the preview iframe via postMessage, using a callback pattern on ProjectManager - Validate previewPath schema: must start with /, no whitespace, max 2048 chars --- src/components/app-builder/AppBuilderChat.tsx | 38 ++++++++- .../app-builder/AppBuilderPreview.tsx | 18 +++++ src/components/app-builder/ProjectManager.ts | 23 ++++++ .../app-builder/project-manager/streaming.ts | 16 ++++ src/components/cloud-agent/MessageContent.tsx | 77 +++++++++++++++--- src/lib/app-builder/app-builder-service.ts | 15 +++- src/lib/app-builder/constants.ts | 79 +++++++++++++++---- src/lib/app-builder/types.ts | 2 + src/routers/app-builder-router.ts | 1 + src/routers/app-builder/schemas.ts | 7 ++ .../organization-app-builder-router.ts | 1 + 11 files changed, 240 insertions(+), 37 deletions(-) diff --git a/src/components/app-builder/AppBuilderChat.tsx b/src/components/app-builder/AppBuilderChat.tsx index 405d84104..e43021d19 100644 --- a/src/components/app-builder/AppBuilderChat.tsx +++ b/src/components/app-builder/AppBuilderChat.tsx @@ -94,9 +94,11 @@ function UserMessageBubble({ message }: { message: CloudMessage }) { function AssistantMessageBubble({ message, isStreaming, + onPreviewNavigate, }: { message: CloudMessage; isStreaming?: boolean; + onPreviewNavigate?: (path: string) => void; }) { const content = message.text || message.content || ''; @@ -126,6 +128,7 @@ function AssistantMessageBubble({ metadata={message.metadata} partial={message.partial} isStreaming={isStreaming && message.partial} + onPreviewNavigate={onPreviewNavigate} /> @@ -135,7 +138,13 @@ function AssistantMessageBubble({ /** * Memoized static messages - never re-render once complete */ -const StaticMessages = memo(function StaticMessages({ messages }: { messages: CloudMessage[] }) { +const StaticMessages = memo(function StaticMessages({ + messages, + onPreviewNavigate, +}: { + messages: CloudMessage[]; + onPreviewNavigate?: (path: string) => void; +}) { return ( <> {messages.map(msg => { @@ -143,7 +152,13 @@ const StaticMessages = memo(function StaticMessages({ messages }: { messages: Cl if (role === 'user') { return ; } - return ; + return ( + + ); })} ); @@ -155,9 +170,11 @@ const StaticMessages = memo(function StaticMessages({ messages }: { messages: Cl function DynamicMessages({ messages, isStreaming, + onPreviewNavigate, }: { messages: CloudMessage[]; isStreaming: boolean; + onPreviewNavigate?: (path: string) => void; }) { return ( <> @@ -171,6 +188,7 @@ function DynamicMessages({ key={`${msg.ts}-${msg.partial}`} message={msg} isStreaming={isStreaming} + onPreviewNavigate={onPreviewNavigate} /> ); })} @@ -379,6 +397,14 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) { setHasImages(hasUploadedImages); }, []); + // Handle preview navigation from clickable links in chat messages + const handlePreviewNavigate = useCallback( + (path: string) => { + manager.navigatePreview(path); + }, + [manager] + ); + // Handle interrupt using ProjectManager const handleInterrupt = useCallback(() => { manager.interrupt(); @@ -431,8 +457,12 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) { )} - - + + {isStreaming && dynamicMessages.length === 0 && } )} diff --git a/src/components/app-builder/AppBuilderPreview.tsx b/src/components/app-builder/AppBuilderPreview.tsx index 3567a446e..624d5f8bb 100644 --- a/src/components/app-builder/AppBuilderPreview.tsx +++ b/src/components/app-builder/AppBuilderPreview.tsx @@ -441,6 +441,24 @@ export const AppBuilderPreview = memo(function AppBuilderPreview({ manager.setCurrentIframeUrl(null); }, [previewUrl, manager]); + // Register a preview navigation handler so chat links can navigate the iframe + useEffect(() => { + return manager.onPreviewNavigate(path => { + if (!previewUrl || !iframeRef.current?.contentWindow) return; + try { + const targetUrl = new URL(previewUrl); + targetUrl.pathname = path; + targetUrl.search = ''; + iframeRef.current.contentWindow.postMessage( + { type: 'kilo-preview-navigate', url: targetUrl.toString() }, + targetUrl.origin + ); + } catch { + // Invalid URL, ignore + } + }); + }, [manager, previewUrl]); + // Get tRPC for queries const trpc = useTRPC(); diff --git a/src/components/app-builder/ProjectManager.ts b/src/components/app-builder/ProjectManager.ts index b227de027..c70af1e19 100644 --- a/src/components/app-builder/ProjectManager.ts +++ b/src/components/app-builder/ProjectManager.ts @@ -198,6 +198,29 @@ export class ProjectManager { this.store.setState({ gitRepoFullName: repoFullName }); } + private previewNavigateHandler: ((path: string) => void) | null = null; + + /** + * Register a callback that navigates the preview iframe. + * Called by AppBuilderPreview on mount; returns an unregister function. + */ + onPreviewNavigate(handler: (path: string) => void): () => void { + this.previewNavigateHandler = handler; + return () => { + if (this.previewNavigateHandler === handler) { + this.previewNavigateHandler = null; + } + }; + } + + /** + * Navigate the preview iframe to a relative path. + * No-op if the preview hasn't registered a handler (e.g., iframe not ready). + */ + navigatePreview(path: string): void { + this.previewNavigateHandler?.(path); + } + /** * Deploy the project to production. * @returns Promise resolving to deployment result with URL or error diff --git a/src/components/app-builder/project-manager/streaming.ts b/src/components/app-builder/project-manager/streaming.ts index 5c44a4c8c..a79845a18 100644 --- a/src/components/app-builder/project-manager/streaming.ts +++ b/src/components/app-builder/project-manager/streaming.ts @@ -122,11 +122,25 @@ export function createStreamingCoordinator( /** * Calls the appropriate mutation to send a message. */ + /** + * Extract pathname from a full URL, returning '/' on failure. + */ + function getPreviewPath(): string | undefined { + const currentIframeUrl = store.getState().currentIframeUrl; + if (!currentIframeUrl) return undefined; + try { + return new URL(currentIframeUrl).pathname; + } catch { + return undefined; + } + } + async function callSendMessage( message: string, images?: Images, model?: string ): Promise { + const previewPath = getPreviewPath(); if (organizationId) { const result = await trpcClient.organizations.appBuilder.sendMessage.mutate({ projectId, @@ -134,6 +148,7 @@ export function createStreamingCoordinator( message, images, model, + previewPath, }); return result.cloudAgentSessionId; } else { @@ -142,6 +157,7 @@ export function createStreamingCoordinator( message, images, model, + previewPath, }); return result.cloudAgentSessionId; } diff --git a/src/components/cloud-agent/MessageContent.tsx b/src/components/cloud-agent/MessageContent.tsx index f09efe868..9c167c767 100644 --- a/src/components/cloud-agent/MessageContent.tsx +++ b/src/components/cloud-agent/MessageContent.tsx @@ -13,17 +13,33 @@ import { cn } from '@/lib/utils'; import { ToolExecutionCard } from './ToolExecutionCard'; import type { ToolExecution } from './types'; import remarkGfm from 'remark-gfm'; -import type { ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; -function LinkRenderer({ href, children }: { href?: string; children?: ReactNode }) { - return ( - - {children} - - ); +function createMarkdownComponents(onPreviewNavigate?: (path: string) => void) { + function LinkRenderer({ href, children }: { href?: string; children?: ReactNode }) { + if (onPreviewNavigate && href && href.startsWith('/')) { + return ( + { + e.preventDefault(); + onPreviewNavigate(href); + }} + > + {children} + + ); + } + return ( + + {children} + + ); + } + return { a: LinkRenderer }; } -const markdownComponents = { a: LinkRenderer }; +const defaultMarkdownComponents = createMarkdownComponents(); export interface MessageContentProps { content: string; @@ -32,6 +48,8 @@ export interface MessageContentProps { metadata?: Record; partial?: boolean; isStreaming?: boolean; + /** Callback for navigating the preview iframe to a relative path (e.g., "/about") */ + onPreviewNavigate?: (path: string) => void; } /** @@ -45,6 +63,7 @@ export function MessageContent({ metadata, partial, isStreaming, + onPreviewNavigate, }: MessageContentProps) { if (ask === 'tool' || ask === 'use_mcp_tool' || ask === 'command') { return ; @@ -55,7 +74,13 @@ export function MessageContent({ } if (say === 'completion_result') { - return ; + return ( + + ); } if (say === 'command_output') { @@ -63,7 +88,13 @@ export function MessageContent({ } // Default: regular text message - return ; + return ( + + ); } /** @@ -148,10 +179,17 @@ function ApiRequestMessage({ function CompletionResultMessage({ content, isStreaming, + onPreviewNavigate, }: { content: string; isStreaming?: boolean; + onPreviewNavigate?: (path: string) => void; }) { + const components = useMemo( + () => + onPreviewNavigate ? createMarkdownComponents(onPreviewNavigate) : defaultMarkdownComponents, + [onPreviewNavigate] + ); return (
{content ? ( - + {content} ) : isStreaming ? ( @@ -174,7 +212,20 @@ function CompletionResultMessage({ * Regular Text Message * Default message renderer */ -function TextMessage({ content, isStreaming }: { content: string; isStreaming?: boolean }) { +function TextMessage({ + content, + isStreaming, + onPreviewNavigate, +}: { + content: string; + isStreaming?: boolean; + onPreviewNavigate?: (path: string) => void; +}) { + const components = useMemo( + () => + onPreviewNavigate ? createMarkdownComponents(onPreviewNavigate) : defaultMarkdownComponents, + [onPreviewNavigate] + ); return (
{content ? ( - + {content} ) : isStreaming ? ( diff --git a/src/lib/app-builder/app-builder-service.ts b/src/lib/app-builder/app-builder-service.ts index a3a72ea73..128b9e83a 100644 --- a/src/lib/app-builder/app-builder-service.ts +++ b/src/lib/app-builder/app-builder-service.ts @@ -466,7 +466,7 @@ export async function startSessionForProject( * cloudAgentSessionId and should reconnect to the new session's WebSocket. */ export async function sendMessage(input: SendMessageInput): Promise { - const { projectId, owner, message, authToken, images, model } = input; + const { projectId, owner, message, authToken, images, model, previewPath } = input; const project = await getProjectWithOwnershipCheck(projectId, owner); @@ -501,9 +501,13 @@ export async function sendMessage(input: SendMessageInput): Promise