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