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
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ jobs:
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --frozen --dev
- run: uv run ruff check core/ api/ db/ tests/
- run: uv run ruff format --check core/ api/ db/ tests/
- run: uv run --only-group lint ruff check core/ api/ db/ tests/
- run: uv run --only-group lint ruff format --check core/ api/ db/ tests/

# ── Backend: tests ─────────────────────────────────────────────────────
backend-test:
Expand All @@ -34,7 +33,7 @@ jobs:
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --frozen --dev
- run: uv sync --frozen --group test
- run: uv run pytest tests/ -v --tb=short

# ── Frontend: lint + typecheck ─────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
290 changes: 145 additions & 145 deletions frontend/components/chat/question-input.tsx
Original file line number Diff line number Diff line change
@@ -1,145 +1,145 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { Question } from '@/lib/api-types'
interface QuestionInputProps {
questions: Question[]
onSubmit?: (answers: Record<string, string>) => void
}
export function QuestionInput({ questions, onSubmit }: QuestionInputProps) {
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, string>>({})
const [showOther, setShowOther] = useState<Record<number, boolean>>({})
const [otherValues, setOtherValues] = useState<Record<number, string>>({})
const submittedRef = useRef(false)
const isResolved = questions.every((q) => q.selected_answer)
const isInteractive = !!onSubmit && !isResolved && !submittedRef.current
// Auto-submit when all questions have a local answer
useEffect(() => {
if (
!submittedRef.current &&
onSubmit &&
questions.length > 0 &&
questions.every((q) => selectedAnswers[q.question])
) {
submittedRef.current = true
onSubmit(selectedAnswers)
}
}, [selectedAnswers, questions, onSubmit])
const handleOptionClick = useCallback(
(question: Question, label: string) => {
if (submittedRef.current) return
setSelectedAnswers((prev) => ({ ...prev, [question.question]: label }))
},
[]
)
const handleOtherSubmit = useCallback(
(questionIdx: number, question: Question) => {
const value = otherValues[questionIdx]
if (!value?.trim() || submittedRef.current) return
setSelectedAnswers((prev) => ({ ...prev, [question.question]: value.trim() }))
},
[otherValues]
)
return (
<div className="space-y-4 py-1">
{questions.map((q, qi) => {
const localAnswer = selectedAnswers[q.question]
const resolvedAnswer = q.selected_answer
const answered = !!localAnswer || !!resolvedAnswer
return (
<div key={qi} className="space-y-2">
{q.header && (
<p className="text-sm text-muted-foreground">{q.header}</p>
)}
<p className="text-sm font-medium">{q.question}</p>
<div className="flex flex-wrap gap-1.5">
{q.options.map((opt, oi) => {
const isSelected = resolvedAnswer === opt.label || localAnswer === opt.label
if (isInteractive && !localAnswer) {
return (
<Button
key={oi}
variant="outline"
size="sm"
onClick={() => handleOptionClick(q, opt.label)}
title={opt.description || undefined}
className="h-auto px-2 py-1 text-xs border-sky/30 bg-sky/5 hover:bg-sky/10 hover:border-sky/50"
>
{opt.label}
</Button>
)
}
return (
<span
key={oi}
className={`inline-flex rounded-md border px-2 py-1 text-xs ${
isSelected
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border/50 text-muted-foreground'
}`}
>
{opt.label}
</span>
)
})}
{isInteractive && !localAnswer && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowOther((prev) => ({ ...prev, [qi]: true }))}
className="h-auto px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Other
</Button>
)}
</div>
{/* Free-text answer that doesn't match any option */}
{(resolvedAnswer || localAnswer) && !q.options.some((o) => o.label === (resolvedAnswer || localAnswer)) && (
<p className="text-xs text-primary italic">&rarr; {resolvedAnswer || localAnswer}</p>
)}
{isInteractive && !localAnswer && showOther[qi] && (
<div className="flex gap-2">
<Input
placeholder="Your answer..."
value={otherValues[qi] || ''}
onChange={(e) =>
setOtherValues((prev) => ({ ...prev, [qi]: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleOtherSubmit(qi, q)
}
}}
/>
<Button
size="sm"
onClick={() => handleOtherSubmit(qi, q)}
disabled={!otherValues[qi]?.trim()}
>
Submit
</Button>
</div>
)}
</div>
)
})}
</div>
)
}
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'

import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { Question } from '@/lib/api-types'

interface QuestionInputProps {
questions: Question[]
onSubmit?: (answers: Record<string, string>) => void
}

export function QuestionInput({ questions, onSubmit }: QuestionInputProps) {
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, string>>({})
const [showOther, setShowOther] = useState<Record<number, boolean>>({})
const [otherValues, setOtherValues] = useState<Record<number, string>>({})
const submittedRef = useRef(false)

const isResolved = questions.every((q) => q.selected_answer)
const isInteractive = !!onSubmit && !isResolved

// Auto-submit when all questions have a local answer
useEffect(() => {
if (
!submittedRef.current &&
onSubmit &&
questions.length > 0 &&
questions.every((q) => selectedAnswers[q.question])
) {
submittedRef.current = true
onSubmit(selectedAnswers)
}
}, [selectedAnswers, questions, onSubmit])

const handleOptionClick = useCallback(
(question: Question, label: string) => {
if (submittedRef.current) return
setSelectedAnswers((prev) => ({ ...prev, [question.question]: label }))
},
[]
)

const handleOtherSubmit = useCallback(
(questionIdx: number, question: Question) => {
const value = otherValues[questionIdx]
if (!value?.trim() || submittedRef.current) return
setSelectedAnswers((prev) => ({ ...prev, [question.question]: value.trim() }))
},
[otherValues]
)

return (
<div className="space-y-4 py-1">
{questions.map((q, qi) => {
const localAnswer = selectedAnswers[q.question]
const resolvedAnswer = q.selected_answer
const answered = !!localAnswer || !!resolvedAnswer

return (
<div key={qi} className="space-y-2">
{q.header && (
<p className="text-sm text-muted-foreground">{q.header}</p>
)}
<p className="text-sm font-medium">{q.question}</p>

<div className="flex flex-wrap gap-1.5">
{q.options.map((opt, oi) => {
const isSelected = resolvedAnswer === opt.label || localAnswer === opt.label

if (isInteractive && !localAnswer) {
return (
<Button
key={oi}
variant="outline"
size="sm"
onClick={() => handleOptionClick(q, opt.label)}
title={opt.description || undefined}
className="h-auto px-2 py-1 text-xs border-sky/30 bg-sky/5 hover:bg-sky/10 hover:border-sky/50"
>
{opt.label}
</Button>
)
}

return (
<span
key={oi}
className={`inline-flex rounded-md border px-2 py-1 text-xs ${
isSelected
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border/50 text-muted-foreground'
}`}
>
{opt.label}
</span>
)
})}
{isInteractive && !localAnswer && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowOther((prev) => ({ ...prev, [qi]: true }))}
className="h-auto px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Other
</Button>
)}
</div>

{/* Free-text answer that doesn't match any option */}
{(resolvedAnswer || localAnswer) && !q.options.some((o) => o.label === (resolvedAnswer || localAnswer)) && (
<p className="text-xs text-primary italic">&rarr; {resolvedAnswer || localAnswer}</p>
)}

{isInteractive && !localAnswer && showOther[qi] && (
<div className="flex gap-2">
<Input
placeholder="Your answer..."
value={otherValues[qi] || ''}
onChange={(e) =>
setOtherValues((prev) => ({ ...prev, [qi]: e.target.value }))
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleOtherSubmit(qi, q)
}
}}
/>
<Button
size="sm"
onClick={() => handleOtherSubmit(qi, q)}
disabled={!otherValues[qi]?.trim()}
>
Submit
</Button>
</div>
)}
</div>
)
})}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,6 @@ SelectItem.displayName = 'SelectItem'
*
* The `any` cast is required because the TS type is not exported from @blocknote/shadcn.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const portaledShadCNComponents: any = {
Popover: {
Popover,
Expand Down
7 changes: 2 additions & 5 deletions frontend/components/dashboard/card-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,21 @@ function CardEditor({ card, onContentChange, onEditorResize }: CardEditorProps)
useEffect(() => {
cardRegistry.set(card.id, card)
return () => { cardRegistry.delete(card.id) }
}, [card.id, card.type, card.title, card.value, card.fig])
}, [card])

const initialContent = useMemo(
() => (card.content && card.content.length > 0 ? card.content : defaultContent(card)),
// Only compute once per card ID
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- only compute once per card ID
[card.id]
)

const editor = useCreateBlockNote({
schema: dashboardSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialContent: initialContent as any,
})
editorRef.current = editor as unknown as { document: unknown[] }

// Flush any pending debounced save when the component unmounts (e.g. tab switch).
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
if (saveTimerRef.current) {
Expand Down
6 changes: 2 additions & 4 deletions frontend/components/dashboard/dashboard-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ export default function DashboardGrid({
// Track cards and callback in refs so ResizeObserver/event handlers
// always read fresh values without re-creating effects.
const cardsRef = useRef(cards)
cardsRef.current = cards
useEffect(() => { cardsRef.current = cards }, [cards])
const onLayoutChangeRef = useRef(onLayoutChange)
onLayoutChangeRef.current = onLayoutChange
useEffect(() => { onLayoutChangeRef.current = onLayoutChange }, [onLayoutChange])

// IDs of cards the user has manually shrunk below content height.
const compactedRef = useRef(new Set<string>())
Expand Down Expand Up @@ -115,13 +115,11 @@ export default function DashboardGrid({
})

// Persist layout on user drag stop
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDragStop = useCallback((newLayout: any) => {
onLayoutChange(layoutToItems(newLayout))
}, [onLayoutChange])

// Persist layout on user resize stop + track compaction
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleResizeStop = useCallback((newLayout: any, _oldItem: any, newItem: any) => {
// If the user made the card shorter than its content, mark it compacted
// so auto-height doesn't fight the user's choice.
Expand Down
3 changes: 1 addition & 2 deletions frontend/lib/hooks/use-upload-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ const MESSAGES = [
]

export function useUploadMessage(active: boolean) {
const [idx, setIdx] = useState(0)
const [idx, setIdx] = useState(() => Math.floor(Math.random() * MESSAGES.length))

useEffect(() => {
if (!active) return
setIdx(Math.floor(Math.random() * MESSAGES.length))
const id = setInterval(() => {
setIdx((prev) => {
let next: number
Expand Down
Loading
Loading