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
8 changes: 8 additions & 0 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function ChatPageContent() {
const [theme, setTheme] = useState<Theme>("dark")
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [isLoadingThread, setIsLoadingThread] = useState(false)
const [fileWarning, setFileWarning] = useState<string | null>(null)

const { state, dispatch, handleSend, handleStop, handleReset, handleSendRef } =
useDebateEngine({ locale, responseLength, maxRounds })
Expand Down Expand Up @@ -136,6 +137,12 @@ function ChatPageContent() {

sessionStorage.removeItem("quorum_config")

const warningsRaw = sessionStorage.getItem("quorum_file_warnings")
if (warningsRaw) {
sessionStorage.removeItem("quorum_file_warnings")
try { setFileWarning(JSON.parse(warningsRaw).join("\n")) } catch { /* ignore */ }

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

JSON.parse(warningsRaw).join("\n") assumes the stored value parses to an array. If it parses to a non-array (or array contains non-strings), this will throw and warnings will be silently dropped. Consider validating with Array.isArray(...) and string-coercing/filtering elements before joining. Also note that \n won’t render as line breaks in the toast unless you apply white-space: pre-line (or render as a list).

Suggested change
try { setFileWarning(JSON.parse(warningsRaw).join("\n")) } catch { /* ignore */ }
try {
const parsedWarnings: unknown = JSON.parse(warningsRaw)
const normalizedWarnings = Array.isArray(parsedWarnings)
? parsedWarnings
.filter((warning): warning is Exclude<typeof warning, null | undefined> => warning != null)
.map((warning) => String(warning))
.join("\n")
: typeof parsedWarnings === "string"
? parsedWarnings
: ""
if (normalizedWarnings) {
setFileWarning(normalizedWarnings)
}
} catch { /* ignore */ }

Copilot uses AI. Check for mistakes.
}

if (config.models?.length) dispatch({ type: "SET_MODELS", models: config.models })
if (config.responseLength) setResponseLength(config.responseLength)
if (config.rounds) setMaxRounds(config.rounds)
Expand Down Expand Up @@ -490,6 +497,7 @@ function ChatPageContent() {
onStop={handleStop}
disabled={state.isDebating}
locale={locale}
initialFileWarning={fileWarning}
/>
</div>
</div>
Expand Down
47 changes: 32 additions & 15 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ const t = {
attach: "Attach file",
parsing: "Reading files...",
unsupported: "Supported: PDF, DOCX, Excel, and text files",
truncated: (name: string) => `"${name}" is too long - only the first ~20 pages were included`,
empty: (name: string) => `"${name}" appears to be scanned/empty - no text could be extracted`,
settings: "Settings",
signOut: "Sign Out",
tooltips: {
Expand Down Expand Up @@ -118,6 +120,8 @@ const t = {
attach: "파일 첨부",
parsing: "파일 읽는 중...",
unsupported: "지원 형식: PDF, DOCX, Excel, 텍스트 파일",
truncated: (name: string) => `"${name}" 파일이 너무 길어 앞부분만 포함되었습니다`,
empty: (name: string) => `"${name}" 파일에서 텍스트를 추출할 수 없습니다 (스캔 문서일 수 있음)`,
settings: "설정",
signOut: "로그아웃",
tooltips: {
Expand Down Expand Up @@ -315,19 +319,27 @@ export default function Home() {
// Auto-dismiss file error
useEffect(() => {
if (!fileError) return
const timer = setTimeout(() => setFileError(null), 3000)
const timer = setTimeout(() => setFileError(null), 5000)
return () => clearTimeout(timer)
}, [fileError])

const buildPromptWithFiles = async (): Promise<string | null> => {
const buildPromptWithFiles = async (): Promise<{ text: string | null; fileWarnings: string[] }> => {
let messageText = prompt.trim()
const fileWarnings: string[] = []

if (files.length > 0) {
const results = await Promise.allSettled(
files.map(async (af) => {
const content = await parseFile(af.file)
if (content && !content.startsWith("[Unsupported")) {
return `--- File: ${af.file.name} ---\n${content}`
const parsed = await parseFile(af.file)
Comment on lines +328 to +333

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

fileWarnings is mutated via push inside the async files.map used by Promise.allSettled, which makes warning ordering depend on parse completion timing. Consider returning { content, warning } from each mapper and then collecting warnings from the settled results in the original files order for deterministic UX.

Copilot uses AI. Check for mistakes.
if (parsed.warning === "empty") {
fileWarnings.push(t[locale].empty(af.file.name))
return null
}
if (parsed.warning === "truncated") {
fileWarnings.push(t[locale].truncated(af.file.name))
}
if (parsed.text && !parsed.text.startsWith("[Unsupported")) {
return `--- File: ${af.file.name} ---\n${parsed.text}`
}
return null
})
Expand All @@ -351,15 +363,15 @@ export default function Home() {
}
}

return messageText || null
return { text: messageText || null, fileWarnings }
}

const handleSubmit = async () => {
if (isParsing || (!prompt.trim() && files.length === 0)) return

setIsParsing(true)
try {
const messageText = await buildPromptWithFiles()
const { text: messageText, fileWarnings } = await buildPromptWithFiles()
if (!messageText) return

if (shouldShowLoginGate(!!session?.user)) {
Expand All @@ -382,6 +394,9 @@ export default function Home() {
locale,
}
sessionStorage.setItem("quorum_config", JSON.stringify(config))

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

This only sets quorum_file_warnings when warnings exist, but it never clears the key when fileWarnings is empty. If stale data is present in sessionStorage (e.g., from a prior flow that didn’t reach /chat), the chat page may display outdated warnings. Consider sessionStorage.removeItem("quorum_file_warnings") before this conditional, or always set it to an empty array.

Suggested change
sessionStorage.setItem("quorum_config", JSON.stringify(config))
sessionStorage.setItem("quorum_config", JSON.stringify(config))
sessionStorage.removeItem("quorum_file_warnings")

Copilot uses AI. Check for mistakes.
if (fileWarnings.length > 0) {
sessionStorage.setItem("quorum_file_warnings", JSON.stringify(fileWarnings))
}
Comment on lines 326 to +399

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

buildPromptWithFiles now collects fileWarnings, but handleSubmit never displays them on the homepage when submission is blocked (e.g., !messageText early return or the login gate path). This means users can upload a scanned/empty PDF and get no warning, and warnings are also lost when savePendingDebate is used. Consider setting fileError from fileWarnings before any early return, and persisting fileWarnings alongside the pending debate (or otherwise ensuring the chat page can still show them after login).

Copilot uses AI. Check for mistakes.
files.forEach((f) => { if (f.preview) URL.revokeObjectURL(f.preview) })
router.push("/chat")
} finally {
Expand Down Expand Up @@ -503,6 +518,16 @@ export default function Home() {
<span className="text-[10px] sm:text-xs font-mono font-medium text-zinc-900 dark:text-zinc-100">1,250</span>
</motion.div>

<motion.button
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
onClick={() => setShowSettings(true)}
className={cn("w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-all shadow-sm", theme === "lovelace" && "hover:ring-[1.5px] hover:ring-[#eb6f92]/60", theme === "tokyonight" && "hover:ring-[1.5px] hover:ring-[#7aa2f7]/40", theme === "gruvbox" && "hover:ring-[1.5px] hover:ring-[#fe8019]/50", theme === "catppuccin" && "hover:ring-[1.5px] hover:ring-[#cba6f7]/50", theme === "nord" && "hover:ring-[1.5px] hover:ring-[#88c0d0]/50", theme === "solarized" && "hover:ring-[1.5px] hover:ring-[#073642]/50")}
aria-label={t[locale].settings}
>
<Settings2 className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" />
</motion.button>

<div className="relative" data-header-dropdown>
<motion.button
whileTap={{ scale: 0.95 }}
Expand All @@ -522,14 +547,6 @@ export default function Home() {
className="absolute top-full right-0 mt-2 w-48 bg-card border border-border rounded-xl shadow-lg overflow-hidden z-[60]"
>
<div className="p-1">
<button
onClick={() => { setShowDropdown(false); setShowSettings(true) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
<Settings2 className="w-4 h-4" />
{t[locale].settings}
</button>
<div className="h-px bg-border my-1" />
<button
onClick={() => { setShowDropdown(false); signOut() }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
Expand Down
30 changes: 24 additions & 6 deletions src/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import { Send, Square, Paperclip, X, FileText, File, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { parseFile, SUPPORTED_EXTENSIONS } from "@/lib/file-parser"
import type { ParseWarning } from "@/lib/file-parser"

Check warning on line 9 in src/components/MessageInput.tsx

View workflow job for this annotation

GitHub Actions / ci

'ParseWarning' is defined but never used

Check warning on line 9 in src/components/MessageInput.tsx

View workflow job for this annotation

GitHub Actions / ci

'ParseWarning' is defined but never used

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

ParseWarning is imported but never used. With the Next.js TypeScript ESLint config this will typically fail linting; please remove the unused type import (or use it to type parsed.warning).

Suggested change
import type { ParseWarning } from "@/lib/file-parser"

Copilot uses AI. Check for mistakes.

const translations = {
en: { placeholder: "Type your message...", send: "Send", stop: "Stop", attach: "Attach file", parsing: "Reading files...", unsupported: "Supported: PDF, DOCX, Excel, and text files" },
ko: { placeholder: "메시지를 입력하세요...", send: "보내기", stop: "중지", attach: "파일 첨부", parsing: "파일 읽는 중...", unsupported: "지원 형식: PDF, DOCX, Excel, 텍스트 파일" },
en: { placeholder: "Type your message...", send: "Send", stop: "Stop", attach: "Attach file", parsing: "Reading files...", unsupported: "Supported: PDF, DOCX, Excel, and text files", truncated: (name: string) => `"${name}" is too long - only the first ~20 pages were included`, empty: (name: string) => `"${name}" appears to be scanned/empty - no text could be extracted` },
ko: { placeholder: "메시지를 입력하세요...", send: "보내기", stop: "중지", attach: "파일 첨부", parsing: "파일 읽는 중...", unsupported: "지원 형식: PDF, DOCX, Excel, 텍스트 파일", truncated: (name: string) => `"${name}" 파일이 너무 길어 앞부분만 포함되었습니다`, empty: (name: string) => `"${name}" 파일에서 텍스트를 추출할 수 없습니다 (스캔 문서일 수 있음)` },
}

interface AttachedFile {
Expand All @@ -23,11 +24,13 @@
onStop,
disabled,
locale,
initialFileWarning,
}: {
onSend: (text: string, target: Provider | "all") => void
onStop: () => void
disabled: boolean
locale: Locale
initialFileWarning?: string | null
}) {
const [text, setText] = useState("")
const [isDragging, setIsDragging] = useState(false)
Expand Down Expand Up @@ -66,16 +69,26 @@
let messageText = text.trim()

if (attachedFiles.length > 0) {
const warnings: string[] = []
const results = await Promise.allSettled(
attachedFiles.map(async (af) => {
const content = await parseFile(af.file)
if (content && !content.startsWith('[Unsupported')) {
return `--- File: ${af.file.name} ---\n${content}`
const parsed = await parseFile(af.file)
if (parsed.warning === 'empty') {
Comment on lines +72 to +76

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

warnings is being mutated (via push) from within the async callbacks passed to Promise.allSettled. This makes the warning order dependent on parse completion timing, and the subsequent warnings.join("\n") will not render line breaks in HTML unless the toast uses white-space: pre-line (otherwise newlines collapse). Prefer returning warnings alongside each parse result (then flatMap in the original file order), and render as a list / join with a visible delimiter or apply a whitespace-pre-line class.

Copilot uses AI. Check for mistakes.
warnings.push(t.empty(af.file.name))
return null
}
if (parsed.warning === 'truncated') {
warnings.push(t.truncated(af.file.name))
}
if (parsed.text && !parsed.text.startsWith('[Unsupported')) {
return `--- File: ${af.file.name} ---\n${parsed.text}`
}
return null
})
)

if (warnings.length > 0) setFileError(warnings.join('\n'))

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

warnings.join('\n') will not render as multiple lines in the toast because the warning is displayed as plain text in a <div> (HTML collapses newlines). If multiple files trigger warnings, they’ll appear as a single wrapped line. Consider either rendering warnings as a list / inserting <br />s, or adding a whitespace-pre-line (or similar) style to the warning container, or joining with a visible delimiter (e.g., ).

Suggested change
if (warnings.length > 0) setFileError(warnings.join('\n'))
if (warnings.length > 0) setFileError(warnings.join(''))

Copilot uses AI. Check for mistakes.

const fileContents = results.map((r, i) => {
if (r.status === "fulfilled" && r.value) return r.value
if (r.status === "rejected") {
Expand Down Expand Up @@ -111,10 +124,15 @@
}
}

// Show initial file warning passed from homepage
useEffect(() => {
if (initialFileWarning) setFileError(initialFileWarning)
}, [initialFileWarning])

// Auto-dismiss file error
useEffect(() => {
if (!fileError) return
const timer = setTimeout(() => setFileError(null), 3000)
const timer = setTimeout(() => setFileError(null), 5000)
return () => clearTimeout(timer)
}, [fileError])

Expand Down Expand Up @@ -204,7 +222,7 @@
className="group relative flex items-center gap-2 p-2 bg-zinc-50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-xl"
>
{file.preview ? (
<img src={file.preview} alt="" className="w-8 h-8 rounded-lg object-cover" />

Check warning on line 225 in src/components/MessageInput.tsx

View workflow job for this annotation

GitHub Actions / ci

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 225 in src/components/MessageInput.tsx

View workflow job for this annotation

GitHub Actions / ci

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
) : file.file.type.includes("pdf") ? (
<FileText className="w-4 h-4 text-red-500" />
) : (
Expand Down
19 changes: 15 additions & 4 deletions src/lib/file-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,31 @@ const MAX_FILE_CHARS = 50000

export const SUPPORTED_EXTENSIONS = new Set(["pdf", "docx", "xlsx", "xls", "txt", "md", "csv"])

export async function parseFile(file: File): Promise<string> {
export type ParseWarning = "truncated" | "empty"

export interface ParseResult {
text: string
warning?: ParseWarning
}

export async function parseFile(file: File): Promise<ParseResult> {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''

let result: string
if (ext === 'pdf') result = await parsePDF(file)
else if (ext === 'docx') result = await parseDOCX(file)
else if (ext === 'xlsx' || ext === 'xls') result = await parseExcel(file)
else if (ext === 'txt' || ext === 'md' || ext === 'csv') result = await parseText(file)
else return `[Unsupported file type: ${file.name}]`
else return { text: `[Unsupported file type: ${file.name}]` }

if (!result.trim()) {
return { text: '', warning: 'empty' }
}

if (result.length > MAX_FILE_CHARS) {
return result.slice(0, MAX_FILE_CHARS) + '\n[...file truncated]'
return { text: result.slice(0, MAX_FILE_CHARS) + '\n[...file truncated]', warning: 'truncated' }
}
Comment on lines +27 to 33

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

New warning behavior (empty / truncated) and truncation logic is introduced here, but there are no unit tests covering it. Since the repo already uses Vitest for src/lib/* utilities, consider adding tests for parseFile using .txt inputs to verify: (1) whitespace-only returns { text: "", warning: "empty" }, (2) >MAX chars returns warning truncated and includes the truncation marker, (3) small files return no warning.

Copilot uses AI. Check for mistakes.
return result
return { text: result }
}

async function parseText(file: File): Promise<string> {
Expand Down
Loading