fix: shift range from a pinned note in its folder; kanban fits the window; list toolbar; quick dump targets only select; aligned bullets; steady search field; browser voice waits 5 s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m28s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 16:12:01 +01:00
1 parent 5a9c5bac37
commit e2b9daba75
26 files changed
+208 -138

No files matched your search

+20 -16
View File
@@ -204,21 +204,25 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
/>
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
{query && !loading && (
<button
type="button"
onClick={() => {
setQuery('');
setHits([]);
inputRef.current?.focus();
}}
className="text-muted hover:text-main p-1"
title="Eingabe leeren"
>
<X className="w-4 h-4" />
</button>
)}
{/* One fixed slot for the spinner or the clear button, so the field keeps its width while typing */}
<span className="w-6 h-6 shrink-0 flex items-center justify-center">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-accent" />
) : query ? (
<button
type="button"
onClick={() => {
setQuery('');
setHits([]);
inputRef.current?.focus();
}}
className="w-6 h-6 flex items-center justify-center text-muted hover:text-main"
title="Eingabe leeren"
>
<X className="w-4 h-4" />
</button>
) : null}
</span>
<button
type="button"
onClick={onClose}
@@ -269,7 +273,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
`}
>
<div className="mt-0.5 shrink-0 text-base leading-none">
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
</div>
<div className="flex-1 min-w-0 space-y-0.5">
@@ -437,7 +437,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
Wohin damit?
</span>
<span className="text-[11px] text-faint">
Standard: Quick Dump für späteres Swipen
@@ -450,9 +450,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('inbox');
if (content.trim()) {
executeCapture('inbox');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'inbox'
@@ -469,10 +466,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('task');
// With task lists the row below picks one (and saves); otherwise save right away
if (content.trim() && writableTaskLists.length === 0) {
executeCapture('task');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'task'
@@ -508,9 +501,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('document');
if (content.trim()) {
executeCapture('document');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'document'
@@ -539,9 +529,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTargetTaskListId(tl.id);
if (content.trim()) {
executeCapture('task', undefined, tl.id);
}
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
@@ -573,9 +560,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
onClick={() => {
setTarget('list_item');
setTargetListId(lst.id);
if (content.trim()) {
executeCapture('list_item', lst.id);
}
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
@@ -24,7 +24,7 @@ describe('InstantCaptureBar', () => {
sessionStorage.clear();
});
it('shows the list row that fits the target and saves into the chosen task list', async () => {
it('shows the list row that fits the target and saves into the chosen task list on send', async () => {
await act(async () => {
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
});
@@ -38,8 +38,11 @@ describe('InstantCaptureBar', () => {
expect(captureThought).not.toHaveBeenCalled();
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
expect(screen.queryByText('Fremd')).toBeNull();
// Picking the list only selects it as well; saving takes the send button (or Enter)
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
expect(captureThought).not.toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
fireEvent.submit(screen.getByRole('textbox').closest('form')!);
});
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
target: 'task',
+81 -44
View File
@@ -428,6 +428,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
[ownColumnOf, boardIdOf]
);
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll.
const kanbanBoardRef = useRef<HTMLDivElement>(null);
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4);
useEffect(() => {
if (!kanbanOneRow) {
setKanbanColHeight(null);
return;
}
const fit = () => {
const el = kanbanBoardRef.current;
if (!el || window.innerWidth < 1024) {
setKanbanColHeight(null);
return;
}
// From the board's top to the window's bottom; then take back what still overflows (footer, paddings)
const top = el.getBoundingClientRect().top + window.scrollY;
setKanbanColHeight(Math.max(320, window.innerHeight - top - 16));
requestAnimationFrame(() => {
const over = document.documentElement.scrollHeight - window.innerHeight;
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
});
};
fit();
window.addEventListener('resize', fit);
return () => window.removeEventListener('resize', fit);
}, [kanbanOneRow, selectedListId, loading]);
const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
const seen = new Set<string>();
@@ -1976,9 +2005,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
})}
</div>
{/* Group By & Sort By Dropdowns & Manage Columns button */}
{/* Kanban: manage columns and width (the list view has grouping and sorting above the list) */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? (
{viewMode === 'kanban' && (
<>
{!isSelectedListReadOnly && (
<button
@@ -2004,40 +2033,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
)}
</button>
</>
) : (
<>
{/* Group By */}
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Gruppe:</span>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)}
className="h-7 px-2 input-field text-xs bg-panel"
>
<option value="none">Keine</option>
<option value="tag">Nach Hashtag</option>
<option value="status">Nach Status</option>
<option value="priority">Nach Priorität</option>
<option value="due">Nach Fälligkeit</option>
</select>
</div>
{/* Sort By */}
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Sortierung:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-7 px-2 input-field text-xs bg-panel"
>
<option value="custom">Manuell (Drag & Drop)</option>
<option value="created">Erstelldatum</option>
<option value="due">Fälligkeitsdatum</option>
<option value="priority">Priorität</option>
<option value="alpha">Titel (A-Z)</option>
</select>
</div>
</>
)}
</div>
</div>
@@ -2464,6 +2459,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div className="space-y-2">
{kanbanTouch.ghost}
<div
ref={kanbanBoardRef}
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
}`}
@@ -2486,9 +2482,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
} ${blockedForDrag ? 'opacity-40' : ''}`}
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
kanbanColHeight ? '' : 'min-h-[420px]'
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
>
{/* Column Header */}
@@ -2501,7 +2498,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
{/* Tasks List in Column */}
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
{colTasks.map((task) => {
const isDragging = draggingTaskId === task.id;
const readOnly = isTaskReadOnly(task);
@@ -2748,16 +2745,56 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
{/* Grouping and sorting right above the list, then clearing as a plain trash icon (desktop; phones
use the menu) */}
<div className="hidden sm:flex items-center gap-2 ml-auto">
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Gruppe:</span>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)}
className="h-8 px-2 input-field text-xs bg-panel"
>
<option value="none">Keine</option>
<option value="tag">Nach Hashtag</option>
<option value="status">Nach Status</option>
<option value="priority">Nach Priorität</option>
<option value="due">Nach Fälligkeit</option>
</select>
</div>
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Sortierung:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-8 px-2 input-field text-xs bg-panel"
>
<option value="custom">Manuell (Drag & Drop)</option>
<option value="created">Erstelldatum</option>
<option value="due">Fälligkeitsdatum</option>
<option value="priority">Priorität</option>
<option value="alpha">Titel (A-Z)</option>
</select>
</div>
</div>
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
<button onClick={handleEmptyTaskTrash} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
<button
onClick={handleEmptyTaskTrash}
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
title="Papierkorb leeren"
aria-label="Papierkorb leeren"
>
<Trash2 className="w-3.5 h-3.5" />
Papierkorb leeren
</button>
)}
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
<button onClick={handleClearDone} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
<button
onClick={handleClearDone}
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
title="Erledigte in den Papierkorb"
aria-label="Erledigte in den Papierkorb"
>
<Trash2 className="w-3.5 h-3.5" />
Erledigte leeren
</button>
)}
</div>
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
onClick={() => onOpen(l.docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
>
<span className="shrink-0">{l.icon || '📝'}</span>
<span className="shrink-0">{l.icon || '🗒️'}</span>
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
{l.title || 'Unbenannte Notiz'}
</span>
@@ -1290,7 +1290,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{inJournal ? (
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
) : (
doc?.icon || '📝'
doc?.icon || '🗒️'
)}
</button>
{showEmojiPicker && canEdit && (
@@ -362,7 +362,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="text-base shrink-0">{d.icon || '📝'}</span>
<span className="text-base shrink-0">{d.icon || '🗒️'}</span>
<div className="min-w-0">
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'}
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
onClick={() => setTarget(d.id)}
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="shrink-0">{d.icon || '🗒️'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
</button>
@@ -1115,7 +1115,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onClick={() =>
flushDebounce(localContent, { collapsed: !block.collapsed })
}
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
// Same width as the spacer of rows without children (the padding is taken back), so bullets stay aligned
className="p-0.5 -m-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
>
{block.collapsed ? (
@@ -88,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
title="Schreibgeschützt ansehen"
>
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
<span className="text-lg shrink-0">{t.icon || '🗒️'}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
{t.title || 'Unbenannte Notiz'}
@@ -282,7 +282,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="shrink-0">{d.icon || '🗒️'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
@@ -455,6 +455,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [draggingDocIds, setDraggingDocIds] = useState<string[]>([]);
const [trashDragOver, setTrashDragOver] = useState(false);
const lastMarkedRef = useRef<string | null>(null);
// The row last clicked: a pinned note has two rows (pinned and in its folder), the range starts at this one
const lastMarkedRowRef = useRef<HTMLElement | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
@@ -580,14 +582,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, [currentUser, reloadTrash]);
const handleCreateDocument = useCallback(
async (folderPath = '', title = 'Neue Notiz', icon = '📝') => {
async (folderPath = '', title = 'Neue Notiz', icon = '🗒️') => {
if (isCreatingRef.current) return;
isCreatingRef.current = true;
triggerHaptic(10);
try {
const created = await api.createDocument({
title: title.trim() || 'Neue Notiz',
icon: icon.trim() || '📝',
icon: icon.trim() || '🗒️',
folderPath,
blocks: [
{
@@ -624,7 +626,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// The desktop keeps creating at once.
const [newNoteSheet, setNewNoteSheet] = useState<{ folderPath: string } | null>(null);
const [newNoteTitle, setNewNoteTitle] = useState('');
const [newNoteIcon, setNewNoteIcon] = useState('📝');
const [newNoteIcon, setNewNoteIcon] = useState('🗒️');
const openNewNote = useCallback(
(folderPath = '') => {
if (window.innerWidth >= 640) {
@@ -632,7 +634,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return;
}
setNewNoteTitle('');
setNewNoteIcon('📝');
setNewNoteIcon('🗒️');
setNewNoteSheet({ folderPath });
},
[handleCreateDocument]
@@ -794,6 +796,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Ctrl/Cmd+click toggles a note, Shift+click marks the range in the order shown (from the last marked
// or the open note); a plain click clears the marks and opens the note
const handleDocRowClick = (e: React.MouseEvent, id: string, open: () => void) => {
const row = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-doc-row]');
if (e.metaKey || e.ctrlKey) {
setMarkedDocIds((prev) => {
const next = new Set(prev);
@@ -803,23 +806,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return next;
});
lastMarkedRef.current = id;
lastMarkedRowRef.current = row;
return;
}
if (e.shiftKey) {
const order: string[] = [];
treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]').forEach((el) => {
const rowId = el.dataset.docRow;
if (rowId && !order.includes(rowId)) order.push(rowId);
});
const anchor = lastMarkedRef.current || selectedDocId || id;
const a = order.indexOf(anchor);
const b = order.indexOf(id);
const range = a < 0 || b < 0 ? [id] : order.slice(Math.min(a, b), Math.max(a, b) + 1);
// Rows in the order shown; a pinned note appears twice, so positions count, not IDs
const rows = Array.from(treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]') || []);
const b = row ? rows.indexOf(row) : rows.findIndex((el) => el.dataset.docRow === id);
const anchorId = lastMarkedRef.current || selectedDocId || id;
let a = lastMarkedRowRef.current ? rows.indexOf(lastMarkedRowRef.current) : -1;
if (a < 0 || rows[a].dataset.docRow !== anchorId) {
// The open note without a click here: its row nearest to the clicked one
a = -1;
rows.forEach((el, i) => {
if (el.dataset.docRow === anchorId && (a < 0 || Math.abs(i - b) < Math.abs(a - b))) a = i;
});
}
const range =
a < 0 || b < 0
? [id]
: rows.slice(Math.min(a, b), Math.max(a, b) + 1).map((el) => el.dataset.docRow || '').filter(Boolean);
setMarkedDocIds((prev) => new Set([...prev, ...range]));
return;
}
setMarkedDocIds(new Set());
lastMarkedRef.current = id;
lastMarkedRowRef.current = row;
open();
};
@@ -1408,7 +1420,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
map.set(d.id, {
docId: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon || '📝',
icon: d.icon || '🗒️',
folderPath: d.folderPath || '',
matchedBlocks: matched,
});
@@ -1421,7 +1433,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
map.set(dg.docId, {
docId: dg.docId,
title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '📝',
icon: dg.icon || '🗒️',
folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [],
});
@@ -1978,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="shrink-0">{d.icon || '🗒️'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
@@ -2049,7 +2061,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="shrink-0">{d.icon || '🗒️'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
@@ -2351,7 +2363,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
title={meta.docs[0].title}
>
<span>{meta.docs[0].icon || '📝'}</span>
<span>{meta.docs[0].icon || '🗒️'}</span>
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
</div>
</div>
@@ -2452,7 +2464,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
<span>{docGroup.icon || '📝'}</span>
<span>{docGroup.icon || '🗒️'}</span>
<span>{docGroup.title}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
@@ -2702,7 +2714,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
title={item.doc.title}
>
<span>{item.doc.icon || '📝'}</span>
<span>{item.doc.icon || '🗒️'}</span>
<span className="truncate max-w-[120px]">{item.doc.title}</span>
{item.doc.folderPath && (
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
@@ -2803,7 +2815,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
) : (
<ChevronDown className="w-3 h-3 text-accent" />
)}
<span>{doc.icon || '📝'}</span>
<span>{doc.icon || '🗒️'}</span>
<span>{doc.title}</span>
<span className="badge-neutral text-[9px] font-mono">
{docOpenCount} offen
@@ -2955,7 +2967,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}}
>
<div className="flex items-center gap-1.5">
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="📝" />
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="🗒️" />
<input
type="text"
autoFocus
+15 -1
View File
@@ -21,6 +21,8 @@ const LIVE_SILENCE_MS = 800;
const LIVE_TICK_MS = 200;
const LIVE_SPEECH_LEVEL = 0.02;
const MIN_RESTART_INTERVAL_MS = 300;
// Browser recognition on a tap: it stops only after this much silence, not at the first short pause
const BROWSER_SILENCE_STOP_MS = 5000;
/**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
@@ -38,6 +40,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
const timerRef = useRef<number | null>(null);
const segmentTimerRef = useRef<number | null>(null);
const restartTimerRef = useRef<number | null>(null);
const silenceTimerRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
const stoppedRef = useRef(false);
const transcriptRef = useRef('');
@@ -59,6 +62,8 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
segmentTimerRef.current = null;
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
if (silenceTimerRef.current !== null) window.clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = null;
};
useEffect(() => {
@@ -240,8 +245,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous || live;
// A tap (neither hold nor dictation) listens through short pauses and ends after 5 s of silence
const stopOnSilence = !continuous && !live;
recognition.continuous = continuous || live || stopOnSilence;
const armSilenceStop = () => {
if (!stopOnSilence) return;
if (silenceTimerRef.current !== null) window.clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = window.setTimeout(() => void stop(), BROWSER_SILENCE_STOP_MS);
};
armSilenceStop();
recognition.onresult = (event: any) => {
armSilenceStop();
const parts: string[] = [];
for (let i = 0; i < event.results.length; i++) parts.push(event.results[i][0].transcript);
const sessionText = joinSpeechResults(parts);