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
22 changes: 17 additions & 5 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.executor.ResumeTask(msg.task.ID)
}
var initCmd tea.Cmd
m.detailView, initCmd = NewDetailModel(msg.task, m.db, m.executor, m.width, m.height)
m.detailView, initCmd = NewDetailModel(msg.task, m.db, m.executor, m.width, m.height, msg.focusExecutor)
// Set task position in column for display
pos, total := m.kanban.GetTaskPosition()
m.detailView.SetPosition(pos, total)
Expand Down Expand Up @@ -1140,7 +1140,8 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Clear notification after jumping
m.notification = ""
m.notifyTaskID = 0
return m, m.loadTask(taskID)
// Use loadTaskWithFocus to automatically focus the executor pane
return m, m.loadTaskWithFocus(taskID)
}
return m, nil

Expand Down Expand Up @@ -2504,8 +2505,9 @@ type tasksLoadedMsg struct {
}

type taskLoadedMsg struct {
task *db.Task
err error
task *db.Task
err error
focusExecutor bool // Focus executor pane after entering detail view (e.g., from notification jump)
}

type taskCreatedMsg struct {
Expand Down Expand Up @@ -2606,14 +2608,24 @@ func (m *AppModel) loadTasks() tea.Cmd {
}

func (m *AppModel) loadTask(id int64) tea.Cmd {
return m.loadTaskWithOptions(id, false)
}

// loadTaskWithFocus loads a task and focuses the executor pane when entering detail view.
// Used when jumping to a task from a notification.
func (m *AppModel) loadTaskWithFocus(id int64) tea.Cmd {
return m.loadTaskWithOptions(id, true)
}

func (m *AppModel) loadTaskWithOptions(id int64, focusExecutor bool) tea.Cmd {
// Check PR state asynchronously (don't block UI)
if m.executor != nil {
go m.executor.CheckPRStateAndUpdateTask(id)
}

return func() tea.Msg {
task, err := m.db.GetTask(id)
return taskLoadedMsg{task: task, err: err}
return taskLoadedMsg{task: task, err: err, focusExecutor: focusExecutor}
}
}

Expand Down
56 changes: 56 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,3 +670,59 @@ func TestJumpToNotificationKey_DetailView_NoNotification(t *testing.T) {
t.Error("expected nil command when no notification is active")
}
}

func TestJumpToNotificationKey_FocusExecutor(t *testing.T) {
// Create app model with kanban board and notification
tasks := []*db.Task{
{ID: 1, Title: "Task 1", Status: db.StatusBacklog},
{ID: 2, Title: "Task 2", Status: db.StatusBlocked},
}

// Create a mock database for the loadTask call
mockDB, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer mockDB.Close()

// Insert test task
testTask := &db.Task{ID: 2, Title: "Task 2", Status: db.StatusBlocked}
if err := mockDB.CreateTask(testTask); err != nil {
t.Fatalf("Failed to create test task: %v", err)
}

m := &AppModel{
width: 100,
height: 50,
currentView: ViewDashboard,
keys: DefaultKeyMap(),
notification: "⚠ Task #2 needs input: Task 2 (g to jump)",
notifyTaskID: 2,
kanban: NewKanbanBoard(100, 50),
db: mockDB,
}
m.kanban.SetTasks(tasks)
m.kanban.SelectTask(1) // Start with task 1 selected

// Press 'g' to jump to notification
gMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}
_, cmd := m.updateDashboard(gMsg)

// Verify command was returned
if cmd == nil {
t.Fatal("expected command to be returned")
}

// Execute the command to get the message
msg := cmd()

// Verify the message has focusExecutor set to true
loadedMsg, ok := msg.(taskLoadedMsg)
if !ok {
t.Fatalf("expected taskLoadedMsg, got %T", msg)
}

if !loadedMsg.focusExecutor {
t.Error("expected focusExecutor to be true when jumping from notification")
}
}
47 changes: 39 additions & 8 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ type DetailModel struct {
notification string // current notification message
notifyTaskID int64 // task that triggered the notification
notifyUntil time.Time

// Focus executor pane after joining (e.g., when jumping from notification)
focusExecutorOnJoin bool
}

// Message types for async pane loading
Expand Down Expand Up @@ -253,19 +256,21 @@ func (m *DetailModel) ClaudePaneID() string {

// NewDetailModel creates a new detail model.
// Returns the model and an optional command for async pane setup.
func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, height int) (*DetailModel, tea.Cmd) {
// If focusExecutor is true, the executor pane will be focused after panes are joined.
func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, height int, focusExecutor bool) (*DetailModel, tea.Cmd) {
log := GetLogger()
log.Info("NewDetailModel: creating for task %d (%s)", t.ID, t.Title)
log.Info("NewDetailModel: creating for task %d (%s), focusExecutor=%v", t.ID, t.Title, focusExecutor)
log.Debug("NewDetailModel: TMUX env=%q, DaemonSession=%q, ClaudeSessionID=%q",
os.Getenv("TMUX"), t.DaemonSession, t.ClaudeSessionID)

m := &DetailModel{
task: t,
database: database,
executor: exec,
width: width,
height: height,
focused: true, // Initially focused when viewing details
task: t,
database: database,
executor: exec,
width: width,
height: height,
focused: true, // Initially focused when viewing details
focusExecutorOnJoin: focusExecutor,
}

// Load logs
Expand Down Expand Up @@ -440,6 +445,10 @@ func (m *DetailModel) Update(msg tea.Msg) (*DetailModel, tea.Cmd) {
m.daemonSessionID = msg.daemonSessionID
m.cachedWindowTarget = msg.windowTarget
m.paneError = ""
// Focus executor pane if requested (e.g., when jumping from notification)
if m.focusExecutorOnJoin && m.claudePaneID != "" {
m.focusExecutorPane()
}
}
m.viewport.SetContent(m.renderContent())
return m, nil
Expand Down Expand Up @@ -1285,6 +1294,28 @@ func (m *DetailModel) joinTmuxPanes() {

log.Info("joinTmuxPanes: completed for task %d, claudePaneID=%q, workdirPaneID=%q, tuiPaneID=%q",
m.task.ID, m.claudePaneID, m.workdirPaneID, m.tuiPaneID)

// Focus executor pane if requested (e.g., when jumping from notification)
if m.focusExecutorOnJoin && m.claudePaneID != "" {
m.focusExecutorPane()
}
}

// focusExecutorPane focuses the executor (Claude) pane.
func (m *DetailModel) focusExecutorPane() {
if m.claudePaneID == "" {
return
}
log := GetLogger()
log.Info("focusExecutorPane: focusing Claude pane %q", m.claudePaneID)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "tmux", "select-pane", "-t", m.claudePaneID).Run()
if err != nil {
log.Error("focusExecutorPane: select-pane failed: %v", err)
} else {
m.focused = false // TUI is no longer focused, executor is
}
}

// joinTmuxPane is a compatibility wrapper for joinTmuxPanes.
Expand Down