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
43 changes: 32 additions & 11 deletions nerve/gateway/routes/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -139,30 +140,50 @@ 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:
name = status_def["name"]
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}
Expand Down
79 changes: 79 additions & 0 deletions tests/test_task_board_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 62 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 59 additions & 5 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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<any>(`/tasks/${id}`),
createTask: (data: { title: string; content?: string; deadline?: string }) =>
request<any>('/tasks', { method: 'POST', body: JSON.stringify(data) }),
updateTask: (id: string, data: { status?: string; note?: string; content?: string }) =>
request<any>(`/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: () =>
Expand Down
6 changes: 5 additions & 1 deletion web/src/api/websocket.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading