diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 93c3d1ba..80084996 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -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(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) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 2ae7aeae..a598b99c 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -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) diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 5e8dc0e2..ad3b42cc 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -57,6 +57,7 @@ const ( OverlayStats OverlayIdeas OverlaySessions + OverlayTasks OverlayFiles OverlayStatusbar OverlayGrants @@ -317,6 +318,15 @@ type Model struct { sessionListLoading bool 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 paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index cbe99ef2..c106acc8 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -125,6 +125,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.ideasModal()) case OverlaySessions: return m.center(m.sessionsModal()) + case OverlayTasks: + return m.center(m.tasksModal()) case OverlayFiles: return m.center(m.filesModal()) case OverlayStatusbar: @@ -846,6 +848,7 @@ func (m Model) helpBody() []string { {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, {"S / R", "session stats / resume past sessions"}, + {"T", "task board (across projects)"}, {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go new file mode 100644 index 00000000..b80d111d --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -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 + } +} diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go new file mode 100644 index 00000000..b656ec27 --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 83e75465..8b31b9e4 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -116,6 +116,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tasksLoadedMsg: + m.applyTasksLoaded(msg) + return m, nil + case tea.KeyPressMsg: next, cmd := m.handleKey(toKeyMsg(msg)) // 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 // restart + TUI reopen (the live stream only carries running sessions). return m, m.openSessions() + case "T": + // Task board: all tasks across projects over HTTP (GET /tasks). + return m, m.openTasks() case "S": m.openStats() case "G": @@ -641,6 +648,11 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlaySessions { 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 { // In the event inspector, esc first stops an in-progress filter edit (keeping the // query) rather than closing the overlay. @@ -1095,6 +1107,7 @@ func paletteCommands() []paletteCmd { {"artifacts", "v", "view artifacts", "browse this session's artifacts"}, {"stats", "S", "session stats", "metrics for the selected session"}, {"sessions", "R", "resume session", "browse / resume recent sessions"}, + {"tasks", "T", "task board", "browse tasks across projects"}, {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, @@ -1141,6 +1154,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.openStats() case "sessions": return m, m.openSessions() + case "tasks": + return m, m.openTasks() case "config": m.openConfig() case "grants": diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 04c9566e..7083c07a 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -55,6 +55,16 @@ class TaskService( fun list(projectId: ProjectId): List = 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 = + 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) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index 83bc0213..469692ef 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -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