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
|
* 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).
|
* 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
|
* A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes
|
||||||
* routes take a `project` query/body field. [GET /tasks/{id}/context] serves the agent bundle.
|
* 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) {
|
fun Route.taskRoutes(module: ServerModule) {
|
||||||
val service = TaskService(module.eventStore)
|
val service = TaskService(module.eventStore)
|
||||||
@@ -106,14 +107,15 @@ fun Route.taskRoutes(module: ServerModule) {
|
|||||||
|
|
||||||
private fun Route.taskCollectionRoutes(service: TaskService) {
|
private fun Route.taskCollectionRoutes(service: TaskService) {
|
||||||
get {
|
get {
|
||||||
val project = call.request.queryParameters["project"]
|
|
||||||
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing 'project' query parameter")
|
|
||||||
val statusRaw = call.request.queryParameters["status"]
|
val statusRaw = call.request.queryParameters["status"]
|
||||||
val statusFilter = statusRaw?.let {
|
val statusFilter = statusRaw?.let {
|
||||||
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
|
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 }
|
.filter { statusFilter == null || it.state.status == statusFilter }
|
||||||
|
.sortedByDescending { it.state.updatedAt } // most recently touched first
|
||||||
.map { it.toResponse() }
|
.map { it.toResponse() }
|
||||||
call.respond(tasks)
|
call.respond(tasks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ class TaskRoutesTest {
|
|||||||
assertEquals(1, rows.size)
|
assertEquals(1, rows.size)
|
||||||
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
|
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")
|
val fetched = client.get("/tasks/demo-1")
|
||||||
assertEquals(HttpStatusCode.OK, fetched.status)
|
assertEquals(HttpStatusCode.OK, fetched.status)
|
||||||
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
|
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
|
||||||
@@ -199,7 +206,7 @@ class TaskRoutesTest {
|
|||||||
val client = createClient {}
|
val client = createClient {}
|
||||||
client.createTask()
|
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)
|
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status)
|
||||||
val badLink = client.post("/tasks/demo-1/links") {
|
val badLink = client.post("/tasks/demo-1/links") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const (
|
|||||||
OverlayStats
|
OverlayStats
|
||||||
OverlayIdeas
|
OverlayIdeas
|
||||||
OverlaySessions
|
OverlaySessions
|
||||||
|
OverlayTasks
|
||||||
OverlayFiles
|
OverlayFiles
|
||||||
OverlayStatusbar
|
OverlayStatusbar
|
||||||
OverlayGrants
|
OverlayGrants
|
||||||
@@ -317,6 +318,15 @@ type Model struct {
|
|||||||
sessionListLoading bool
|
sessionListLoading bool
|
||||||
sessionListErr string
|
sessionListErr string
|
||||||
|
|
||||||
|
// task board (OverlayTasks) — all tasks across projects, fetched over HTTP from
|
||||||
|
// GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload).
|
||||||
|
taskList []TaskSummary
|
||||||
|
taskListIndex int
|
||||||
|
taskListLoading bool
|
||||||
|
taskListErr string
|
||||||
|
taskDetail bool
|
||||||
|
taskDetailScroll int
|
||||||
|
|
||||||
// command palette
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
paletteIndex int
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.ideasModal())
|
return m.center(m.ideasModal())
|
||||||
case OverlaySessions:
|
case OverlaySessions:
|
||||||
return m.center(m.sessionsModal())
|
return m.center(m.sessionsModal())
|
||||||
|
case OverlayTasks:
|
||||||
|
return m.center(m.tasksModal())
|
||||||
case OverlayFiles:
|
case OverlayFiles:
|
||||||
return m.center(m.filesModal())
|
return m.center(m.filesModal())
|
||||||
case OverlayStatusbar:
|
case OverlayStatusbar:
|
||||||
@@ -846,6 +848,7 @@ func (m Model) helpBody() []string {
|
|||||||
{"e", "event inspector (/ to filter)"},
|
{"e", "event inspector (/ to filter)"},
|
||||||
{"t / v", "tools / artifacts"},
|
{"t / v", "tools / artifacts"},
|
||||||
{"S / R", "session stats / resume past sessions"},
|
{"S / R", "session stats / resume past sessions"},
|
||||||
|
{"T", "task board (across projects)"},
|
||||||
{"m / g", "swap model / edit config"},
|
{"m / g", "swap model / edit config"},
|
||||||
{"G / I", "grants / idea board"},
|
{"G / I", "grants / idea board"},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse
|
||||||
|
// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields
|
||||||
|
// decode to the zero value ("" / nil) here.
|
||||||
|
type TaskSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
AcceptanceCriteria []string `json:"acceptanceCriteria"`
|
||||||
|
AffectedPaths []string `json:"affectedPaths"`
|
||||||
|
Claimant string `json:"claimant"`
|
||||||
|
Links []TaskLinkWire `json:"links"`
|
||||||
|
Notes []TaskNoteWire `json:"notes"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskLinkWire struct {
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TargetKind string `json:"targetKind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskNoteWire struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks /
|
||||||
|
// err is meaningful; a non-empty err means the fetch failed and the board shows it.
|
||||||
|
type tasksLoadedMsg struct {
|
||||||
|
tasks []TaskSummary
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and
|
||||||
|
// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws
|
||||||
|
// client); an empty base yields an error message rather than a panic.
|
||||||
|
func fetchTasks(base string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if base == "" {
|
||||||
|
return tasksLoadedMsg{err: "no server address"}
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: sessionsHTTPTimeout}
|
||||||
|
resp, err := client.Get(base + "/tasks")
|
||||||
|
if err != nil {
|
||||||
|
return tasksLoadedMsg{err: "fetch failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return tasksLoadedMsg{err: "server returned " + resp.Status}
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return tasksLoadedMsg{err: "read failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
var out []TaskSummary
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return tasksLoadedMsg{err: "decode failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
return tasksLoadedMsg{tasks: out}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is
|
||||||
|
// global (not bound to a session), so it opens from anywhere.
|
||||||
|
func (m *Model) openTasks() tea.Cmd {
|
||||||
|
m.overlay = OverlayTasks
|
||||||
|
m.taskListIndex = 0
|
||||||
|
m.taskListErr = ""
|
||||||
|
m.taskDetail = false
|
||||||
|
m.taskDetailScroll = 0
|
||||||
|
m.taskListLoading = true
|
||||||
|
return fetchTasks(m.client.HTTPBase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
|
||||||
|
// surfacing any fetch error in place of a crash.
|
||||||
|
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
|
||||||
|
m.taskListLoading = false
|
||||||
|
if msg.err != "" {
|
||||||
|
m.taskListErr = msg.err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.taskListErr = ""
|
||||||
|
m.taskList = msg.tasks
|
||||||
|
if m.taskListIndex >= len(m.taskList) {
|
||||||
|
m.taskListIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedTask returns the highlighted task, or false when the list is empty / the
|
||||||
|
// cursor is out of range.
|
||||||
|
func (m Model) selectedTask() (TaskSummary, bool) {
|
||||||
|
if m.taskListIndex < 0 || m.taskListIndex >= len(m.taskList) {
|
||||||
|
return TaskSummary{}, false
|
||||||
|
}
|
||||||
|
return m.taskList[m.taskListIndex], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTasksKey owns every key while the board is open. In list mode: navigate, r
|
||||||
|
// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and
|
||||||
|
// enter/esc returns to the list.
|
||||||
|
func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.taskDetail {
|
||||||
|
switch {
|
||||||
|
case k.Type == keyEsc || k.Type == keyEnter:
|
||||||
|
m.taskDetail = false
|
||||||
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
|
m.scrollTaskDetail(-1)
|
||||||
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
|
m.scrollTaskDetail(1)
|
||||||
|
case k.Type == keyPgUp:
|
||||||
|
m.scrollTaskDetail(-m.taskDetailBodyH())
|
||||||
|
case k.Type == keyPgDown:
|
||||||
|
m.scrollTaskDetail(m.taskDetailBodyH())
|
||||||
|
case runeIs(k, "g"):
|
||||||
|
m.taskDetailScroll = 0
|
||||||
|
case runeIs(k, "G"):
|
||||||
|
m.taskDetailScroll = m.taskDetailMaxScroll()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case k.Type == keyEsc || runeIs(k, "T"):
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
|
if m.taskListIndex > 0 {
|
||||||
|
m.taskListIndex--
|
||||||
|
}
|
||||||
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
|
if m.taskListIndex < len(m.taskList)-1 {
|
||||||
|
m.taskListIndex++
|
||||||
|
}
|
||||||
|
case runeIs(k, "r"):
|
||||||
|
return m, m.openTasks()
|
||||||
|
case k.Type == keyEnter:
|
||||||
|
if _, ok := m.selectedTask(); ok {
|
||||||
|
m.taskDetail = true
|
||||||
|
m.taskDetailScroll = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskDetailBodyH is the number of detail lines visible in the detail pane.
|
||||||
|
func (m Model) taskDetailBodyH() int {
|
||||||
|
h := m.height*70/100 - 6
|
||||||
|
if h < 4 {
|
||||||
|
h = 4
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom.
|
||||||
|
func (m Model) taskDetailMaxScroll() int {
|
||||||
|
max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH()
|
||||||
|
if max < 0 {
|
||||||
|
max = 0
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max].
|
||||||
|
func (m *Model) scrollTaskDetail(delta int) {
|
||||||
|
max := m.taskDetailMaxScroll()
|
||||||
|
off := m.taskDetailScroll + delta
|
||||||
|
if off > max {
|
||||||
|
off = max
|
||||||
|
}
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
m.taskDetailScroll = off
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) tasksModal() string {
|
||||||
|
if m.taskDetail {
|
||||||
|
return m.taskDetailModal()
|
||||||
|
}
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("tasks"))
|
||||||
|
if len(m.taskList) > 0 {
|
||||||
|
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if m.taskListErr != "" {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
if m.taskListLoading && len(m.taskList) == 0 {
|
||||||
|
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
if len(m.taskList) == 0 {
|
||||||
|
b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windowed roster, keeping the selected row in view.
|
||||||
|
bodyH := m.height*70/100 - 6
|
||||||
|
if bodyH < 4 {
|
||||||
|
bodyH = 4
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if len(m.taskList) > bodyH {
|
||||||
|
off = m.taskListIndex - bodyH/2
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
if off > len(m.taskList)-bodyH {
|
||||||
|
off = len(m.taskList) - bodyH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := off + bodyH
|
||||||
|
if end > len(m.taskList) {
|
||||||
|
end = len(m.taskList)
|
||||||
|
}
|
||||||
|
for i := off; i < end; i++ {
|
||||||
|
b.WriteString(m.taskListRow(i) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||||
|
{"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"},
|
||||||
|
}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskListRow renders one board line: marker, id, status, title, and claimant.
|
||||||
|
func (m Model) taskListRow(i int) string {
|
||||||
|
t := m.theme
|
||||||
|
tk := m.taskList[i]
|
||||||
|
|
||||||
|
marker := mbg(t, " ", t.P.BgPanel)
|
||||||
|
idFg := t.P.Fg
|
||||||
|
if i == m.taskListIndex {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||||
|
idFg = t.P.FgStrong
|
||||||
|
}
|
||||||
|
|
||||||
|
idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12))
|
||||||
|
statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
|
||||||
|
Render(padRaw(tk.Status, 12))
|
||||||
|
|
||||||
|
claim := ""
|
||||||
|
if tk.Claimant != "" {
|
||||||
|
claim = " @" + shortSessionID(tk.Claimant)
|
||||||
|
}
|
||||||
|
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - len([]rune(claim))
|
||||||
|
if titleW < 8 {
|
||||||
|
titleW = 8
|
||||||
|
}
|
||||||
|
titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
|
||||||
|
claimCell := mbg(t, claim, t.P.Faint)
|
||||||
|
|
||||||
|
return marker + idCell + " " + statusCell + " " + titleCell + claimCell
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) taskDetailModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
tk, _ := m.selectedTask()
|
||||||
|
|
||||||
|
body := m.taskDetailBody(w - 4)
|
||||||
|
bodyH := m.taskDetailBodyH()
|
||||||
|
off := m.taskDetailScroll
|
||||||
|
if mx := m.taskDetailMaxScroll(); off > mx {
|
||||||
|
off = mx
|
||||||
|
}
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("task " + tk.ID))
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
|
||||||
|
Render(" " + tk.Status))
|
||||||
|
if len(body) > bodyH {
|
||||||
|
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
end := off + bodyH
|
||||||
|
if end > len(body) {
|
||||||
|
end = len(body)
|
||||||
|
}
|
||||||
|
for i := off; i < end; i++ {
|
||||||
|
b.WriteString(body[i] + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance
|
||||||
|
// criteria, affected paths, links, and notes. Built entirely from the list payload, so
|
||||||
|
// it needs no extra fetch.
|
||||||
|
func (m Model) taskDetailBody(width int) []string {
|
||||||
|
t := m.theme
|
||||||
|
tk, ok := m.selectedTask()
|
||||||
|
if !ok {
|
||||||
|
return []string{mbg(t, " (no task selected)", t.P.Faint)}
|
||||||
|
}
|
||||||
|
if width < 8 {
|
||||||
|
width = 8
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
section := func(label string) {
|
||||||
|
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label))
|
||||||
|
}
|
||||||
|
put := func(s string, fg color.Color) {
|
||||||
|
for _, ln := range wrapLines([]string{s}, width) {
|
||||||
|
out = append(out, mbg(t, ln, fg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
put(tk.Title, t.P.FgStrong)
|
||||||
|
if tk.Claimant != "" {
|
||||||
|
out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
|
||||||
|
if tk.Goal != "" {
|
||||||
|
section("goal")
|
||||||
|
put(tk.Goal, t.P.Fg)
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
if len(tk.AcceptanceCriteria) > 0 {
|
||||||
|
section("acceptance criteria")
|
||||||
|
for _, c := range tk.AcceptanceCriteria {
|
||||||
|
put("- "+c, t.P.Fg)
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
if len(tk.AffectedPaths) > 0 {
|
||||||
|
section("affected paths")
|
||||||
|
for _, p := range tk.AffectedPaths {
|
||||||
|
out = append(out, mbg(t, clip(" "+p, width), t.P.Dim))
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
if len(tk.Links) > 0 {
|
||||||
|
section("links")
|
||||||
|
for _, l := range tk.Links {
|
||||||
|
out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg))
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
if len(tk.Notes) > 0 {
|
||||||
|
section("notes")
|
||||||
|
for _, n := range tk.Notes {
|
||||||
|
put("["+n.Author+"] "+n.Body, t.P.Fg)
|
||||||
|
}
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the trailing blank so the body never has a dangling empty line.
|
||||||
|
for len(out) > 0 && out[len(out)-1] == "" {
|
||||||
|
out = out[:len(out)-1]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskStatusColor maps a task status to a palette color for the board / detail header.
|
||||||
|
func taskStatusColor(t Theme, status string) color.Color {
|
||||||
|
switch status {
|
||||||
|
case "DONE":
|
||||||
|
return t.P.OK
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return t.P.Accent
|
||||||
|
case "IN_REVIEW":
|
||||||
|
return t.P.Accent2
|
||||||
|
case "BLOCKED":
|
||||||
|
return t.P.Bad
|
||||||
|
case "CANCELLED":
|
||||||
|
return t.P.Dim
|
||||||
|
default: // TODO
|
||||||
|
return t.P.Faint
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tasksModel() Model {
|
||||||
|
m := NewModel(nil)
|
||||||
|
m.width, m.height = 120, 40
|
||||||
|
m.theme = NewTheme(SoftBlue)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleTasks is a deterministic two-row board used across the tests.
|
||||||
|
func sampleTasks() []TaskSummary {
|
||||||
|
return []TaskSummary{
|
||||||
|
{
|
||||||
|
ID: "auth-1", Key: "auth-1", Status: "IN_PROGRESS", Title: "JWT refresh",
|
||||||
|
Goal: "users stay authenticated", AcceptanceCriteria: []string{"rotates"},
|
||||||
|
Claimant: "aaaaaaaa-1111",
|
||||||
|
Links: []TaskLinkWire{{TargetID: "adr-7", Type: "IMPLEMENTS", TargetKind: "DOC"}},
|
||||||
|
Notes: []TaskNoteWire{{Author: "AGENT", Body: "kickoff"}},
|
||||||
|
},
|
||||||
|
{ID: "billing-3", Key: "billing-3", Status: "DONE", Title: "Invoice export"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasksOverlayToggle(t *testing.T) {
|
||||||
|
m := tasksModel()
|
||||||
|
// `T` opens the board from the idle list.
|
||||||
|
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("T")})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.overlay != OverlayTasks {
|
||||||
|
t.Fatalf("expected OverlayTasks after T, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
if !m.taskListLoading {
|
||||||
|
t.Fatalf("expected loading state right after open")
|
||||||
|
}
|
||||||
|
// `esc` closes it (routed through the overlay key handler).
|
||||||
|
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.overlay != OverlayNone {
|
||||||
|
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasksLoadedPopulatesAndRenders(t *testing.T) {
|
||||||
|
m := tasksModel()
|
||||||
|
m.overlay = OverlayTasks
|
||||||
|
m.taskListLoading = true
|
||||||
|
m.applyTasksLoaded(tasksLoadedMsg{tasks: sampleTasks()})
|
||||||
|
if m.taskListLoading {
|
||||||
|
t.Fatalf("expected loading cleared after load")
|
||||||
|
}
|
||||||
|
if len(m.taskList) != 2 {
|
||||||
|
t.Fatalf("expected 2 tasks, got %d", len(m.taskList))
|
||||||
|
}
|
||||||
|
out := m.tasksModal()
|
||||||
|
if !strings.Contains(out, "auth-1") || !strings.Contains(out, "billing-3") {
|
||||||
|
t.Fatalf("modal missing task ids:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "IN_PROGRESS") || !strings.Contains(out, "DONE") {
|
||||||
|
t.Fatalf("modal missing statuses:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasksEnterOpensDetailWithBundleFields(t *testing.T) {
|
||||||
|
m := tasksModel()
|
||||||
|
m.overlay = OverlayTasks
|
||||||
|
m.taskList = sampleTasks()
|
||||||
|
m.taskListIndex = 0
|
||||||
|
|
||||||
|
updated, _ := m.handleTasksKey(keyMsg{Type: keyEnter})
|
||||||
|
m = updated.(Model)
|
||||||
|
if !m.taskDetail {
|
||||||
|
t.Fatalf("expected detail pane open after enter")
|
||||||
|
}
|
||||||
|
out := m.tasksModal()
|
||||||
|
for _, want := range []string{"users stay authenticated", "rotates", "adr-7", "kickoff"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("detail missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// esc returns to the list, not all the way out.
|
||||||
|
updated, _ = m.handleTasksKey(keyMsg{Type: keyEsc})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.taskDetail {
|
||||||
|
t.Fatalf("expected detail closed after esc")
|
||||||
|
}
|
||||||
|
if m.overlay != OverlayTasks {
|
||||||
|
t.Fatalf("expected to stay on the board after esc from detail, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasksNavigationBoundsChecked(t *testing.T) {
|
||||||
|
m := tasksModel()
|
||||||
|
m.overlay = OverlayTasks
|
||||||
|
m.taskList = sampleTasks()
|
||||||
|
// Up at the top is a no-op (clamped at 0).
|
||||||
|
updated, _ := m.handleTasksKey(keyMsg{Type: keyUp})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.taskListIndex != 0 {
|
||||||
|
t.Fatalf("expected index clamped to 0 at top, got %d", m.taskListIndex)
|
||||||
|
}
|
||||||
|
// Down then clamped at the last row.
|
||||||
|
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
|
||||||
|
m = updated.(Model)
|
||||||
|
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.taskListIndex != 1 {
|
||||||
|
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.taskListIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasksFetchErrorShownNoPanic(t *testing.T) {
|
||||||
|
m := tasksModel()
|
||||||
|
m.overlay = OverlayTasks
|
||||||
|
m.taskListLoading = true
|
||||||
|
m.applyTasksLoaded(tasksLoadedMsg{err: "server returned 503 Service Unavailable"})
|
||||||
|
if m.taskListLoading {
|
||||||
|
t.Fatalf("expected loading cleared on error")
|
||||||
|
}
|
||||||
|
if m.taskListErr == "" {
|
||||||
|
t.Fatalf("expected error recorded")
|
||||||
|
}
|
||||||
|
out := m.tasksModal()
|
||||||
|
if !strings.Contains(out, "503") {
|
||||||
|
t.Fatalf("modal should surface the fetch error:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case tasksLoadedMsg:
|
||||||
|
m.applyTasksLoaded(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
next, cmd := m.handleKey(toKeyMsg(msg))
|
next, cmd := m.handleKey(toKeyMsg(msg))
|
||||||
// A keypress may have entered a typing/active state (insert mode, steering, …);
|
// A keypress may have entered a typing/active state (insert mode, steering, …);
|
||||||
@@ -300,6 +304,9 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Resume browser: recent sessions over HTTP, reachable even after a server
|
// Resume browser: recent sessions over HTTP, reachable even after a server
|
||||||
// restart + TUI reopen (the live stream only carries running sessions).
|
// restart + TUI reopen (the live stream only carries running sessions).
|
||||||
return m, m.openSessions()
|
return m, m.openSessions()
|
||||||
|
case "T":
|
||||||
|
// Task board: all tasks across projects over HTTP (GET /tasks).
|
||||||
|
return m, m.openTasks()
|
||||||
case "S":
|
case "S":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
case "G":
|
case "G":
|
||||||
@@ -641,6 +648,11 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.overlay == OverlaySessions {
|
if m.overlay == OverlaySessions {
|
||||||
return m.handleSessionsKey(k)
|
return m.handleSessionsKey(k)
|
||||||
}
|
}
|
||||||
|
// Tasks overlay owns all its keys: r refresh emits a cmd and enter toggles a detail
|
||||||
|
// pane, so it can't share the generic no-cmd esc path below.
|
||||||
|
if m.overlay == OverlayTasks {
|
||||||
|
return m.handleTasksKey(k)
|
||||||
|
}
|
||||||
if k.Type == keyEsc {
|
if k.Type == keyEsc {
|
||||||
// In the event inspector, esc first stops an in-progress filter edit (keeping the
|
// In the event inspector, esc first stops an in-progress filter edit (keeping the
|
||||||
// query) rather than closing the overlay.
|
// query) rather than closing the overlay.
|
||||||
@@ -1095,6 +1107,7 @@ func paletteCommands() []paletteCmd {
|
|||||||
{"artifacts", "v", "view artifacts", "browse this session's artifacts"},
|
{"artifacts", "v", "view artifacts", "browse this session's artifacts"},
|
||||||
{"stats", "S", "session stats", "metrics for the selected session"},
|
{"stats", "S", "session stats", "metrics for the selected session"},
|
||||||
{"sessions", "R", "resume session", "browse / resume recent sessions"},
|
{"sessions", "R", "resume session", "browse / resume recent sessions"},
|
||||||
|
{"tasks", "T", "task board", "browse tasks across projects"},
|
||||||
{"config", "g", "edit config", "view / change correx settings"},
|
{"config", "g", "edit config", "view / change correx settings"},
|
||||||
{"grants", "G", "grants", "view / revoke standing grants"},
|
{"grants", "G", "grants", "view / revoke standing grants"},
|
||||||
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
{"statusbar", "", "status bar", "show / hide status-bar segments"},
|
||||||
@@ -1141,6 +1154,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
m.openStats()
|
m.openStats()
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return m, m.openSessions()
|
return m, m.openSessions()
|
||||||
|
case "tasks":
|
||||||
|
return m, m.openTasks()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "grants":
|
case "grants":
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ class TaskService(
|
|||||||
fun list(projectId: ProjectId): List<Task> =
|
fun list(projectId: ProjectId): List<Task> =
|
||||||
board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
|
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? {
|
fun getTask(taskId: TaskId): Task? {
|
||||||
val state = board(projectOf(taskId))[taskId] ?: return null
|
val state = board(projectOf(taskId))[taskId] ?: return null
|
||||||
return if (state.deleted) null else Task(taskId, state)
|
return if (state.deleted) null else Task(taskId, state)
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ class TaskServiceTest {
|
|||||||
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
|
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
|
@Test
|
||||||
fun `keys keep incrementing even after deletion`() = runBlocking {
|
fun `keys keep incrementing even after deletion`() = runBlocking {
|
||||||
val first = service.createTask(project, "one", "g").taskId
|
val first = service.createTask(project, "one", "g").taskId
|
||||||
|
|||||||
Reference in New Issue
Block a user