diff --git a/apps/web/package.json b/apps/web/package.json
index 680225a..1a423f2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -43,6 +43,7 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.98.0",
"@tanstack/react-table": "^8.21.3",
+ "@tiptap/core": "^3.29.2",
"@tiptap/extension-code-block-lowlight": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
"@tiptap/extension-mention": "^3.29.2",
diff --git a/apps/web/src/components/entities/editor-slash-menu.tsx b/apps/web/src/components/entities/editor-slash-menu.tsx
new file mode 100644
index 0000000..9a02907
--- /dev/null
+++ b/apps/web/src/components/entities/editor-slash-menu.tsx
@@ -0,0 +1,217 @@
+import { useState, useEffect } from "react";
+import { Extension } from "@tiptap/core";
+import { ReactRenderer } from "@tiptap/react";
+import { cn } from "@/lib/utils";
+import Suggestion from "@tiptap/suggestion";
+import type { SuggestionKeyDownProps } from "@tiptap/suggestion";
+
+interface SlashCommandItem {
+ title: string;
+ description?: string;
+ command: (props: { editor: any; range: any }) => void;
+}
+
+function SlashCommandList({
+ items,
+ command,
+ onClose,
+}: {
+ items: SlashCommandItem[];
+ command: (item: SlashCommandItem) => void;
+ onClose: () => void;
+}) {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [items]);
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "ArrowUp") {
+ setSelectedIndex((i) => (i + items.length - 1) % items.length);
+ return true;
+ }
+ if (e.key === "ArrowDown") {
+ setSelectedIndex((i) => (i + 1) % items.length);
+ return true;
+ }
+ if (e.key === "Enter") {
+ command(items[selectedIndex]);
+ return true;
+ }
+ if (e.key === "Escape") {
+ onClose();
+ return true;
+ }
+ return false;
+ };
+
+ document.addEventListener("keydown", onKeyDown, true);
+ return () => document.removeEventListener("keydown", onKeyDown, true);
+ }, [items, selectedIndex, command, onClose]);
+
+ if (items.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {items.map((item, i) => (
+
+ ))}
+
+ );
+}
+
+const allCommands: SlashCommandItem[] = [
+ {
+ title: "Heading 1",
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 1 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 2",
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 2 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 3",
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run();
+ },
+ },
+ {
+ title: "Bullet List",
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+ },
+ {
+ title: "Todo List",
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
+ },
+ },
+ {
+ title: "Code Block",
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
+ },
+ },
+ {
+ title: "Divider",
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run();
+ },
+ },
+ {
+ title: "Wikilink",
+ description: "Link to a note",
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).insertContent("[[").run();
+ },
+ },
+];
+
+export const SlashCommand = Extension.create({
+ name: "slashCommand",
+
+ addOptions() {
+ return {
+ suggestion: {
+ char: "/",
+ },
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ char: "/",
+ command: ({ editor, range, props }: any) =>
+ props.command({ editor, range }),
+ items: ({ query }: { query: string }) =>
+ allCommands.filter((item) =>
+ item.title.toLowerCase().includes(query.toLowerCase())
+ ),
+ render: () => {
+ let component: ReactRenderer;
+ let unmount: (() => void) | undefined;
+
+ return {
+ onStart: (props: any) => {
+ component = new ReactRenderer(SlashCommandList, {
+ props: {
+ ...props,
+ onClose: () => {
+ props.editor.chain().focus().run();
+ },
+ },
+ editor: props.editor,
+ });
+
+ unmount = props.mount(component.element);
+ },
+
+ onUpdate: (props: any) => {
+ component.updateProps(props);
+ },
+
+ onKeyDown: (props: SuggestionKeyDownProps) => {
+ if (props.event.key === "Escape") {
+ unmount?.();
+ component?.destroy();
+ return true;
+ }
+ return false;
+ },
+
+ onExit: () => {
+ unmount?.();
+ component?.destroy();
+ },
+ };
+ },
+ }),
+ ];
+ },
+});
diff --git a/apps/web/src/components/entities/note-editor.tsx b/apps/web/src/components/entities/note-editor.tsx
index 4ffddea..8c983d7 100644
--- a/apps/web/src/components/entities/note-editor.tsx
+++ b/apps/web/src/components/entities/note-editor.tsx
@@ -1,11 +1,93 @@
-import { memo, useEffect, useRef } from "react";
+import { memo, useEffect, useRef, useState, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
+import { SlashCommand } from "./editor-slash-menu";
+import { cn } from "@/lib/utils";
const AUTOSAVE_DEBOUNCE_MS = 800;
+interface WikilinkSearchResult {
+ id: string;
+ title: string;
+}
+
+function WikilinkPopover({
+ query,
+ onSelect,
+ onClose,
+}: {
+ query: string;
+ onSelect: (title: string) => void;
+ onClose: () => void;
+}) {
+ const [results, setResults] = useState([]);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ if (!query) {
+ setResults([]);
+ return;
+ }
+ const abort = new AbortController();
+ fetch(`/api/search?q=${encodeURIComponent(query)}&types=note&limit=8`, {
+ credentials: "include",
+ signal: abort.signal,
+ })
+ .then((r) => r.json())
+ .then((data) => setResults(data?.items || []))
+ .catch(() => {});
+ return () => abort.abort();
+ }, [query]);
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((i) => (i + results.length - 1) % results.length);
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((i) => (i + 1) % results.length);
+ } else if (e.key === "Enter" && results.length > 0) {
+ e.preventDefault();
+ onSelect(results[selectedIndex].title);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", onKeyDown, true);
+ return () => document.removeEventListener("keydown", onKeyDown, true);
+ }, [results, selectedIndex, onSelect, onClose]);
+
+ if (results.length === 0 && !query) return null;
+
+ return (
+
+ {results.length === 0 ? (
+
+ No notes found
+
+ ) : (
+ results.map((r, i) => (
+
+ ))
+ )}
+
+ );
+}
+
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
@@ -21,6 +103,9 @@ export const NoteEditor = memo(function NoteEditor({
const latestHtmlRef = useRef(initialContent || "");
const dirtyRef = useRef(false);
const saveTimerRef = useRef | null>(null);
+ const [wikilinkOpen, setWikilinkOpen] = useState(false);
+ const [wikilinkQuery, setWikilinkQuery] = useState("");
+ const wikilinkRangeRef = useRef<{ from: number; to: number } | null>(null);
const editor = useEditor(
{
@@ -28,6 +113,7 @@ export const NoteEditor = memo(function NoteEditor({
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
+ SlashCommand,
],
content: initialContent || "",
editorProps: {
@@ -62,9 +148,44 @@ export const NoteEditor = memo(function NoteEditor({
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
+ // Wikilink detection: listen for [[ input and track query text
+ const handleWikilinkInput = () => {
+ const { state } = editor;
+ const { from } = state.selection;
+
+ if (wikilinkOpen && wikilinkRangeRef.current) {
+ // Update the query as user types after [[
+ const queryText = state.doc.textBetween(
+ wikilinkRangeRef.current.from + 2,
+ from,
+ "\n"
+ );
+ setWikilinkQuery(queryText);
+ // Close if user deleted the [[
+ if (!queryText && from <= wikilinkRangeRef.current.from + 2) {
+ const textBefore = state.doc.textBetween(Math.max(0, from - 2), from, "\n");
+ if (textBefore !== "[[") {
+ setWikilinkOpen(false);
+ wikilinkRangeRef.current = null;
+ }
+ }
+ } else {
+ // Detect new [[ opening
+ const textBefore = state.doc.textBetween(Math.max(0, from - 2), from, "\n");
+ if (textBefore === "[[") {
+ wikilinkRangeRef.current = { from: from - 2, to: from };
+ setWikilinkOpen(true);
+ setWikilinkQuery("");
+ }
+ }
+ };
+
+ editor.on("update", handleWikilinkInput);
+
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
+ editor.off("update", handleWikilinkInput);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
@@ -75,7 +196,30 @@ export const NoteEditor = memo(function NoteEditor({
onSave(latestHtmlRef.current);
}
};
- }, [editor, onSave]);
+ }, [editor, onSave, wikilinkOpen]);
+
+ const handleWikilinkSelect = useCallback(
+ (title: string) => {
+ if (!editor || !wikilinkRangeRef.current) return;
+ const { from, to } = wikilinkRangeRef.current;
+ editor
+ .chain()
+ .focus()
+ .deleteRange({ from, to })
+ .insertContent(`[[${title}]]`)
+ .run();
+ setWikilinkOpen(false);
+ setWikilinkQuery("");
+ wikilinkRangeRef.current = null;
+ },
+ [editor]
+ );
+
+ const handleWikilinkClose = useCallback(() => {
+ setWikilinkOpen(false);
+ setWikilinkQuery("");
+ wikilinkRangeRef.current = null;
+ }, []);
if (!editor) return null;
@@ -93,6 +237,13 @@ export const NoteEditor = memo(function NoteEditor({
}
`}
+ {wikilinkOpen && (
+
+ )}
);
});
diff --git a/apps/web/src/components/shell/quick-capture.tsx b/apps/web/src/components/shell/quick-capture.tsx
new file mode 100644
index 0000000..3b527e0
--- /dev/null
+++ b/apps/web/src/components/shell/quick-capture.tsx
@@ -0,0 +1,3 @@
+export function QuickCapture() {
+ return null;
+}
diff --git a/apps/web/src/components/shell/status-bar.tsx b/apps/web/src/components/shell/status-bar.tsx
new file mode 100644
index 0000000..167203c
--- /dev/null
+++ b/apps/web/src/components/shell/status-bar.tsx
@@ -0,0 +1,3 @@
+export function StatusBar() {
+ return null;
+}
diff --git a/apps/web/src/lib/stores/use-saved-views-store.ts b/apps/web/src/lib/stores/use-saved-views-store.ts
new file mode 100644
index 0000000..8152ebd
--- /dev/null
+++ b/apps/web/src/lib/stores/use-saved-views-store.ts
@@ -0,0 +1,43 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export interface SavedView {
+ id: string;
+ name: string;
+ filters: {
+ search: string;
+ projectId: string;
+ stateId: string;
+ priority: string;
+ };
+ createdAt: string;
+}
+
+interface SavedViewsState {
+ views: SavedView[];
+ addView: (name: string, filters: SavedView["filters"]) => void;
+ removeView: (id: string) => void;
+ updateView: (id: string, updates: Partial) => void;
+}
+
+export const useSavedViewsStore = create()(
+ persist(
+ (set) => ({
+ views: [],
+ addView: (name, filters) =>
+ set((state) => ({
+ views: [
+ ...state.views,
+ { id: crypto.randomUUID(), name, filters, createdAt: new Date().toISOString() },
+ ],
+ })),
+ removeView: (id) =>
+ set((state) => ({ views: state.views.filter((v) => v.id !== id) })),
+ updateView: (id, updates) =>
+ set((state) => ({
+ views: state.views.map((v) => (v.id === id ? { ...v, ...updates } : v)),
+ })),
+ }),
+ { name: "project-e-saved-views" }
+ )
+);
diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx
index 32b5b3b..f1e67ae 100644
--- a/apps/web/src/routes/_app.tsx
+++ b/apps/web/src/routes/_app.tsx
@@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar";
import { Topbar } from "@/components/shell/topbar";
import { CommandPalette } from "@/components/shell/command-palette";
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
+import { QuickCapture } from "@/components/shell/quick-capture";
+import { StatusBar } from "@/components/shell/status-bar";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() {
useKeyboardShortcuts();
- // Apply persisted appearance preferences (density, reduced motion, font size)
- // right after the first paint. The settings page updates these live while
- // open; this covers reloads where the settings page was never visited.
useEffect(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
@@ -38,9 +37,11 @@ function AppLayout() {
>
+
+
);
}
diff --git a/apps/web/src/routes/_app/tasks.tsx b/apps/web/src/routes/_app/tasks.tsx
index 2ec70fa..9ece005 100644
--- a/apps/web/src/routes/_app/tasks.tsx
+++ b/apps/web/src/routes/_app/tasks.tsx
@@ -9,7 +9,7 @@ import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react";
+import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal, Bookmark, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -29,6 +29,7 @@ import { PRIORITY } from "@/lib/status-colors";
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
+import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
@@ -261,6 +262,11 @@ function TasksPage() {
const projects = projectsData?.items || [];
const [filterProjectId, setFilterProjectId] = useState("");
+ const [saveViewOpen, setSaveViewOpen] = useState(false);
+ const [viewName, setViewName] = useState("");
+ const savedViews = useSavedViewsStore((s) => s.views);
+ const addSavedView = useSavedViewsStore((s) => s.addView);
+ const removeSavedView = useSavedViewsStore((s) => s.removeView);
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", filterProjectId],
@@ -505,6 +511,82 @@ function TasksPage() {
)}
+
+ {/* Saved views */}
+ {savedViews.length > 0 && (
+
+ )}
+
+
{isLoading ? (
diff --git a/bun.lock b/bun.lock
index e31104b..346cab3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -75,6 +75,7 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.98.0",
"@tanstack/react-table": "^8.21.3",
+ "@tiptap/core": "^3.29.2",
"@tiptap/extension-code-block-lowlight": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
"@tiptap/extension-mention": "^3.29.2",