Skip to content

Commit a10de28

Browse files
Olasunkanmi OyinlolaOlasunkanmi Oyinlola
authored andcommitted
feat(ui): Add download functionality for bot responses
- Implemented a download button in the bot message component to allow users to download the bot's response as a Markdown file. - Added a new component for the download button with loading state. - Created and utilities to handle the conversion and download process. - Refactored skeleton loader styles into a centralized module with theme presets for consistent UI. - Updated CSS to include styles for the download button and header button container.
1 parent f95f2df commit a10de28

17 files changed

+583
-550
lines changed

webviewUi/src/components/botMessage.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
import DOMPurify from "dompurify";
33
import React from "react";
44
import { BotIcon } from "./botIcon";
5+
import { DownloadIcon } from "./downloadIcon";
6+
import { downloadAsMarkdown } from "../utils/downloadMarkdown";
57
import { IParseURL, parseUrl } from "../utils/parseUrl";
68
import UrlCardList from "./urlCardList";
79

810
interface CodeBlockProps {
911
language?: string;
1012
content: string;
11-
isLoading?: boolean;
1213
}
1314

1415
export const BotMessage: React.FC<CodeBlockProps> = ({ language, content }) => {
@@ -18,6 +19,19 @@ export const BotMessage: React.FC<CodeBlockProps> = ({ language, content }) => {
1819
if (sanitizedContent.includes("favicon")) {
1920
parsedUrls = parseUrl(sanitizedContent);
2021
}
22+
23+
const handleDownload = () => {
24+
console.log("Download button clicked");
25+
console.log("Content length:", sanitizedContent.length);
26+
console.log("Content preview:", sanitizedContent.substring(0, 100));
27+
28+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
29+
const filename = `codebuddy-response-${timestamp}`;
30+
console.log("Filename:", filename);
31+
32+
downloadAsMarkdown(sanitizedContent, filename);
33+
};
34+
2135
return (
2236
<>
2337
{content.includes("thinking") ? (
@@ -30,6 +44,9 @@ export const BotMessage: React.FC<CodeBlockProps> = ({ language, content }) => {
3044
<div className="code-block">
3145
<div className="code-header">
3246
<span className="language-label">{language}</span>
47+
<div className="header-buttons">
48+
<DownloadIcon onClick={handleDownload} />
49+
</div>
3350
</div>
3451
{parsedUrls.length > 0 ? (
3552
<div className="doc-content">
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React, { useState } from "react";
2+
3+
interface DownloadIconProps {
4+
onClick: () => void;
5+
className?: string;
6+
title?: string;
7+
}
8+
9+
export const DownloadIcon: React.FC<DownloadIconProps> = ({
10+
onClick,
11+
className = "",
12+
title = "Download as Markdown",
13+
}) => {
14+
const [isDownloading, setIsDownloading] = useState(false);
15+
16+
const handleClick = async () => {
17+
setIsDownloading(true);
18+
try {
19+
await onClick();
20+
} finally {
21+
setTimeout(() => setIsDownloading(false), 1000);
22+
}
23+
};
24+
25+
return (
26+
<button
27+
onClick={handleClick}
28+
className={`download-button ${className} ${isDownloading ? "downloading" : ""}`}
29+
title={title}
30+
aria-label={title}
31+
disabled={isDownloading}
32+
>
33+
<svg
34+
width="16"
35+
height="16"
36+
viewBox="0 0 24 24"
37+
fill="none"
38+
stroke="currentColor"
39+
strokeWidth="2"
40+
strokeLinecap="round"
41+
strokeLinejoin="round"
42+
>
43+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
44+
<polyline points="7,10 12,15 17,10" />
45+
<line x1="12" y1="15" x2="12" y2="3" />
46+
</svg>
47+
<span className="download-text">{isDownloading ? "..." : "MD"}</span>
48+
</button>
49+
);
50+
};

webviewUi/src/components/skeletonLoader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BotIcon } from "./botIcon";
33

44
export const SkeletonLoader: React.FC = () => {
55
return (
6-
<div className="skeleton-loader">
6+
<div className="skeleton-loader-container">
77
<div className="skeleton-header">
88
<BotIcon isBlinking={true} />
99
<div className="skeleton-line skeleton-line-short"></div>

webviewUi/src/components/webview.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export const WebviewUI = () => {
6262

6363
useEffect(() => {
6464
const messageHandler = (event: any) => {
65-
setIsBotLoading(true);
6665
const message = event.data;
6766
switch (message.type) {
6867
case "bot-response":
@@ -75,6 +74,7 @@ export const WebviewUI = () => {
7574
alias: "O",
7675
},
7776
]);
77+
setIsBotLoading(false);
7878
break;
7979
case "bootstrap":
8080
setFolders(message);
@@ -86,10 +86,10 @@ export const WebviewUI = () => {
8686
console.log(error);
8787
throw new Error(error.message);
8888
}
89-
9089
break;
9190
case "error":
9291
console.error("Extension error", message.payload);
92+
setIsBotLoading(false);
9393
break;
9494
case "onActiveworkspaceUpdate":
9595
setActiveEditor(message.message ?? "");
@@ -111,11 +111,14 @@ export const WebviewUI = () => {
111111
}
112112
};
113113
window.addEventListener("message", messageHandler);
114-
highlightCodeBlocks(hljsApi, messages);
115-
setIsBotLoading(false);
116114
return () => {
117115
window.removeEventListener("message", messageHandler);
118116
};
117+
}, []);
118+
119+
// Separate effect for highlighting code blocks
120+
useEffect(() => {
121+
highlightCodeBlocks(hljsApi, messages);
119122
}, [messages]);
120123

121124
const handleClearHistory = () => {

webviewUi/src/index.css

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ vscode-button>.codicon {
276276
border-radius: 6px;
277277
font-family: "JetBrains Mono", SF Mono, "Geist Mono", "Fira Code", "Fira Mono", "Menlo", "Consolas", "DejaVu Sans Mono", monospace;
278278
margin: 16px 0;
279+
position: relative;
279280
}
280281

281282
.code-header {
@@ -285,6 +286,23 @@ vscode-button>.codicon {
285286
padding: 8px 16px;
286287
border-top-left-radius: 6px;
287288
border-top-right-radius: 6px;
289+
position: relative;
290+
}
291+
292+
.code-header .language-label {
293+
flex: 1;
294+
}
295+
296+
.header-buttons {
297+
display: flex;
298+
align-items: center;
299+
gap: 8px;
300+
}
301+
302+
.code-header .download-button,
303+
.code-header .copy-button {
304+
flex-shrink: 0;
305+
margin-left: 0;
288306
}
289307

290308
.language-label {
@@ -545,9 +563,13 @@ hr {
545563
padding: 4px 8px;
546564
font-size: 12px;
547565
cursor: pointer;
548-
margin-left: 12px;
566+
margin-left: 8px;
549567
transition: background 0.3s ease, border-color 0.3s ease;
550-
float: right;
568+
background: transparent;
569+
color: var(--vscode-editor-foreground);
570+
display: flex;
571+
align-items: center;
572+
justify-content: center;
551573
}
552574

553575
.copy-button:hover {
@@ -556,6 +578,49 @@ hr {
556578
color: white;
557579
}
558580

581+
.download-button {
582+
border: 1px solid #28a745;
583+
background: transparent;
584+
border-radius: 8px;
585+
padding: 6px 8px;
586+
font-size: 12px;
587+
cursor: pointer;
588+
transition: all 0.3s ease;
589+
display: flex;
590+
align-items: center;
591+
justify-content: center;
592+
color: var(--vscode-editor-foreground);
593+
margin-left: 8px;
594+
}
595+
596+
.download-button:hover {
597+
background: #28a745;
598+
border-color: #28a745;
599+
color: white;
600+
}
601+
602+
.download-button.downloading {
603+
opacity: 0.6;
604+
cursor: not-allowed;
605+
}
606+
607+
.download-button:disabled {
608+
opacity: 0.6;
609+
cursor: not-allowed;
610+
}
611+
612+
.download-button svg {
613+
width: 14px;
614+
height: 14px;
615+
margin-right: 4px;
616+
}
617+
618+
.download-text {
619+
font-size: 10px;
620+
font-weight: 600;
621+
opacity: 0.8;
622+
}
623+
559624
.app-container {
560625
padding: 20px;
561626
background-color: #1a1a1a;
@@ -868,4 +933,4 @@ hr {
868933
cursor: pointer;
869934
transition: background-color 0.3s ease;
870935
color: var(--vscode-dropdown-foreground);
871-
}
936+
}

webviewUi/src/themes/atom-one-dark.ts

Lines changed: 6 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
generateSkeletonLoaderCSS,
3+
SKELETON_THEME_PRESETS,
4+
} from "./skeletonLoader";
5+
16
export const oneDarkCss = `/*
27
38
Atom One Dark by Daniel Gamage
@@ -93,62 +98,4 @@ hue-6-2: #e6c07b
9398
text-decoration: underline;
9499
}
95100
96-
/* Skeleton Loader Styles */
97-
.skeleton-loader {
98-
background: var(--vscode-editor-background, #282c34);
99-
border-radius: 8px;
100-
padding: 16px;
101-
margin: 8px 0;
102-
border: 1px solid var(--vscode-widget-border, #3e4451);
103-
}
104-
105-
.skeleton-header {
106-
display: flex;
107-
align-items: center;
108-
gap: 8px;
109-
margin-bottom: 12px;
110-
}
111-
112-
.skeleton-content {
113-
display: flex;
114-
flex-direction: column;
115-
gap: 8px;
116-
}
117-
118-
.skeleton-line {
119-
height: 12px;
120-
background: linear-gradient(90deg,
121-
var(--vscode-editor-background, #282c34) 0%,
122-
var(--vscode-input-background, #3e4451) 50%,
123-
var(--vscode-editor-background, #282c34) 100%
124-
);
125-
border-radius: 6px;
126-
background-size: 200px 100%;
127-
background-position: -200px 0;
128-
animation: skeleton-pulse 1.5s ease-in-out infinite;
129-
}
130-
131-
.skeleton-line-short {
132-
width: 30%;
133-
}
134-
135-
.skeleton-line-medium {
136-
width: 60%;
137-
}
138-
139-
.skeleton-line-long {
140-
width: 80%;
141-
}
142-
143-
.skeleton-line-full {
144-
width: 100%;
145-
}
146-
147-
@keyframes skeleton-pulse {
148-
0% {
149-
background-position: -200px 0;
150-
}
151-
100% {
152-
background-position: calc(200px + 100%) 0;
153-
}
154-
}`;
101+
${generateSkeletonLoaderCSS(SKELETON_THEME_PRESETS.atomOneDark)}`;

0 commit comments

Comments
 (0)