fix: resolve TypeScript errors in electron-standalone

- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking
This commit is contained in:
dayuan.jiang
2026-01-20 19:49:12 +09:00
parent 60994d281e
commit fa06c61538
2 changed files with 402 additions and 366 deletions

View File

@@ -9,7 +9,14 @@ import {
Send, Send,
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react" import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast" import { ErrorToast } from "@/components/error-toast"
@@ -138,6 +145,10 @@ function showValidationErrors(errors: string[], dict: any) {
} }
} }
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps { interface ChatInputProps {
input: string input: string
status: "submitted" | "streaming" | "ready" | "error" status: "submitted" | "streaming" | "ready" | "error"
@@ -165,134 +176,212 @@ interface ChatInputProps {
onFocused?: () => void onFocused?: () => void
} }
export function ChatInput({ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
input, function ChatInput(
status, {
onSubmit, input,
onChange, status,
files = [], onSubmit,
onFileChange = () => {}, onChange,
pdfData = new Map(), files = [],
urlData, onFileChange = () => {},
onUrlChange, pdfData = new Map(),
sessionId, urlData,
error = null, onUrlChange,
models = [], sessionId,
selectedModelId, error = null,
onModelSelect = () => {}, models = [],
showUnvalidatedModels = false, selectedModelId,
onConfigureModels = () => {}, onModelSelect = () => {},
shouldFocus = false, showUnvalidatedModels = false,
onFocused, onConfigureModels = () => {},
}: ChatInputProps) { shouldFocus = false,
const dict = useDictionary() onFocused,
const { },
chartXML, ref,
diagramHistory, ) {
saveDiagramToFile, const dict = useDictionary()
showSaveDialog, const {
setShowSaveDialog, chartXML,
} = useDiagram() diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
// Focus the textarea when shouldFocus becomes true // Expose focus method via ref
// Use setTimeout to ensure focus happens after drawio iframe settles useImperativeHandle(ref, () => ({
useEffect(() => { focus: () => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus() textareaRef.current?.focus()
onFocused?.() },
}, 150) }))
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false) // Focus the textarea when shouldFocus becomes true
const [showUrlDialog, setShowUrlDialog] = useState(false) // Use setTimeout to ensure focus happens after drawio iframe settles
const [isExtractingUrl, setIsExtractingUrl] = useState(false) useEffect(() => {
const [sendShortcut, setSendShortcut] = useState("ctrl-enter") if (shouldFocus) {
// Allow retry when there's an error (even if status is still "streaming" or "submitted") const timer = setTimeout(() => {
const isDisabled = textareaRef.current?.focus()
(status === "streaming" || status === "submitted") && !error onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const adjustTextareaHeight = useCallback(() => { const [showHistory, setShowHistory] = useState(false)
const textarea = textareaRef.current const [showUrlDialog, setShowUrlDialog] = useState(false)
if (textarea) { const [isExtractingUrl, setIsExtractingUrl] = useState(false)
textarea.style.height = "auto" const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` // Allow retry when there's an error (even if status is still "streaming" or "submitted")
} const isDisabled =
}, []) (status === "streaming" || status === "submitted") && !error
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes const adjustTextareaHeight = useCallback(() => {
useEffect(() => { const textarea = textareaRef.current
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) if (textarea) {
if (stored) setSendShortcut(stored) textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const handleChange = (e: CustomEvent<string>) => // Load send shortcut preference from localStorage and listen for changes
setSendShortcut(e.detail) useEffect(() => {
window.addEventListener( const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
"sendShortcutChange", if (stored) setSendShortcut(stored)
handleChange as EventListener,
) const handleChange = (e: CustomEvent<string>) =>
return () => setSendShortcut(e.detail)
window.removeEventListener( window.addEventListener(
"sendShortcutChange", "sendShortcutChange",
handleChange as EventListener, handleChange as EventListener,
) )
}, []) return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e) onChange(e)
adjustTextareaHeight() adjustTextareaHeight()
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend = const shouldSend =
sendShortcut === "enter" sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey ? e.key === "Enter" &&
: (e.metaKey || e.ctrlKey) && e.key === "Enter" !e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) { if (shouldSend) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget.closest("form") const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
form.requestSubmit() form.requestSubmit()
}
} }
} }
}
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return if (isDisabled) return
const items = e.clipboardData.items const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"), item.type.startsWith("image/"),
) )
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = ( const imageFiles = (
await Promise.all( await Promise.all(
imageItems.map(async (item, index) => { imageItems.map(async (item, index) => {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) return null if (!file) return null
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }, { type: file.type },
) )
}), }),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
) )
).filter((f): f is File => f !== null) showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles( const { validFiles, errors } = validateFiles(
imageFiles, supportedFiles,
files.length, files.length,
dict, dict,
) )
@@ -301,278 +390,219 @@ export function ChatInput({
onFileChange([...files, ...validFiles]) onFileChange([...files, ...validFiles])
} }
} }
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlExtract = async (url: string) => {
const newFiles = Array.from(e.target.files || []) if (!onUrlChange) return
const { validFiles, errors } = validateFiles(
newFiles, setIsExtractingUrl(true)
files.length,
dict, try {
) const existing = urlData
showValidationErrors(errors, dict) ? new Map(urlData)
if (validFiles.length > 0) { : new Map<string, UrlData>()
onFileChange([...files, ...validFiles]) existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
} }
if (fileInputRef.current) { return (
fileInputRef.current.value = "" <form
} onSubmit={onSubmit}
} className={`w-full transition-all duration-200 ${
isDragging
const handleRemoveFile = (fileToRemove: File) => { ? "ring-2 ring-primary ring-offset-2 rounded-2xl"
onFileChange(files.filter((file) => file !== fileToRemove)) : ""
if (fileInputRef.current) { }`}
fileInputRef.current.value = "" onDragOver={handleDragOver}
} onDragLeave={handleDragLeave}
} onDrop={handleDrop}
>
const triggerFileInput = () => { {/* File & URL previews */}
fileInputRef.current?.click() {(files.length > 0 || (urlData && urlData.size > 0)) && (
} <div className="mb-3">
<FilePreviewList
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { files={files}
e.preventDefault() onRemoveFile={handleRemoveFile}
e.stopPropagation() pdfData={pdfData}
setIsDragging(true) urlData={urlData}
} onRemoveUrl={
onUrlChange
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => { ? (url) => {
e.preventDefault() const next = new Map(urlData)
e.stopPropagation() next.delete(url)
setIsDragging(false) onUrlChange(next)
} }
: undefined
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => { }
e.preventDefault() />
e.stopPropagation() </div>
setIsDragging(false) )}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
if (isDisabled) return <Textarea
ref={textareaRef}
const droppedFiles = e.dataTransfer.files value={input}
const supportedFiles = Array.from(droppedFiles).filter((file) => onChange={handleChange}
isValidFileType(file), onKeyDown={handleKeyDown}
) onPaste={handlePaste}
placeholder={dict.chat.placeholder}
const { validFiles, errors } = validateFiles( disabled={isDisabled}
supportedFiles, aria-label="Chat input"
files.length, className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/> />
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50"> <div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden"> <div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled || !isRealDiagram(chartXML)}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowUrlDialog(true)} onClick={() => setShowHistory(true)}
disabled={isDisabled} disabled={
tooltipContent={dict.chat.ExtractURL} isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
<Link className="h-4 w-4" /> <History className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
)}
<input <ButtonWithTooltip
type="file" type="button"
ref={fileInputRef} variant="ghost"
className="hidden" size="sm"
onChange={handleFileChange} onClick={() => setShowSaveDialog(true)}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml" disabled={
multiple isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
</div> <HistoryDialog
<HistoryDialog showHistory={showHistory}
showHistory={showHistory} onToggleHistory={setShowHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/> />
)} <SaveDialog
</form> open={showSaveDialog}
) onOpenChange={setShowSaveDialog}
} onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
},
)

View File

@@ -30,5 +30,11 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "packages", "electron", "dist-electron"] "exclude": [
"node_modules",
"packages",
"electron",
"electron-standalone",
"dist-electron"
]
} }