From 25042fa8847f12dc1c2391e05e1ab4589baccfa0 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:59:38 +0000 Subject: [PATCH 1/3] Open tasks in a modal over the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a board card navigated to the full task page, tearing down the board and losing its scroll position — for what is usually a glance at the description. /tasks/:taskId now renders either way depending on how it was reached. Entered from the board it carries a `background` location in history state, so App renders the board's route tree and overlays the task as a dialog; a cold load, a refresh, or a shared link has no such state and gets the ordinary full page. The URL is the same either way, so links stay shareable and Back still means back. First use of location.state in the app. The editor itself moves into TaskDetailBody, shared by the page and the modal so the two can't drift — only the surrounding chrome differs. One behaviour change fell out of the extraction: content no longer resyncs from the store while edits are unsaved, because a task_updated broadcast arriving mid-typing would otherwise discard what you were writing. Also adds page-scoped shortcuts (b/l to switch view, n for new, / to focus search) and the Tasks section in ShortcutsModal — whose comment says the list is manual and must be kept in sync. Card-level movement is left to dnd-kit's own keyboard handling rather than reimplemented. Deletes components/Tasks/TaskList.tsx: 89 lines, zero importers, dead since the page was rewritten. Co-Authored-By: Claude Opus 5 --- web/src/App.tsx | 23 +++- web/src/components/ShortcutsModal.tsx | 11 ++ web/src/components/Tasks/TaskDetailBody.tsx | 124 +++++++++++++++++++ web/src/components/Tasks/TaskDetailModal.tsx | 76 ++++++++++++ web/src/components/Tasks/TaskList.tsx | 89 ------------- web/src/pages/TasksPage.tsx | 42 ++++++- web/src/utils/keyboard.ts | 2 +- 7 files changed, 274 insertions(+), 93 deletions(-) create mode 100644 web/src/components/Tasks/TaskDetailBody.tsx create mode 100644 web/src/components/Tasks/TaskDetailModal.tsx delete mode 100644 web/src/components/Tasks/TaskList.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c37a15e..c771d42a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from 'react'; -import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import type { Location } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import { ws } from './api/websocket'; import { useChatStore } from './stores/chatStore'; @@ -12,6 +13,7 @@ import { ChatPage } from './pages/ChatPage'; import { FilesPage } from './pages/FilesPage'; import { TasksPage } from './pages/TasksPage'; import { TaskDetailPage } from './pages/TaskDetailPage'; +import { TaskDetailModal } from './components/Tasks/TaskDetailModal'; import { DiagnosticsPage } from './pages/DiagnosticsPage'; import { MemuPage } from './pages/MemuPage'; import { SourcesPage } from './pages/SourcesPage'; @@ -31,6 +33,8 @@ import { ShortcutsModal } from './components/ShortcutsModal'; function App() { const { authenticated, checking, checkAuth } = useAuthStore(); const { handleWSMessage, loadSessions } = useChatStore(); + // Above the early returns — hooks can't run conditionally. + const location = useLocation(); useEffect(() => { checkAuth(); }, []); @@ -45,10 +49,18 @@ function App() { if (checking) return null; if (!authenticated) return ; + // Background-location routing: when a route is entered with a + // `background` location in history state, render *that* location's route + // tree and overlay the real one as a modal. Reaching the same URL + // directly — a cold load, a refresh, a shared link — carries no such + // state and renders the ordinary full page. Used by the task board so + // opening a card doesn't tear the board down. + const background = (location.state as { background?: Location } | null)?.background; + return ( <> - + }> } /> } /> @@ -70,6 +82,13 @@ function App() { } /> + + {background && ( + + } /> + + )} + diff --git a/web/src/components/ShortcutsModal.tsx b/web/src/components/ShortcutsModal.tsx index 50697b0b..b0d1b4d2 100644 --- a/web/src/components/ShortcutsModal.tsx +++ b/web/src/components/ShortcutsModal.tsx @@ -44,6 +44,17 @@ const SECTIONS: Section[] = [ { combo: { shift: true, key: 'Enter' }, description: 'New line' }, ], }, + { + title: 'Tasks', + items: [ + { combo: { key: 'b' }, description: 'Board view' }, + { combo: { key: 'l' }, description: 'List view' }, + { combo: { key: 'n' }, description: 'New task' }, + { combo: { key: '/' }, description: 'Focus task search' }, + { combo: { key: 'Space' }, description: 'Pick up / drop a focused card' }, + { combo: { key: 'ArrowUp' }, description: 'Move a picked-up card (with arrows)' }, + ], + }, ]; export function ShortcutsModal() { diff --git a/web/src/components/Tasks/TaskDetailBody.tsx b/web/src/components/Tasks/TaskDetailBody.tsx new file mode 100644 index 00000000..30e5804c --- /dev/null +++ b/web/src/components/Tasks/TaskDetailBody.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Calendar, Edit3, ExternalLink, Eye, Save } from 'lucide-react'; +import type { Task } from '../../api/client'; +import { useTaskStore } from '../../stores/taskStore'; +import { StatusBadge, StatusSelect } from './StatusControls'; +import { MarkdownContent } from '../Chat/MarkdownContent'; + +/** + * The task editor itself — metadata row, edit/preview toggle, markdown + * body, save. + * + * Shared by the full page and the board's modal so the two can't drift. + * Only the surrounding chrome differs: the page supplies a back button and + * heading, the modal supplies the dialog frame. + */ +export function TaskDetailBody({ task }: { task: Task }) { + const saving = useTaskStore((s) => s.saving); + const saveTaskContent = useTaskStore((s) => s.saveTaskContent); + const updateStatus = useTaskStore((s) => s.updateStatus); + + const [mode, setMode] = useState<'edit' | 'preview'>('preview'); + const [localContent, setLocalContent] = useState(''); + const [dirty, setDirty] = useState(false); + + // Adopt the loaded markdown, but never clobber unsaved edits — a + // task_updated broadcast can land mid-typing. + useEffect(() => { + if (task.content != null && !dirty) { + setLocalContent(task.content); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.content]); + + const handleSave = useCallback(async () => { + if (!dirty) return; + await saveTaskContent(task.id, localContent); + setDirty(false); + }, [task.id, dirty, localContent, saveTaskContent]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + void handleSave(); + } + }, [handleSave]); + + return ( +
+
+ + updateStatus(task.id, status)} + className="text-[12px] px-2 py-1 bg-surface-raised border border-border rounded text-text-muted outline-none cursor-pointer" + /> + {task.deadline && ( + + {task.deadline} + + )} + {task.source && from {task.source}} + {task.source_url && ( + + source + + )} + +
+ {dirty && ( + + )} +
+ + +
+
+
+ + {mode === 'edit' ? ( +