From 58991ef7f7b2022cf46103f0f4054ed6688f070b Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Mon, 11 May 2026 15:49:53 +0900 Subject: [PATCH 1/2] fix: show task conversation in flat-mode preview Selecting a task in the flat conversation view previously rendered only a brief Task summary on the right. Use the same rich preview path as tree mode so the actual task conversation entries are shown. --- internal/tui/conversation.go | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 5a6f1d3..30f3386 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -789,13 +789,10 @@ func (a *App) updateConvPreview() { a.setConvPreviewText(renderTaskMarkerPreview(item, pw)) return } - if a.conv.leftPaneMode == convPaneTree && item.bgTaskID != "" { + if item.bgTaskID != "" { entry = a.buildBgJobPreviewEntry(item.bgTaskID) - } else if a.conv.leftPaneMode == convPaneTree { - entry = a.buildTaskPreviewEntry(item.task) } else { - a.setConvPreviewText(renderTaskSummary(item.task, pw)) - return + entry = a.buildTaskPreviewEntry(item.task) } } @@ -1797,28 +1794,6 @@ func renderTaskMarkerPreview(item convItem, width int) string { return sb.String() } -// renderTaskSummary renders a summary for a task in the preview pane. -func renderTaskSummary(task session.TaskItem, width int) string { - var sb strings.Builder - status := "○ pending" - switch task.Status { - case "completed": - status = "✓ completed" - case "in_progress": - status = "◉ in progress" - } - sb.WriteString(taskBadgeStyle.Render("Task: "+task.ID) + " " + status + "\n") - sb.WriteString("\n" + task.Subject + "\n") - if task.Description != "" { - sb.WriteString("\n" + dimStyle.Render("Description:") + "\n") - sb.WriteString(wrapText(task.Description, width-2) + "\n") - } - if len(task.BlockedBy) > 0 { - sb.WriteString("\n" + dimStyle.Render("Blocked by: ") + strings.Join(task.BlockedBy, ", ") + "\n") - } - return sb.String() -} - // findTaskAgents returns all subagents referenced by Agent tool_use blocks // in the conversation, resolved via the toolUseToAgent map. func (a *App) findTaskAgents() []session.Subagent { From 0837882b0f5c830e1e5271ab6803ecf4ed657c23 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Mon, 11 May 2026 16:02:32 +0900 Subject: [PATCH 2/2] fix: scope task preview to the selected task ID The flat-mode task preview was rendering identical entries for every task because the fallback path in extractTaskEntries used strings.Contains over the raw ToolInput/Text JSON. With ordinal task IDs ("1", "2", ... "10"), the substring check matched every TaskCreate/TaskUpdate that happened to contain the digit. - Compare the taskId JSON field exactly instead of substring matching. - For pending tasks (no in_progress range), surface the originating TaskCreate via TaskCreate ordinal (the in-memory loader assigns IDs in creation order). --- internal/tui/conversation.go | 59 +++++++++++++++--- internal/tui/extract_task_entries_test.go | 75 +++++++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 internal/tui/extract_task_entries_test.go diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 30f3386..50cdf31 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -7,6 +7,7 @@ import ( "log" "os" "sort" + "strconv" "strings" "time" @@ -2631,6 +2632,21 @@ func extractTaskEntries(entries []session.Entry, taskID string) []session.Entry var ranges []taskRange curStart := -1 + // Map TaskCreate ordinal → entry index, so we can locate the originating + // TaskCreate for sequentially-numbered task IDs (e.g. "1","2","3",...). + taskCreateEntryByOrdinal := make(map[int]int) + taskCreateOrdinal := 0 + for i, e := range entries { + for _, b := range e.Content { + if b.Type == "tool_use" && b.ToolName == "TaskCreate" { + taskCreateOrdinal++ + if _, exists := taskCreateEntryByOrdinal[taskCreateOrdinal]; !exists { + taskCreateEntryByOrdinal[taskCreateOrdinal] = i + } + } + } + } + for i, e := range entries { for _, b := range e.Content { if b.Type != "tool_use" || !isTaskTool(b.ToolName) { @@ -2658,20 +2674,49 @@ func extractTaskEntries(entries []session.Entry, taskID string) []session.Entry } if len(ranges) == 0 { - // Fallback: collect ALL entries that mention this task ID - // (TaskCreate, TaskUpdate, TaskGet, tool_results referencing the task) + // Fallback: collect ALL entries that reference this exact task ID. + // Use JSON field comparison so taskId "1" doesn't match "10", "11", etc. + // Also include the originating TaskCreate when the task ID is a + // 1-based ordinal (the in-memory loader assigns them in creation order). var result []session.Entry - for _, e := range entries { + seen := make(map[int]bool) + if ord, err := strconv.Atoi(taskID); err == nil && ord > 0 { + if idx, ok := taskCreateEntryByOrdinal[ord]; ok { + result = append(result, entries[idx]) + seen[idx] = true + } + } + for i, e := range entries { + if seen[i] { + continue + } for _, b := range e.Content { match := false - if b.Type == "tool_use" && isTaskTool(b.ToolName) && strings.Contains(b.ToolInput, taskID) { - match = true + if b.Type == "tool_use" && isTaskTool(b.ToolName) { + var in struct { + TaskID string `json:"taskId"` + ID string `json:"id"` + } + if json.Unmarshal([]byte(b.ToolInput), &in) == nil { + if in.TaskID == taskID || in.ID == taskID { + match = true + } + } } - if b.Type == "tool_result" && strings.Contains(b.Text, taskID) { - match = true + if !match && b.Type == "tool_result" && b.Text != "" { + var out struct { + TaskID string `json:"taskId"` + ID string `json:"id"` + } + if json.Unmarshal([]byte(b.Text), &out) == nil { + if out.TaskID == taskID || out.ID == taskID { + match = true + } + } } if match { result = append(result, e) + seen[i] = true break } } diff --git a/internal/tui/extract_task_entries_test.go b/internal/tui/extract_task_entries_test.go new file mode 100644 index 0000000..e197623 --- /dev/null +++ b/internal/tui/extract_task_entries_test.go @@ -0,0 +1,75 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func mkEntry(role string, blocks ...session.ContentBlock) session.Entry { + return session.Entry{Role: role, Timestamp: time.Now(), Content: blocks} +} + +func mkTaskCreate(subject string) session.ContentBlock { + return session.ContentBlock{ + Type: "tool_use", + ToolName: "TaskCreate", + ToolInput: `{"subject":"` + subject + `"}`, + } +} + +func mkTaskUpdate(taskID, status string) session.ContentBlock { + return session.ContentBlock{ + Type: "tool_use", + ToolName: "TaskUpdate", + ToolInput: `{"taskId":"` + taskID + `","status":"` + status + `"}`, + } +} + +// Pending tasks (no in_progress/completed) should resolve to only the +// originating TaskCreate, not a slew of unrelated tasks that share digits. +func TestExtractTaskEntries_PendingTaskMatchesOriginatingCreate(t *testing.T) { + entries := []session.Entry{ + mkEntry("assistant", mkTaskCreate("Task one")), + mkEntry("assistant", mkTaskUpdate("1", "in_progress")), + mkEntry("assistant", mkTaskUpdate("1", "completed")), + mkEntry("assistant", mkTaskCreate("Task two")), + mkEntry("assistant", mkTaskUpdate("2", "in_progress")), + mkEntry("assistant", mkTaskUpdate("2", "completed")), + mkEntry("assistant", mkTaskCreate("Task three")), + mkEntry("assistant", mkTaskCreate("Task four pending")), + } + + got := extractTaskEntries(entries, "4") + if len(got) != 1 { + t.Fatalf("expected 1 entry for pending task 4, got %d", len(got)) + } + if got[0].Content[0].ToolInput != `{"subject":"Task four pending"}` { + t.Fatalf("expected TaskCreate for task 4, got %q", got[0].Content[0].ToolInput) + } +} + +// taskId "1" must not match "10", "11", "21", etc. +func TestExtractTaskEntries_NoSubstringMatch(t *testing.T) { + entries := []session.Entry{ + mkEntry("assistant", mkTaskCreate("First")), // task 1 + mkEntry("assistant", mkTaskUpdate("10", "in_progress")), // unrelated + mkEntry("assistant", mkTaskUpdate("10", "completed")), + mkEntry("assistant", mkTaskUpdate("11", "in_progress")), + mkEntry("assistant", mkTaskUpdate("11", "completed")), + } + + got := extractTaskEntries(entries, "1") + for _, e := range got { + for _, b := range e.Content { + if b.ToolName == "TaskUpdate" { + // Any TaskUpdate in the result must have taskId == "1". + if !strings.Contains(b.ToolInput, `"taskId":"1"`) || strings.Contains(b.ToolInput, `"taskId":"10"`) || strings.Contains(b.ToolInput, `"taskId":"11"`) { + t.Fatalf("task 1 fallback should not include unrelated TaskUpdate: %q", b.ToolInput) + } + } + } + } +}