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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user