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: 3 additions & 3 deletions internal/db/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?
Expand All @@ -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 = ?
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 30 additions & 14 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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{}{}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
25 changes: 24 additions & 1 deletion internal/ui/command_palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
112 changes: 112 additions & 0 deletions internal/ui/command_palette_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ui

import (
"testing"
"time"

"github.com/bborn/workflow/internal/db"
)
Expand Down Expand Up @@ -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)
}
}