diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index ed238400..6fb5b544 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -130,6 +130,7 @@ async def search_tasks(q: str, status: str = "", user: dict = Depends(require_au async def task_board( limit: int = _BOARD_LANE_LIMIT, tag: str = "", + q: str = "", user: dict = Depends(require_auth), ): """Every lane in one round trip: statuses + their ordered tasks. @@ -139,15 +140,25 @@ async def task_board( keeps the lanes consistent with each other (a task that moves mid-load can't appear in two lanes or neither) and keeps the client from having to know the status list before it can start fetching. + + ``q`` searches server-side, per lane. It has to happen here rather than + as a client-side filter over the loaded cards: a lane is paginated, so + filtering what the client holds would silently miss matches deeper in + the lane and report "no results" for tasks that exist. Hits keep their + lane order rather than FTS relevance order — the board is spatial, and + reordering cards under a search would move them away from where the + person looking already knows they are. """ deps = get_deps() limit = max(1, min(limit, 200)) tag_filter = tag.strip().lower() or None + query = q.strip() + statuses = await deps.db.list_task_statuses() - # One GROUP BY covers every lane's total; only a tag filter (which the - # grouped count can't express) needs the per-lane fallback. - counts = {} if tag_filter else await deps.db.count_tasks_by_status() + # One GROUP BY covers every lane's total; a tag filter (which the grouped + # count can't express) or a search needs the per-lane path instead. + counts = {} if (tag_filter or query) else await deps.db.count_tasks_by_status() lanes = [] for status_def in statuses: @@ -155,14 +166,24 @@ async def task_board( lane_limit = ( min(limit, _BOARD_DONE_LANE_LIMIT) if name == TERMINAL_STATUS else limit ) - tasks = await deps.db.list_tasks( - status=name, tag=tag_filter, sort="position", limit=lane_limit, - ) - total = ( - await deps.db.count_tasks(status=name, tag=tag_filter) - if tag_filter - else counts.get(name, 0) - ) + if query: + tasks = await deps.db.search_tasks( + query=query, status=name, tag=tag_filter, limit=lane_limit, + ) + # search_tasks ranks by relevance; the board wants lane order. + tasks.sort(key=lambda t: (t.get("position") or 0.0, t["id"])) + # No count query for a search: the lane holds every hit unless it + # hit the cap, in which case "+N more" would be a guess anyway. + total = len(tasks) + else: + tasks = await deps.db.list_tasks( + status=name, tag=tag_filter, sort="position", limit=lane_limit, + ) + total = ( + await deps.db.count_tasks(status=name, tag=tag_filter) + if tag_filter + else counts.get(name, 0) + ) lanes.append({"status": name, "total": total, "tasks": tasks}) return {"statuses": statuses, "lanes": lanes} diff --git a/tests/test_task_board_api.py b/tests/test_task_board_api.py index 09b03c14..95978021 100644 --- a/tests/test_task_board_api.py +++ b/tests/test_task_board_api.py @@ -132,6 +132,85 @@ async def test_board_route_is_not_shadowed_by_the_id_route(self, setup): tags = setup.client.get("/api/tasks/tags").json() assert "tags" in tags, "GET /api/tasks/tags resolved to the detail route" + # ── Board search ───────────────────────────────────────────────────── + + async def test_board_search_filters_every_lane(self, setup): + await self._create(setup, "Fix the widget encoder") + await self._create(setup, "Unrelated chore") + + body = setup.client.get("/api/tasks/board?q=encoder").json() + titles = [t["title"] for lane in body["lanes"] for t in lane["tasks"]] + + assert titles == ["Fix the widget encoder"] + + async def test_board_search_reports_matching_totals(self, setup): + await self._create(setup, "Fix the widget encoder") + await self._create(setup, "Unrelated chore") + + body = setup.client.get("/api/tasks/board?q=encoder").json() + pending = next(l for l in body["lanes"] if l["status"] == "pending") + + # A stale total would offer "+N more" for tasks the search excluded. + assert pending["total"] == 1 + + async def test_board_search_spans_lanes(self, setup): + keep = await self._create(setup, "Encoder work in progress") + setup.client.patch(f"/api/tasks/{keep}", json={"status": "in_progress"}) + await self._create(setup, "Encoder work pending") + + body = setup.client.get("/api/tasks/board?q=encoder").json() + by_lane = {l["status"]: len(l["tasks"]) for l in body["lanes"]} + + # Search narrows lanes; it does not collapse them into one list. + assert by_lane["pending"] == 1 + assert by_lane["in_progress"] == 1 + + async def test_board_search_keeps_lane_order_not_relevance_order(self, setup): + first = await self._create(setup, "Encoder alpha") + second = await self._create(setup, "Encoder beta") + # Put them in an order relevance ranking would not produce. + setup.client.post(f"/api/tasks/{second}/move", json={"before_id": first}) + + body = setup.client.get("/api/tasks/board?q=encoder").json() + pending = next(l for l in body["lanes"] if l["status"] == "pending") + + assert [t["id"] for t in pending["tasks"]] == [first, second] + + async def test_board_search_finds_a_task_beyond_the_lane_page(self, setup): + """Why this is server-side rather than a client-side filter. + + The board holds one page per lane, so filtering what the client + already has would silently miss anything deeper and report no + results for a task that exists. + """ + for i in range(4): + await self._create(setup, f"Filler task {i}") + await self._create(setup, "Buried encoder task") + + body = setup.client.get("/api/tasks/board?limit=2&q=encoder").json() + titles = [t["title"] for lane in body["lanes"] for t in lane["tasks"]] + + assert titles == ["Buried encoder task"] + + async def test_board_search_with_no_matches_returns_empty_lanes(self, setup): + await self._create(setup, "Something else entirely") + + body = setup.client.get("/api/tasks/board?q=nonexistentterm").json() + + # Lanes still present, just empty — the client needs the columns to + # render its "no matches" state in the right shape. + assert body["lanes"] + assert all(len(l["tasks"]) == 0 for l in body["lanes"]) + + async def test_board_search_combines_with_a_tag_filter(self, setup): + await self._create(setup, "Encoder backend work", tags="backend") + await self._create(setup, "Encoder frontend work", tags="frontend") + + body = setup.client.get("/api/tasks/board?q=encoder&tag=backend").json() + titles = [t["title"] for lane in body["lanes"] for t in lane["tasks"]] + + assert titles == ["Encoder backend work"] + # ── Tag facets ─────────────────────────────────────────────────────── async def test_tags_endpoint_counts_and_ranks(self, setup): diff --git a/web/package-lock.json b/web/package-lock.json index aa6e0d14..a488fc18 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,9 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@pierre/diffs": "^1.2.7", "highlight.js": "^11.11.1", "lucide-react": "^0.575.0", @@ -571,6 +574,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -6995,6 +7051,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/web/package.json b/web/package.json index b6b4814f..e0ccea87 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,9 @@ "test:watch": "vitest" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@pierre/diffs": "^1.2.7", "highlight.js": "^11.11.1", "lucide-react": "^0.575.0", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6789f0ae..c7b4f504 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,6 +10,21 @@ export interface TaskStatusDef { created_at?: string; } +export interface Task { + id: string; + title: string; + status: string; + deadline: string | null; + source: string; + source_url: string | null; + tags: string; + /** Board rank within its lane, ascending. Server-assigned. */ + position: number; + created_at: string; + updated_at: string; + content?: string; +} + export interface UltracodeUsage { input_tokens?: number; cached_input_tokens?: number; @@ -332,9 +347,10 @@ export const api = { }), // Tasks - listTasks: (params?: { status?: string; sort?: string; limit?: number; offset?: number }) => { + listTasks: (params?: { status?: string; tag?: string; sort?: string; limit?: number; offset?: number }) => { const qs = new URLSearchParams(); if (params?.status) qs.set('status', params.status); + if (params?.tag) qs.set('tag', params.tag); if (params?.sort) qs.set('sort', params.sort); if (params?.limit !== undefined) qs.set('limit', String(params.limit)); if (params?.offset !== undefined) qs.set('offset', String(params.offset)); @@ -350,11 +366,49 @@ export const api = { `/tasks/search?${qs}`, ); }, + /** Every board lane in one round trip. */ + getTaskBoard: (params?: { tag?: string; limit?: number; q?: string }) => { + const qs = new URLSearchParams(); + if (params?.tag) qs.set('tag', params.tag); + if (params?.q) qs.set('q', params.q); + if (params?.limit !== undefined) qs.set('limit', String(params.limit)); + const q = qs.toString(); + return request<{ + statuses: TaskStatusDef[]; + lanes: { status: string; total: number; tasks: Task[] }[]; + }>(`/tasks/board${q ? '?' + q : ''}`); + }, + listTaskTags: (includeDone = false) => + request<{ tags: { name: string; count: number }[] }>( + `/tasks/tags${includeDone ? '?include_done=true' : ''}`, + ), + /** + * Move a task within or between lanes. Sends the neighbours it should + * land between rather than a rank — the server computes the ordering, + * so a board a few seconds stale can't write a conflicting position. + */ + moveTask: (id: string, data: { status?: string; before_id?: string | null; after_id?: string | null }) => + request<{ task: Task }>(`/tasks/${id}/move`, { + method: 'POST', + body: JSON.stringify(data), + }), getTask: (id: string) => request(`/tasks/${id}`), - createTask: (data: { title: string; content?: string; deadline?: string }) => - request('/tasks', { method: 'POST', body: JSON.stringify(data) }), - updateTask: (id: string, data: { status?: string; note?: string; content?: string }) => - request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + createTask: (data: { + title: string; content?: string; deadline?: string; + tags?: string; status?: string; confirm_duplicate?: boolean; + }) => request<{ task: Task; message: string }>('/tasks', { + method: 'POST', body: JSON.stringify(data), + }), + /** + * Partial update. Server reads `deadline` and `tags` by *presence*: omit + * the key to leave the field alone, pass '' to clear it. + */ + updateTask: (id: string, data: { + status?: string; note?: string; content?: string; title?: string; + deadline?: string; tags?: string; + }) => request<{ task: Task; task_id: string; updated: boolean }>(`/tasks/${id}`, { + method: 'PATCH', body: JSON.stringify(data), + }), // Task statuses (configurable) listTaskStatuses: () => diff --git a/web/src/api/websocket.ts b/web/src/api/websocket.ts index 8095e944..e1274476 100644 --- a/web/src/api/websocket.ts +++ b/web/src/api/websocket.ts @@ -1,5 +1,5 @@ import { getToken } from './client'; -import type { ReviewLoop, WorkflowRun } from './client'; +import type { ReviewLoop, Task, WorkflowRun } from './client'; import type { WorkflowSnapshot } from '../types/chat'; export type WSMessage = @@ -35,6 +35,10 @@ export type WSMessage = | { type: 'workflow_progress'; session_id: string; tool_use_id: string; workflow: WorkflowSnapshot } | { type: 'workflow_run_update'; session_id: string | null; run: WorkflowRun } | { type: 'review_loop_update'; session_id: string | null; loop: ReviewLoop; message?: { role: string; content: string; channel?: string; created_at?: string } } + // Global (session_id is always null): a task row changed anywhere — the + // API, another tab, or the agent in an unrelated session. Deliberately + // NOT view-scoped; the board reflects all of them. + | { type: 'task_updated'; session_id: null; event: 'created' | 'updated' | 'moved' | 'done'; task: Task } | { type: 'wakeup'; session_id: string } | { type: 'auto_turn'; session_id: string } | { type: 'model_changed'; session_id: string; from_model: string; to_model: string; downgrade: boolean } diff --git a/web/src/components/Tasks/Board/BoardCard.tsx b/web/src/components/Tasks/Board/BoardCard.tsx new file mode 100644 index 00000000..1d76644f --- /dev/null +++ b/web/src/components/Tasks/Board/BoardCard.tsx @@ -0,0 +1,147 @@ +import { memo } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Calendar, Clock, ExternalLink } from 'lucide-react'; +import type { Task } from '../../../api/client'; +import { formatTimeAgo } from '../../../utils/dateGroups'; + +/** + * Deadline urgency, as a token class rather than a raw colour so it tracks + * the theme. Overdue and due-today are the two states worth interrupting + * someone for; anything further out is informational. + */ +function deadlineTone(deadline: string): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const due = new Date(`${deadline}T00:00:00`); + if (Number.isNaN(due.getTime())) return 'text-text-dim'; + const days = Math.round((due.getTime() - today.getTime()) / 86_400_000); + if (days < 0) return 'text-hue-red'; + if (days === 0) return 'text-hue-orange'; + if (days <= 3) return 'text-hue-yellow'; + return 'text-text-dim'; +} + +function parseTags(tags: string | null | undefined): string[] { + return (tags || '').split(',').map((t) => t.trim()).filter(Boolean); +} + +export interface BoardCardProps { + task: Task; + onOpen: (task: Task) => void; +} + +/** + * A single draggable card. + * + * Deliberately lighter than the list view's `TaskCard`: at ~280px there is + * no room for the inline status ` -
- {!isSearching && ( +
+ {!isSearching && !isBoard && (