Skip to content

Commit cc510f2

Browse files
committed
feat(textarea): initial working context bar
1 parent 7d79fb5 commit cc510f2

File tree

4 files changed

+630
-7
lines changed

4 files changed

+630
-7
lines changed

webview-ui/src/components/chat/ChatLexicalTextArea.tsx

Lines changed: 254 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@ import { AutoApproveDropdown } from "./AutoApproveDropdown"
2020
import { StandardTooltip } from "../ui"
2121
import { IndexingStatusBadge } from "./IndexingStatusBadge"
2222
import { VolumeX } from "lucide-react"
23+
import { getIconForFilePath, getIconUrlByName } from "vscode-material-icons"
2324

2425
import { MentionNode } from "./lexical/MentionNode"
2526
import { LexicalMentionPlugin } from "./lexical/LexicalMentionPlugin"
27+
import { LexicalSelectAllPlugin } from "./lexical/LexicalSelectAllPlugin"
2628
import ContextMenu from "./ContextMenu"
2729
import { ContextMenuOptionType, getContextMenuOptions, SearchResult } from "@/utils/context-mentions"
30+
import Thumbnails from "../common/Thumbnails"
31+
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
32+
import { removeLeadingNonAlphanumeric } from "@/utils/removeLeadingNonAlphanumeric"
2833

2934
type ChatTextAreaProps = {
3035
inputValue: string
@@ -57,11 +62,11 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
5762
setInputValue,
5863
selectApiConfigDisabled,
5964
placeholderText,
60-
// selectedImages,
61-
// setSelectedImages,
65+
selectedImages,
66+
setSelectedImages,
6267
// onSend,
6368
// onSelectImages,
64-
// shouldDisableImages,
69+
shouldDisableImages,
6570
// onHeightChange,
6671
mode,
6772
setMode,
@@ -88,7 +93,6 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
8893
} = useExtensionState()
8994

9095
const [isFocused, setIsFocused] = useState(false)
91-
const [isDraggingOver, _setIsDraggingOver] = useState(false)
9296
const [showContextMenu, setShowContextMenu] = useState(false)
9397
const [searchQuery, setSearchQuery] = useState("")
9498
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
@@ -121,13 +125,186 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
121125
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
122126
}, [])
123127

124-
const [isTtsPlaying, _setIsTtsPlaying] = useState(false)
128+
const [isTtsPlaying, setIsTtsPlaying] = useState(false)
129+
const [isDraggingOver, setIsDraggingOver] = useState(false)
130+
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
131+
132+
// Get the icons base uri on mount
133+
useEffect(() => {
134+
const w = window as any
135+
setMaterialIconsBaseUri(w.MATERIAL_ICONS_BASE_URI)
136+
}, [])
137+
138+
// Extract mentions from input value for context display - only after finishing mention
139+
const [validMentions, setValidMentions] = useState<string[]>([])
140+
141+
// Update mentions only when they are complete (not live)
142+
useEffect(() => {
143+
const mentionRegex = /@([^@\s]+)(?=\s|$)/g // Only match completed mentions (followed by space or end)
144+
const mentions = []
145+
let match
146+
while ((match = mentionRegex.exec(inputValue)) !== null) {
147+
mentions.push(match[1])
148+
}
149+
150+
// Only update if mentions actually changed
151+
if (JSON.stringify(mentions) !== JSON.stringify(validMentions)) {
152+
setValidMentions(mentions)
153+
}
154+
}, [inputValue, validMentions])
155+
156+
// Smart filename disambiguation - like VSCode tabs
157+
const getDisplayName = useCallback((mention: string, allMentions: string[]) => {
158+
// Remove leading non-alphanumeric and trailing slash
159+
const path = removeLeadingNonAlphanumeric(mention).replace(/\/$/, "")
160+
const pathList = path.split("/")
161+
const filename = pathList.at(-1) || mention
162+
163+
// Check if there are other mentions with the same filename
164+
const sameFilenames = allMentions.filter((m) => {
165+
const otherPath = removeLeadingNonAlphanumeric(m).replace(/\/$/, "")
166+
const otherFilename = otherPath.split("/").at(-1) || m
167+
return otherFilename === filename && m !== mention
168+
})
169+
170+
if (sameFilenames.length === 0) {
171+
return filename // No conflicts, just show filename
172+
}
173+
174+
// There are conflicts, need to show directory to disambiguate
175+
if (pathList.length > 1) {
176+
// Show filename with first directory
177+
return `${pathList[pathList.length - 2]}/${filename}`
178+
}
179+
180+
return filename
181+
}, [])
182+
183+
// Get material icon for mention
184+
const getMaterialIconForMention = useCallback(
185+
(mention: string) => {
186+
const name = mention.split("/").filter(Boolean).at(-1) ?? ""
187+
const iconName = getIconForFilePath(name)
188+
return getIconUrlByName(iconName, materialIconsBaseUri)
189+
},
190+
[materialIconsBaseUri],
191+
)
192+
193+
// Check if we should show the context bar
194+
const shouldShowContextBar = validMentions.length > 0 || selectedImages.length > 0
195+
196+
// Handle image pasting
197+
const handlePaste = useCallback(
198+
async (e: React.ClipboardEvent) => {
199+
const items = e.clipboardData.items
200+
const acceptedTypes = ["png", "jpeg", "webp"]
201+
202+
const imageItems = Array.from(items).filter((item) => {
203+
const [type, subtype] = item.type.split("/")
204+
return type === "image" && acceptedTypes.includes(subtype)
205+
})
206+
207+
if (!shouldDisableImages && imageItems.length > 0) {
208+
e.preventDefault()
209+
210+
const imagePromises = imageItems.map((item) => {
211+
return new Promise<string | null>((resolve) => {
212+
const blob = item.getAsFile()
213+
214+
if (!blob) {
215+
resolve(null)
216+
return
217+
}
218+
219+
const reader = new FileReader()
220+
221+
reader.onloadend = () => {
222+
if (reader.error) {
223+
console.error(t("chat:errorReadingFile"), reader.error)
224+
resolve(null)
225+
} else {
226+
const result = reader.result
227+
resolve(typeof result === "string" ? result : null)
228+
}
229+
}
230+
231+
reader.readAsDataURL(blob)
232+
})
233+
})
234+
235+
const imageDataArray = await Promise.all(imagePromises)
236+
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
237+
238+
if (dataUrls.length > 0) {
239+
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
240+
} else {
241+
console.warn(t("chat:noValidImages"))
242+
}
243+
}
244+
},
245+
[shouldDisableImages, setSelectedImages, t],
246+
)
247+
248+
// Handle drag and drop
249+
const handleDrop = useCallback(
250+
async (e: React.DragEvent<HTMLDivElement>) => {
251+
e.preventDefault()
252+
setIsDraggingOver(false)
253+
254+
const files = Array.from(e.dataTransfer.files)
255+
256+
if (files.length > 0) {
257+
const acceptedTypes = ["png", "jpeg", "webp"]
258+
259+
const imageFiles = files.filter((file) => {
260+
const [type, subtype] = file.type.split("/")
261+
return type === "image" && acceptedTypes.includes(subtype)
262+
})
263+
264+
if (!shouldDisableImages && imageFiles.length > 0) {
265+
const imagePromises = imageFiles.map((file) => {
266+
return new Promise<string | null>((resolve) => {
267+
const reader = new FileReader()
268+
269+
reader.onloadend = () => {
270+
if (reader.error) {
271+
console.error(t("chat:errorReadingFile"), reader.error)
272+
resolve(null)
273+
} else {
274+
const result = reader.result
275+
resolve(typeof result === "string" ? result : null)
276+
}
277+
}
278+
279+
reader.readAsDataURL(file)
280+
})
281+
})
282+
283+
const imageDataArray = await Promise.all(imagePromises)
284+
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
285+
286+
if (dataUrls.length > 0) {
287+
setSelectedImages((prevImages) =>
288+
[...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
289+
)
290+
} else {
291+
console.warn(t("chat:noValidImages"))
292+
}
293+
}
294+
}
295+
},
296+
[shouldDisableImages, setSelectedImages, t],
297+
)
125298

126299
useEffect(() => {
127300
const messageHandler = (event: MessageEvent) => {
128301
const message = event.data
129302

130-
if (message.type === "commitSearchResults") {
303+
if (message.type === "ttsStart") {
304+
setIsTtsPlaying(true)
305+
} else if (message.type === "ttsStop") {
306+
setIsTtsPlaying(false)
307+
} else if (message.type === "commitSearchResults") {
131308
const commits = message.commits.map((commit: any) => ({
132309
type: ContextMenuOptionType.Git,
133310
value: commit.hash,
@@ -401,7 +578,75 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
401578
"space-y-1 bg-editor-background outline-none border border-none box-border",
402579
isEditMode ? "p-2 w-full" : "relative px-1.5 pb-1 w-[calc(100%-16px)] ml-auto mr-auto",
403580
)}>
404-
<div className="relative">
581+
{/* Context Bar */}
582+
{shouldShowContextBar && (
583+
<div className="mb-2">
584+
<div className="flex items-center gap-1 p-2 bg-vscode-input-background border border-vscode-focusBorder rounded overflow-x-auto">
585+
{/* Context mentions */}
586+
{validMentions.map((mention, index) => {
587+
const displayName = getDisplayName(mention, validMentions)
588+
const iconUrl = getMaterialIconForMention(mention)
589+
return (
590+
<div
591+
key={index}
592+
className="flex items-center gap-1 px-2 py-1 bg-vscode-editor-background text-vscode-editor-foreground rounded text-xs whitespace-nowrap flex-shrink-0">
593+
<img
594+
src={iconUrl}
595+
alt="File"
596+
style={{
597+
width: "12px",
598+
height: "12px",
599+
flexShrink: 0,
600+
}}
601+
/>
602+
<span>{displayName}</span>
603+
</div>
604+
)
605+
})}
606+
607+
{/* Images */}
608+
{selectedImages.length > 0 && (
609+
<Thumbnails
610+
images={selectedImages}
611+
setImages={setSelectedImages}
612+
style={{
613+
marginBottom: 0,
614+
display: "flex",
615+
gap: 4,
616+
}}
617+
/>
618+
)}
619+
</div>
620+
</div>
621+
)}
622+
623+
<div
624+
className="relative"
625+
onDrop={handleDrop}
626+
onDragOver={(e) => {
627+
// Only allowed to drop images/files on shift key pressed.
628+
if (!e.shiftKey) {
629+
setIsDraggingOver(false)
630+
return
631+
}
632+
633+
e.preventDefault()
634+
setIsDraggingOver(true)
635+
e.dataTransfer.dropEffect = "copy"
636+
}}
637+
onDragLeave={(e) => {
638+
e.preventDefault()
639+
const rect = e.currentTarget.getBoundingClientRect()
640+
641+
if (
642+
e.clientX <= rect.left ||
643+
e.clientX >= rect.right ||
644+
e.clientY <= rect.top ||
645+
e.clientY >= rect.bottom
646+
) {
647+
setIsDraggingOver(false)
648+
}
649+
}}>
405650
<LexicalComposer initialConfig={initialConfig}>
406651
<PlainTextPlugin
407652
contentEditable={
@@ -442,6 +687,7 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
442687
)}
443688
onFocus={() => setIsFocused(true)}
444689
onBlur={() => setIsFocused(false)}
690+
onPaste={handlePaste}
445691
/>
446692
}
447693
ErrorBoundary={LexicalErrorBoundary}
@@ -453,6 +699,7 @@ export const ChatLexicalTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaP
453699
onMentionTrigger={handleMentionTrigger}
454700
onMentionHide={handleMentionHide}
455701
/>
702+
<LexicalSelectAllPlugin />
456703
</LexicalComposer>
457704

458705
{showContextMenu && (

0 commit comments

Comments
 (0)