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
@@ -55,6 +55,16 @@ class TaskService(
fun list(projectId: ProjectId): List<Task> =
board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
/**
* Every task across every project, for a cross-project board. Enumerates the per-project task
* streams ([TaskStreams.PREFIX]) and rebuilds each — there is no global task stream, so this is
* the union of the per-project boards.
*/
fun listAll(): List<Task> =
eventStore.allSessionIds()
.filter { it.value.startsWith(TaskStreams.PREFIX) }
.flatMap { stream -> list(ProjectId(stream.value.removePrefix(TaskStreams.PREFIX))) }
fun getTask(taskId: TaskId): Task? {
val state = board(projectOf(taskId))[taskId] ?: return null
return if (state.deleted) null else Task(taskId, state)
@@ -72,6 +72,14 @@ class TaskServiceTest {
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
}
@Test
fun `listAll spans every project's stream`() = runBlocking {
service.createTask(project, "a", "g")
service.createTask(ProjectId("billing"), "b", "g")
assertEquals(setOf("auth-1", "billing-1"), service.listAll().map { it.taskId.value }.toSet())
}
@Test
fun `keys keep incrementing even after deletion`() = runBlocking {
val first = service.createTask(project, "one", "g").taskId