diff --git a/internal/db/dependencies.go b/internal/db/dependencies.go index f8706b54..636e1348 100644 --- a/internal/db/dependencies.go +++ b/internal/db/dependencies.go @@ -111,7 +111,7 @@ func (db *DB) GetBlockers(taskID int64) ([]*Task, error) { COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), COALESCE(t.dangerous_mode, 0), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, - t.last_distilled_at + t.last_distilled_at, t.last_accessed_at FROM tasks t JOIN task_dependencies d ON t.id = d.blocker_id WHERE d.blocked_id = ? @@ -135,7 +135,7 @@ func (db *DB) GetBlockedBy(taskID int64) ([]*Task, error) { COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), COALESCE(t.dangerous_mode, 0), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, - t.last_distilled_at + t.last_distilled_at, t.last_accessed_at FROM tasks t JOIN task_dependencies d ON t.id = d.blocked_id WHERE d.blocker_id = ? @@ -309,7 +309,7 @@ func scanTaskRows(rows *sql.Rows) ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 531f3ac6..32a56e34 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -242,6 +242,8 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN shell_pane_id TEXT DEFAULT ''`, // tmux pane ID for shell pane (e.g., "%1235") // Auto-generated project context for caching exploration results `ALTER TABLE projects ADD COLUMN context TEXT DEFAULT ''`, // Auto-generated project context (codebase summary, patterns, etc.) + // Last accessed timestamp for tracking recently visited tasks in command palette + `ALTER TABLE tasks ADD COLUMN last_accessed_at DATETIME`, // When task was last accessed/opened in UI } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 798848a6..12733257 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -38,6 +38,8 @@ type Task struct { CompletedAt *LocalTime // Distillation tracking LastDistilledAt *LocalTime // When task was last distilled for learnings + // UI tracking + LastAccessedAt *LocalTime // When task was last accessed/opened in the UI } // Task statuses @@ -167,7 +169,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE id = ? `, id).Scan( &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, @@ -176,7 +178,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -207,7 +209,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE 1=1 ` args := []interface{}{} @@ -259,7 +261,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -282,7 +284,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks ORDER BY created_at DESC, id DESC LIMIT 1 @@ -293,7 +295,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -320,7 +322,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE ( title LIKE ? COLLATE NOCASE @@ -350,7 +352,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -564,6 +566,20 @@ func (db *DB) UpdateTaskPaneIDs(taskID int64, claudePaneID, shellPaneID string) return nil } +// UpdateTaskLastAccessedAt updates the last_accessed_at timestamp for a task. +// This is used to track when a task was last accessed/opened in the UI, +// enabling the command palette to show recently visited tasks first. +func (db *DB) UpdateTaskLastAccessedAt(taskID int64) error { + _, err := db.Exec(` + UPDATE tasks SET last_accessed_at = CURRENT_TIMESTAMP + WHERE id = ? + `, taskID) + if err != nil { + return fmt.Errorf("update task last accessed at: %w", err) + } + return nil +} + // DeleteTask deletes a task. func (db *DB) DeleteTask(id int64) error { // Get task before deleting for event emission @@ -655,7 +671,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -667,7 +683,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -688,7 +704,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -708,7 +724,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } @@ -728,7 +744,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, last_accessed_at FROM tasks WHERE branch_name != '' AND status NOT IN (?, ?) ORDER BY created_at DESC @@ -748,7 +764,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.LastAccessedAt, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 1d9deed6..55c7117c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2748,6 +2748,12 @@ func (m *AppModel) loadTaskWithOptions(id int64, focusExecutor bool) tea.Cmd { go m.executor.CheckPRStateAndUpdateTask(id) } + // Update last accessed timestamp (async, don't block UI) + if m.db != nil { + database := m.db + go database.UpdateTaskLastAccessedAt(id) + } + return func() tea.Msg { task, err := m.db.GetTask(id) return taskLoadedMsg{task: task, err: err, focusExecutor: focusExecutor} diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index a8f350ca..db31d94d 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -143,12 +143,35 @@ func statusPriority(status string) int { // filterTasks filters tasks based on the search query using fuzzy matching. // Results are sorted by match score, with best matches first. +// When no query is provided, tasks are sorted by recency (last accessed first). // When a query is provided, it also searches the database to find older/done tasks. func (m *CommandPaletteModel) filterTasks() { query := strings.TrimSpace(m.searchInput.Value()) if query == "" { - m.filteredTasks = m.allTasks + // No query - sort by last accessed time (most recent first) + // Make a copy to avoid modifying the original slice + m.filteredTasks = make([]*db.Task, len(m.allTasks)) + copy(m.filteredTasks, m.allTasks) + sort.Slice(m.filteredTasks, func(i, j int) bool { + // Tasks with LastAccessedAt set come before those without + iAccessed := m.filteredTasks[i].LastAccessedAt + jAccessed := m.filteredTasks[j].LastAccessedAt + + // If both have been accessed, sort by most recent first + if iAccessed != nil && jAccessed != nil { + return iAccessed.Time.After(jAccessed.Time) + } + // Tasks that have been accessed come before those that haven't + if iAccessed != nil && jAccessed == nil { + return true + } + if iAccessed == nil && jAccessed != nil { + return false + } + // Neither has been accessed - fall back to created_at (newest first) + return m.filteredTasks[i].CreatedAt.Time.After(m.filteredTasks[j].CreatedAt.Time) + }) } else { queryLower := strings.ToLower(query) diff --git a/internal/ui/command_palette_test.go b/internal/ui/command_palette_test.go index b460d828..854eeeaa 100644 --- a/internal/ui/command_palette_test.go +++ b/internal/ui/command_palette_test.go @@ -2,6 +2,7 @@ package ui import ( "testing" + "time" "github.com/bborn/workflow/internal/db" ) @@ -372,3 +373,114 @@ func TestMatchesQueryPRSearch(t *testing.T) { }) } } + +func TestFilterTasksSortsByLastAccessedWhenNoQuery(t *testing.T) { + // Create test tasks with different last_accessed_at times + now := time.Now() + oldAccess := db.LocalTime{Time: now.Add(-2 * time.Hour)} + recentAccess := db.LocalTime{Time: now.Add(-1 * time.Hour)} + mostRecentAccess := db.LocalTime{Time: now.Add(-30 * time.Minute)} + + tasks := []*db.Task{ + {ID: 1, Title: "Old task", CreatedAt: db.LocalTime{Time: now.Add(-3 * time.Hour)}, LastAccessedAt: &oldAccess}, + {ID: 2, Title: "Recent task", CreatedAt: db.LocalTime{Time: now.Add(-2 * time.Hour)}, LastAccessedAt: &recentAccess}, + {ID: 3, Title: "Most recent task", CreatedAt: db.LocalTime{Time: now.Add(-1 * time.Hour)}, LastAccessedAt: &mostRecentAccess}, + {ID: 4, Title: "Never accessed", CreatedAt: db.LocalTime{Time: now.Add(-30 * time.Minute)}, LastAccessedAt: nil}, + } + + m := &CommandPaletteModel{ + allTasks: tasks, + } + // Empty query - should sort by last_accessed_at + m.searchInput.SetValue("") + m.filterTasks() + + if len(m.filteredTasks) != 4 { + t.Fatalf("Expected 4 tasks, got %d", len(m.filteredTasks)) + } + + // First should be most recently accessed (ID 3) + if m.filteredTasks[0].ID != 3 { + t.Errorf("First task should be ID 3 (most recently accessed), got ID %d", m.filteredTasks[0].ID) + } + + // Second should be recently accessed (ID 2) + if m.filteredTasks[1].ID != 2 { + t.Errorf("Second task should be ID 2 (recently accessed), got ID %d", m.filteredTasks[1].ID) + } + + // Third should be old accessed (ID 1) + if m.filteredTasks[2].ID != 1 { + t.Errorf("Third task should be ID 1 (old accessed), got ID %d", m.filteredTasks[2].ID) + } + + // Fourth should be never accessed (ID 4) - uses created_at as fallback + if m.filteredTasks[3].ID != 4 { + t.Errorf("Fourth task should be ID 4 (never accessed), got ID %d", m.filteredTasks[3].ID) + } +} + +func TestFilterTasksNeverAccessedSortsByCreatedAt(t *testing.T) { + // Test that tasks that have never been accessed are sorted by created_at + now := time.Now() + + tasks := []*db.Task{ + {ID: 1, Title: "Oldest", CreatedAt: db.LocalTime{Time: now.Add(-3 * time.Hour)}, LastAccessedAt: nil}, + {ID: 2, Title: "Middle", CreatedAt: db.LocalTime{Time: now.Add(-2 * time.Hour)}, LastAccessedAt: nil}, + {ID: 3, Title: "Newest", CreatedAt: db.LocalTime{Time: now.Add(-1 * time.Hour)}, LastAccessedAt: nil}, + } + + m := &CommandPaletteModel{ + allTasks: tasks, + } + // Empty query - should sort by created_at (newest first) when no access times + m.searchInput.SetValue("") + m.filterTasks() + + if len(m.filteredTasks) != 3 { + t.Fatalf("Expected 3 tasks, got %d", len(m.filteredTasks)) + } + + // Should be sorted by created_at descending (newest first) + if m.filteredTasks[0].ID != 3 { + t.Errorf("First task should be ID 3 (newest created), got ID %d", m.filteredTasks[0].ID) + } + if m.filteredTasks[1].ID != 2 { + t.Errorf("Second task should be ID 2 (middle created), got ID %d", m.filteredTasks[1].ID) + } + if m.filteredTasks[2].ID != 1 { + t.Errorf("Third task should be ID 1 (oldest created), got ID %d", m.filteredTasks[2].ID) + } +} + +func TestFilterTasksAccessedBeforeNeverAccessed(t *testing.T) { + // Test that accessed tasks always come before never-accessed tasks + now := time.Now() + oldAccess := db.LocalTime{Time: now.Add(-24 * time.Hour)} // Accessed long ago + + tasks := []*db.Task{ + // Never accessed but created very recently + {ID: 1, Title: "Never accessed new", CreatedAt: db.LocalTime{Time: now.Add(-1 * time.Minute)}, LastAccessedAt: nil}, + // Accessed long ago + {ID: 2, Title: "Accessed old", CreatedAt: db.LocalTime{Time: now.Add(-48 * time.Hour)}, LastAccessedAt: &oldAccess}, + } + + m := &CommandPaletteModel{ + allTasks: tasks, + } + // Empty query + m.searchInput.SetValue("") + m.filterTasks() + + if len(m.filteredTasks) != 2 { + t.Fatalf("Expected 2 tasks, got %d", len(m.filteredTasks)) + } + + // Accessed task (even if old) should come before never-accessed task + if m.filteredTasks[0].ID != 2 { + t.Errorf("First task should be ID 2 (has been accessed), got ID %d", m.filteredTasks[0].ID) + } + if m.filteredTasks[1].ID != 1 { + t.Errorf("Second task should be ID 1 (never accessed), got ID %d", m.filteredTasks[1].ID) + } +}