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
51 changes: 28 additions & 23 deletions frontend/src/components/AIConversationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export default function AIConversationPanel({
const [conversation, setConversation] = useState<ConversationMessage[]>([])
const [sessions, setSessions] = useState<AISession[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
// Chat | Recipes | History | Assets — four tabs sharing this one side panel.
// Chat | History | Assets | Recipes — four tabs sharing this one side panel.
const [panelTab, setPanelTab] = useState<'chat' | 'recipes' | 'history' | 'assets'>('chat')
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameText, setRenameText] = useState('')
Expand Down Expand Up @@ -1743,7 +1743,7 @@ export default function AIConversationPanel({
onMouseDown={startResize}
/>

{/* Header: Chat | Recipes | History tabs */}
{/* Header: Chat | History | Assets | Recipes tabs */}
<div className="shrink-0 flex items-center justify-between px-2 py-1.5 border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-0.5 text-xs font-semibold">
<button
Expand All @@ -1757,17 +1757,6 @@ export default function AIConversationPanel({
<Sparkles className="w-3.5 h-3.5" />
Assistant
</button>
<button
onClick={() => setPanelTab('recipes')}
className={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors ${
panelTab === 'recipes'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<BookOpen className="w-3.5 h-3.5" />
Recipes
</button>
<button
onClick={() => setPanelTab('history')}
disabled={!sessionsEnabled}
Expand All @@ -1776,9 +1765,10 @@ export default function AIConversationPanel({
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
title="Show chat history"
aria-label="Show chat history"
>
<History className="w-3.5 h-3.5" />
History
</button>
<button
onClick={() => setPanelTab('assets')}
Expand All @@ -1792,17 +1782,19 @@ export default function AIConversationPanel({
<Paperclip className="w-3.5 h-3.5" />
Assets
</button>
<button
onClick={() => setPanelTab('recipes')}
className={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors ${
panelTab === 'recipes'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<BookOpen className="w-3.5 h-3.5" />
Recipes
</button>
</div>
<div className="flex items-center gap-1">
{voiceCapable && (
<button
onClick={() => (voiceOpen ? void endVoiceSession(true) : void openVoiceSession())}
className={`btn-ghost p-1 ${voiceOpen ? 'text-indigo-500' : ''}`}
title={voiceOpen ? 'End voice mode' : 'Start voice mode'}
>
<AudioLines className="w-4 h-4" />
</button>
)}
<button
onClick={() => void handleNewSession()}
className="btn-ghost p-1"
Expand Down Expand Up @@ -2373,6 +2365,19 @@ export default function AIConversationPanel({
)}
</button>
)}
{voiceCapable && (
<button
className={`shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
voiceOpen ? 'text-indigo-500' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`}
onClick={() => (voiceOpen ? void endVoiceSession(true) : void openVoiceSession())}
onMouseDown={(e) => e.preventDefault()}
title={voiceOpen ? 'End voice mode' : 'Start voice mode'}
aria-label={voiceOpen ? 'End voice mode' : 'Start voice mode'}
>
<AudioLines className="w-4 h-4" />
</button>
)}
<textarea
ref={inputRef}
value={input}
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/components/ActivityIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Link2, Loader2, Sparkles, X,
} from 'lucide-react'
import { useActivityStore, jobKey } from '@/stores/activity'
import { isActive, type ActivityJob, type ActivityKind } from '@/api/activity'
import { isActive, isSettled, type ActivityJob, type ActivityKind } from '@/api/activity'
import { useDropdown } from '@/hooks/useDropdown'
import { formatBytes, formatTimeAgo } from '@/utils/format'

Expand Down Expand Up @@ -63,6 +63,7 @@ export default function ActivityIndicator({ onInsert }: Props) {
const jobs = useActivityStore((s) => s.jobs)
const cancel = useActivityStore((s) => s.cancel)
const dismiss = useActivityStore((s) => s.dismiss)
const clearSettled = useActivityStore((s) => s.clearSettled)
const resume = useActivityStore((s) => s.resume)
const navigate = useNavigate()
const { open, setOpen, triggerRef, dropdownRef, style } = useDropdown('right')
Expand Down Expand Up @@ -129,8 +130,16 @@ export default function ActivityIndicator({ onInsert }: Props) {
className="z-50 w-80 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
style={style}
>
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
Background tasks
<div className="px-3 py-2 flex items-center justify-between text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
<span>Background tasks</span>
<button
className="btn-ghost px-1.5 py-0.5 text-xs font-medium disabled:opacity-40 disabled:pointer-events-none"
onClick={clearSettled}
disabled={!list.some(isSettled)}
title="Remove all completed tasks"
>
Clear
</button>
</div>
<div className="max-h-96 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-700">
{list.map((job) => {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/CategoryBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { Category } from '@/api/categories'
export default function CategoryBadge({ category }: { category: Category }) {
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium text-white"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium text-white max-w-full"
style={{ backgroundColor: category.color }}
>
<span>{category.emoji}</span>
<span>{category.label}</span>
<span className="shrink-0">{category.emoji}</span>
<span className="truncate">{category.label}</span>
</span>
)
}
2 changes: 1 addition & 1 deletion frontend/src/components/FolderIconBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function FolderChip({ folder, onOpen, onOpenDynamic, onMove, onCustomize, onDele

const isDynamic = isDynamicFolder(folder)
// A dynamic folder is a leaf that runs a search — never a drop target for notes/folders.
const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `folder-drop:${folder.id}`, disabled: isDynamic })
const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `folder-drop:${folder.id}`, data: { folderId: folder.id }, disabled: isDynamic })
const { setNodeRef: setDragRef, attributes, listeners, isDragging } = useDraggable({
id: `folder-drag:${folder.id}`,
data: { type: 'folder', folderId: folder.id },
Expand Down
72 changes: 69 additions & 3 deletions frontend/src/components/FolderTreePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useDraggable, useDroppable } from '@dnd-kit/core'
import {
Home, ChevronDown, MoreVertical, FolderPlus, Plus, Upload, FolderInput, Palette, Trash2,
PanelLeftClose, Folder as FolderIcon, Search, Globe,
Expand All @@ -23,6 +24,10 @@ interface Props {
onCustomize: (folder: Folder) => void
onDelete: (folder: Folder) => void // parent decides archive vs. permanent delete
onEmptyArchive: () => void
/** Folder currently being dragged (from here or the folder bar), so rows that can't
* accept it — itself, its descendants, its current parent — stop offering to. The
* drag/drop context itself lives in the parent; this panel only registers rows. */
draggingFolderId?: string | null
storageKey?: string
}

Expand All @@ -48,11 +53,17 @@ interface MenuState { target: MenuTarget; top: number; left: number }

const MENU_WIDTH = 192 // matches w-48

// Hovering a collapsed folder this long mid-drag opens it, so nested targets are reachable.
const DRAG_EXPAND_MS = 600

interface RowCtx {
currentFolderId: string | null
archiveId: string | null
byId: Map<string, Folder>
draggingFolderId: string | null
expanded: Set<string>
toggleExpand: (id: string) => void
expand: (id: string) => void
openMenu: (target: MenuTarget, btn: HTMLElement) => void
onOpenFolder: (id: string | null) => void
onOpenDynamic: (folder: Folder) => void
Expand All @@ -66,6 +77,19 @@ function rowClasses(active: boolean): string {
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700/60'
}`
}
const DROP_HIGHLIGHT = 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30'

/** Whether a folder being dragged may be dropped onto `target` (null = the root). */
function canDropFolderOn(
dragging: string | null,
target: string | null,
byId: Map<string, Folder>,
): boolean {
if (!dragging) return true
if (target === dragging) return false
if (byId.get(dragging)?.parent_folder_id === target) return false // already there
return target === null || !ancestorIds(target, byId).includes(dragging)
}
const ACTION_BTN =
'p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 shrink-0 transition-opacity opacity-60 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100'

Expand All @@ -80,10 +104,35 @@ function TreeRow({ node, depth, inArchive, ctx }: { node: FolderNode; depth: num
const btnRef = useRef<HTMLButtonElement>(null)
const kind: MenuKind = isBin ? 'bin' : isDynamic ? 'dynamic' : inArchive ? 'archived' : 'normal'

// Ids are prefixed `tree-` because the folder bar registers the same folders as
// `folder-drag:`/`folder-drop:` in the one shared DndContext, and ids must be unique.
// A dynamic folder is a leaf that runs a search, so nothing can be dropped on it.
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `tree-drop:${folder.id}`,
data: { folderId: folder.id },
disabled: isDynamic || !canDropFolderOn(ctx.draggingFolderId, folder.id, ctx.byId),
})
// The Archive Bin is app-managed: it can receive items but never be moved.
const { setNodeRef: setDragRef, attributes, listeners, isDragging } = useDraggable({
id: `tree-drag:${folder.id}`,
data: { type: 'folder', folderId: folder.id },
disabled: isBin,
})

useEffect(() => {
if (!isOver || !hasChildren || isOpen) return
const timer = setTimeout(() => ctx.expand(folder.id), DRAG_EXPAND_MS)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOver, hasChildren, isOpen, folder.id])

return (
<>
<div
className={rowClasses(isActive)}
ref={(el) => { setDropRef(el); setDragRef(el) }}
{...attributes}
{...listeners}
className={`${rowClasses(isActive)} ${isOver ? DROP_HIGHLIGHT : ''} ${isDragging ? 'opacity-40' : ''}`}
style={{ paddingLeft: `${0.25 + depth * 0.85}rem` }}
onClick={() => (isDynamic ? ctx.onOpenDynamic(folder) : ctx.onOpenFolder(folder.id))}
title={isDynamic ? folder.search_query ?? undefined : undefined}
Expand All @@ -94,6 +143,7 @@ function TreeRow({ node, depth, inArchive, ctx }: { node: FolderNode; depth: num
// not a folder has children.
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 shrink-0 text-gray-400"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ctx.toggleExpand(folder.id) }}
title={isOpen ? 'Collapse' : 'Expand'}
>
Expand All @@ -115,6 +165,7 @@ function TreeRow({ node, depth, inArchive, ctx }: { node: FolderNode; depth: num
ref={btnRef}
className={ACTION_BTN}
title="Folder actions"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); if (btnRef.current) ctx.openMenu({ kind, folder }, btnRef.current) }}
>
<MoreVertical className="w-3.5 h-3.5" />
Expand All @@ -141,6 +192,7 @@ export default function FolderTreePanel({
onCustomize,
onDelete,
onEmptyArchive,
draggingFolderId = null,
storageKey = 'folder-tree-panel',
}: Props) {
const openKey = `${storageKey}-open`
Expand Down Expand Up @@ -223,6 +275,10 @@ export default function FolderTreePanel({
})
}

function expand(id: string) {
setExpanded((prev) => (prev.has(id) ? prev : new Set(prev).add(id)))
}

function openMenu(target: MenuTarget, btn: HTMLElement) {
const rect = btn.getBoundingClientRect()
// Anchor by the button's left edge (the panel hugs the screen's left), clamped
Expand Down Expand Up @@ -258,8 +314,16 @@ export default function FolderTreePanel({
window.addEventListener('mouseup', onMouseUp)
}

const ctx: RowCtx = { currentFolderId, archiveId, expanded, toggleExpand, openMenu, onOpenFolder, onOpenDynamic }
const ctx: RowCtx = {
currentFolderId, archiveId, byId, draggingFolderId, expanded, toggleExpand, expand, openMenu, onOpenFolder, onOpenDynamic,
}
const rootBtnRef = useRef<HTMLButtonElement>(null)
// "All notes" is the root: dropping here moves a note or folder out to the top level.
const { setNodeRef: setRootDropRef, isOver: rootIsOver } = useDroppable({
id: 'tree-drop:root',
data: { folderId: null },
disabled: !canDropFolderOn(draggingFolderId, null, byId),
})

function menuItem(key: string, Icon: LucideIcon, label: string, onClick: () => void, danger = false) {
return (
Expand Down Expand Up @@ -364,7 +428,8 @@ export default function FolderTreePanel({
<nav className="flex-1 min-h-0 overflow-y-auto py-1 px-1">
{/* Root "All notes" */}
<div
className={rowClasses(rootActive)}
ref={setRootDropRef}
className={`${rowClasses(rootActive)} ${rootIsOver ? DROP_HIGHLIGHT : ''}`}
style={{ paddingLeft: '0.25rem' }}
onClick={() => onOpenFolder(null)}
>
Expand All @@ -375,6 +440,7 @@ export default function FolderTreePanel({
ref={rootBtnRef}
className={ACTION_BTN}
title="Add here"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); if (rootBtnRef.current) openMenu({ kind: 'root', folder: null }, rootBtnRef.current) }}
>
<MoreVertical className="w-3.5 h-3.5" />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/NoteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa

{/* Top row: category + time + pin */}
<div className="relative z-10 flex items-center justify-between px-3 pt-3 gap-2">
<div style={hasImage ? { filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))' } : undefined}>
<div className="min-w-0" style={hasImage ? { filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))' } : undefined}>
{category
? <CategoryBadge category={category} />
: <span className="text-xs text-gray-400">Uncategorised</span>}
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/stores/activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* The "Clear" button in the Background tasks dropdown.
*
* What it must keep matters as much as what it removes: a running job is still doing
* work, and a plan awaiting approval is waiting on the user. Neither is "completed", so
* neither may disappear from under them.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'

// The store reaches settings.ts, which reads localStorage while the module loads, and
// running jobs write document.title (see syncDocumentTitle). Vitest runs in node here,
// so both globals must exist before the imports below evaluate — hence vi.hoisted.
vi.hoisted(() => {
const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
Object.assign(globalThis, { localStorage: storage, document: { title: 'Gecko Notes' } })
})

import type { ActivityJob, ActivityStatus } from '@/api/activity'
import { jobKey, useActivityStore } from './activity'

function job(id: string, status: ActivityStatus): ActivityJob {
return {
id,
kind: 'video',
status,
stage: '',
progress: 0,
detail: '',
title: id,
note_id: null,
note_title: '',
locks_note: false,
result_url: null,
error_message: null,
meta: {},
}
}

describe('clearSettled', () => {
beforeEach(() => {
useActivityStore.getState().reset()
})

it('removes done, failed and cancelled jobs but keeps running, queued and awaiting-approval ones', () => {
const all = [
job('done', 'done'),
job('error', 'error'),
job('cancelled', 'cancelled'),
job('processing', 'processing'),
job('queued', 'queued'),
job('plan', 'awaiting_approval'),
]
useActivityStore.setState({ jobs: Object.fromEntries(all.map((j) => [jobKey(j), j])) })

useActivityStore.getState().clearSettled()

const left = Object.values(useActivityStore.getState().jobs).map((j) => j.id).sort()
expect(left).toEqual(['plan', 'processing', 'queued'])
})

it('leaves an empty list empty', () => {
useActivityStore.getState().clearSettled()
expect(useActivityStore.getState().jobs).toEqual({})
})
})
Loading
Loading