Skip to content

Commit 1e3071b

Browse files
authored
Merge pull request #11 from aiedwardyi/fix/file-feedback-and-thread-status
fix: file attachment UX, thread status, and homepage hydration
2 parents 92c2c86 + 8e28057 commit 1e3071b

4 files changed

Lines changed: 231 additions & 82 deletions

File tree

src/app/chat/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ function ChatPageContent() {
301301
prevMessageCount.current = 0
302302
setIsLoadingThread(true)
303303
handleReset()
304+
// Prevent the continue-thread effect from misinterpreting this reset
305+
// as the user continuing a completed thread
306+
prevShowSummary.current = false
304307

305308
persistence.loadThread(threadParam).then((thread) => {
306309
if (!thread) {

src/app/page.tsx

Lines changed: 186 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import React, { useState, useEffect, useRef } from "react"
44
import { useRouter } from "next/navigation"
5-
import { Sun, Moon, Star, Heart, Flame, Cat, Snowflake, Send, Check, User, Settings2, LogOut, LogIn, X, Sparkles, Paperclip, Sunrise } from "lucide-react"
5+
import { Sun, Moon, Star, Heart, Flame, Cat, Snowflake, Send, Check, User, Settings2, LogOut, LogIn, X, Sparkles, Paperclip, Sunrise, FileText, File, Loader2 } from "lucide-react"
66
import SettingsModal from "@/components/SettingsModal"
77
import { motion, AnimatePresence } from "framer-motion"
88
import { THEMES } from "@/types"
@@ -12,6 +12,7 @@ import { useSession, signIn, signOut } from "next-auth/react"
1212
import { shouldShowLoginGate, savePendingDebate } from "@/components/LoginGate"
1313
import LoginGateModal from "@/components/LoginGate"
1414
import { timeAgo } from "@/lib/time"
15+
import { parseFile, SUPPORTED_EXTENSIONS } from "@/lib/file-parser"
1516

1617
/* ─── Model SVG Icons ─── */
1718

@@ -80,6 +81,9 @@ const t = {
8081
roundsCount: "Rounds",
8182
models: "Participants",
8283
keyboardHint: "to submit",
84+
attach: "Attach file",
85+
parsing: "Reading files...",
86+
unsupported: "Supported: PDF, DOCX, Excel, and text files",
8387
settings: "Settings",
8488
signOut: "Sign Out",
8589
tooltips: {
@@ -111,6 +115,9 @@ const t = {
111115
roundsCount: "라운드 수",
112116
models: "참여 모델",
113117
keyboardHint: "눌러서 시작",
118+
attach: "파일 첨부",
119+
parsing: "파일 읽는 중...",
120+
unsupported: "지원 형식: PDF, DOCX, Excel, 텍스트 파일",
114121
settings: "설정",
115122
signOut: "로그아웃",
116123
tooltips: {
@@ -141,25 +148,26 @@ function modelDisplayName(id: Provider): string {
141148
export default function Home() {
142149
const router = useRouter()
143150
const [theme, setTheme] = useState<Theme>("dark")
144-
const [locale, setLocale] = useState<Locale>(() => {
145-
if (typeof window === "undefined") return "ko"
146-
const saved = localStorage.getItem("quorum_locale")
147-
return saved === "en" || saved === "ko" ? saved : "ko"
148-
})
151+
const [locale, setLocale] = useState<Locale>("ko")
149152
const [prompt, setPrompt] = useState("")
150153
const [selectedModels, setSelectedModels] = useState<Provider[]>(["gemini", "perplexity", "claude", "gpt"])
151-
const [responseLength, setResponseLength] = useState<ResponseLength>(() => {
152-
if (typeof window === "undefined") return "short"
153-
const saved = localStorage.getItem("quorum_responseLength")
154-
return saved === "short" || saved === "medium" || saved === "long" ? saved : "short"
155-
})
156-
const [rounds, setRounds] = useState<number>(() => {
157-
if (typeof window === "undefined") return 1
158-
const saved = localStorage.getItem("quorum_rounds")
159-
if (saved) { const n = parseInt(saved, 10); if ([1, 2, 3, 5].includes(n)) return n }
160-
return 1
161-
})
154+
const [responseLength, setResponseLength] = useState<ResponseLength>("short")
155+
const [rounds, setRounds] = useState<number>(1)
156+
157+
// Hydrate persisted settings from localStorage after mount
158+
useEffect(() => {
159+
const savedLocale = localStorage.getItem("quorum_locale")
160+
if (savedLocale === "en" || savedLocale === "ko") setLocale(savedLocale)
161+
const savedLength = localStorage.getItem("quorum_responseLength")
162+
if (savedLength === "short" || savedLength === "medium" || savedLength === "long") setResponseLength(savedLength)
163+
const savedRounds = localStorage.getItem("quorum_rounds")
164+
if (savedRounds) { const n = parseInt(savedRounds, 10); if ([1, 2, 3, 5].includes(n)) setRounds(n) }
165+
}, [])
162166
const [isFocused, setIsFocused] = useState(false)
167+
const [files, setFiles] = useState<{ id: string; file: File; preview?: string }[]>([])
168+
const [isDragging, setIsDragging] = useState(false)
169+
const [isParsing, setIsParsing] = useState(false)
170+
const [fileError, setFileError] = useState<string | null>(null)
163171
const textareaRef = useRef<HTMLTextAreaElement>(null)
164172
const fileInputRef = useRef<HTMLInputElement>(null)
165173

@@ -171,8 +179,6 @@ export default function Home() {
171179
// Header & Settings state
172180
const [showDropdown, setShowDropdown] = useState(false)
173181
const [showSettings, setShowSettings] = useState(false)
174-
const [files, setFiles] = useState<File[]>([])
175-
const [isDragging, setIsDragging] = useState(false)
176182
const [recentThreads, setRecentThreads] = useState<ThreadSummary[]>([])
177183

178184
useEffect(() => {
@@ -274,28 +280,113 @@ export default function Home() {
274280
}
275281
}
276282

277-
const handleSubmit = () => {
278-
if (!prompt.trim()) return
279-
if (shouldShowLoginGate(!!session?.user)) {
280-
savePendingDebate({
281-
prompt: prompt.trim(),
283+
// File helpers
284+
const addFiles = (incoming: File[]) => {
285+
const supported: File[] = []
286+
let hasUnsupported = false
287+
for (const file of incoming) {
288+
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
289+
if (SUPPORTED_EXTENSIONS.has(ext)) {
290+
supported.push(file)
291+
} else {
292+
hasUnsupported = true
293+
}
294+
}
295+
if (hasUnsupported) setFileError(t[locale].unsupported)
296+
if (supported.length === 0) return
297+
setFiles((prev) => [
298+
...prev,
299+
...supported.map((file) => ({
300+
id: Math.random().toString(36).substr(2, 9),
301+
file,
302+
preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
303+
})),
304+
])
305+
}
306+
307+
const removeFile = (id: string) => {
308+
setFiles((prev) => {
309+
const removed = prev.find((f) => f.id === id)
310+
if (removed?.preview) URL.revokeObjectURL(removed.preview)
311+
return prev.filter((f) => f.id !== id)
312+
})
313+
}
314+
315+
// Auto-dismiss file error
316+
useEffect(() => {
317+
if (!fileError) return
318+
const timer = setTimeout(() => setFileError(null), 3000)
319+
return () => clearTimeout(timer)
320+
}, [fileError])
321+
322+
const buildPromptWithFiles = async (): Promise<string | null> => {
323+
let messageText = prompt.trim()
324+
325+
if (files.length > 0) {
326+
const results = await Promise.allSettled(
327+
files.map(async (af) => {
328+
const content = await parseFile(af.file)
329+
if (content && !content.startsWith("[Unsupported")) {
330+
return `--- File: ${af.file.name} ---\n${content}`
331+
}
332+
return null
333+
})
334+
)
335+
336+
const fileContents = results
337+
.map((r, i) => {
338+
if (r.status === "fulfilled" && r.value) return r.value
339+
if (r.status === "rejected") {
340+
console.error(`Failed to parse ${files[i].file.name}:`, r.reason)
341+
return `--- File: ${files[i].file.name} ---\n[Error: Could not read file]`
342+
}
343+
return null
344+
})
345+
.filter(Boolean) as string[]
346+
347+
if (fileContents.length > 0) {
348+
messageText = messageText
349+
? `${messageText}\n\n${fileContents.join("\n\n")}`
350+
: fileContents.join("\n\n")
351+
}
352+
}
353+
354+
return messageText || null
355+
}
356+
357+
const handleSubmit = async () => {
358+
if (isParsing || (!prompt.trim() && files.length === 0)) return
359+
360+
setIsParsing(true)
361+
try {
362+
const messageText = await buildPromptWithFiles()
363+
if (!messageText) return
364+
365+
if (shouldShowLoginGate(!!session?.user)) {
366+
savePendingDebate({
367+
prompt: messageText,
368+
models: selectedModels,
369+
responseLength,
370+
rounds,
371+
locale,
372+
})
373+
setShowGate(true)
374+
return
375+
}
376+
377+
const config = {
378+
prompt: messageText,
282379
models: selectedModels,
283380
responseLength,
284381
rounds,
285382
locale,
286-
})
287-
setShowGate(true)
288-
return
289-
}
290-
const config = {
291-
prompt: prompt.trim(),
292-
models: selectedModels,
293-
responseLength,
294-
rounds,
295-
locale,
383+
}
384+
sessionStorage.setItem("quorum_config", JSON.stringify(config))
385+
files.forEach((f) => { if (f.preview) URL.revokeObjectURL(f.preview) })
386+
router.push("/chat")
387+
} finally {
388+
setIsParsing(false)
296389
}
297-
sessionStorage.setItem("quorum_config", JSON.stringify(config))
298-
router.push("/chat")
299390
}
300391

301392
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -305,23 +396,6 @@ export default function Home() {
305396
}
306397
}
307398

308-
const handleDragOver = (e: React.DragEvent) => {
309-
e.preventDefault()
310-
setIsDragging(true)
311-
}
312-
313-
const handleDragLeave = (e: React.DragEvent) => {
314-
e.preventDefault()
315-
setIsDragging(false)
316-
}
317-
318-
const handleDrop = (e: React.DragEvent) => {
319-
e.preventDefault()
320-
setIsDragging(false)
321-
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
322-
setFiles((prev) => [...prev, ...Array.from(e.dataTransfer.files)])
323-
}
324-
}
325399

326400
return (
327401
<div className="relative min-h-screen overflow-hidden bg-background text-foreground font-[family-name:var(--font-geist-sans)] selection:bg-zinc-200 dark:selection:bg-zinc-800 transition-colors duration-300 flex flex-col">
@@ -493,16 +567,32 @@ export default function Home() {
493567
{/* Textarea */}
494568
<motion.div
495569
className={`relative group rounded-3xl p-[2px] overflow-hidden -mx-4 sm:-mx-6 ${isDragging ? "ring-4 ring-purple-500" : ""}`}
496-
onDragOver={handleDragOver}
497-
onDragLeave={handleDragLeave}
498-
onDrop={handleDrop}
570+
onDragOver={(e) => { e.preventDefault(); if (!isParsing) setIsDragging(true) }}
571+
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false) }}
572+
onDrop={(e) => {
573+
e.preventDefault()
574+
setIsDragging(false)
575+
if (!isParsing && e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files))
576+
}}
499577
initial={false}
500578
animate={{ scale: isFocused ? 1.02 : 1 }}
501579
transition={{ type: "spring", stiffness: 300, damping: 20 }}
502580
>
503581
<div className={`absolute inset-0 bg-[conic-gradient(from_0deg,red,purple,blue,red)] animate-rotate-border ${isFocused ? "opacity-100" : "opacity-50"}`} />
504582

505583
<div className="relative bg-background rounded-[22px] p-4 sm:p-6">
584+
<AnimatePresence>
585+
{fileError && (
586+
<motion.div
587+
initial={{ opacity: 0, height: 0 }}
588+
animate={{ opacity: 1, height: "auto" }}
589+
exit={{ opacity: 0, height: 0 }}
590+
className="mb-3 px-3 py-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-xl"
591+
>
592+
{fileError}
593+
</motion.div>
594+
)}
595+
</AnimatePresence>
506596
<textarea
507597
ref={textareaRef}
508598
value={prompt}
@@ -514,13 +604,36 @@ export default function Home() {
514604
className="w-full bg-transparent text-lg min-[375px]:text-xl sm:text-2xl md:text-3xl lg:text-4xl font-medium tracking-tight placeholder:text-zinc-400 dark:placeholder:text-zinc-600 resize-none outline-none min-h-[100px] sm:min-h-[120px] leading-[1.15]"
515605
autoFocus
516606
/>
517-
<div className="flex items-center justify-between mt-2">
607+
{files.length > 0 && (
608+
<div className="flex flex-wrap gap-2 mt-3">
609+
{files.map((af) => (
610+
<div
611+
key={af.id}
612+
className="group/file flex items-center gap-2 px-3 py-1.5 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-full text-sm"
613+
>
614+
{af.file.type.includes("pdf") ? (
615+
<FileText className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
616+
) : (
617+
<File className="w-3.5 h-3.5 text-zinc-400 flex-shrink-0" />
618+
)}
619+
<span className="truncate max-w-[150px] text-zinc-700 dark:text-zinc-300">{af.file.name}</span>
620+
<button onClick={() => removeFile(af.id)} className="hover:text-red-500 transition-colors">
621+
<X size={14} />
622+
</button>
623+
</div>
624+
))}
625+
</div>
626+
)}
627+
<div className="flex items-center justify-between mt-3">
518628
<button
519629
type="button"
520630
onClick={() => fileInputRef.current?.click()}
521-
className="p-2 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
631+
disabled={isParsing}
632+
title={t[locale].attach}
633+
aria-label={t[locale].attach}
634+
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-all active:scale-95 disabled:opacity-50"
522635
>
523-
<Paperclip size={20} />
636+
<Paperclip className="w-5 h-5" />
524637
</button>
525638
<input
526639
type="file"
@@ -529,24 +642,11 @@ export default function Home() {
529642
multiple
530643
accept=".pdf,.docx,.xlsx,.xls,.txt,.md,.csv"
531644
onChange={(e) => {
532-
if (e.target.files) {
533-
setFiles((prev) => [...prev, ...Array.from(e.target.files!)])
534-
}
645+
if (e.target.files) addFiles(Array.from(e.target.files))
646+
e.target.value = ""
535647
}}
536648
/>
537649
</div>
538-
{files.length > 0 && (
539-
<div className="flex flex-wrap gap-2 mt-4">
540-
{files.map((file, index) => (
541-
<div key={index} className="flex items-center gap-2 bg-zinc-200 dark:bg-zinc-800 px-3 py-1 rounded-full text-sm">
542-
<span className="truncate max-w-[150px]">{file.name}</span>
543-
<button onClick={() => setFiles(files.filter((_, i) => i !== index))} className="hover:text-red-500">
544-
<X size={14} />
545-
</button>
546-
</div>
547-
))}
548-
</div>
549-
)}
550650
</div>
551651
</motion.div>
552652

@@ -704,11 +804,20 @@ export default function Home() {
704804
{/* Submit Button */}
705805
<button
706806
onClick={handleSubmit}
707-
disabled={!prompt.trim()}
807+
disabled={isParsing || (!prompt.trim() && files.length === 0)}
708808
className="cursor-pointer w-full lg:w-auto group relative flex items-center justify-center gap-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-6 py-4 sm:py-3.5 rounded-2xl text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-zinc-900/10 dark:shadow-zinc-100/10 mt-2 lg:mt-0"
709809
>
710-
<Send size={16} className="transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
711-
{t[locale].start}
810+
{isParsing ? (
811+
<>
812+
<Loader2 size={16} className="animate-spin" />
813+
{t[locale].parsing}
814+
</>
815+
) : (
816+
<>
817+
<Send size={16} className="transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
818+
{t[locale].start}
819+
</>
820+
)}
712821
</button>
713822
</div>
714823
</div>

0 commit comments

Comments
 (0)