Hide sources notes (#273)

* fix: add missing overflow wrapper to notebooks list page

Adds flex-1 overflow-y-auto wrapper to enable proper scrolling
when notebook list exceeds viewport height. Matches the layout
pattern used by all other dashboard pages.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: reorder transformation routes to prevent dynamic route interception

Moved static routes (/transformations/execute and /transformations/default-prompt)
before dynamic routes (/transformations/{transformation_id}) to ensure FastAPI
matches them correctly. Previously, requests to static routes were incorrectly
captured by the dynamic route handler.

Fixes #250

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: bump to 1.2.1

* hide source and notes panel - fixes #193

* feat: improve layout for mobile views

* bump version to 1.2.2

* fix: address PR review feedback for collapsible columns

- Remove unused CollapseButton component from CollapsibleColumn.tsx
- Rename useCollapseButton to createCollapseButton (not a React hook)
- Move dialogs outside Card in SourcesColumn.tsx for consistency
- Add useMemo for collapseButton in both columns to prevent re-renders

* feat: support multiple sources

* fix: prevent ChatColumn double mounting on desktop

Add useIsDesktop hook to conditionally render mobile view only on
mobile screens. Previously, the mobile ChatColumn was hidden via CSS
on desktop but still mounted, causing duplicate hooks initialization
and redundant network requests.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Luis Novo 2025-11-25 16:59:26 -03:00 committed by GitHub
parent b42cc06e65
commit 45a99831a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1285 additions and 830 deletions

191
.github/README.md vendored
View file

@ -1,191 +0,0 @@
# GitHub Workflow & Templates
This folder contains GitHub-specific configuration for Open Notebook's contribution workflow.
## 📋 Issue Templates
We have three issue templates to guide contributors:
### 🐛 Bug Report (`ISSUE_TEMPLATE/bug_report.yml`)
For reporting bugs or unexpected behavior when the app is running but misbehaving.
**Key Features:**
- Structured format for bug details
- Environment and version information
- Checkbox for contributors who want to fix the issue
- Automatic `bug` and `needs-triage` labels
**When to Use:** App is installed and running, but something doesn't work as expected.
### ✨ Feature Request (`ISSUE_TEMPLATE/feature_request.yml`)
For suggesting new features or improvements.
**Key Features:**
- Description of the feature
- Explanation of why it would be helpful
- Space for proposed solution
- Checkbox for contributors who want to implement it
- Automatic `enhancement` and `needs-triage` labels
**When to Use:** You have an idea for a new feature or improvement.
### 🔧 Installation Issue (`ISSUE_TEMPLATE/installation_issue.yml`)
For problems with installation or setup.
**Key Features:**
- Deployment type selection
- Environment details
- Error message collection
- Automatic `installation` label
**When to Use:** Having trouble getting Open Notebook running.
## 🔄 Pull Request Template
The PR template (`pull_request_template.md`) ensures contributors provide all necessary information:
**Sections:**
- Description and related issue
- Type of change
- Testing details
- Design alignment with project principles
- Comprehensive checklist for code quality, testing, documentation
- Screenshots for UI changes
**Key Checkpoints:**
- References an approved issue
- Aligns with [design principles](../DESIGN_PRINCIPLES.md)
- Includes tests and documentation
- Follows code style guidelines
## 🚀 GitHub Actions
Our CI/CD workflows in `.github/workflows/`:
### `build-and-release.yml`
- Builds Docker images on releases
- Publishes to Docker Hub and GitHub Container Registry
- Supports multi-platform builds (amd64, arm64)
### `build-dev.yml`
- Builds dev images on pushes to main
- Tags with commit SHA for testing
### `claude-code-review.yml`
- Automated code review using Claude Code
- Runs on pull requests
- Provides AI-powered suggestions
## 📖 How the Contribution Flow Works
```
┌─────────────────────────────────────────────────────────┐
│ 1. Contributor identifies a bug or has a feature idea │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 2. Creates an issue using appropriate template │
│ - Describes the problem/feature │
│ - Checks "I am a developer..." if willing to work │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 3. Maintainer reviews issue (within 48 hours) │
│ - Assesses alignment with design principles │
│ - Labels appropriately │
│ - Asks for clarification if needed │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 4. If approved: Contributor proposes solution approach │
│ - Discusses implementation strategy │
│ - Maintainer provides feedback │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 5. Issue is assigned to contributor │
│ - Contributor forks repo │
│ - Creates feature branch from main │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 6. Contributor develops solution │
│ - Reads DESIGN_PRINCIPLES.md │
│ - Reads docs/development/architecture.md │
│ - Writes code, tests, documentation │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 7. Creates Pull Request │
│ - Uses PR template │
│ - References issue number │
│ - Fills out all checklist items │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 8. Maintainer reviews PR │
│ - Checks code quality │
│ - Verifies tests pass │
│ - Ensures alignment with architecture │
│ - Provides feedback or approves │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 9. If approved: PR is merged! 🎉 │
│ - Contributor is thanked │
│ - Issue is closed │
│ - Changes included in next release │
└─────────────────────────────────────────────────────────┘
```
## 🎯 Why This Process?
### Prevents Wasted Effort
- Contributors don't spend time on code that won't be merged
- Ensures alignment with project vision before coding starts
### Maintains Quality
- All code reviewed against design principles
- Consistent architecture across contributions
- Proper testing and documentation
### Respects Everyone's Time
- Clear expectations upfront
- Structured feedback process
- Efficient review process
### Protects Project Vision
- Maintainers can guide direction
- Features align with long-term goals
- Technical debt is minimized
## 📚 Related Documentation
For contributors:
- [CONTRIBUTING.md](../CONTRIBUTING.md) - Contribution guidelines
- [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md) - Project vision and principles
- [docs/development/architecture.md](../docs/development/architecture.md) - Technical architecture
For maintainers:
- [MAINTAINER_GUIDE.md](../MAINTAINER_GUIDE.md) - How to review and manage contributions
## 🤝 Questions?
- Join our [Discord](https://discord.gg/37XJPXfz2w)
- Open a [Discussion](https://github.com/lfnovo/open-notebook/discussions)
- Read the [FAQ](../docs/troubleshooting/faq.md)
---
**Thank you for contributing to Open Notebook!** Your contributions help make this the best open-source research tool available.

View file

@ -44,7 +44,17 @@ class SourceProcessingOutput(CommandOutput):
error_message: Optional[str] = None
@command("process_source", app="open_notebook")
@command(
"process_source",
app="open_notebook",
retry={
"max_attempts": 5,
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 30,
"retry_on": [RuntimeError],
},
)
async def process_source_command(
input_data: SourceProcessingInput,
) -> SourceProcessingOutput:
@ -124,10 +134,15 @@ async def process_source_command(
processing_time=processing_time,
)
except RuntimeError as e:
# Transaction conflicts should be retried by surreal-commands
logger.warning(f"Transaction conflict, will retry: {e}")
raise
except Exception as e:
# Other errors are permanent failures
processing_time = time.time() - start_time
logger.error(f"Source processing failed: {e}")
logger.exception(e)
return SourceProcessingOutput(
success=False,

View file

@ -11,6 +11,11 @@ import { useNotebook } from '@/lib/hooks/use-notebooks'
import { useSources } from '@/lib/hooks/use-sources'
import { useNotes } from '@/lib/hooks/use-notes'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
import { useIsDesktop } from '@/lib/hooks/use-media-query'
import { cn } from '@/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FileText, StickyNote, MessageSquare } from 'lucide-react'
export type ContextMode = 'off' | 'insights' | 'full'
@ -29,6 +34,15 @@ export default function NotebookPage() {
const { data: sources, isLoading: sourcesLoading, refetch: refetchSources } = useSources(notebookId)
const { data: notes, isLoading: notesLoading } = useNotes(notebookId)
// Get collapse states for dynamic layout
const { sourcesCollapsed, notesCollapsed } = useNotebookColumnsStore()
// Detect desktop to avoid double-mounting ChatColumn
const isDesktop = useIsDesktop()
// Mobile tab state (Sources, Notes, or Chat)
const [mobileActiveTab, setMobileActiveTab] = useState<'sources' | 'notes' | 'chat'>('chat')
// Context selection state
const [contextSelections, setContextSelections] = useState<ContextSelections>({
sources: {},
@ -105,32 +119,98 @@ export default function NotebookPage() {
<NotebookHeader notebook={notebook} />
</div>
<div className="flex-1 p-6 pt-6 overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-full min-h-0">
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6 h-full min-h-0">
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<SourcesColumn
sources={sources}
isLoading={sourcesLoading}
notebookId={notebookId}
notebookName={notebook?.name}
onRefresh={refetchSources}
contextSelections={contextSelections.sources}
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
/>
<div className="flex-1 p-6 pt-6 overflow-hidden flex flex-col">
{/* Mobile: Tabbed interface - only render on mobile to avoid double-mounting */}
{!isDesktop && (
<>
<div className="lg:hidden mb-4">
<Tabs value={mobileActiveTab} onValueChange={(value) => setMobileActiveTab(value as 'sources' | 'notes' | 'chat')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sources" className="gap-2">
<FileText className="h-4 w-4" />
Sources
</TabsTrigger>
<TabsTrigger value="notes" className="gap-2">
<StickyNote className="h-4 w-4" />
Notes
</TabsTrigger>
<TabsTrigger value="chat" className="gap-2">
<MessageSquare className="h-4 w-4" />
Chat
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<NotesColumn
notes={notes}
isLoading={notesLoading}
notebookId={notebookId}
contextSelections={contextSelections.notes}
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
/>
{/* Mobile: Show only active tab */}
<div className="flex-1 overflow-hidden lg:hidden">
{mobileActiveTab === 'sources' && (
<SourcesColumn
sources={sources}
isLoading={sourcesLoading}
notebookId={notebookId}
notebookName={notebook?.name}
onRefresh={refetchSources}
contextSelections={contextSelections.sources}
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
/>
)}
{mobileActiveTab === 'notes' && (
<NotesColumn
notes={notes}
isLoading={notesLoading}
notebookId={notebookId}
contextSelections={contextSelections.notes}
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
/>
)}
{mobileActiveTab === 'chat' && (
<ChatColumn
notebookId={notebookId}
contextSelections={contextSelections}
/>
)}
</div>
</>
)}
{/* Desktop: Collapsible columns layout */}
<div className={cn(
'hidden lg:flex h-full min-h-0 gap-6 transition-all duration-150',
'flex-row'
)}>
{/* Sources Column */}
<div className={cn(
'transition-all duration-150',
sourcesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'
)}>
<SourcesColumn
sources={sources}
isLoading={sourcesLoading}
notebookId={notebookId}
notebookName={notebook?.name}
onRefresh={refetchSources}
contextSelections={contextSelections.sources}
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
/>
</div>
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* Notes Column */}
<div className={cn(
'transition-all duration-150',
notesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'
)}>
<NotesColumn
notes={notes}
isLoading={notesLoading}
notebookId={notebookId}
contextSelections={contextSelections.notes}
onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}
/>
</div>
{/* Chat Column - always expanded, takes remaining space */}
<div className="transition-all duration-150 flex-1">
<ChatColumn
notebookId={notebookId}
contextSelections={contextSelections}

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { NoteResponse } from '@/lib/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -20,6 +20,8 @@ import { ContextToggle } from '@/components/common/ContextToggle'
import { ContextMode } from '../[id]/page'
import { useDeleteNote } from '@/lib/hooks/use-notes'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
interface NotesColumnProps {
notes?: NoteResponse[]
@ -43,6 +45,13 @@ export function NotesColumn({
const deleteNote = useDeleteNote()
// Collapsible column state
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
const collapseButton = useMemo(
() => createCollapseButton(toggleNotes, 'Notes'),
[toggleNotes]
)
const handleDeleteClick = (noteId: string) => {
setNoteToDelete(noteId)
setDeleteDialogOpen(true)
@ -62,113 +71,123 @@ export function NotesColumn({
return (
<>
<Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Notes</CardTitle>
<Button
size="sm"
onClick={() => {
setEditingNote(null)
setShowAddDialog(true)
}}
>
<Plus className="h-4 w-4 mr-2" />
Write Note
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : !notes || notes.length === 0 ? (
<EmptyState
icon={StickyNote}
title="No notes yet"
description="Create your first note to capture insights and observations."
/>
) : (
<div className="space-y-3">
{notes.map((note) => (
<div
key={note.id}
className="p-3 border rounded-lg card-hover group relative cursor-pointer"
onClick={() => setEditingNote(note)}
<CollapsibleColumn
isCollapsed={notesCollapsed}
onToggle={toggleNotes}
collapsedIcon={StickyNote}
collapsedLabel="Notes"
>
<Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">Notes</CardTitle>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => {
setEditingNote(null)
setShowAddDialog(true)
}}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{note.note_type === 'ai' ? (
<Bot className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
<Badge variant="secondary" className="text-xs">
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })}
</span>
{/* Context toggle - only show if handler provided */}
{onContextModeChange && contextSelections?.[note.id] && (
<div onClick={(event) => event.stopPropagation()}>
<ContextToggle
mode={contextSelections[note.id]}
hasInsights={false}
onChange={(mode) => onContextModeChange(note.id, mode)}
/>
</div>
)}
{/* Ellipsis menu for delete action */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(note.id)
}}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{note.title && (
<h4 className="text-sm font-medium mb-2">{note.title}</h4>
)}
{note.content && (
<p className="text-sm text-muted-foreground line-clamp-3">
{note.content}
</p>
)}
</div>
))}
<Plus className="h-4 w-4 mr-2" />
Write Note
</Button>
{collapseButton}
</div>
</div>
)}
</CardContent>
</Card>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : !notes || notes.length === 0 ? (
<EmptyState
icon={StickyNote}
title="No notes yet"
description="Create your first note to capture insights and observations."
/>
) : (
<div className="space-y-3">
{notes.map((note) => (
<div
key={note.id}
className="p-3 border rounded-lg card-hover group relative cursor-pointer"
onClick={() => setEditingNote(note)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{note.note_type === 'ai' ? (
<Bot className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
<Badge variant="secondary" className="text-xs">
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })}
</span>
{/* Context toggle - only show if handler provided */}
{onContextModeChange && contextSelections?.[note.id] && (
<div onClick={(event) => event.stopPropagation()}>
<ContextToggle
mode={contextSelections[note.id]}
hasInsights={false}
onChange={(mode) => onContextModeChange(note.id, mode)}
/>
</div>
)}
{/* Ellipsis menu for delete action */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(note.id)
}}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{note.title && (
<h4 className="text-sm font-medium mb-2">{note.title}</h4>
)}
{note.content && (
<p className="text-sm text-muted-foreground line-clamp-3">
{note.content}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</CollapsibleColumn>
<NoteEditorDialog
open={showAddDialog || Boolean(editingNote)}

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { SourceListResponse } from '@/lib/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -20,6 +20,8 @@ import { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useModalManager } from '@/lib/hooks/use-modal-manager'
import { ContextMode } from '../[id]/page'
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
interface SourcesColumnProps {
sources?: SourceListResponse[]
@ -51,6 +53,13 @@ export function SourcesColumn({
const deleteSource = useDeleteSource()
const retrySource = useRetrySource()
const removeFromNotebook = useRemoveSourceFromNotebook()
// Collapsible column state
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
const collapseButton = useMemo(
() => createCollapseButton(toggleSources, 'Sources'),
[toggleSources]
)
const handleDeleteClick = (sourceId: string) => {
setSourceToDelete(sourceId)
@ -102,67 +111,80 @@ export function SourcesColumn({
const handleSourceClick = (sourceId: string) => {
openModal('source', sourceId)
}
return (
<Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Sources</CardTitle>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Source
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" />
Add New Source
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
<Link2 className="h-4 w-4 mr-2" />
Add Existing Source
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : !sources || sources.length === 0 ? (
<EmptyState
icon={FileText}
title="No sources yet"
description="Add your first source to start building your knowledge base."
/>
) : (
<div className="space-y-3">
{sources.map((source) => (
<SourceCard
key={source.id}
source={source}
onClick={handleSourceClick}
onDelete={handleDeleteClick}
onRetry={handleRetry}
onRemoveFromNotebook={handleRemoveFromNotebook}
onRefresh={onRefresh}
showRemoveFromNotebook={true}
contextMode={contextSelections?.[source.id]}
onContextModeChange={onContextModeChange
? (mode) => onContextModeChange(source.id, mode)
: undefined
}
return (
<>
<CollapsibleColumn
isCollapsed={sourcesCollapsed}
onToggle={toggleSources}
collapsedIcon={FileText}
collapsedLabel="Sources"
>
<Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">Sources</CardTitle>
<div className="flex items-center gap-2">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Source
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" />
Add New Source
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
<Link2 className="h-4 w-4 mr-2" />
Add Existing Source
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{collapseButton}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : !sources || sources.length === 0 ? (
<EmptyState
icon={FileText}
title="No sources yet"
description="Add your first source to start building your knowledge base."
/>
))}
</div>
)}
</CardContent>
) : (
<div className="space-y-3">
{sources.map((source) => (
<SourceCard
key={source.id}
source={source}
onClick={handleSourceClick}
onDelete={handleDeleteClick}
onRetry={handleRetry}
onRemoveFromNotebook={handleRemoveFromNotebook}
onRefresh={onRefresh}
showRemoveFromNotebook={true}
contextMode={contextSelections?.[source.id]}
onContextModeChange={onContextModeChange
? (mode) => onContextModeChange(source.id, mode)
: undefined
}
/>
))}
</div>
)}
</CardContent>
</Card>
</CollapsibleColumn>
<AddSourceDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
@ -197,6 +219,6 @@ export function SourcesColumn({
isLoading={removeFromNotebook.isPending}
confirmVariant="default"
/>
</Card>
</>
)
}

View file

@ -0,0 +1,93 @@
'use client'
import { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ChevronLeft, LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface CollapsibleColumnProps {
isCollapsed: boolean
onToggle: () => void
collapsedIcon: LucideIcon
collapsedLabel: string
children: ReactNode
}
export function CollapsibleColumn({
isCollapsed,
onToggle,
collapsedIcon: CollapsedIcon,
collapsedLabel,
children,
}: CollapsibleColumnProps) {
if (isCollapsed) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onToggle}
className={cn(
'flex flex-col items-center justify-center gap-3',
'w-12 h-full min-h-0',
'border rounded-lg',
'bg-card hover:bg-accent/50',
'transition-all duration-150',
'cursor-pointer group',
'py-6'
)}
aria-label={`Expand ${collapsedLabel}`}
>
<CollapsedIcon className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
<div
className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors whitespace-nowrap"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', textOrientation: 'mixed' }}
>
{collapsedLabel}
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Expand {collapsedLabel}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return (
<div className="h-full min-h-0 transition-all duration-150">
{children}
</div>
)
}
// Factory function to create a collapse button for card headers
export function createCollapseButton(onToggle: () => void, label: string) {
return (
<div className="hidden lg:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
className="h-7 w-7 hover:bg-accent"
aria-label={`Collapse ${label}`}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Collapse {label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View file

@ -1,10 +1,11 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { LoaderIcon } from 'lucide-react'
import { LoaderIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
@ -14,7 +15,7 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { WizardContainer, WizardStep } from '@/components/ui/wizard-container'
import { SourceTypeStep } from './steps/SourceTypeStep'
import { SourceTypeStep, parseAndValidateUrls } from './steps/SourceTypeStep'
import { NotebooksStep } from './steps/NotebooksStep'
import { ProcessingStep } from './steps/ProcessingStep'
import { useNotebooks } from '@/lib/hooks/use-notebooks'
@ -23,6 +24,8 @@ import { useCreateSource } from '@/lib/hooks/use-sources'
import { useSettings } from '@/lib/hooks/use-settings'
import { CreateSourceRequest } from '@/lib/types/api'
const MAX_BATCH_SIZE = 50
const createSourceSchema = z.object({
type: z.enum(['link', 'upload', 'text']),
title: z.string().optional(),
@ -80,6 +83,13 @@ interface ProcessingState {
progress?: number
}
interface BatchProgress {
total: number
completed: number
failed: number
currentItem?: string
}
export function AddSourceDialog({
open,
onOpenChange,
@ -94,6 +104,10 @@ export function AddSourceDialog({
)
const [selectedTransformations, setSelectedTransformations] = useState<string[]>([])
// Batch-specific state
const [urlValidationErrors, setUrlValidationErrors] = useState<{ url: string; line: number }[]>([])
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
// Cleanup timeouts to prevent memory leaks
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -158,12 +172,51 @@ export function AddSourceDialog({
const watchedFile = watch('file')
const watchedTitle = watch('title')
// Batch mode detection
const { isBatchMode, itemCount, parsedUrls, parsedFiles } = useMemo(() => {
let urlCount = 0
let fileCount = 0
let parsedUrls: string[] = []
let parsedFiles: File[] = []
if (selectedType === 'link' && watchedUrl) {
const { valid } = parseAndValidateUrls(watchedUrl)
parsedUrls = valid
urlCount = valid.length
}
if (selectedType === 'upload' && watchedFile) {
const fileList = watchedFile as FileList
if (fileList?.length) {
parsedFiles = Array.from(fileList)
fileCount = parsedFiles.length
}
}
const isBatchMode = urlCount > 1 || fileCount > 1
const itemCount = selectedType === 'link' ? urlCount : fileCount
return { isBatchMode, itemCount, parsedUrls, parsedFiles }
}, [selectedType, watchedUrl, watchedFile])
// Check for batch size limit
const isOverLimit = itemCount > MAX_BATCH_SIZE
// Step validation - now reactive with watched values
const isStepValid = (step: number): boolean => {
switch (step) {
case 1:
if (!selectedType) return false
// Check batch size limit
if (isOverLimit) return false
// Check for URL validation errors
if (urlValidationErrors.length > 0) return false
if (selectedType === 'link') {
// In batch mode, check that we have at least one valid URL
if (isBatchMode) {
return parsedUrls.length > 0
}
return !!watchedUrl && watchedUrl.trim() !== ''
}
if (selectedType === 'text') {
@ -172,7 +225,7 @@ export function AddSourceDialog({
}
if (selectedType === 'upload') {
if (watchedFile instanceof FileList) {
return watchedFile.length > 0
return watchedFile.length > 0 && watchedFile.length <= MAX_BATCH_SIZE
}
return !!watchedFile
}
@ -189,11 +242,27 @@ export function AddSourceDialog({
const handleNextStep = (e?: React.MouseEvent) => {
e?.preventDefault()
e?.stopPropagation()
// Validate URLs when leaving step 1 in link mode
if (currentStep === 1 && selectedType === 'link' && watchedUrl) {
const { invalid } = parseAndValidateUrls(watchedUrl)
if (invalid.length > 0) {
setUrlValidationErrors(invalid)
return
}
setUrlValidationErrors([])
}
if (currentStep < 3 && isStepValid(currentStep)) {
setCurrentStep(currentStep + 1)
}
}
// Clear URL validation errors when user edits
const handleClearUrlErrors = () => {
setUrlValidationErrors([])
}
const handlePrevStep = (e?: React.MouseEvent) => {
e?.preventDefault()
e?.stopPropagation()
@ -223,43 +292,127 @@ export function AddSourceDialog({
setSelectedTransformations(updated)
}
// Single source submission
const submitSingleSource = async (data: CreateSourceFormData): Promise<void> => {
const createRequest: CreateSourceRequest = {
type: data.type,
notebooks: selectedNotebooks,
url: data.type === 'link' ? data.url : undefined,
content: data.type === 'text' ? data.content : undefined,
title: data.title,
transformations: selectedTransformations,
embed: data.embed,
delete_source: false,
async_processing: true,
}
if (data.type === 'upload' && data.file) {
const file = data.file instanceof FileList ? data.file[0] : data.file
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
requestWithFile.file = file
}
await createSource.mutateAsync(createRequest)
}
// Batch submission
const submitBatch = async (data: CreateSourceFormData): Promise<{ success: number; failed: number }> => {
const results = { success: 0, failed: 0 }
const items: { type: 'url' | 'file'; value: string | File }[] = []
// Collect items to process
if (data.type === 'link' && parsedUrls.length > 0) {
parsedUrls.forEach(url => items.push({ type: 'url', value: url }))
} else if (data.type === 'upload' && parsedFiles.length > 0) {
parsedFiles.forEach(file => items.push({ type: 'file', value: file }))
}
setBatchProgress({
total: items.length,
completed: 0,
failed: 0,
})
// Process each item sequentially
for (let i = 0; i < items.length; i++) {
const item = items[i]
const itemLabel = item.type === 'url'
? (item.value as string).substring(0, 50) + '...'
: (item.value as File).name
setBatchProgress(prev => prev ? {
...prev,
currentItem: itemLabel,
} : null)
try {
const createRequest: CreateSourceRequest = {
type: item.type === 'url' ? 'link' : 'upload',
notebooks: selectedNotebooks,
url: item.type === 'url' ? item.value as string : undefined,
transformations: selectedTransformations,
embed: data.embed,
delete_source: false,
async_processing: true,
}
if (item.type === 'file') {
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
requestWithFile.file = item.value as File
}
await createSource.mutateAsync(createRequest)
results.success++
} catch (error) {
console.error(`Error creating source for ${itemLabel}:`, error)
results.failed++
}
setBatchProgress(prev => prev ? {
...prev,
completed: results.success,
failed: results.failed,
} : null)
}
return results
}
// Form submission
const onSubmit = async (data: CreateSourceFormData) => {
try {
setProcessing(true)
setProcessingStatus({ message: 'Submitting source for processing...' })
const createRequest: CreateSourceRequest = {
type: data.type,
notebooks: selectedNotebooks,
url: data.type === 'link' ? data.url : undefined,
content: data.type === 'text' ? data.content : undefined,
title: data.title,
transformations: selectedTransformations,
embed: data.embed,
delete_source: false,
async_processing: true, // Always use async processing for frontend submissions
if (isBatchMode) {
// Batch submission
setProcessingStatus({ message: `Processing ${itemCount} sources...` })
const results = await submitBatch(data)
// Show summary toast
if (results.failed === 0) {
toast.success(`${results.success} source${results.success !== 1 ? 's' : ''} created successfully`)
} else if (results.success === 0) {
toast.error(`Failed to create all ${results.failed} sources`)
} else {
toast.warning(`${results.success} succeeded, ${results.failed} failed`)
}
handleClose()
} else {
// Single source submission
setProcessingStatus({ message: 'Submitting source for processing...' })
await submitSingleSource(data)
handleClose()
}
if (data.type === 'upload' && data.file) {
const file = data.file instanceof FileList ? data.file[0] : data.file
const requestWithFile = createRequest as CreateSourceRequest & { file?: File }
requestWithFile.file = file
}
await createSource.mutateAsync(createRequest)
// Close immediately - the toast will show the success message
handleClose()
} catch (error) {
console.error('Error creating source:', error)
setProcessingStatus({
setProcessingStatus({
message: 'Error creating source. Please try again.',
})
timeoutRef.current = setTimeout(() => {
setProcessing(false)
setProcessingStatus(null)
setBatchProgress(null)
}, 3000)
}
}
@ -277,6 +430,8 @@ export function AddSourceDialog({
setProcessing(false)
setProcessingStatus(null)
setSelectedNotebooks(defaultNotebookId ? [defaultNotebookId] : [])
setUrlValidationErrors([])
setBatchProgress(null)
// Reset to default transformations
if (transformations.length > 0) {
@ -293,16 +448,25 @@ export function AddSourceDialog({
// Processing view
if (processing) {
const progressPercent = batchProgress
? Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)
: undefined
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]" showCloseButton={true}>
<DialogHeader>
<DialogTitle>Processing Source</DialogTitle>
<DialogTitle>
{batchProgress ? 'Processing Batch' : 'Processing Source'}
</DialogTitle>
<DialogDescription>
Your source is being processed. This may take a few moments.
{batchProgress
? `Processing ${batchProgress.total} sources. This may take a few moments.`
: 'Your source is being processed. This may take a few moments.'
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3">
<LoaderIcon className="h-5 w-5 animate-spin text-primary" />
@ -310,11 +474,48 @@ export function AddSourceDialog({
{processingStatus?.message || 'Processing...'}
</span>
</div>
{processingStatus?.progress && (
{/* Batch progress */}
{batchProgress && (
<>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5 text-green-600">
<CheckCircleIcon className="h-4 w-4" />
{batchProgress.completed} completed
</span>
{batchProgress.failed > 0 && (
<span className="flex items-center gap-1.5 text-destructive">
<XCircleIcon className="h-4 w-4" />
{batchProgress.failed} failed
</span>
)}
</div>
<span className="text-muted-foreground">
{batchProgress.completed + batchProgress.failed} / {batchProgress.total}
</span>
</div>
{batchProgress.currentItem && (
<p className="text-xs text-muted-foreground truncate">
Current: {batchProgress.currentItem}
</p>
)}
</>
)}
{/* Single source progress */}
{!batchProgress && processingStatus?.progress && (
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${processingStatus.progress}%` }}
/>
</div>
@ -351,6 +552,8 @@ export function AddSourceDialog({
register={register}
// @ts-expect-error - Type inference issue with zod schema
errors={errors}
urlValidationErrors={urlValidationErrors}
onClearUrlErrors={handleClearUrlErrors}
/>
)}

View file

@ -1,5 +1,6 @@
"use client"
import { useMemo } from "react"
import { Control, FieldErrors, UseFormRegister, useWatch } from "react-hook-form"
import { FileIcon, LinkIcon, FileTextIcon } from "lucide-react"
import { FormSection } from "@/components/ui/form-section"
@ -7,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Controller } from "react-hook-form"
interface CreateSourceFormData {
@ -21,6 +23,45 @@ interface CreateSourceFormData {
async_processing: boolean
}
// Helper functions for batch URL parsing
function parseUrls(text: string): string[] {
return text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
}
function validateUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
export function parseAndValidateUrls(text: string): {
valid: string[]
invalid: { url: string; line: number }[]
} {
const lines = text.split('\n')
const valid: string[] = []
const invalid: { url: string; line: number }[] = []
lines.forEach((line, index) => {
const trimmed = line.trim()
if (trimmed.length === 0) return // skip empty lines
if (validateUrl(trimmed)) {
valid.push(trimmed)
} else {
invalid.push({ url: trimmed, line: index + 1 })
}
})
return { valid, invalid }
}
const SOURCE_TYPES = [
{
value: 'link' as const,
@ -46,11 +87,41 @@ interface SourceTypeStepProps {
control: Control<CreateSourceFormData>
register: UseFormRegister<CreateSourceFormData>
errors: FieldErrors<CreateSourceFormData>
urlValidationErrors?: { url: string; line: number }[]
onClearUrlErrors?: () => void
}
export function SourceTypeStep({ control, register, errors }: SourceTypeStepProps) {
// Watch the selected type to make title conditional
const MAX_BATCH_SIZE = 50
export function SourceTypeStep({ control, register, errors, urlValidationErrors, onClearUrlErrors }: SourceTypeStepProps) {
// Watch the selected type and inputs to detect batch mode
const selectedType = useWatch({ control, name: 'type' })
const urlInput = useWatch({ control, name: 'url' })
const fileInput = useWatch({ control, name: 'file' })
// Batch mode detection
const { isBatchMode, itemCount, urlCount, fileCount } = useMemo(() => {
let urlCount = 0
let fileCount = 0
if (selectedType === 'link' && urlInput) {
const urls = parseUrls(urlInput)
urlCount = urls.length
}
if (selectedType === 'upload' && fileInput) {
const fileList = fileInput as FileList
fileCount = fileList?.length || 0
}
const isBatchMode = urlCount > 1 || fileCount > 1
const itemCount = selectedType === 'link' ? urlCount : fileCount
return { isBatchMode, itemCount, urlCount, fileCount }
}, [selectedType, urlInput, fileInput])
// Check for batch size limit
const isOverLimit = itemCount > MAX_BATCH_SIZE
return (
<div className="space-y-6">
<FormSection
@ -85,34 +156,98 @@ export function SourceTypeStep({ control, register, errors }: SourceTypeStepProp
{/* Type-specific fields */}
{type.value === 'link' && (
<div>
<Label htmlFor="url" className="mb-2 block">URL *</Label>
<Input
<div className="flex items-center justify-between mb-2">
<Label htmlFor="url">URL(s) *</Label>
{urlCount > 0 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
{urlCount} URL{urlCount !== 1 ? 's' : ''}
{isOverLimit && ` (max ${MAX_BATCH_SIZE})`}
</Badge>
)}
</div>
<Textarea
id="url"
{...register('url')}
placeholder="https://example.com/article"
type="url"
{...register('url', {
onChange: () => onClearUrlErrors?.()
})}
placeholder="Enter URLs, one per line&#10;https://example.com/article1&#10;https://example.com/article2"
rows={urlCount > 1 ? 6 : 2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Paste multiple URLs (one per line) to batch import
</p>
{errors.url && (
<p className="text-sm text-destructive mt-1">{errors.url.message}</p>
)}
{urlValidationErrors && urlValidationErrors.length > 0 && (
<div className="mt-2 p-3 bg-destructive/10 rounded-md border border-destructive/20">
<p className="text-sm font-medium text-destructive mb-2">
Invalid URLs detected:
</p>
<ul className="space-y-1">
{urlValidationErrors.map((error, idx) => (
<li key={idx} className="text-xs text-destructive flex items-start gap-2">
<span className="font-mono bg-destructive/20 px-1 rounded">
Line {error.line}
</span>
<span className="truncate">{error.url}</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground mt-2">
Please fix or remove invalid URLs to continue
</p>
</div>
)}
</div>
)}
{type.value === 'upload' && (
<div>
<Label htmlFor="file" className="mb-2 block">File *</Label>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="file">File(s) *</Label>
{fileCount > 0 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
{fileCount} file{fileCount !== 1 ? 's' : ''}
{isOverLimit && ` (max ${MAX_BATCH_SIZE})`}
</Badge>
)}
</div>
<Input
id="file"
type="file"
multiple
{...register('file')}
accept=".pdf,.doc,.docx,.pptx,.ppt,.xlsx,.xls,.txt,.md,.epub,.mp4,.avi,.mov,.wmv,.mp3,.wav,.m4a,.aac,.jpg,.jpeg,.png,.tiff,.zip,.tar,.gz,.html"
/>
<p className="text-xs text-muted-foreground mt-1">
Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)
Select multiple files to batch import. Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)
</p>
{fileCount > 1 && fileInput instanceof FileList && (
<div className="mt-2 p-3 bg-muted rounded-md">
<p className="text-xs font-medium mb-2">Selected files:</p>
<ul className="space-y-1 max-h-32 overflow-y-auto">
{Array.from(fileInput).map((file, idx) => (
<li key={idx} className="text-xs text-muted-foreground flex items-center gap-2">
<FileIcon className="h-3 w-3" />
<span className="truncate">{file.name}</span>
<span className="text-muted-foreground/50">
({(file.size / 1024).toFixed(1)} KB)
</span>
</li>
))}
</ul>
</div>
)}
{errors.file && (
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
)}
{isOverLimit && selectedType === 'upload' && (
<p className="text-sm text-destructive mt-1">
Maximum {MAX_BATCH_SIZE} files allowed per batch
</p>
)}
</div>
)}
@ -140,22 +275,41 @@ export function SourceTypeStep({ control, register, errors }: SourceTypeStepProp
)}
</FormSection>
<FormSection
title={selectedType === 'text' ? "Title *" : "Title (optional)"}
description={selectedType === 'text'
? "A title is required for text content"
: "If left empty, a title will be generated from the content"
}
>
<Input
id="title"
{...register('title')}
placeholder="Give your source a descriptive title"
/>
{errors.title && (
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
)}
</FormSection>
{/* Hide title field in batch mode - titles will be auto-generated */}
{!isBatchMode && (
<FormSection
title={selectedType === 'text' ? "Title *" : "Title (optional)"}
description={selectedType === 'text'
? "A title is required for text content"
: "If left empty, a title will be generated from the content"
}
>
<Input
id="title"
{...register('title')}
placeholder="Give your source a descriptive title"
/>
{errors.title && (
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
)}
</FormSection>
)}
{/* Batch mode indicator */}
{isBatchMode && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Badge variant="default">Batch Mode</Badge>
<span className="text-sm font-medium">
{itemCount} {selectedType === 'link' ? 'URLs' : 'files'} will be processed
</span>
</div>
<p className="text-xs text-muted-foreground">
Titles will be automatically generated for each source.
The same notebooks and transformations will be applied to all items.
</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import { useState, useEffect } from 'react'
/**
* Hook to detect if viewport matches a media query.
* Returns false during SSR to avoid hydration mismatches.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia(query)
setMatches(mediaQuery.matches)
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [query])
return matches
}
/**
* Returns true if viewport is >= 1024px (Tailwind's 'lg' breakpoint)
*/
export function useIsDesktop(): boolean {
return useMediaQuery('(min-width: 1024px)')
}

View file

@ -0,0 +1,27 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface NotebookColumnsState {
sourcesCollapsed: boolean
notesCollapsed: boolean
toggleSources: () => void
toggleNotes: () => void
setSources: (collapsed: boolean) => void
setNotes: (collapsed: boolean) => void
}
export const useNotebookColumnsStore = create<NotebookColumnsState>()(
persist(
(set) => ({
sourcesCollapsed: false,
notesCollapsed: false,
toggleSources: () => set((state) => ({ sourcesCollapsed: !state.sourcesCollapsed })),
toggleNotes: () => set((state) => ({ notesCollapsed: !state.notesCollapsed })),
setSources: (collapsed) => set({ sourcesCollapsed: collapsed }),
setNotes: (collapsed) => set({ notesCollapsed: collapsed }),
}),
{
name: 'notebook-columns-storage',
}
)
)

View file

@ -73,6 +73,10 @@ async def repo_query(
if isinstance(result, str):
raise RuntimeError(result)
return result
except RuntimeError as e:
# RuntimeError is raised for retriable transaction conflicts - log without stack trace
logger.error(str(e))
raise
except Exception as e:
logger.exception(e)
raise
@ -87,6 +91,9 @@ async def repo_create(table: str, data: Dict[str, Any]) -> Dict[str, Any]:
try:
async with db_connection() as connection:
return parse_record_ids(await connection.insert(table, data))
except RuntimeError as e:
logger.error(str(e))
raise
except Exception as e:
logger.exception(e)
raise RuntimeError("Failed to create record")
@ -144,18 +151,6 @@ async def repo_update(
raise RuntimeError(f"Failed to update record: {str(e)}")
async def repo_get_news_by_jota_id(jota_id: str) -> Dict[str, Any]:
try:
results = await repo_query(
"SELECT * omit embedding FROM news where jota_id=$jota_id",
{"jota_id": jota_id},
)
return parse_record_ids(results)
except Exception as e:
logger.exception(e)
raise RuntimeError(f"Failed to fetch record: {str(e)}")
async def repo_delete(record_id: Union[str, RecordID]):
"""Delete a record by record id"""

View file

@ -158,6 +158,9 @@ class ObjectModel(BaseModel):
except ValidationError as e:
logger.error(f"Validation failed: {e}")
raise
except RuntimeError:
# Transaction conflicts should propagate for retry
raise
except Exception as e:
logger.error(f"Error saving record: {e}")
raise DatabaseOperationError(e)

View file

@ -1,6 +1,6 @@
[project]
name = "open-notebook"
version = "1.2.1"
version = "1.2.2"
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"}

753
uv.lock

File diff suppressed because it is too large Load diff