fix(tui): preserve bounded subagent activity rows
- commit
- 778aec9794aa20a680f65f593ce4abf1593b4e6d
- author
- brice · · 2026-08-20 09:06 UTC
- parent
- 9f5e8fd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +236 -40
M packages/agent/modes/interactive_rendering.go +18 -10
| @@ -744,11 +744,6 @@ func (i *Interactive) redraw() { | |||
| 744 | 744 | // Floating independent views no longer replace the main bottom band: the | |
| 745 | 745 | // underlying chat, status, and editor keep updating behind the pane. | |
| 746 | 746 | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | |
| 747 | - | var allResidentSubagentLines []string | |
| 748 | - | if !residentViewActive && i.cfg.ResidentManager != nil { | |
| 749 | - | snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0) | |
| 750 | - | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |
| 751 | - | } | |
| 752 | 747 | var workingLines []string | |
| 753 | 748 | if busyPrefix != "" && !workingWithStatus { | |
| 754 | 749 | workingLines = []string{" " + busyPrefix} | |
| @@ -849,11 +844,24 @@ func (i *Interactive) redraw() { | |||
| 849 | 844 | var residentSubagentLines []string | |
| 850 | 845 | var bottom []string | |
| 851 | 846 | var inputStartRow int | |
| 852 | - | if len(allResidentSubagentLines) > 0 { | |
| 853 | - | residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int { | |
| 854 | - | candidateBottom, _ := composeBottom(candidate) | |
| 855 | - | return len(candidateBottom) | |
| 856 | - | }) | |
| 847 | + | if !residentViewActive && i.cfg.ResidentManager != nil { | |
| 848 | + | residentCapacity := 0 | |
| 849 | + | for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- { | |
| 850 | + | candidateBottom, _ := composeBottom(make([]string, candidateRows)) | |
| 851 | + | if len(candidateBottom) <= maxBottomRows { | |
| 852 | + | residentCapacity = candidateRows | |
| 853 | + | break | |
| 854 | + | } | |
| 855 | + | } | |
| 856 | + | if residentCapacity > 0 { | |
| 857 | + | residentLines, hiddenResidents := renderResidentSubagentActivityPage(i.cfg.Theme, i.cfg.ResidentManager, i.spin.FrameAt(i.clock()), residentCapacity, mainCols, i.clock()) | |
| 858 | + | if len(residentLines) > 0 || hiddenResidents > 0 { | |
| 859 | + | residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, residentLines, hiddenResidents, maxBottomRows, mainCols, func(candidate []string) int { | |
| 860 | + | candidateBottom, _ := composeBottom(candidate) | |
| 861 | + | return len(candidateBottom) | |
| 862 | + | }) | |
| 863 | + | } | |
| 864 | + | } | |
| 857 | 865 | } | |
| 858 | 866 | bottom, inputStartRow = composeBottom(residentSubagentLines) | |
| 859 | 867 | ||
| @@ -744,11 +744,6 @@ func (i *Interactive) redraw() { | |||||
| 744 | // Floating independent views no longer replace the main bottom band: the | 744 | // Floating independent views no longer replace the main bottom band: the | ||
| 745 | // underlying chat, status, and editor keep updating behind the pane. | 745 | // underlying chat, status, and editor keep updating behind the pane. | ||
| 746 | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | 746 | residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil | ||
| 747 | - | var allResidentSubagentLines []string | |||
| 748 | - | if !residentViewActive && i.cfg.ResidentManager != nil { | |||
| 749 | - | snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0) | |||
| 750 | - | allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock()) | |||
| 751 | - | } | |||
| 752 | var workingLines []string | 747 | var workingLines []string | ||
| 753 | if busyPrefix != "" && !workingWithStatus { | 748 | if busyPrefix != "" && !workingWithStatus { | ||
| 754 | workingLines = []string{" " + busyPrefix} | 749 | workingLines = []string{" " + busyPrefix} | ||
| @@ -849,11 +844,24 @@ func (i *Interactive) redraw() { | |||||
| 849 | var residentSubagentLines []string | 844 | var residentSubagentLines []string | ||
| 850 | var bottom []string | 845 | var bottom []string | ||
| 851 | var inputStartRow int | 846 | var inputStartRow int | ||
| 852 | - | if len(allResidentSubagentLines) > 0 { | 847 | + | if !residentViewActive && i.cfg.ResidentManager != nil { |
| 853 | - | residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int { | 848 | + | residentCapacity := 0 |
| 854 | - | candidateBottom, _ := composeBottom(candidate) | 849 | + | for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- { |
| 855 | - | return len(candidateBottom) | 850 | + | candidateBottom, _ := composeBottom(make([]string, candidateRows)) |
| 856 | - | }) | 851 | + | if len(candidateBottom) <= maxBottomRows { |
| 852 | + | residentCapacity = candidateRows | |||
| 853 | + | break | |||
| 854 | + | } | |||
| 855 | + | } | |||
| 856 | + | if residentCapacity > 0 { | |||
| 857 | + | residentLines, hiddenResidents := renderResidentSubagentActivityPage(i.cfg.Theme, i.cfg.ResidentManager, i.spin.FrameAt(i.clock()), residentCapacity, mainCols, i.clock()) | |||
| 858 | + | if len(residentLines) > 0 || hiddenResidents > 0 { | |||
| 859 | + | residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, residentLines, hiddenResidents, maxBottomRows, mainCols, func(candidate []string) int { | |||
| 860 | + | candidateBottom, _ := composeBottom(candidate) | |||
| 861 | + | return len(candidateBottom) | |||
| 862 | + | }) | |||
| 863 | + | } | |||
| 864 | + | } | |||
| 857 | } | 865 | } | ||
| 858 | bottom, inputStartRow = composeBottom(residentSubagentLines) | 866 | bottom, inputStartRow = composeBottom(residentSubagentLines) | ||
| 859 | 867 | ||||
M packages/agent/modes/resident_subagent_indicator.go +72 -17
| @@ -18,8 +18,8 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner( | |||
| 18 | 18 | // renderResidentSubagentActivityLines renders the non-sensitive background | |
| 19 | 19 | // work indicator shown directly below the main editor. A queued child is | |
| 20 | 20 | // waiting for a resident scheduler slot; only running children use the | |
| 21 | - | // animated glyph. Height limiting happens after this function so all active | |
| 22 | - | // same-host children can be shown whenever the terminal has room. | |
| 21 | + | // animated glyph. Height limiting happens after this function so every active | |
| 22 | + | // child in the provided page can be shown whenever the terminal has room. | |
| 23 | 23 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | |
| 24 | 24 | lines := make([]string, 0, len(snapshots)*2) | |
| 25 | 25 | for _, snapshot := range snapshots { | |
| @@ -146,6 +146,18 @@ func formatResidentSubagentActivityAge(started, now time.Time) string { | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) { | |
| 150 | + | if manager == nil || snapshotLimit <= 0 { | |
| 151 | + | return nil, 0 | |
| 152 | + | } | |
| 153 | + | snapshots, total := manager.ActiveSnapshotPage(snapshotLimit) | |
| 154 | + | hidden := total - len(snapshots) | |
| 155 | + | if hidden < 0 { | |
| 156 | + | hidden = 0 | |
| 157 | + | } | |
| 158 | + | return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden | |
| 159 | + | } | |
| 160 | + | ||
| 149 | 161 | func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string { | |
| 150 | 162 | if len(lines) == 0 && hidden == 0 { | |
| 151 | 163 | return nil | |
| @@ -163,30 +175,73 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, | |||
| 163 | 175 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | |
| 164 | 176 | return nil | |
| 165 | 177 | } | |
| 166 | - | groups := make([][]string, 0, len(lines)) | |
| 167 | - | for _, line := range lines { | |
| 168 | - | if strings.HasPrefix(line, " ") && len(groups) > 0 { | |
| 169 | - | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |
| 170 | - | continue | |
| 171 | - | } | |
| 172 | - | groups = append(groups, []string{line}) | |
| 178 | + | groups := residentSubagentActivityLineGroups(lines) | |
| 179 | + | if len(groups) == 0 { | |
| 180 | + | return []string{residentSubagentActivityOverflowLine(theme, hidden, width)} | |
| 173 | 181 | } | |
| 174 | 182 | if hidden == 0 && len(lines) <= maxRows { | |
| 175 | 183 | return lines | |
| 176 | 184 | } | |
| 185 | + | ||
| 177 | 186 | if maxRows == 1 { | |
| 178 | - | return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)} | |
| 187 | + | if hidden == 0 && len(groups) == 1 { | |
| 188 | + | return []string{groups[0][0]} | |
| 189 | + | } | |
| 190 | + | return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)} | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | visibleGroups := len(groups) | |
| 194 | + | overflowHidden := hidden | |
| 195 | + | if hidden > 0 { | |
| 196 | + | if visibleGroups > maxRows-1 { | |
| 197 | + | visibleGroups = maxRows - 1 | |
| 198 | + | } | |
| 199 | + | overflowHidden = hidden + len(groups) - visibleGroups | |
| 200 | + | } else if visibleGroups > maxRows { | |
| 201 | + | visibleGroups = maxRows - 1 | |
| 202 | + | overflowHidden = len(groups) - visibleGroups | |
| 179 | 203 | } | |
| 204 | + | if visibleGroups < 0 { | |
| 205 | + | visibleGroups = 0 | |
| 206 | + | } | |
| 207 | + | if visibleGroups > len(groups) { | |
| 208 | + | visibleGroups = len(groups) | |
| 209 | + | } | |
| 210 | + | ||
| 180 | 211 | out := make([]string, 0, maxRows) | |
| 181 | - | visibleGroups := 0 | |
| 182 | - | for _, group := range groups { | |
| 183 | - | if len(out)+len(group) > maxRows-1 { | |
| 184 | - | break | |
| 212 | + | for index := 0; index < visibleGroups && len(out) < maxRows; index++ { | |
| 213 | + | group := groups[index] | |
| 214 | + | out = append(out, group[0]) | |
| 215 | + | remainingActivityRows := visibleGroups - index - 1 | |
| 216 | + | reservedRows := remainingActivityRows | |
| 217 | + | if overflowHidden > 0 { | |
| 218 | + | reservedRows++ | |
| 219 | + | } | |
| 220 | + | metadataRows := maxRows - len(out) - reservedRows | |
| 221 | + | for _, metadataLine := range group[1:] { | |
| 222 | + | if metadataRows <= 0 || len(out) >= maxRows { | |
| 223 | + | break | |
| 224 | + | } | |
| 225 | + | out = append(out, metadataLine) | |
| 226 | + | metadataRows-- | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | if overflowHidden > 0 && len(out) < maxRows { | |
| 230 | + | out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width)) | |
| 231 | + | } | |
| 232 | + | return out | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func residentSubagentActivityLineGroups(lines []string) [][]string { | |
| 236 | + | groups := make([][]string, 0, len(lines)) | |
| 237 | + | for _, line := range lines { | |
| 238 | + | if strings.HasPrefix(line, " ") && len(groups) > 0 { | |
| 239 | + | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |
| 240 | + | continue | |
| 185 | 241 | } | |
| 186 | - | out = append(out, group...) | |
| 187 | - | visibleGroups++ | |
| 242 | + | groups = append(groups, []string{line}) | |
| 188 | 243 | } | |
| 189 | - | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width)) | |
| 244 | + | return groups | |
| 190 | 245 | } | |
| 191 | 246 | ||
| 192 | 247 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | |
| @@ -18,8 +18,8 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner( | |||||
| 18 | // renderResidentSubagentActivityLines renders the non-sensitive background | 18 | // renderResidentSubagentActivityLines renders the non-sensitive background | ||
| 19 | // work indicator shown directly below the main editor. A queued child is | 19 | // work indicator shown directly below the main editor. A queued child is | ||
| 20 | // waiting for a resident scheduler slot; only running children use the | 20 | // waiting for a resident scheduler slot; only running children use the | ||
| 21 | - | // animated glyph. Height limiting happens after this function so all active | 21 | + | // animated glyph. Height limiting happens after this function so every active |
| 22 | - | // same-host children can be shown whenever the terminal has room. | 22 | + | // child in the provided page can be shown whenever the terminal has room. |
| 23 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | 23 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | ||
| 24 | lines := make([]string, 0, len(snapshots)*2) | 24 | lines := make([]string, 0, len(snapshots)*2) | ||
| 25 | for _, snapshot := range snapshots { | 25 | for _, snapshot := range snapshots { | ||
| @@ -146,6 +146,18 @@ func formatResidentSubagentActivityAge(started, now time.Time) string { | |||||
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | + | func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) { | |||
| 150 | + | if manager == nil || snapshotLimit <= 0 { | |||
| 151 | + | return nil, 0 | |||
| 152 | + | } | |||
| 153 | + | snapshots, total := manager.ActiveSnapshotPage(snapshotLimit) | |||
| 154 | + | hidden := total - len(snapshots) | |||
| 155 | + | if hidden < 0 { | |||
| 156 | + | hidden = 0 | |||
| 157 | + | } | |||
| 158 | + | return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden | |||
| 159 | + | } | |||
| 160 | + | ||||
| 149 | func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string { | 161 | func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string { | ||
| 150 | if len(lines) == 0 && hidden == 0 { | 162 | if len(lines) == 0 && hidden == 0 { | ||
| 151 | return nil | 163 | return nil | ||
| @@ -163,30 +175,73 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, | |||||
| 163 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | 175 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | ||
| 164 | return nil | 176 | return nil | ||
| 165 | } | 177 | } | ||
| 166 | - | groups := make([][]string, 0, len(lines)) | 178 | + | groups := residentSubagentActivityLineGroups(lines) |
| 167 | - | for _, line := range lines { | 179 | + | if len(groups) == 0 { |
| 168 | - | if strings.HasPrefix(line, " ") && len(groups) > 0 { | 180 | + | return []string{residentSubagentActivityOverflowLine(theme, hidden, width)} |
| 169 | - | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |||
| 170 | - | continue | |||
| 171 | - | } | |||
| 172 | - | groups = append(groups, []string{line}) | |||
| 173 | } | 181 | } | ||
| 174 | if hidden == 0 && len(lines) <= maxRows { | 182 | if hidden == 0 && len(lines) <= maxRows { | ||
| 175 | return lines | 183 | return lines | ||
| 176 | } | 184 | } | ||
| 185 | + | ||||
| 177 | if maxRows == 1 { | 186 | if maxRows == 1 { | ||
| 178 | - | return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)} | 187 | + | if hidden == 0 && len(groups) == 1 { |
| 188 | + | return []string{groups[0][0]} | |||
| 189 | + | } | |||
| 190 | + | return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)} | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | visibleGroups := len(groups) | |||
| 194 | + | overflowHidden := hidden | |||
| 195 | + | if hidden > 0 { | |||
| 196 | + | if visibleGroups > maxRows-1 { | |||
| 197 | + | visibleGroups = maxRows - 1 | |||
| 198 | + | } | |||
| 199 | + | overflowHidden = hidden + len(groups) - visibleGroups | |||
| 200 | + | } else if visibleGroups > maxRows { | |||
| 201 | + | visibleGroups = maxRows - 1 | |||
| 202 | + | overflowHidden = len(groups) - visibleGroups | |||
| 179 | } | 203 | } | ||
| 204 | + | if visibleGroups < 0 { | |||
| 205 | + | visibleGroups = 0 | |||
| 206 | + | } | |||
| 207 | + | if visibleGroups > len(groups) { | |||
| 208 | + | visibleGroups = len(groups) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 180 | out := make([]string, 0, maxRows) | 211 | out := make([]string, 0, maxRows) | ||
| 181 | - | visibleGroups := 0 | 212 | + | for index := 0; index < visibleGroups && len(out) < maxRows; index++ { |
| 182 | - | for _, group := range groups { | 213 | + | group := groups[index] |
| 183 | - | if len(out)+len(group) > maxRows-1 { | 214 | + | out = append(out, group[0]) |
| 184 | - | break | 215 | + | remainingActivityRows := visibleGroups - index - 1 |
| 216 | + | reservedRows := remainingActivityRows | |||
| 217 | + | if overflowHidden > 0 { | |||
| 218 | + | reservedRows++ | |||
| 219 | + | } | |||
| 220 | + | metadataRows := maxRows - len(out) - reservedRows | |||
| 221 | + | for _, metadataLine := range group[1:] { | |||
| 222 | + | if metadataRows <= 0 || len(out) >= maxRows { | |||
| 223 | + | break | |||
| 224 | + | } | |||
| 225 | + | out = append(out, metadataLine) | |||
| 226 | + | metadataRows-- | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | if overflowHidden > 0 && len(out) < maxRows { | |||
| 230 | + | out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width)) | |||
| 231 | + | } | |||
| 232 | + | return out | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func residentSubagentActivityLineGroups(lines []string) [][]string { | |||
| 236 | + | groups := make([][]string, 0, len(lines)) | |||
| 237 | + | for _, line := range lines { | |||
| 238 | + | if strings.HasPrefix(line, " ") && len(groups) > 0 { | |||
| 239 | + | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |||
| 240 | + | continue | |||
| 185 | } | 241 | } | ||
| 186 | - | out = append(out, group...) | 242 | + | groups = append(groups, []string{line}) |
| 187 | - | visibleGroups++ | |||
| 188 | } | 243 | } | ||
| 189 | - | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width)) | 244 | + | return groups |
| 190 | } | 245 | } | ||
| 191 | 246 | ||||
| 192 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | 247 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | ||
M packages/agent/modes/resident_subagent_indicator_test.go +127 -11
| @@ -51,15 +51,7 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||
| 51 | 51 | }, nil | |
| 52 | 52 | }) | |
| 53 | 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 54 | - | for index := 0; index < 10; index++ { | |
| 55 | - | spec := subagents.ResidentChildSpec{ | |
| 56 | - | ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), | |
| 57 | - | Provider: "openai", Model: "gpt-test", | |
| 58 | - | } | |
| 59 | - | if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil { | |
| 60 | - | t.Fatalf("Spawn(%d): %v", index, err) | |
| 61 | - | } | |
| 62 | - | } | |
| 54 | + | spawnResidentIndicatorChildren(t, manager, 10) | |
| 63 | 55 | ||
| 64 | 56 | term := &residentIndicatorTerminal{cols: 80, rows: 40} | |
| 65 | 57 | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | |
| @@ -86,6 +78,93 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||
| 86 | 78 | } | |
| 87 | 79 | } | |
| 88 | 80 | ||
| 81 | + | func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) { | |
| 82 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 83 | + | return func(ctx context.Context, _ string) error { | |
| 84 | + | <-ctx.Done() | |
| 85 | + | return ctx.Err() | |
| 86 | + | }, nil | |
| 87 | + | }) | |
| 88 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 89 | + | spawnResidentIndicatorChildren(t, manager, 6) | |
| 90 | + | ||
| 91 | + | term := &residentIndicatorTerminal{cols: 80, rows: 8} | |
| 92 | + | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | |
| 93 | + | interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) } | |
| 94 | + | interactive.rend.Resize(term.cols, term.rows) | |
| 95 | + | term.Reset() | |
| 96 | + | interactive.redraw() | |
| 97 | + | ||
| 98 | + | plain := stripANSIBytes(term.String()) | |
| 99 | + | if count := strings.Count(plain, "agent-"); count != 1 { | |
| 100 | + | t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain) | |
| 101 | + | } | |
| 102 | + | if !strings.Contains(plain, "… 5 more active subagents") { | |
| 103 | + | t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) { | |
| 108 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 109 | + | return func(ctx context.Context, _ string) error { | |
| 110 | + | <-ctx.Done() | |
| 111 | + | return ctx.Err() | |
| 112 | + | }, nil | |
| 113 | + | }) | |
| 114 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 115 | + | spawnResidentIndicatorChildren(t, manager, 2) | |
| 116 | + | ||
| 117 | + | term := &residentIndicatorTerminal{cols: 80, rows: 8} | |
| 118 | + | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | |
| 119 | + | interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) } | |
| 120 | + | interactive.rend.Resize(term.cols, term.rows) | |
| 121 | + | term.Reset() | |
| 122 | + | interactive.redraw() | |
| 123 | + | ||
| 124 | + | plain := stripANSIBytes(term.String()) | |
| 125 | + | for index := 0; index < 2; index++ { | |
| 126 | + | if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) { | |
| 127 | + | t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | if strings.Contains(plain, "more active subagents") { | |
| 131 | + | t.Fatalf("interactive output summarized fitting active children:\n%s", plain) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) { | |
| 136 | + | t.Helper() | |
| 137 | + | for index := 0; index < count; index++ { | |
| 138 | + | spec := subagents.ResidentChildSpec{ | |
| 139 | + | ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), | |
| 140 | + | Provider: "openai", Model: "gpt-test", | |
| 141 | + | } | |
| 142 | + | if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil { | |
| 143 | + | t.Fatalf("Spawn(%d): %v", index, err) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) { | |
| 149 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 150 | + | return func(ctx context.Context, _ string) error { | |
| 151 | + | <-ctx.Done() | |
| 152 | + | return ctx.Err() | |
| 153 | + | }, nil | |
| 154 | + | }) | |
| 155 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 156 | + | spawnResidentIndicatorChildren(t, manager, 5) | |
| 157 | + | ||
| 158 | + | lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)) | |
| 159 | + | plain := strings.Join(plainResidentIndicatorLines(lines), "\n") | |
| 160 | + | if hidden != 3 { | |
| 161 | + | t.Fatalf("hidden active children = %d, want 3", hidden) | |
| 162 | + | } | |
| 163 | + | if count := strings.Count(plain, "agent-"); count != 2 { | |
| 164 | + | t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 89 | 168 | func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) { | |
| 90 | 169 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 91 | 170 | var snapshots []subagents.ResidentSnapshot | |
| @@ -153,6 +232,26 @@ func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t | |||
| 153 | 232 | } | |
| 154 | 233 | } | |
| 155 | 234 | ||
| 235 | + | func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) { | |
| 236 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 237 | + | const width = 34 | |
| 238 | + | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |
| 239 | + | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | |
| 240 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, | |
| 241 | + | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |
| 242 | + | }}, width, now) | |
| 243 | + | if plain := plainResidentIndicatorLines(lines); len(plain) < 3 { | |
| 244 | + | t.Fatalf("activity lines = %#v, want wrapped metadata", plain) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int { | |
| 248 | + | return len(candidate) | |
| 249 | + | })) | |
| 250 | + | if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") { | |
| 251 | + | t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 156 | 255 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | |
| 157 | 256 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 158 | 257 | now := started.Add(2 * time.Second) | |
| @@ -202,10 +301,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) { | |||
| 202 | 301 | } | |
| 203 | 302 | } | |
| 204 | 303 | ||
| 205 | - | func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) { | |
| 304 | + | func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) { | |
| 305 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80)) | |
| 306 | + | want := []string{" … 3 more active subagents"} | |
| 307 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 308 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) { | |
| 206 | 313 | lines := []string{" agent one", " usage one", " agent two", " usage two"} | |
| 207 | 314 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80)) | |
| 208 | - | want := []string{" agent one", " usage one", " … 1 more active subagents"} | |
| 315 | + | want := []string{" agent one", " usage one", " agent two"} | |
| 316 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 317 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) { | |
| 322 | + | lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"} | |
| 323 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80)) | |
| 324 | + | want := []string{" agent one", " … 2 more active subagents"} | |
| 209 | 325 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 210 | 326 | t.Fatalf("limited lines = %#v, want %#v", got, want) | |
| 211 | 327 | } | |
| @@ -51,15 +51,7 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||||
| 51 | }, nil | 51 | }, nil | ||
| 52 | }) | 52 | }) | ||
| 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | 53 | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | ||
| 54 | - | for index := 0; index < 10; index++ { | 54 | + | spawnResidentIndicatorChildren(t, manager, 10) |
| 55 | - | spec := subagents.ResidentChildSpec{ | |||
| 56 | - | ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), | |||
| 57 | - | Provider: "openai", Model: "gpt-test", | |||
| 58 | - | } | |||
| 59 | - | if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil { | |||
| 60 | - | t.Fatalf("Spawn(%d): %v", index, err) | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | 55 | ||||
| 64 | term := &residentIndicatorTerminal{cols: 80, rows: 40} | 56 | term := &residentIndicatorTerminal{cols: 80, rows: 40} | ||
| 65 | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | 57 | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | ||
| @@ -86,6 +78,93 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl | |||||
| 86 | } | 78 | } | ||
| 87 | } | 79 | } | ||
| 88 | 80 | ||||
| 81 | + | func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) { | |||
| 82 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 83 | + | return func(ctx context.Context, _ string) error { | |||
| 84 | + | <-ctx.Done() | |||
| 85 | + | return ctx.Err() | |||
| 86 | + | }, nil | |||
| 87 | + | }) | |||
| 88 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 89 | + | spawnResidentIndicatorChildren(t, manager, 6) | |||
| 90 | + | ||||
| 91 | + | term := &residentIndicatorTerminal{cols: 80, rows: 8} | |||
| 92 | + | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | |||
| 93 | + | interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) } | |||
| 94 | + | interactive.rend.Resize(term.cols, term.rows) | |||
| 95 | + | term.Reset() | |||
| 96 | + | interactive.redraw() | |||
| 97 | + | ||||
| 98 | + | plain := stripANSIBytes(term.String()) | |||
| 99 | + | if count := strings.Count(plain, "agent-"); count != 1 { | |||
| 100 | + | t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain) | |||
| 101 | + | } | |||
| 102 | + | if !strings.Contains(plain, "… 5 more active subagents") { | |||
| 103 | + | t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) { | |||
| 108 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 109 | + | return func(ctx context.Context, _ string) error { | |||
| 110 | + | <-ctx.Done() | |||
| 111 | + | return ctx.Err() | |||
| 112 | + | }, nil | |||
| 113 | + | }) | |||
| 114 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 115 | + | spawnResidentIndicatorChildren(t, manager, 2) | |||
| 116 | + | ||||
| 117 | + | term := &residentIndicatorTerminal{cols: 80, rows: 8} | |||
| 118 | + | interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark}) | |||
| 119 | + | interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) } | |||
| 120 | + | interactive.rend.Resize(term.cols, term.rows) | |||
| 121 | + | term.Reset() | |||
| 122 | + | interactive.redraw() | |||
| 123 | + | ||||
| 124 | + | plain := stripANSIBytes(term.String()) | |||
| 125 | + | for index := 0; index < 2; index++ { | |||
| 126 | + | if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) { | |||
| 127 | + | t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | if strings.Contains(plain, "more active subagents") { | |||
| 131 | + | t.Fatalf("interactive output summarized fitting active children:\n%s", plain) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | for index := 0; index < count; index++ { | |||
| 138 | + | spec := subagents.ResidentChildSpec{ | |||
| 139 | + | ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), | |||
| 140 | + | Provider: "openai", Model: "gpt-test", | |||
| 141 | + | } | |||
| 142 | + | if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil { | |||
| 143 | + | t.Fatalf("Spawn(%d): %v", index, err) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) { | |||
| 149 | + | manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 150 | + | return func(ctx context.Context, _ string) error { | |||
| 151 | + | <-ctx.Done() | |||
| 152 | + | return ctx.Err() | |||
| 153 | + | }, nil | |||
| 154 | + | }) | |||
| 155 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 156 | + | spawnResidentIndicatorChildren(t, manager, 5) | |||
| 157 | + | ||||
| 158 | + | lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)) | |||
| 159 | + | plain := strings.Join(plainResidentIndicatorLines(lines), "\n") | |||
| 160 | + | if hidden != 3 { | |||
| 161 | + | t.Fatalf("hidden active children = %d, want 3", hidden) | |||
| 162 | + | } | |||
| 163 | + | if count := strings.Count(plain, "agent-"); count != 2 { | |||
| 164 | + | t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 89 | func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) { | 168 | func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) { | ||
| 90 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | 169 | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | ||
| 91 | var snapshots []subagents.ResidentSnapshot | 170 | var snapshots []subagents.ResidentSnapshot | ||
| @@ -153,6 +232,26 @@ func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t | |||||
| 153 | } | 232 | } | ||
| 154 | } | 233 | } | ||
| 155 | 234 | ||||
| 235 | + | func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) { | |||
| 236 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |||
| 237 | + | const width = 34 | |||
| 238 | + | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |||
| 239 | + | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | |||
| 240 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}, | |||
| 241 | + | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |||
| 242 | + | }}, width, now) | |||
| 243 | + | if plain := plainResidentIndicatorLines(lines); len(plain) < 3 { | |||
| 244 | + | t.Fatalf("activity lines = %#v, want wrapped metadata", plain) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int { | |||
| 248 | + | return len(candidate) | |||
| 249 | + | })) | |||
| 250 | + | if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") { | |||
| 251 | + | t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 156 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | 255 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | ||
| 157 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | 256 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | ||
| 158 | now := started.Add(2 * time.Second) | 257 | now := started.Add(2 * time.Second) | ||
| @@ -202,10 +301,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) { | |||||
| 202 | } | 301 | } | ||
| 203 | } | 302 | } | ||
| 204 | 303 | ||||
| 205 | - | func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) { | 304 | + | func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) { |
| 305 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80)) | |||
| 306 | + | want := []string{" … 3 more active subagents"} | |||
| 307 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |||
| 308 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) { | |||
| 206 | lines := []string{" agent one", " usage one", " agent two", " usage two"} | 313 | lines := []string{" agent one", " usage one", " agent two", " usage two"} | ||
| 207 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80)) | 314 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80)) | ||
| 208 | - | want := []string{" agent one", " usage one", " … 1 more active subagents"} | 315 | + | want := []string{" agent one", " usage one", " agent two"} |
| 316 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |||
| 317 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) { | |||
| 322 | + | lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"} | |||
| 323 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80)) | |||
| 324 | + | want := []string{" agent one", " … 2 more active subagents"} | |||
| 209 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | 325 | if strings.Join(got, "\n") != strings.Join(want, "\n") { | ||
| 210 | t.Fatalf("limited lines = %#v, want %#v", got, want) | 326 | t.Fatalf("limited lines = %#v, want %#v", got, want) | ||
| 211 | } | 327 | } | ||
M packages/agent/subagents/resident_manager.go +5 -2
| @@ -729,8 +729,8 @@ func (m *ResidentManager) RecentSnapshotPage(offset, limit int) ([]ResidentSnaps | |||
| 729 | 729 | return result, total | |
| 730 | 730 | } | |
| 731 | 731 | ||
| 732 | - | // ActiveSnapshotPage returns only queued and running children for compact | |
| 733 | - | // activity surfaces. It deliberately exposes no prompt, transcript, or path. | |
| 732 | + | // ActiveSnapshotPage returns only same-host queued and running children for | |
| 733 | + | // compact activity surfaces. It deliberately exposes no prompt, transcript, or path. | |
| 734 | 734 | func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) { | |
| 735 | 735 | if m == nil { | |
| 736 | 736 | return nil, 0 | |
| @@ -757,6 +757,9 @@ func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int | |||
| 757 | 757 | if _, exists := live[id]; exists { | |
| 758 | 758 | continue | |
| 759 | 759 | } | |
| 760 | + | if snapshot.OwnedElsewhere { | |
| 761 | + | continue | |
| 762 | + | } | |
| 760 | 763 | if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning { | |
| 761 | 764 | result = append(result, snapshot) | |
| 762 | 765 | } | |
| @@ -729,8 +729,8 @@ func (m *ResidentManager) RecentSnapshotPage(offset, limit int) ([]ResidentSnaps | |||||
| 729 | return result, total | 729 | return result, total | ||
| 730 | } | 730 | } | ||
| 731 | 731 | ||||
| 732 | - | // ActiveSnapshotPage returns only queued and running children for compact | 732 | + | // ActiveSnapshotPage returns only same-host queued and running children for |
| 733 | - | // activity surfaces. It deliberately exposes no prompt, transcript, or path. | 733 | + | // compact activity surfaces. It deliberately exposes no prompt, transcript, or path. |
| 734 | func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) { | 734 | func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) { | ||
| 735 | if m == nil { | 735 | if m == nil { | ||
| 736 | return nil, 0 | 736 | return nil, 0 | ||
| @@ -757,6 +757,9 @@ func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int | |||||
| 757 | if _, exists := live[id]; exists { | 757 | if _, exists := live[id]; exists { | ||
| 758 | continue | 758 | continue | ||
| 759 | } | 759 | } | ||
| 760 | + | if snapshot.OwnedElsewhere { | |||
| 761 | + | continue | |||
| 762 | + | } | |||
| 760 | if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning { | 763 | if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning { | ||
| 761 | result = append(result, snapshot) | 764 | result = append(result, snapshot) | ||
| 762 | } | 765 | } | ||
M packages/agent/subagents/resident_snapshot_test.go +14 -0
| @@ -39,3 +39,17 @@ func TestResidentManagerRecentSnapshotPageOrdersLatestTransitionFirst(t *testing | |||
| 39 | 39 | t.Fatalf("recent page = %#v/%d", page, total) | |
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | + | ||
| 43 | + | func TestResidentManagerActiveSnapshotPageSkipsForeignActiveChildren(t *testing.T) { | |
| 44 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 45 | + | manager := &ResidentManager{children: map[string]*ResidentChild{}, recovered: map[string]ResidentSnapshot{ | |
| 46 | + | "foreign": {ID: "foreign", State: ResidentRunning, OwnedElsewhere: true, UpdatedAt: now}, | |
| 47 | + | "local-newer": {ID: "local-newer", State: ResidentRunning, UpdatedAt: now.Add(-time.Minute)}, | |
| 48 | + | "local-older": {ID: "local-older", State: ResidentQueued, UpdatedAt: now.Add(-time.Hour)}, | |
| 49 | + | "idle": {ID: "idle", State: ResidentIdle, UpdatedAt: now.Add(time.Minute)}, | |
| 50 | + | }, recoveredSpec: map[string]ResidentChildSpec{}, pending: map[string]struct{}{}} | |
| 51 | + | page, total := manager.ActiveSnapshotPage(1) | |
| 52 | + | if total != 2 || len(page) != 1 || page[0].ID != "local-newer" { | |
| 53 | + | t.Fatalf("active page = %#v/%d, want bounded same-host active children", page, total) | |
| 54 | + | } | |
| 55 | + | } | |
| @@ -39,3 +39,17 @@ func TestResidentManagerRecentSnapshotPageOrdersLatestTransitionFirst(t *testing | |||||
| 39 | t.Fatalf("recent page = %#v/%d", page, total) | 39 | t.Fatalf("recent page = %#v/%d", page, total) | ||
| 40 | } | 40 | } | ||
| 41 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | func TestResidentManagerActiveSnapshotPageSkipsForeignActiveChildren(t *testing.T) { | |||
| 44 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |||
| 45 | + | manager := &ResidentManager{children: map[string]*ResidentChild{}, recovered: map[string]ResidentSnapshot{ | |||
| 46 | + | "foreign": {ID: "foreign", State: ResidentRunning, OwnedElsewhere: true, UpdatedAt: now}, | |||
| 47 | + | "local-newer": {ID: "local-newer", State: ResidentRunning, UpdatedAt: now.Add(-time.Minute)}, | |||
| 48 | + | "local-older": {ID: "local-older", State: ResidentQueued, UpdatedAt: now.Add(-time.Hour)}, | |||
| 49 | + | "idle": {ID: "idle", State: ResidentIdle, UpdatedAt: now.Add(time.Minute)}, | |||
| 50 | + | }, recoveredSpec: map[string]ResidentChildSpec{}, pending: map[string]struct{}{}} | |||
| 51 | + | page, total := manager.ActiveSnapshotPage(1) | |||
| 52 | + | if total != 2 || len(page) != 1 || page[0].ID != "local-newer" { | |||
| 53 | + | t.Fatalf("active page = %#v/%d, want bounded same-host active children", page, total) | |||
| 54 | + | } | |||
| 55 | + | } | |||