Skip to content
Draft
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
38 changes: 34 additions & 4 deletions src/components/app-builder/AppBuilderChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';

Expand Down Expand Up @@ -126,6 +128,7 @@ function AssistantMessageBubble({
metadata={message.metadata}
partial={message.partial}
isStreaming={isStreaming && message.partial}
onPreviewNavigate={onPreviewNavigate}
/>
</div>
</div>
Expand All @@ -135,15 +138,27 @@ 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 => {
const role = getMessageRole(msg);
if (role === 'user') {
return <UserMessageBubble key={msg.ts} message={msg} />;
}
return <AssistantMessageBubble key={msg.ts} message={msg} />;
return (
<AssistantMessageBubble
key={msg.ts}
message={msg}
onPreviewNavigate={onPreviewNavigate}
/>
);
})}
</>
);
Expand All @@ -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 (
<>
Expand All @@ -171,6 +188,7 @@ function DynamicMessages({
key={`${msg.ts}-${msg.partial}`}
message={msg}
isStreaming={isStreaming}
onPreviewNavigate={onPreviewNavigate}
/>
);
})}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -431,8 +457,12 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) {
</Button>
</div>
)}
<StaticMessages messages={staticMessages} />
<DynamicMessages messages={dynamicMessages} isStreaming={isStreaming} />
<StaticMessages messages={staticMessages} onPreviewNavigate={handlePreviewNavigate} />
<DynamicMessages
messages={dynamicMessages}
isStreaming={isStreaming}
onPreviewNavigate={handlePreviewNavigate}
/>
{isStreaming && dynamicMessages.length === 0 && <TypingIndicator />}
</>
)}
Expand Down
18 changes: 18 additions & 0 deletions src/components/app-builder/AppBuilderPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
23 changes: 23 additions & 0 deletions src/components/app-builder/ProjectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/components/app-builder/project-manager/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,33 @@ 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<string> {
const previewPath = getPreviewPath();
if (organizationId) {
const result = await trpcClient.organizations.appBuilder.sendMessage.mutate({
projectId,
organizationId,
message,
images,
model,
previewPath,
});
return result.cloudAgentSessionId;
} else {
Expand All @@ -142,6 +157,7 @@ export function createStreamingCoordinator(
message,
images,
model,
previewPath,
});
return result.cloudAgentSessionId;
}
Expand Down
77 changes: 64 additions & 13 deletions src/components/cloud-agent/MessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
function createMarkdownComponents(onPreviewNavigate?: (path: string) => void) {
function LinkRenderer({ href, children }: { href?: string; children?: ReactNode }) {
if (onPreviewNavigate && href && href.startsWith('/')) {
return (
<a
href={href}
onClick={e => {
e.preventDefault();
onPreviewNavigate(href);
}}
>
{children}
</a>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
return { a: LinkRenderer };
}

const markdownComponents = { a: LinkRenderer };
const defaultMarkdownComponents = createMarkdownComponents();

export interface MessageContentProps {
content: string;
Expand All @@ -32,6 +48,8 @@ export interface MessageContentProps {
metadata?: Record<string, unknown>;
partial?: boolean;
isStreaming?: boolean;
/** Callback for navigating the preview iframe to a relative path (e.g., "/about") */
onPreviewNavigate?: (path: string) => void;
}

/**
Expand All @@ -45,6 +63,7 @@ export function MessageContent({
metadata,
partial,
isStreaming,
onPreviewNavigate,
}: MessageContentProps) {
if (ask === 'tool' || ask === 'use_mcp_tool' || ask === 'command') {
return <ToolMessage metadata={metadata} partial={partial} ask={ask} content={content} />;
Expand All @@ -55,15 +74,27 @@ export function MessageContent({
}

if (say === 'completion_result') {
return <CompletionResultMessage content={content} isStreaming={isStreaming} />;
return (
<CompletionResultMessage
content={content}
isStreaming={isStreaming}
onPreviewNavigate={onPreviewNavigate}
/>
);
}

if (say === 'command_output') {
return <CommandOutputMessage content={content} />;
}

// Default: regular text message
return <TextMessage content={content} isStreaming={isStreaming} />;
return (
<TextMessage
content={content}
isStreaming={isStreaming}
onPreviewNavigate={onPreviewNavigate}
/>
);
}

/**
Expand Down Expand Up @@ -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 (
<div
className={cn(
Expand All @@ -160,7 +198,7 @@ function CompletionResultMessage({
)}
>
{content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
) : isStreaming ? (
Expand All @@ -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 (
<div
className={cn(
Expand All @@ -183,7 +234,7 @@ function TextMessage({ content, isStreaming }: { content: string; isStreaming?:
)}
>
{content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
) : isStreaming ? (
Expand Down
15 changes: 12 additions & 3 deletions src/lib/app-builder/app-builder-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InitiateSessionV2Output> {
const { projectId, owner, message, authToken, images, model } = input;
const { projectId, owner, message, authToken, images, model, previewPath } = input;

const project = await getProjectWithOwnershipCheck(projectId, owner);

Expand Down Expand Up @@ -501,9 +501,13 @@ export async function sendMessage(input: SendMessageInput): Promise<InitiateSess
// Session was prepared with internal gitUrl, but project is now on GitHub —
// create a new session with the GitHub repo
if (session.gitUrl && !session.githubRepo) {
// Prepend preview path context for GitHub migration path too
const migrationPrompt = previewPath
? `[The user is currently viewing: ${previewPath}]\n\n${message}`
: message;
const { cloudAgentSessionId: newSessionId } = await client.prepareSession({
githubRepo: project.git_repo_full_name,
prompt: message,
prompt: migrationPrompt,
mode: 'code',
model: effectiveModel,
upstreamBranch: 'main',
Expand Down Expand Up @@ -556,10 +560,15 @@ export async function sendMessage(input: SendMessageInput): Promise<InitiateSess
gitToken = tokenResult.token;
}

// Prepend preview path context to the prompt if available
const prompt = previewPath
? `[The user is currently viewing: ${previewPath}]\n\n${message}`
: message;

// Send message to existing session using V2 mutation (returns immediately)
const result = await client.sendMessageV2({
cloudAgentSessionId: project.session_id,
prompt: message,
prompt,
mode: 'code',
model: effectiveModel,
autoCommit: true,
Expand Down
Loading