Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to td are documented in this file.

## [Unreleased]

### Monitor

- **Empty panes name the next step** (td-70d392, td-219916). Current Work, Board, and Activity Log leave a blank line under the header and indent empty copy to the title. Current Work tells you to start a task with td. Board distinguishes a database with no issues from a filter that matches nothing. Embedded Sidecar adds `Next: Press [3] for Workspaces…` only when there are no tasks yet; standalone `td monitor` never mentions Sidecar tabs.
- **Getting Started title and subtitle sit together as one heading** (td-1495cc). Install guidance sits above the buttons; `?` / `H` hints sit below.
- **Note create/update events log as "created note" / "updated note"** (td-545473) instead of "created issue" / "updated issue".

## [v0.61.0] - 2026-08-20

### Monitor
Expand Down
27 changes: 23 additions & 4 deletions pkg/monitor/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ func FetchDataWithSearchMode(database *db.DB, sessionID string, startedAt time.T
}
}

if live, err := database.ListIssues(db.ListIssuesOptions{Limit: 1}); err == nil {
msg.HasIssues = len(live) > 0
}

// Get in-progress issues
inProgress, _ := database.ListIssues(db.ListIssuesOptions{
Status: []models.Status{models.StatusInProgress},
Expand Down Expand Up @@ -590,17 +594,32 @@ func fetchRecentHandoffs(database *db.DB, since time.Time) []RecentHandoff {
return result
}

func isNoteAction(action models.ActionLog) bool {
et := strings.ToLower(strings.TrimSpace(action.EntityType))
if et == "note" || et == "notes" {
return true
}
return strings.HasPrefix(strings.ToLower(action.EntityID), "nt-")
}

func actionEntityNoun(action models.ActionLog) string {
if isNoteAction(action) {
return "note"
}
return "issue"
}

// formatActionMessage creates a human-readable message for an action
func formatActionMessage(action models.ActionLog) string {
switch action.ActionType {
case models.ActionCreate:
return "created issue"
return "created " + actionEntityNoun(action)
case models.ActionUpdate:
return "updated issue"
return "updated " + actionEntityNoun(action)
case models.ActionDelete:
return "deleted issue"
return "deleted " + actionEntityNoun(action)
case models.ActionRestore:
return "restored issue"
return "restored " + actionEntityNoun(action)
case models.ActionStart:
return "started work"
case models.ActionReview:
Expand Down
71 changes: 71 additions & 0 deletions pkg/monitor/data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,74 @@ func TestComputeBoardIssueCategoriesClosedDepUnblocks(t *testing.T) {
t.Errorf("dependent with closed blocker: got %q, want %q", issues[0].Category, CategoryReady)
}
}

func TestFormatActionMessageNoteVsIssue(t *testing.T) {
tests := []struct {
name string
act models.ActionLog
want string
}{
{
name: "create issue",
act: models.ActionLog{ActionType: models.ActionCreate, EntityType: "issue", EntityID: "td-abc"},
want: "created issue",
},
{
name: "update issue",
act: models.ActionLog{ActionType: models.ActionUpdate, EntityType: "issue", EntityID: "td-abc"},
want: "updated issue",
},
{
name: "create note by entity type",
act: models.ActionLog{ActionType: models.ActionCreate, EntityType: "note", EntityID: "nt-abc123"},
want: "created note",
},
{
name: "update note by entity type",
act: models.ActionLog{ActionType: models.ActionUpdate, EntityType: "note", EntityID: "nt-abc123"},
want: "updated note",
},
{
name: "create note by nt- id",
act: models.ActionLog{ActionType: models.ActionCreate, EntityType: "", EntityID: "nt-ffffff"},
want: "created note",
},
{
name: "started work stays generic",
act: models.ActionLog{ActionType: models.ActionStart, EntityType: "issue", EntityID: "td-abc"},
want: "started work",
},
{
name: "approve stays generic",
act: models.ActionLog{ActionType: models.ActionApprove, EntityType: "issue", EntityID: "td-abc"},
want: "approved",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatActionMessage(tt.act); got != tt.want {
t.Fatalf("formatActionMessage() = %q, want %q", got, tt.want)
}
})
}
}

func TestFetchDataHasIssues(t *testing.T) {
baseDir := t.TempDir()
database, err := db.Initialize(baseDir)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
defer database.Close()

empty := FetchData(database, "test-session", time.Now(), "", false, SortByPriority)
if empty.HasIssues {
t.Fatal("empty database reported HasIssues=true")
}

createTestIssue(t, database, "first", models.StatusOpen)
withIssue := FetchData(database, "test-session", time.Now(), "", false, SortByPriority)
if !withIssue.HasIssues {
t.Fatal("database with an issue reported HasIssues=false")
}
}
195 changes: 195 additions & 0 deletions pkg/monitor/empty_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package monitor

import (
"strings"
"testing"
"unicode"

"github.com/charmbracelet/x/ansi"
"github.com/marcus/td/internal/models"
)

func contentRows(rendered string) []string {
var rows []string
for _, line := range strings.Split(rendered, "\n") {
plain := ansi.Strip(line)
if strings.Contains(plain, "│") {
rows = append(rows, plain)
}
}
return rows
}

func interior(plain string) string {
runes := []rune(plain)
start, end := -1, -1
for i, r := range runes {
if r == '│' {
if start < 0 {
start = i
}
end = i
}
}
if start < 0 || end <= start {
return ""
}
return string(runes[start+1 : end])
}

func firstLetterCol(plain string) int {
for i, r := range []rune(plain) {
if unicode.IsLetter(r) {
return i
}
}
return -1
}

func assertEmptyStateLayout(t *testing.T, rendered, titleNeedle, bodyNeedle string) {
t.Helper()
rows := contentRows(rendered)
if len(rows) < 3 {
t.Fatalf("want at least 3 inner rows, got %d in %q", len(rows), ansi.Strip(rendered))
}
if !strings.Contains(rows[0], titleNeedle) {
t.Fatalf("title row %q does not contain %q", rows[0], titleNeedle)
}
if strings.TrimSpace(interior(rows[1])) != "" {
t.Fatalf("expected blank line below header, got %q", rows[1])
}
bodyIdx := -1
for i := 2; i < len(rows); i++ {
if strings.Contains(rows[i], bodyNeedle) {
bodyIdx = i
break
}
}
if bodyIdx < 0 {
t.Fatalf("body %q not found in %q", bodyNeedle, strings.Join(rows, "\n"))
}
titleCol := firstLetterCol(rows[0])
bodyCol := firstLetterCol(rows[bodyIdx])
if titleCol < 0 || bodyCol < 0 {
t.Fatalf("missing title/body letters: title=%d body=%d", titleCol, bodyCol)
}
if titleCol != bodyCol {
t.Fatalf("empty-state text col %d does not align with title col %d\ntitle: %q\nbody: %q",
bodyCol, titleCol, rows[0], rows[bodyIdx])
}
}

func TestEmptyStateCurrentWorkCopyAndAlignment(t *testing.T) {
m := newTestModel()
m.Width = 80

out := m.renderCurrentWorkPanel(10)
if strings.Contains(out, "No current work") {
t.Fatal("legacy 'No current work' still rendered")
}
assertEmptyStateLayout(t, out, "CURRENT WORK", "Ask an agent")
if strings.Contains(ansi.Strip(out), "Workspaces") {
t.Fatal("standalone monitor must not mention Sidecar Workspaces")
}

m.Embedded = true
m.HasIssues = false
embedded := m.renderCurrentWorkPanel(12)
plain := ansi.Strip(embedded)
if !strings.Contains(plain, "Press [3] for Workspaces") {
t.Fatalf("embedded empty current work missing next-step, got %q", plain)
}
assertEmptyStateLayout(t, embedded, "CURRENT WORK", "Ask an agent")

m.HasIssues = true
withTasks := m.renderCurrentWorkPanel(12)
if strings.Contains(ansi.Strip(withTasks), "Workspaces") {
t.Fatal("embedded empty current work must not pitch Workspaces when tasks already exist")
}
if !strings.Contains(ansi.Strip(withTasks), "Ask an agent") {
t.Fatal("empty current work copy should remain when tasks exist but nothing is in progress")
}
}

func TestEmptyStateActivityCopyAndAlignment(t *testing.T) {
m := newTestModel()
m.Width = 80
out := m.renderActivityPanel(8)
assertEmptyStateLayout(t, out, "ACTIVITY LOG", "No recent activity")
if strings.Contains(ansi.Strip(out), "Workspaces") {
t.Fatal("activity empty state must not mention Workspaces")
}

m.Embedded = true
embedded := m.renderActivityPanel(8)
if strings.Contains(ansi.Strip(embedded), "Workspaces") {
t.Fatal("activity empty state must not mention Workspaces even when embedded")
}
}

func TestEmptyStateBoardZeroIssuesVsFiltered(t *testing.T) {
m := newTestModel()
m.Width = 90
m.BoardMode.Board = &models.Board{Name: "Main"}

zero := m.renderTaskListBoardView(12)
if !strings.Contains(ansi.Strip(zero), emptyBoardNoTasksMsg) {
t.Fatalf("zero-issue board missing no-tasks copy: %q", ansi.Strip(zero))
}
if strings.Contains(zero, "No issues match the board query") {
t.Fatal("zero-issue board used filter-mismatch copy")
}
assertEmptyStateLayout(t, zero, "BOARD", "No tasks yet")
if strings.Contains(ansi.Strip(zero), "Workspaces") {
t.Fatal("standalone board empty state mentioned Workspaces")
}

m.HasIssues = true
filtered := m.renderTaskListBoardView(12)
plain := ansi.Strip(filtered)
if !strings.Contains(plain, emptyBoardFilteredMsg) {
t.Fatalf("filtered board missing mismatch copy: %q", plain)
}
if strings.Contains(plain, emptyBoardNoTasksMsg) {
t.Fatal("filtered board used zero-issue copy")
}
assertEmptyStateLayout(t, filtered, "BOARD", "No issues match")

m.HasIssues = false
m.Embedded = true
embedded := m.renderTaskListBoardView(14)
if !strings.Contains(ansi.Strip(embedded), "Press [3] for Workspaces") {
t.Fatalf("embedded zero-issue board missing next-step: %q", ansi.Strip(embedded))
}

m.HasIssues = true
embeddedFiltered := m.renderTaskListBoardView(14)
if strings.Contains(ansi.Strip(embeddedFiltered), "Workspaces") {
t.Fatal("filter-mismatch board must not show workspace next-step")
}
}

func TestEmptyStateBoardSwimlanesMatchesBacklog(t *testing.T) {
m := newTestModel()
m.Width = 90
m.BoardMode.Board = &models.Board{Name: "Main"}

zero := m.renderBoardSwimlanesView(12)
assertEmptyStateLayout(t, zero, "BOARD", "No tasks yet")

m.HasIssues = true
filtered := m.renderBoardSwimlanesView(12)
assertEmptyStateLayout(t, filtered, "BOARD", "No issues match")
}

func TestEmptyStateEmbeddedNextStepDisappearsWhenWorkExists(t *testing.T) {
m := newTestModel()
m.Width = 90
m.Embedded = true
m.CurrentWorkRows = []string{"td-abc"}
m.FocusedIssue = &models.Issue{ID: "td-abc", Title: "Doing it", Status: models.StatusInProgress, Type: models.TypeTask, Priority: models.PriorityP2}
out := m.renderCurrentWorkPanel(10)
if strings.Contains(ansi.Strip(out), "Workspaces") {
t.Fatal("next-step must disappear once current work exists")
}
}
24 changes: 15 additions & 9 deletions pkg/monitor/getting_started.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ func (m *Model) createGettingStartedModal() *modal.Modal {
fileName = filepath.Base(m.AgentFilePath)
}

md := m.newModal("Welcome to td!", ModalTypeHelp, modal.WithWidth(60), modal.WithHints(false))
md := m.newModal("", ModalTypeHelp, modal.WithWidth(60), modal.WithHints(false))

md.AddSection(modal.Text("Task management for AI agents."))
// Centered title and subtitle with no blank line in between
md.AddSection(modal.CenteredTitle("Welcome to td!"))
md.AddSection(modal.CenteredMuted("Task management for AI agents."))
md.AddSection(modal.Spacer())

// Agent prompt guidance
md.AddSection(modal.Text("To use td, just prompt your agent:"))
md.AddSection(modal.Text(`"Use td to plan my feature and implement it."`))
md.AddSection(modal.Spacer())

// Guidance install instruction right above the buttons
if m.AgentFileTDNeedsUpdate {
md.AddSection(modal.Text("Updated td guidance is available for " + fileName))
} else if m.AgentFileHasTD {
Expand All @@ -30,13 +38,7 @@ func (m *Model) createGettingStartedModal() *modal.Modal {
}
md.AddSection(modal.Spacer())

md.AddSection(modal.Text("PROMPT: \"Use td to plan my feature and implement it.\""))
md.AddSection(modal.Spacer())

md.AddSection(modal.Text("Press ? for help · H to reopen this modal"))
md.AddSection(modal.Spacer())

// Only show Install button if not already installed
// Action buttons
if m.AgentFileHasTD && !m.AgentFileTDNeedsUpdate {
md.AddSection(modal.Buttons(
modal.Btn(" Close ", "close"),
Expand All @@ -51,6 +53,10 @@ func (m *Model) createGettingStartedModal() *modal.Modal {
modal.Btn(" Close ", "close"),
))
}
md.AddSection(modal.Spacer())

// Help and reopen hints below the buttons
md.AddSection(modal.CenteredMuted("Press ? for help · H to reopen this modal"))

return md
}
Loading