diff --git a/internal/ui/app.go b/internal/ui/app.go index 3761616..02a8511 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) @@ -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 @@ -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 { @@ -2606,6 +2608,16 @@ 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) @@ -2613,7 +2625,7 @@ func (m *AppModel) loadTask(id int64) tea.Cmd { return func() tea.Msg { task, err := m.db.GetTask(id) - return taskLoadedMsg{task: task, err: err} + return taskLoadedMsg{task: task, err: err, focusExecutor: focusExecutor} } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 804e107..8479d84 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -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") + } +} diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 9b11f68..182476d 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -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 @@ -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 @@ -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 @@ -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.