package main import ( "context" "encoding/json" "fmt" "os" "slices" "strings" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" ) // showMessageThread displays a full conversation thread for a message func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) { // Get the starting message var startMsg *types.Issue var err error if daemonClient != nil { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID}) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &startMsg); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } } else { startMsg, err = store.GetIssue(ctx, messageID) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err) os.Exit(1) } } if startMsg == nil { fmt.Fprintf(os.Stderr, "Message %s not found\n", messageID) os.Exit(1) } // Find the root of the thread by following replies-to dependencies upward // Per Decision 004, RepliesTo is now stored as a dependency, not an Issue field rootMsg := startMsg seen := make(map[string]bool) seen[rootMsg.ID] = true for { // Find parent via replies-to dependency parentID := findRepliesTo(ctx, rootMsg.ID, daemonClient, store) if parentID == "" { break // No parent, this is the root } if seen[parentID] { break // Avoid infinite loops } seen[parentID] = true var parentMsg *types.Issue if daemonClient != nil { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: parentID}) if err != nil { break // Parent not found, use current as root } if err := json.Unmarshal(resp.Data, &parentMsg); err != nil { break } } else { parentMsg, _ = store.GetIssue(ctx, parentID) } if parentMsg == nil { break } rootMsg = parentMsg } // Now collect all messages in the thread // Start from root and find all replies // Build a map of child ID -> parent ID for display purposes threadMessages := []*types.Issue{rootMsg} threadIDs := map[string]bool{rootMsg.ID: true} repliesTo := map[string]string{} // child ID -> parent ID queue := []string{rootMsg.ID} // BFS to find all replies for len(queue) > 0 { currentID := queue[0] queue = queue[1:] // Find all messages that reply to currentID via replies-to dependency // Per Decision 004, replies are found via dependents with type replies-to replies := findReplies(ctx, currentID, daemonClient, store) for _, reply := range replies { if threadIDs[reply.ID] { continue // Already seen } threadMessages = append(threadMessages, reply) threadIDs[reply.ID] = true repliesTo[reply.ID] = currentID // Track parent for display queue = append(queue, reply.ID) } } // Sort by creation time slices.SortFunc(threadMessages, func(a, b *types.Issue) int { return a.CreatedAt.Compare(b.CreatedAt) }) if jsonOutput { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") _ = encoder.Encode(threadMessages) return } // Display the thread fmt.Printf("\n%s Thread: %s\n", ui.RenderAccent("📬"), rootMsg.Title) fmt.Println(strings.Repeat("─", 66)) for _, msg := range threadMessages { // Show indent based on depth (count replies_to chain using our map) depth := 0 parent := repliesTo[msg.ID] for parent != "" && depth < 5 { depth++ parent = repliesTo[parent] } indent := strings.Repeat(" ", depth) // Format timestamp timeStr := msg.CreatedAt.Format("2006-01-02 15:04") // Status indicator statusIcon := "📧" if msg.Status == types.StatusClosed { statusIcon = "✓" } fmt.Printf("%s%s %s %s\n", indent, statusIcon, ui.RenderAccent(msg.ID), ui.RenderMuted(timeStr)) fmt.Printf("%s From: %s To: %s\n", indent, msg.Sender, msg.Assignee) if parentID := repliesTo[msg.ID]; parentID != "" { fmt.Printf("%s Re: %s\n", indent, parentID) } fmt.Printf("%s %s: %s\n", indent, ui.RenderMuted("Subject"), msg.Title) if msg.Description != "" { // Indent the body bodyLines := strings.Split(msg.Description, "\n") for _, line := range bodyLines { fmt.Printf("%s %s\n", indent, line) } } fmt.Println() } fmt.Printf("Total: %d messages in thread\n\n", len(threadMessages)) } // findRepliesTo finds the parent ID that this issue replies to via replies-to dependency. // Returns empty string if no parent found. func findRepliesTo(ctx context.Context, issueID string, daemonClient *rpc.Client, store storage.Storage) string { if daemonClient != nil { // In daemon mode, use Show to get dependencies with metadata resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID}) if err != nil { return "" } // Parse the full show response to get dependencies type showResponse struct { Dependencies []struct { ID string `json:"id"` DependencyType string `json:"dependency_type"` } `json:"dependencies"` } var details showResponse if err := json.Unmarshal(resp.Data, &details); err != nil { return "" } for _, dep := range details.Dependencies { if dep.DependencyType == string(types.DepRepliesTo) { return dep.ID } } return "" } // Direct mode - query storage deps, err := store.GetDependencyRecords(ctx, issueID) if err != nil { return "" } for _, dep := range deps { if dep.Type == types.DepRepliesTo { return dep.DependsOnID } } return "" } // findReplies finds all issues that reply to this issue via replies-to dependency. func findReplies(ctx context.Context, issueID string, daemonClient *rpc.Client, store storage.Storage) []*types.Issue { if daemonClient != nil { // In daemon mode, use Show to get dependents with metadata resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID}) if err != nil { return nil } // Parse the full show response to get dependents type showResponse struct { Dependents []struct { types.Issue DependencyType string `json:"dependency_type"` } `json:"dependents"` } var details showResponse if err := json.Unmarshal(resp.Data, &details); err != nil { return nil } var replies []*types.Issue for _, dep := range details.Dependents { if dep.DependencyType == string(types.DepRepliesTo) { issue := dep.Issue // Copy to avoid aliasing replies = append(replies, &issue) } } return replies } // Direct mode - query storage if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { deps, err := sqliteStore.GetDependentsWithMetadata(ctx, issueID) if err != nil { return nil } var replies []*types.Issue for _, dep := range deps { if dep.DependencyType == types.DepRepliesTo { issue := dep.Issue // Copy to avoid aliasing replies = append(replies, &issue) } } return replies } allDeps, err := store.GetAllDependencyRecords(ctx) if err != nil { return nil } var replies []*types.Issue for childID, deps := range allDeps { for _, dep := range deps { if dep.Type == types.DepRepliesTo && dep.DependsOnID == issueID { issue, _ := store.GetIssue(ctx, childID) if issue != nil { replies = append(replies, issue) } } } } return replies }