feat(tasks): TUI task board

Add a cross-project task board to the TUI (T / palette): a roster fetched
from GET /tasks with vim-style nav, refresh, and an enter-to-open detail
pane (goal, criteria, paths, links, notes) built from the list payload — no
extra fetch. Server: TaskService.listAll() and a project-optional GET /tasks
(scoped with ?project=, whole board without; recent-first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:32:48 +00:00
parent 70b4357316
commit 1d7fab4ee4
9 changed files with 593 additions and 6 deletions
@@ -82,8 +82,9 @@ data class NoteRequest(val body: String, val author: String? = null)
/**
* Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
* append to the event log, which stays the single source of truth (nothing is stored separately).
* A task's project is encoded in its id, so item routes need only the `{id}`; the collection
* routes take a `project` query/body field. [GET /tasks/{id}/context] serves the agent bundle.
* A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes
* an optional `project` (cross-project board without it); create requires `project` in the body.
* [GET /tasks/{id}/context] serves the agent bundle.
*/
fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore)
@@ -106,14 +107,15 @@ fun Route.taskRoutes(module: ServerModule) {
private fun Route.taskCollectionRoutes(service: TaskService) {
get {
val project = call.request.queryParameters["project"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing 'project' query parameter")
val statusRaw = call.request.queryParameters["status"]
val statusFilter = statusRaw?.let {
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
}
val tasks = service.list(ProjectId(project))
// project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"]
val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll())
.filter { statusFilter == null || it.state.status == statusFilter }
.sortedByDescending { it.state.updatedAt } // most recently touched first
.map { it.toResponse() }
call.respond(tasks)
}
@@ -103,6 +103,13 @@ class TaskRoutesTest {
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
// No project → cross-project board lists it too.
val all = client.get("/tasks")
assertEquals(HttpStatusCode.OK, all.status)
val allRows = testJson.parseToJsonElement(all.bodyAsText()) as JsonArray
assertEquals(1, allRows.size)
assertEquals("demo-1", (allRows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val fetched = client.get("/tasks/demo-1")
assertEquals(HttpStatusCode.OK, fetched.status)
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
@@ -199,7 +206,7 @@ class TaskRoutesTest {
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.BadRequest, client.get("/tasks").status)
assertEquals(HttpStatusCode.BadRequest, client.get("/tasks?status=NONSENSE").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status)
val badLink = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)