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..b1f88052 100644 --- a/web/src/components/ShortcutsModal.tsx +++ b/web/src/components/ShortcutsModal.tsx @@ -5,6 +5,12 @@ import { Modal } from './ui/Modal'; interface DisplayShortcut { combo: ShortcutCombo; description: string; + /** + * Override the rendered key label. For a binding that is a *set* of keys + * rather than one combo — the arrow cluster — formatting a single combo + * would document a quarter of it. + */ + label?: string; } interface Section { @@ -14,7 +20,9 @@ interface Section { /** * Static display of every keyboard binding. The runtime handlers live in - * App.tsx (global) and ChatPage.tsx (chat-scoped) — keep this list in sync + * App.tsx (global), ChatPage.tsx (chat-scoped) and TasksPage.tsx + * (tasks-scoped), with Enter/Shift+Enter owned by ChatInput and the board's + * Space/arrow bindings by dnd-kit's keyboard sensor — keep this list in sync * with those when bindings change. */ const SECTIONS: Section[] = [ @@ -44,6 +52,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' }, label: '↑ ↓ ← →', description: 'Move a picked-up card' }, + ], + }, ]; export function ShortcutsModal() { @@ -67,7 +86,7 @@ export function ShortcutsModal() { className="flex items-center justify-between gap-4 py-1" > {item.description} - + ))} @@ -78,10 +97,10 @@ export function ShortcutsModal() { ); } -function Kbd({ combo }: { combo: ShortcutCombo }) { +function Kbd({ combo, label }: { combo: ShortcutCombo; label?: string }) { return ( - {formatCombo(combo)} + {label ?? formatCombo(combo)} ); } diff --git a/web/src/components/Tasks/TaskDetailBody.test.tsx b/web/src/components/Tasks/TaskDetailBody.test.tsx new file mode 100644 index 00000000..3bc9aa1e --- /dev/null +++ b/web/src/components/Tasks/TaskDetailBody.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Task } from '../../api/client'; +import { TaskDetailBody } from './TaskDetailBody'; + +vi.mock('../../api/client', () => ({ + api: { updateTask: vi.fn() }, +})); + +/** + * The dirty guard is the one piece of behaviour here that a reader cannot + * verify by looking: it only shows itself when a re-render arrives while the + * textarea holds unsaved text. Now that the full page and the board's modal + * both render this component, a regression would silently discard typing in + * two places at once. + */ + +function task(content: string): Task & { content: string } { + return { + id: 't1', title: 'Fix the encoder', status: 'pending', position: 1024, + deadline: null, source: 'manual', source_url: null, tags: '', + created_at: '2026-08-05T00:00:00Z', updated_at: '2026-08-05T00:00:00Z', + content, + }; +} + +const editor = () => screen.getByPlaceholderText('Task content...'); + +async function startEditing() { + await userEvent.click(screen.getByTitle('Edit')); +} + +describe('TaskDetailBody content sync', () => { + it('adopts the task content when nothing has been typed', async () => { + const { rerender } = render(); + await startEditing(); + expect(editor()).toHaveValue('# original'); + + rerender(); + + // Clean editor: the newer content is strictly better than what is shown. + expect(editor()).toHaveValue('# rewritten elsewhere'); + }); + + it('keeps unsaved edits when the task content changes underneath', async () => { + const { rerender } = render(); + await startEditing(); + await userEvent.type(editor(), ' plus my notes'); + expect(editor()).toHaveValue('# original plus my notes'); + + rerender(); + + // The whole point: an incoming update must not throw away typing. + expect(editor()).toHaveValue('# original plus my notes'); + }); + + it('offers Save only once something has been typed', async () => { + render(); + await startEditing(); + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument(); + + await userEvent.type(editor(), '!'); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Tasks/TaskDetailBody.tsx b/web/src/components/Tasks/TaskDetailBody.tsx new file mode 100644 index 00000000..dee04e70 --- /dev/null +++ b/web/src/components/Tasks/TaskDetailBody.tsx @@ -0,0 +1,129 @@ +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. + // + // Defensive rather than a fix for something reachable today: the only + // writers of `content` are the initial fetch and the user's own save, + // because a task_updated broadcast carries the `tasks` row and that table + // has no content column. The guard is here so that stops being a thing + // anyone has to know before adding one. + 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' ? ( +