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
68 changes: 65 additions & 3 deletions modules/playground/components/ai-chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Bot,
Send,
Expand All @@ -20,6 +26,7 @@ import {
Zap,
Code2,
ChevronDown,
Download,
} from "lucide-react";
import {
useAI,
Expand All @@ -33,6 +40,10 @@ import { useFileExplorer } from "@/modules/playground/hooks/useFileExplorer";
import { toast } from "sonner";
import type { TemplateFolder } from "@/modules/playground/lib/path-to-json";
import { useChat } from "@ai-sdk/react";
import {
exportChatAsMarkdown,
exportChatAsJSON,
} from "@/modules/playground/lib/chat-export";

interface AIChatPanelProps {
templateData: TemplateFolder | null;
Expand Down Expand Up @@ -297,6 +308,34 @@ export default function AIChatPanel({
}
};

const handleExportMarkdown = () => {
if (messages.length === 0) {
toast.error("No messages to export");
return;
}
try {
exportChatAsMarkdown(messages);
toast.success("Chat exported as Markdown");
} catch (error) {
console.error("Export error:", error);
toast.error("Failed to export chat");
}
};

const handleExportJSON = () => {
if (messages.length === 0) {
toast.error("No messages to export");
return;
}
try {
exportChatAsJSON(messages);
toast.success("Chat exported as JSON");
} catch (error) {
console.error("Export error:", error);
toast.error("Failed to export chat");
}
};

const clearChat = () => setMessages([]);
const currentProvider = PROVIDERS.find((p) => p.id === provider) || PROVIDERS[0];

Expand All @@ -316,9 +355,32 @@ export default function AIChatPanel({
</SheetDescription>
</div>
</div>
<Button size="icon" variant="ghost" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" onClick={clearChat} title="Clear chat">
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Export chat"
disabled={messages.length === 0}
>
<Download className="h-4 w-4" />
</Button>
Comment on lines +361 to +369

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add explicit aria-label to icon-only action buttons.

These controls currently rely on title; screen-reader support for title is inconsistent. Add aria-label for reliable accessibility.

Suggested fix
                                     <Button 
                                         size="icon" 
                                         variant="ghost" 
                                         className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors" 
                                         title="Export chat"
+                                        aria-label="Export chat"
                                         disabled={messages.length === 0}
                                     >
                                         <Download className="h-4 w-4" />
                                     </Button>
...
-                            <Button size="icon" variant="ghost" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" onClick={clearChat} title="Clear chat">
+                            <Button size="icon" variant="ghost" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" onClick={clearChat} title="Clear chat" aria-label="Clear chat">
                                 <Trash2 className="h-4 w-4" />
                             </Button>

Also applies to: 380-382

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/playground/components/ai-chat-panel.tsx` around lines 361 - 369, Add
explicit aria-label attributes to the icon-only <Button> elements in
ai-chat-panel (the ones using the <Download> icon and the other icon-only button
around lines referenced) so screen readers get a reliable description instead of
relying on title; update the <Button> components that currently have
title="Export chat" (and the other icon-only button) to include a matching
aria-label (e.g., aria-label="Export chat") while preserving
disabled={messages.length === 0}, size="icon", variant="ghost", and existing
className props.

</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleExportMarkdown} className="cursor-pointer">
<span>📄 Export as Markdown</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportJSON} className="cursor-pointer">
<span>📋 Export as JSON</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="icon" variant="ghost" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" onClick={clearChat} title="Clear chat">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>

Expand Down
150 changes: 150 additions & 0 deletions modules/playground/lib/chat-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Chat Export Utilities
* Handles exporting chat messages to Markdown and JSON formats
*/

export interface ExportMessage {
role: "user" | "assistant";
content: string;
timestamp?: Date;
}

/**
* Format messages for Markdown export
*/
export function exportToMarkdown(messages: any[]): string {
if (messages.length === 0) {
return "# Chat Transcript\n\nNo messages to export.";
}

const timestamp = new Date().toLocaleString();
let markdown = `# Chat Transcript\n\n`;
markdown += `**Exported on:** ${timestamp}\n\n`;
markdown += `---\n\n`;

messages.forEach((msg, index) => {
const rawParts: any[] = (msg as any).parts ?? [];
const textParts = rawParts.filter((p: any) => p.type === "text");
const textContent: string = (
textParts.map((p: any) => p.text).join("") ||
(msg as any).content ||
""
);

// Skip synthetic messages
if (msg.role === "user" && textParts.length === 0) {
return;
}

const roleLabel = msg.role === "user" ? "👤 You" : "🤖 Assistant";
const msgTime = msg.createdAt
? new Date(msg.createdAt).toLocaleTimeString()
: "";

markdown += `## ${roleLabel}`;
if (msgTime) {
markdown += ` *(${msgTime})*`;
}
markdown += `\n\n`;
markdown += `${textContent}\n\n`;
markdown += `---\n\n`;
});

return markdown;
}

/**
* Format messages for JSON export
*/
export function exportToJSON(messages: any[]): string {
const exportData = {
version: "1.0",
exportedAt: new Date().toISOString(),
messageCount: messages.length,
messages: messages
Comment on lines +63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

messageCount can be incorrect after filtering synthetic messages.

Line 63 uses the original array length, but Line 94 removes entries. Metadata can disagree with messages.length in the exported JSON.

Suggested fix
 export function exportToJSON(messages: any[]): string {
+  const exportedMessages = messages
+    .map((msg) => {
+      // ...existing mapping logic
+    })
+    .filter(Boolean);
+
   const exportData = {
     version: "1.0",
     exportedAt: new Date().toISOString(),
-    messageCount: messages.length,
-    messages: messages
-      .map((msg) => {
-        // ...
-      })
-      .filter(Boolean),
+    messageCount: exportedMessages.length,
+    messages: exportedMessages,
   };

Also applies to: 94-94

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/playground/lib/chat-export.ts` around lines 63 - 64, The exported
JSON currently sets messageCount to messages.length before synthetic entries are
removed, causing a mismatch; after the synthetic-message filtering step (the
code that removes synthetic messages from messages), recompute and set
messageCount using the filtered array (e.g., use filteredMessages.length or the
post-filter messages variable) so the messageCount and messages fields in the
export match; update any other places that rely on messages.length to use the
filtered collection instead.

.map((msg) => {
const rawParts: any[] = (msg as any).parts ?? [];
const textParts = rawParts.filter((p: any) => p.type === "text");
const textContent: string = (
textParts.map((p: any) => p.text).join("") ||
(msg as any).content ||
""
);

// Skip synthetic messages
if (msg.role === "user" && textParts.length === 0) {
return null;
}

return {
id: msg.id,
role: msg.role,
content: textContent,
timestamp: msg.createdAt ? new Date(msg.createdAt).toISOString() : null,
toolInvocations: rawParts
.filter((p: any) => p.type?.startsWith("tool-") || p.type === "tool-invocation")
.map((ti: any) => ({
toolName: ti.toolName ?? ti.type?.split("-").slice(1).join("-"),
toolCallId: ti.toolCallId,
input: ti.input,
state: ti.state,
})),
Comment on lines +84 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid exporting non-visible tool inputs by default.

Line 89 serializes ti.input, which can include hidden/internal payloads not shown in the transcript UI. That creates a privacy leak risk in exported files.

Suggested fix
         return {
           id: msg.id,
           role: msg.role,
           content: textContent,
           timestamp: msg.createdAt ? new Date(msg.createdAt).toISOString() : null,
           toolInvocations: rawParts
             .filter((p: any) => p.type?.startsWith("tool-") || p.type === "tool-invocation")
             .map((ti: any) => ({
               toolName: ti.toolName ?? ti.type?.split("-").slice(1).join("-"),
               toolCallId: ti.toolCallId,
-              input: ti.input,
               state: ti.state,
             })),
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/playground/lib/chat-export.ts` around lines 84 - 91, The map that
builds toolInvocations is exporting raw ti.input (in the toolInvocations map
over rawParts), which may contain hidden/internal payloads; change it to export
only a sanitized/visible input (e.g., use a new helper like
sanitizeToolInput(ti) or prefer an explicit field such as ti.inputVisible or
ti.visibleInput) and ensure the sanitizer strips internal fields before
serialization; update the mapping (the toolInvocations block) to call that
sanitizer or select only safe properties (toolName, toolCallId, state,
visibleInput) instead of exporting ti.input verbatim.

};
})
.filter(Boolean),
};

return JSON.stringify(exportData, null, 2);
}

/**
* Generate filename with timestamp
*/
export function generateFilename(format: "md" | "json"): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const date = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");

const timestamp = `${year}${month}${date}-${hours}${minutes}`;
const ext = format === "md" ? "md" : "json";

return `chat-${timestamp}.${ext}`;
}

/**
* Trigger file download in browser
*/
export function downloadFile(content: string, filename: string): void {
const blob = new Blob([content], {
type: filename.endsWith(".json") ? "application/json" : "text/markdown"
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

/**
* Export chat messages to Markdown file
*/
export function exportChatAsMarkdown(messages: any[]): void {
const markdown = exportToMarkdown(messages);
const filename = generateFilename("md");
downloadFile(markdown, filename);
}

/**
* Export chat messages to JSON file
*/
export function exportChatAsJSON(messages: any[]): void {
const json = exportToJSON(messages);
const filename = generateFilename("json");
downloadFile(json, filename);
}