Merge dev into main (v0.54.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
This commit is contained in:
commit
f01f2af2aa
15 files changed
+348
-18
No files matched your search
+12
-1
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.54.0] - 2026-11-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- KI-Agenten können über MCP Unteraufgaben anlegen, abhaken, wieder öffnen, umbenennen und entfernen (`add_subtask`, `update_subtask`). Dafür reicht das Recht „Aufgaben ändern“; ist die Aufgabe mit einer Notiz verknüpft, ziehen die Checkboxen dort mit.
|
||||||
|
- Am Handy lässt sich die Liste über den Titel oben wechseln: Ein Tipp auf den Namen der offenen Aufgabenliste bzw. Liste klappt alle Listen mit ihren Zählern auf.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Beim Klick in einen Punkt wurde die Zeile ein paar Pixel höher, sodass alles darunter kurz nach unten sprang.
|
||||||
|
- Aufgaben-Details am Handy: Die Herkunfts-Badge eines API-Schlüssels ragte über den Knopf „Beobachten“. Am Handy steht die Herkunft jetzt nur noch unten; der Knopf „Beobachten“ ist so hoch wie die Knöpfe daneben.
|
||||||
|
|
||||||
## [0.53.0] - 2026-11-01
|
## [0.53.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -797,7 +807,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...dev
|
||||||
|
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
|
||||||
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0
|
||||||
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
|
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
|
||||||
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
|
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Admin
|
|||||||
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
||||||
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
||||||
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
||||||
|
| `add_subtask` | `tasks:write` | Unteraufgabe anhängen (`taskId`, `title`) |
|
||||||
|
| `update_subtask` | `tasks:write` | Unteraufgabe abhaken (`completed`), umbenennen (`title`) oder entfernen (`delete`) |
|
||||||
| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben |
|
| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben |
|
||||||
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
||||||
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx)
|
|||||||
- Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage.
|
- Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage.
|
||||||
- Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag.
|
- Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag.
|
||||||
- Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten.
|
- Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten.
|
||||||
|
- In der Aufgaben-Detailansicht steht die Badge am Handy nicht im Kopf (kein Platz neben den Knöpfen); dort zeigen nur die Details unten die Herkunft.
|
||||||
|
|
||||||
|
## Liste wechseln (Handy)
|
||||||
|
|
||||||
|
Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx)
|
||||||
|
|
||||||
|
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
|
||||||
|
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
|
||||||
|
|
||||||
## Mikrofon in Eingabezeilen
|
## Mikrofon in Eingabezeilen
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.53.0",
|
"version": "0.54.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.53.0",
|
"version": "0.54.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.53.0",
|
"version": "0.54.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
import { CountBadge } from './CountBadge';
|
||||||
|
|
||||||
|
export interface ListSwitcherItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListSwitcherProps {
|
||||||
|
// Shown on the button: the list that is open now
|
||||||
|
label: string;
|
||||||
|
items: ListSwitcherItem[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title of the open list in the phone header (Doku/ui-patterns.md): a tap opens all lists to switch to
|
||||||
|
export const ListSwitcher: React.FC<ListSwitcherProps> = ({ label, items, activeId, onSelect }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: Event) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', close);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', close);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
title="Liste wechseln"
|
||||||
|
className="h-11 -my-1 max-w-full flex items-center gap-1 text-sm font-semibold text-main cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-muted flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Liste wechseln"
|
||||||
|
className="absolute left-0 top-full mt-1 z-40 w-72 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto py-1 bg-panel border border-border rounded-card shadow-panel"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = item.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
if (!active) onSelect(item.id);
|
||||||
|
}}
|
||||||
|
className={`w-full h-11 px-3 flex items-center gap-2 text-left text-sm cursor-pointer hover:bg-panel-elevated ${
|
||||||
|
active ? 'text-main font-semibold' : 'text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||||
|
<span className="truncate flex-1">{item.title}</span>
|
||||||
|
<CountBadge count={item.count} />
|
||||||
|
{active && <Check className="w-4 h-4 text-accent flex-shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ListSwitcher } from '../ListSwitcher';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ id: 'a', title: 'Haushalt', icon: '🏠', count: 3 },
|
||||||
|
{ id: 'b', title: 'Arbeit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ListSwitcher', () => {
|
||||||
|
it('opens all lists on a tap and switches to the chosen one', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Haushalt/ }));
|
||||||
|
const options = screen.getAllByRole('option');
|
||||||
|
expect(options).toHaveLength(2);
|
||||||
|
expect(options[0].getAttribute('aria-selected')).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: /Arbeit/ }));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('b');
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes without a change on the open list, Escape or a tap outside', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
|
||||||
|
const button = screen.getByRole('button', { name: /Haushalt/ });
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: /Haushalt/ }));
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.pointerDown(document.body);
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
|||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
import { OriginBadge } from '../../components/OriginBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
@@ -1170,9 +1171,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
||||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
{activeList ? (
|
||||||
{activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
|
<ListSwitcher
|
||||||
</span>
|
label={[activeList.icon, activeList.title].filter(Boolean).join(' ')}
|
||||||
|
items={lists.map((lst) => ({
|
||||||
|
id: lst.id,
|
||||||
|
title: lst.title,
|
||||||
|
icon: lst.icon,
|
||||||
|
count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length,
|
||||||
|
}))}
|
||||||
|
activeId={activeList.id}
|
||||||
|
onSelect={setActiveListId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-main truncate">🛒 Einkaufsliste</span>
|
||||||
|
)}
|
||||||
{openItems.length > 0 && (
|
{openItems.length > 0 && (
|
||||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||||
{openItems.length} offen
|
{openItems.length} offen
|
||||||
|
|||||||
@@ -632,7 +632,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
|
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
|
||||||
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
||||||
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
|
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
|
||||||
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
|
<code className="font-mono">complete_task</code>, <code className="font-mono">add_subtask</code>,{' '}
|
||||||
|
<code className="font-mono">update_subtask</code>, <code className="font-mono">delete_task</code>,{' '}
|
||||||
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
|
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
|
||||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-semibold text-main truncate">
|
{/* Phones: squeezed to a single letter next to the buttons, so left out */}
|
||||||
|
<span className="hidden sm:inline text-xs font-semibold text-main truncate">
|
||||||
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||||
</span>
|
</span>
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
@@ -245,7 +246,10 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<span>Nur Lesen</span>
|
<span>Nur Lesen</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
{/* Phones: no room next to the buttons; the details stand at the bottom anyway */}
|
||||||
|
<span className="hidden sm:inline-flex min-w-0">
|
||||||
|
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -254,7 +258,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
onClick={handleToggleWatch}
|
onClick={handleToggleWatch}
|
||||||
disabled={watchBusy}
|
disabled={watchBusy}
|
||||||
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
|
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
|
className={`btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
|
||||||
isWatching ? 'border-accent text-accent' : ''
|
isWatching ? 'border-accent text-accent' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { VoiceInputButton } from '../../components/VoiceInputButton';
|
|||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
@@ -1953,9 +1954,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
||||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
<ListSwitcher
|
||||||
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
label={currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
||||||
</span>
|
items={[
|
||||||
|
{ id: 'all', title: 'Alle Aufgaben', icon: '📋', count: openTasksCount },
|
||||||
|
...safeTaskLists.map((tl) => ({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })),
|
||||||
|
]}
|
||||||
|
activeId={selectedListId}
|
||||||
|
onSelect={setSelectedListId}
|
||||||
|
/>
|
||||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||||
{viewMode === 'kanban'
|
{viewMode === 'kanban'
|
||||||
? 'Kanban'
|
? 'Kanban'
|
||||||
|
|||||||
@@ -1437,6 +1437,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Display block: inline, the focused field would sit on a text baseline and leave a gap below
|
||||||
|
it, so the row grew by a few pixels on every click */}
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textareaRef.current = el;
|
textareaRef.current = el;
|
||||||
@@ -1482,7 +1484,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
className={`block w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||||
} ${
|
} ${
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
|
|||||||
+96
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
@@ -134,8 +135,9 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|||||||
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
||||||
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
|
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
|
||||||
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
||||||
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
"to see open tasks, complete_task to check one off, add_subtask/update_subtask for a task's " +
|
||||||
"lists and check_list_item to tick items, list_notes/get_note to read notes and create_note for a " +
|
"checklist, list_lists/get_list for shopping and other lists and check_list_item to tick items, " +
|
||||||
|
"list_notes/get_note to read notes and create_note for a " +
|
||||||
"new one. IDs come from the list tools; never guess them. Tasks and list items you create or " +
|
"new one. IDs come from the list tools; never guess them. Tasks and list items you create or " +
|
||||||
"change show the user a badge with the name of your API key.",
|
"change show the user a badge with the name of your API key.",
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -604,6 +606,98 @@ var mcpTools = []mcpTool{
|
|||||||
return toTaskView(t), nil
|
return toTaskView(t), nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "add_subtask",
|
||||||
|
Description: "Add a subtask (checklist item) to a task. Returns the task with all its subtasks.",
|
||||||
|
Scope: storage.ScopeTasksWrite,
|
||||||
|
Schema: schema([]string{"taskId", "title"}, map[string]interface{}{
|
||||||
|
"taskId": prop("string", "Task id"),
|
||||||
|
"title": prop("string", "Subtask text"),
|
||||||
|
}),
|
||||||
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
|
var a struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := decodeArgs(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(a.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("'title' darf nicht leer sein")
|
||||||
|
}
|
||||||
|
t, err := s.store.GetThoughtByID(userID, a.TaskID)
|
||||||
|
if err != nil || t == nil {
|
||||||
|
return nil, errors.New("Aufgabe nicht gefunden")
|
||||||
|
}
|
||||||
|
subs := append(append([]domain.SubTask{}, t.Subtasks...), domain.SubTask{
|
||||||
|
ID: domain.NewID("st"), Title: title, CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
updated, err := s.updateTask(userID, a.TaskID, taskPatch{Subtasks: &subs, actor: actor})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toTaskView(updated), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_subtask",
|
||||||
|
Description: "Tick a subtask off (completed), reopen it (completed=false), rename it (title) or remove it (delete=true).",
|
||||||
|
Scope: storage.ScopeTasksWrite,
|
||||||
|
Schema: schema([]string{"taskId", "subtaskId"}, map[string]interface{}{
|
||||||
|
"taskId": prop("string", "Task id"),
|
||||||
|
"subtaskId": prop("string", "Subtask id from get_task"),
|
||||||
|
"completed": prop("boolean", "true = done"),
|
||||||
|
"title": prop("string", "New text"),
|
||||||
|
"delete": prop("boolean", "true = remove the subtask"),
|
||||||
|
}),
|
||||||
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
||||||
|
var a struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
SubtaskID string `json:"subtaskId"`
|
||||||
|
Completed *bool `json:"completed"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Delete bool `json:"delete"`
|
||||||
|
}
|
||||||
|
if err := decodeArgs(args, &a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.Title != nil && strings.TrimSpace(*a.Title) == "" {
|
||||||
|
return nil, errors.New("'title' darf nicht leer sein")
|
||||||
|
}
|
||||||
|
t, err := s.store.GetThoughtByID(userID, a.TaskID)
|
||||||
|
if err != nil || t == nil {
|
||||||
|
return nil, errors.New("Aufgabe nicht gefunden")
|
||||||
|
}
|
||||||
|
subs := make([]domain.SubTask, 0, len(t.Subtasks))
|
||||||
|
found := false
|
||||||
|
for _, st := range t.Subtasks {
|
||||||
|
if st.ID != a.SubtaskID {
|
||||||
|
subs = append(subs, st)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if a.Delete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Completed != nil {
|
||||||
|
st.IsCompleted = *a.Completed
|
||||||
|
}
|
||||||
|
if a.Title != nil {
|
||||||
|
st.Title = strings.TrimSpace(*a.Title)
|
||||||
|
}
|
||||||
|
subs = append(subs, st)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, errors.New("Unteraufgabe nicht gefunden")
|
||||||
|
}
|
||||||
|
updated, err := s.updateTask(userID, a.TaskID, taskPatch{Subtasks: &subs, actor: actor})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toTaskView(updated), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "delete_task",
|
Name: "delete_task",
|
||||||
Description: "Delete a task, e.g. one created by mistake: it goes to the task trash, where the user can restore it. Prefer complete_task for work that is done.",
|
Description: "Delete a task, e.g. one created by mistake: it goes to the task trash, where the user can restore it. Prefer complete_task for work that is done.",
|
||||||
|
|||||||
@@ -289,6 +289,63 @@ func TestMCPCreateDeleteAndOrigin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agents manage a task's subtasks one at a time; the rest of the checklist stays as it is.
|
||||||
|
func TestMCPSubtasks(t *testing.T) {
|
||||||
|
srv, store, token := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
|
key, _ := store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write"})
|
||||||
|
call := func(id int, name, args string) (map[string]interface{}, bool) {
|
||||||
|
t.Helper()
|
||||||
|
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
|
||||||
|
result, isErr := callResult(t, res)
|
||||||
|
if isErr {
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
return result["structuredContent"].(map[string]interface{}), false
|
||||||
|
}
|
||||||
|
|
||||||
|
task, _ := srv.createTask(claims.UserID, "Umzug", "", "manual", "", "", "", nil)
|
||||||
|
call(1, "add_subtask", `{"taskId":"`+task.ID+`","title":"Kartons kaufen"}`)
|
||||||
|
res, isErr := call(2, "add_subtask", `{"taskId":"`+task.ID+`","title":" Strom ummelden "}`)
|
||||||
|
if isErr {
|
||||||
|
t.Fatalf("add_subtask: %v", res)
|
||||||
|
}
|
||||||
|
subs := res["subtasks"].([]interface{})
|
||||||
|
if len(subs) != 2 || subs[1].(map[string]interface{})["title"] != "Strom ummelden" {
|
||||||
|
t.Fatalf("subtasks after adding: %v", subs)
|
||||||
|
}
|
||||||
|
first := subs[0].(map[string]interface{})["id"].(string)
|
||||||
|
second := subs[1].(map[string]interface{})["id"].(string)
|
||||||
|
|
||||||
|
call(3, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+first+`","completed":true}`)
|
||||||
|
call(4, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+second+`","title":"Strom und Gas ummelden"}`)
|
||||||
|
th, _ := store.GetThoughtByID(claims.UserID, task.ID)
|
||||||
|
if len(th.Subtasks) != 2 || !th.Subtasks[0].IsCompleted || th.Subtasks[1].IsCompleted || th.Subtasks[1].Title != "Strom und Gas ummelden" {
|
||||||
|
t.Fatalf("subtasks after updating: %+v", th.Subtasks)
|
||||||
|
}
|
||||||
|
if th.Origin == nil || th.Origin.ChangedVia != "mcp" {
|
||||||
|
t.Fatalf("subtask change should count as agent change: %+v", th.Origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
call(5, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+first+`","delete":true}`)
|
||||||
|
if th, _ = store.GetThoughtByID(claims.UserID, task.ID); len(th.Subtasks) != 1 || th.Subtasks[0].ID != second {
|
||||||
|
t.Fatalf("subtasks after deleting: %+v", th.Subtasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, isErr = call(6, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"st_unknown","completed":true}`); !isErr {
|
||||||
|
t.Fatalf("unknown subtask must fail")
|
||||||
|
}
|
||||||
|
if _, isErr = call(7, "add_subtask", `{"taskId":"`+task.ID+`","title":" "}`); !isErr {
|
||||||
|
t.Fatalf("empty title must fail")
|
||||||
|
}
|
||||||
|
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||||
|
foreign, _ := store.CreateThoughtWithDetails(other.ID, "Fremd", "normal", "manual", "", "", "todo", "")
|
||||||
|
if _, isErr = call(8, "add_subtask", `{"taskId":"`+foreign.ID+`","title":"Spam"}`); !isErr {
|
||||||
|
t.Fatalf("key must not change foreign tasks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it.
|
// Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it.
|
||||||
func TestMCPSwitchOff(t *testing.T) {
|
func TestMCPSwitchOff(t *testing.T) {
|
||||||
srv, _, session := setupTestServer(t)
|
srv, _, session := setupTestServer(t)
|
||||||
|
|||||||
Reference in new issue
Block a user