feat(tui): show dependency readiness on the task board

Each task row gets a 2-col glyph — ● ready (workable now) / ○ waiting (blocked) /
blank — and the detail pane shows "ready to work" or "blocked — waiting on <ids>",
fed by the new GET /tasks ready/blockedBy fields. Makes a decomposed graph legible
at a glance; a dependency-blocked task is status TODO (not the red BLOCKED lifecycle
state), so the glyph is what distinguishes them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 11:54:24 +00:00
parent f3b3145f36
commit 6f2ee1c654
2 changed files with 46 additions and 2 deletions
+26 -2
View File
@@ -27,6 +27,10 @@ type TaskSummary struct {
Notes []TaskNoteWire `json:"notes"` Notes []TaskNoteWire `json:"notes"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
// Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps);
// BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values.
Ready bool `json:"ready"`
BlockedBy []string `json:"blockedBy"`
} }
type TaskLinkWire struct { type TaskLinkWire struct {
@@ -336,14 +340,29 @@ func (m Model) taskListRow(tasks []TaskSummary, i int) string {
if tk.Claimant != "" { if tk.Claimant != "" {
claim = " @" + shortSessionID(tk.Claimant) claim = " @" + shortSessionID(tk.Claimant)
} }
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - len([]rune(claim)) readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim))
if titleW < 8 { if titleW < 8 {
titleW = 8 titleW = 8
} }
titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg) titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
claimCell := mbg(t, claim, t.P.Faint) claimCell := mbg(t, claim, t.P.Faint)
return marker + idCell + " " + statusCell + " " + titleCell + claimCell return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell
}
// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads
// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it
// is waiting on unfinished blockers, blank for anything already in flight or finished.
func taskReadyCell(t Theme, tk TaskSummary) string {
switch {
case tk.Ready:
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2))
case len(tk.BlockedBy) > 0:
return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2))
default:
return mbg(t, " ", t.P.BgPanel)
}
} }
func (m Model) taskDetailModal() string { func (m Model) taskDetailModal() string {
@@ -407,6 +426,11 @@ func (m Model) taskDetailBody(width int) []string {
if tk.Claimant != "" { if tk.Claimant != "" {
out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint)) out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
} }
if tk.Ready {
out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK))
} else if len(tk.BlockedBy) > 0 {
out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim))
}
out = append(out, "") out = append(out, "")
if tk.Goal != "" { if tk.Goal != "" {
@@ -148,6 +148,26 @@ func TestTasksFilterNarrowsList(t *testing.T) {
} }
} }
func TestTasksReadyAndBlockedSurfaced(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = []TaskSummary{
{ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold app", Ready: true},
{ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth view", BlockedBy: []string{"webui-2"}},
}
// A ready task's detail says so; a blocked task's detail names what it waits on.
m.taskDetail = true
m.taskListIndex = 0
if out := m.tasksModal(); !strings.Contains(out, "ready to work") {
t.Fatalf("expected ready marker in detail:\n%s", out)
}
m.taskListIndex = 1
if out := m.tasksModal(); !strings.Contains(out, "waiting on webui-2") {
t.Fatalf("expected blocked-by detail naming the blocker:\n%s", out)
}
}
func TestTasksFetchErrorShownNoPanic(t *testing.T) { func TestTasksFetchErrorShownNoPanic(t *testing.T) {
m := tasksModel() m := tasksModel()
m.overlay = OverlayTasks m.overlay = OverlayTasks