Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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(); }, []);

Expand All @@ -45,10 +49,18 @@ function App() {
if (checking) return null;
if (!authenticated) return <LoginPage />;

// 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 (
<>
<GlobalShortcuts />
<Routes>
<Routes location={background ?? location}>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/chat" replace />} />
<Route path="/chat/:sessionId?" element={<ChatPage />} />
Expand All @@ -70,6 +82,13 @@ function App() {
<Route path="/diagnostics" element={<DiagnosticsPage />} />
</Route>
</Routes>

{background && (
<Routes>
<Route path="/tasks/:taskId" element={<TaskDetailModal />} />
</Routes>
)}

<NotificationToast />
<ShortcutsModal />
</>
Expand Down
27 changes: 23 additions & 4 deletions web/src/components/ShortcutsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[] = [
Expand Down Expand Up @@ -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' },
Comment thread
alex-clickhouse marked this conversation as resolved.
{ 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() {
Expand All @@ -67,7 +86,7 @@ export function ShortcutsModal() {
className="flex items-center justify-between gap-4 py-1"
>
<span className="text-[13px] text-text-secondary">{item.description}</span>
<Kbd combo={item.combo} />
<Kbd combo={item.combo} label={item.label} />
</div>
))}
</div>
Expand All @@ -78,10 +97,10 @@ export function ShortcutsModal() {
);
}

function Kbd({ combo }: { combo: ShortcutCombo }) {
function Kbd({ combo, label }: { combo: ShortcutCombo; label?: string }) {
return (
<kbd className="px-2 py-1 text-[11px] font-mono text-text-secondary bg-surface border border-border-subtle rounded shrink-0 tabular-nums">
{formatCombo(combo)}
{label ?? formatCombo(combo)}
</kbd>
);
}
68 changes: 68 additions & 0 deletions web/src/components/Tasks/TaskDetailBody.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TaskDetailBody task={task('# original')} />);
await startEditing();
expect(editor()).toHaveValue('# original');

rerender(<TaskDetailBody task={task('# rewritten elsewhere')} />);

// 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(<TaskDetailBody task={task('# original')} />);
await startEditing();
await userEvent.type(editor(), ' plus my notes');
expect(editor()).toHaveValue('# original plus my notes');

rerender(<TaskDetailBody task={task('# rewritten elsewhere')} />);

// 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(<TaskDetailBody task={task('# original')} />);
await startEditing();
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();

await userEvent.type(editor(), '!');

expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
});
129 changes: 129 additions & 0 deletions web/src/components/Tasks/TaskDetailBody.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/
Comment thread
alex-clickhouse marked this conversation as resolved.
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 (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-3 px-5 py-2.5 text-[12px] border-b border-border-subtle shrink-0 flex-wrap">
<StatusBadge status={task.status} />
<StatusSelect
value={task.status}
onChange={(status) => 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 && (
<span className="flex items-center gap-1 text-text-dim">
<Calendar size={11} /> {task.deadline}
</span>
)}
{task.source && <span className="text-text-faint">from {task.source}</span>}
{task.source_url && (
<a
href={task.source_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent hover:underline"
>
<ExternalLink size={11} /> source
</a>
)}

<div className="ml-auto flex items-center gap-2">
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] bg-accent hover:bg-accent-hover text-white rounded-md cursor-pointer disabled:opacity-50"
>
<Save size={12} /> {saving ? 'Saving...' : 'Save'}
</button>
)}
<div className="flex bg-surface-raised rounded-md border border-border">
<button
onClick={() => setMode('edit')}
aria-pressed={mode === 'edit'}
title="Edit"
className={`px-2.5 py-1 rounded-l-md cursor-pointer transition-colors
${mode === 'edit' ? 'text-text' : 'text-text-dim hover:text-text-muted'}`}
>
<Edit3 size={13} />
</button>
<button
onClick={() => setMode('preview')}
aria-pressed={mode === 'preview'}
title="Preview"
className={`px-2.5 py-1 rounded-r-md cursor-pointer transition-colors
${mode === 'preview' ? 'text-text' : 'text-text-dim hover:text-text-muted'}`}
>
<Eye size={13} />
</button>
</div>
</div>
</div>

{mode === 'edit' ? (
<textarea
value={localContent}
onChange={(e) => { setLocalContent(e.target.value); setDirty(true); }}
onKeyDown={handleKeyDown}
className="flex-1 min-h-0 p-5 bg-bg-sunken text-[13px] text-text font-mono leading-relaxed outline-none resize-none"
spellCheck={false}
placeholder="Task content..."
/>
) : (
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5">
{localContent
? <MarkdownContent content={localContent} />
: <span className="text-text-faint italic">No content</span>}
</div>
)}
</div>
);
}
77 changes: 77 additions & 0 deletions web/src/components/Tasks/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Maximize2 } from 'lucide-react';
import { useTaskStore } from '../../stores/taskStore';
import { useTaskStatusStore } from '../../stores/taskStatusStore';
import { Modal } from '../ui/Modal';
import { TaskDetailBody } from './TaskDetailBody';

/**
* `/tasks/:taskId` rendered over the board.
*
* Mounted only when the route was reached with a `background` location in
* history state (see `TasksPage.openTask`). A cold load or a refresh of
* the same URL has no such state and falls through to the full
* `TaskDetailPage` — so the URL stays shareable and the back button still
* means "back", while clicking a card doesn't tear down the board.
*/
export function TaskDetailModal() {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();

const selectedTask = useTaskStore((s) => s.selectedTask);
const detailLoading = useTaskStore((s) => s.detailLoading);
const loadTask = useTaskStore((s) => s.loadTask);
const clearSelectedTask = useTaskStore((s) => s.clearSelectedTask);
const loadStatuses = useTaskStatusStore((s) => s.load);

useEffect(() => {
if (taskId) void loadTask(taskId);
void loadStatuses();
return () => clearSelectedTask();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);

// -1 rather than navigate('/tasks'): the board is still mounted behind
// this, so going back restores it with its scroll position intact.
const close = () => navigate(-1);

const title = detailLoading
? 'Loading...'
: selectedTask?.title ?? 'Task not found';

return (
<Modal
open
onClose={close}
title={
<span className="flex items-center gap-2 min-w-0">
<span className="truncate">{title}</span>
{taskId && (
<button
onClick={() => navigate(`/tasks/${taskId}`, { replace: true })}
title="Open as full page"
aria-label="Open as full page"
className="shrink-0 text-text-faint hover:text-text-muted cursor-pointer"
>
<Maximize2 size={13} />
</button>
)}
</span>
}
// The task body is a document, not a form — it needs room to read.
size="wide"
// Markdown editing behind a backdrop click is too much to lose.
closeOnBackdrop={false}
className="h-[85vh] max-h-[85vh]"
>
{detailLoading && (
<div className="p-8 text-center text-text-faint text-[13px]">Loading...</div>
)}
{!detailLoading && !selectedTask && (
<div className="p-8 text-center text-text-faint text-[13px]">Task not found.</div>
)}
{!detailLoading && selectedTask && <TaskDetailBody task={selectedTask} />}
</Modal>
);
}
Loading
Loading