From 0badbe9f76a34b712cf63131cacdb5a09b97aa7b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 12:11:10 -0400 Subject: [PATCH 1/8] Scope All to the current conversation tab strip --- .../unreleased/1939-all-current-tabs.md | 12 ++ internal/e2e/open_tabs_wall_e2e_test.go | 117 ++++++++++++++++++ internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 64 +++++----- internal/tui3/chattabs.go | 2 +- internal/tui3/wall.go | 25 +++- internal/tui3/wallcatalog.go | 15 ++- internal/tui3/wallcontract.go | 4 +- internal/tui3/walldock.go | 14 +-- internal/tui3/walldock_test.go | 8 +- internal/tui3/walldoor_test.go | 64 +++++++++- internal/tui3/wallview.go | 3 + 12 files changed, 274 insertions(+), 56 deletions(-) create mode 100644 docs/changes/unreleased/1939-all-current-tabs.md create mode 100644 internal/e2e/open_tabs_wall_e2e_test.go diff --git a/docs/changes/unreleased/1939-all-current-tabs.md b/docs/changes/unreleased/1939-all-current-tabs.md new file mode 100644 index 0000000000..d6914455a4 --- /dev/null +++ b/docs/changes/unreleased/1939-all-current-tabs.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: All shows the conversation tabs in the current view +pr: 1939 +surface: [chat, docs] +invalidates: + - "The tab strip's All button opened the complete saved library; it now shows only the conversation tabs in the current view." + - "All discarded the selected team; its cards now follow that team's strip and opening a card keeps the team selected." +--- + +Normal All cards disappear when their tabs close. The dock's All button shares +the same scope. The full saved library remains available through /wall and alt+v. diff --git a/internal/e2e/open_tabs_wall_e2e_test.go b/internal/e2e/open_tabs_wall_e2e_test.go new file mode 100644 index 0000000000..c5279c0f24 --- /dev/null +++ b/internal/e2e/open_tabs_wall_e2e_test.go @@ -0,0 +1,117 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestAllShowsTheCurrentTabsOnTheTerminal(t *testing.T) { + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatalf("requires CODEAF_E2E_MODEL=%s", model) + } + home := newHome(t, map[string]any{"memory.enabled": "off", "models.fallbacks": "", "model_pool": "off"}) + workspace := newWorkspace(t, "open-tabs", false) + var members []teamstore.Member + for i, title := range []string{"Planning notes", "Design review", "Testing notes", "Saved research"} { + dir := filepath.Join(home, "v3", "projects", strings.ReplaceAll(workspace, string(filepath.Separator), "-"), fmt.Sprintf("aa55aa55aa55%04d", i)) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "transcript.jsonl") + header, _ := json.Marshal(map[string]any{"type": "session", "version": 1, "id": filepath.Base(dir), "cwd": workspace, "model": model, "timestamp": time.Now().UTC()}) + named, _ := json.Marshal(map[string]any{"type": "title", "title": title}) + message, _ := json.Marshal(map[string]any{"type": "message", "role": "user", "content": title}) + reply, _ := json.Marshal(map[string]any{"type": "message", "role": "assistant", "content": "Saved notes for " + title + "."}) + data := string(header) + "\n" + string(named) + "\n" + string(message) + "\n" + string(reply) + "\n" + if err := os.WriteFile(file, []byte(data), 0600); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(dir, session.Meta{ID: filepath.Base(dir), Title: title, Workspace: workspace}); err != nil { + t.Fatal(err) + } + members = append(members, teamstore.Member{Key: file, File: file, Word: title, Where: workspace}) + } + if err := teamstore.Save(home, []teamstore.Team{{ID: teamstore.NewID(), Name: "Product", Members: members[:2]}, {ID: teamstore.NewID(), Name: "Quality", Members: members[2:3]}}); err != nil { + t.Fatal(err) + } + stopRecoveryEngineOnCleanup(t, nil, home, workspace) + r := startDefault(t, "open-tabs-wall", home, workspace, 140, 42, "chat", "--session", members[0].File, "--model", model, "--one-model", "--debug") + if out, err := exec.Command("tmux", "set-option", "-t", r.name, "status", "off").CombinedOutput(); err != nil { + t.Fatalf("terminal status: %v: %s", err, out) + } + r.lit("Reply with only OPEN_TABS_READY. Do not change files.") + r.keys("Enter") + cleanChatWaitAnswer(t, r, "OPEN_TABS_READY", time.Minute) + for _, title := range []string{"Design review", "Testing notes"} { + r.lit("/new") + r.keys("Enter") + r.lit("/resume") + r.keys("Enter") + r.lit(title) + r.keys("Enter") + r.waitFor(20*time.Second, title) + } + capture := func(stage string) { + t.Helper() + cleanChatCapture(t, r, stage) + if hook := os.Getenv("CODEAF_E2E_SCREENSHOT_HOOK"); hook != "" { + if out, err := exec.Command(hook, r.name, stage).CombinedOutput(); err != nil { + t.Fatalf("screenshot: %v: %s", err, out) + } + } + } + // Compare the visible strip with the grid, not with the saved-session count. + strip := strings.Split(r.capture(), "\n")[2] + openCount := 0 + for _, title := range []string{"Planning notes", "Design review", "Testing notes"} { + if strings.Contains(strip, title) { + openCount++ + } + } + if openCount < 2 || !strings.Contains(strip, "Design review") || !strings.Contains(strip, "Testing notes") { + t.Fatalf("fixture needs its two resumed tabs on the strip: %s", strip) + } + r.mouseClick(138, 3) + normal := r.waitFor(20*time.Second, "open tabs", fmt.Sprintf("%d conversations", openCount)) + if strings.Contains(normal, "Saved research") { + t.Fatal("saved history appeared in All") + } + capture("01-normal-open-tabs") + r.keys("Escape") + r.lit("/quit") + r.keys("Enter") + r.mouseClick(138, 3) + closed := r.waitFor(20*time.Second, "open tabs", fmt.Sprintf("%d conversations", openCount-1)) + if strings.Contains(closed, "Testing notes") || strings.Contains(closed, "Saved research") { + t.Fatal("All retained the closed tab or included saved history") + } + capture("03-after-closing-tab") + r.keys("Escape") + // The team dropdown is the leftmost control on the tab strip. + r.mouseClick(4, 3) + r.waitFor(10*time.Second, "Product") + r.keys("Enter") + r.mouseClick(138, 3) + team := r.waitFor(20*time.Second, "open tabs", "in Product", "2 conversations") + if !strings.Contains(team, "in Product") || !strings.Contains(team, "2 conversations") { + t.Fatal("All did not show the selected team's two tabs") + } + if strings.Contains(team, "Testing notes") || strings.Contains(team, "Saved research") { + t.Fatal("All included a conversation outside the selected team's tabs") + } + capture("02-team-open-tabs") + cleanChatAuditModels(t, home, model, "open-tabs-wall") +} diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 8cbe05b057..0e9087ec55 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/status` | `/info`, `/context` | — | prints every fact the status line knows, one per line | | `/status` | `/info`, `/context` | `--json` | prints the same facts as one JSON object, keys in the same order | | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | -| `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | +| `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`; `▦ All` shows only current tabs) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | | `/helpers` | | `` | asks the team this conversation manages to suggest helpers: sends your words after it, or bare a one-sentence default ask, shown in full; makes no team, and in a conversation that manages none it says to make one with `+ New from this session`, last in the Teams dropdown while no team is selected, which makes the team at once and puts `/helpers` and the ask in the box; on home it says to open that conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 576ca6f901..ef6b35f663 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -35,34 +35,32 @@ returns to ordinary Chats. Narrow strips keep their scroll arrows and a fixed ` ## The conversations view (the wall): how do I see all my conversations at once? -The **conversations view** on Chats shows every saved, non-deleted conversation, including -conversations whose tabs are dismissed and older archived conversations. It reads the same -conversation catalog as `alt+k`, without the switcher's visible-row limit. It is independent -of teams: choosing an overlay does not filter the grid. Browsing and selecting saved cards -never starts work or opens their tabs. Live held conversations update; other cards show saved -previews. Over a hosted connection, saved previews are unavailable unless already cached. - -Open the grid with: - -- `alt+v` (or `√`, composed by `option+v` on some Mac keyboards) -- `/wall` -- `▦ All` at the permanently right-aligned end of the Chats tab strip, including when tabs overflow -- `▦ All` on the dock under the message box, drawn once a second conversation is open - -The strip belongs to Chats; from a place use `alt+v` or `/wall`. On strips narrower than the -12-column header floor it is absent. The title reads `Conversations · all saved conversations`. -Membership dots on tiles are context; they do not filter the grid or edit teams. -Hovering a conversation tab also highlights `▦ All`, revealing the alternate grid view. -The toolbar offers `+ New team s`, `Filter /`, `Columns − +`, `Help ?` and `Back`. - -Cancel with `alt+v`, `esc`, `Back`, or the strip's `▦ All`. Cancelling restores the original -team overlay, conversation and draft. Opening any tile enters ordinary Chats with no overlay, -even when it is the current conversation. You can select an overlay afterward with the Teams dropdown. Closing an open card's tab removes that tab; its conversation stays in the grid and work keeps running. -A saved card without a tab offers no Close action. - -The grid also offers team creation from selected cards. Membership editing, manager assignment, -settings and organization live on Teams. The grid has no team filter. Its hover hint reads -`The grid of all your conversations · alt+v`. +The **conversations view**, opened with `/wall` or `alt+v`, shows every saved, non-deleted conversation, +including dismissed tabs and older archived conversations. It reads the same catalog +as `alt+k`, without the switcher's visible-row limit. Browsing saved cards never +starts work or opens their tabs. Live held conversations update; other cards show +saved previews. Over a hosted connection, saved previews may be unavailable. + +## All — the tabs in this view + +The permanently right-aligned **`▦ All`** button opens a grid of the conversation +tabs in the current strip, including tabs hidden by horizontal overflow. It does +not add older saved chats, dismissed normal tabs or chats open only in another +window. With a team selected, it shows that team's tab row, including its manager; +without a team, it shows the normal open conversation tabs. Work-room tabs and +an untouched new-chat placeholder are not conversation cards. + +The dock's `▦ All` opens the same view. Its heading says `Conversations · open tabs`, followed by the selected +team's name when applicable. The hover hint is +`The grid of tabs in this view`. Use `/wall` or `alt+v` (also `√` on some Mac +keyboards) to browse the full saved library instead. + +Cancel with `esc`, `Back`, or `▦ All` to restore the conversation and draft. +Opening a card in All keeps the selected team; opening a card from the full Chats +library enters ordinary Chats. Closing a normal tab removes its card from All; +it remains saved in the full library, and running work continues. Team tab membership follows +the selected team's strip. The toolbar offers `+ New team s`, `Filter /`, +`Columns − +`, `Help ?` and `Back`. ## The tabs dock under the message box @@ -75,11 +73,11 @@ while it is idle. Amber is only for waiting on you. One open conversation draws Rest the pointer on a square and that square takes the hover ground. The hint line says `Go to Shipping the parser · running · click`, with `waiting on you` or `idle` in the middle. On the square in front it says `Shipping the parser · you are here`. On `▦ All` -it says `The grid of all your conversations · alt+v`, as the strip's `▦ All` does, +it says `The grid of tabs in this view`, as the strip's `▦ All` does, and the hover ground covers the glyph and the word together: they are one button. A press on a square goes to that conversation and does not open this view. A press -anywhere on `▦ All` opens the conversations view, the same as `alt+v`. A press on the +anywhere on `▦ All` opens the current strip's grid. A press on the square in front does nothing. On a narrow row the word `All` is the first thing to go, leaving `▦`. The squares stay, then fewer of them with a `+N`, then the dock is not drawn. @@ -136,7 +134,7 @@ selection controls, including cards waiting for an answer. A **team** is a named group of conversations, such as `harbor`. A conversation can belong to several teams. The Chats overlay shows all the selected team's members, manager first; -the `▦ All` grid always shows all saved conversations regardless of team. +the `▦ All` grid shows the conversation tabs in that selected team's strip. Create a team with **+ New team** below the Teams sidebar list. **+ Add subteam** beside Add chat creates one under the selected team. A dedicated dialog over Teams offers Name, @@ -240,8 +238,8 @@ The chip's x restores ordinary Chats and selects All teams in Teams. With no ove the keyboard starts on the first available team; none of the radio marks is selected. `↑` and `↓` move, `enter` chooses, and `esc` or a press outside cancels. Long pickers scroll with the arrows or wheel, keeping the selected row visible. On very narrow strips the dropdown gives way to the current tab and -the fixed right-edge `▦ All` button. The grid button opens all saved conversations; -it does not select an overlay. +the fixed right-edge `▦ All` button. The grid button shows the current strip's conversation tabs; +it keeps the selected overlay. ## A team's manager diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 4100aaf10c..d4d763f4ac 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1396,7 +1396,7 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) { a.closeWall() return nil, true } - return a.openWall(), true + return a.openTabsWall(), true case tabClose: return a.tabDismiss(hit.tab), true case tabHere: diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index aeb98cce80..9ad2d49893 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -78,6 +78,14 @@ func wallOpenPressed(msg tea.KeyPressMsg) bool { // first frame draws whatever the cache already holds; the readings land a // moment later and the tiles fill in. func (a *app) openWall() tea.Cmd { + return a.openWallFor(false) +} + +func (a *app) openTabsWall() tea.Cmd { + return a.openWallFor(true) +} + +func (a *app) openWallFor(openTabs bool) tea.Cmd { a.teamsEnsure() if a.pageShowing() { a.leavePlace() @@ -86,12 +94,16 @@ func (a *app) openWall() tea.Cmd { now := a.now() a.wall.returnTeam, a.wall.returnView = a.teamViews.id, a.tabView a.wall.on = true + a.wall.openTabs = openTabs a.wall.catalogGen++ - a.wall.catalogBusy, a.wall.savedReading, a.wall.catalogKnown = false, false, false + a.wall.catalogBusy, a.wall.savedReading, a.wall.catalogKnown = false, false, openTabs a.wall.catalogAt = time.Time{} a.wall.catalog = a.conversationCatalog(session.World{}) a.wall.selecting = false a.wall.activeID = "" + if openTabs { + a.wall.activeID = a.wall.returnTeam + } a.wall.openedAt = now a.wall.naming, a.wall.filterOn = false, false a.wall.places = nil @@ -147,8 +159,7 @@ func (a *app) closeWall() { a.touch() } -// wallShown reads the full saved catalog and live tabs. Team overlays -// select Chats tabs only; they cannot hide an unrelated conversation in the grid. +// The library reads saved conversations; All reads only the current strip's tabs. func (a *app) wallShown(now time.Time) []wallTile { return a.wallTiles(now) } @@ -207,6 +218,7 @@ func (a *app) wallFrame(width, height int) []string { a.wall.spinning = false } view := wallView{ + openTabs: a.wall.openTabs, tiles: tiles, focus: a.wall.focus, scroll: a.wall.scroll, @@ -239,6 +251,9 @@ func (a *app) wallFrame(width, height int) []string { doorHot: a.hot.kind == hoverTab && a.wall.door.pressable() && a.hot.index == a.wall.door.from, } view.total = len(a.wallCandidates()) + if a.wall.openTabs { + view.team = a.wall.activeID + } for _, tab := range a.wallMarkedTabs(tiles) { view.selectedNames = append(view.selectedNames, tab.word) } @@ -524,7 +539,9 @@ func (a *app) wallOpen(tiles []wallTile, i int) tea.Cmd { if a.teamViews.id == a.wall.returnTeam { a.tabView = a.wall.returnView } - a.teamViewSet("") + if !a.wall.openTabs { + a.teamViewSet("") + } a.closeWall() a.wallZoomDone() return a.hopLand(a.tabGo(tab)) diff --git a/internal/tui3/wallcatalog.go b/internal/tui3/wallcatalog.go index 73eece4847..6240cd3823 100644 --- a/internal/tui3/wallcatalog.go +++ b/internal/tui3/wallcatalog.go @@ -21,7 +21,7 @@ type wallSavedReadMsg struct { } func (a *app) wallCatalogRead() tea.Cmd { - if !a.wall.on || a.wall.catalogBusy { + if !a.wall.on || a.wall.openTabs || a.wall.catalogBusy { return nil } now := a.now() @@ -151,8 +151,17 @@ func (a *app) wallTakeSavedRead(msg wallSavedReadMsg) { a.touch() } -// The cached catalog includes dismissed tabs. Current strip records refresh -// live identities, while saved metadata supplies state and cost for other cards. +// All shares the strip's membership and ordering, including its selected team. +// The separate Chats library still includes saved and dismissed conversations. func (a *app) wallCandidates() []conversationCandidate { + if a.wall.openTabs { + var out []conversationCandidate + for _, tab := range a.teamStripTabs(a.tabList()) { + if !tab.start && !tab.work && tab.key != "" && !a.conversationDeleted(tab.file) { + out = append(out, conversationCandidate{tab: tab}) + } + } + return out + } return a.conversationCatalogMerge(a.wall.catalog) } diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 896c45d8d3..6ae59f2085 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -9,7 +9,7 @@ import ( // ── THE WALL AND ITS TEAMS: THE SHAPES THE THREE HALVES AGREE ON ─────────── // -// The wall is a full-frame grid of all saved conversations plus live tabs, +// The wall is a full-frame grid of the library or the current strip's tabs, // each drawn from a cached live or saved transcript tail. A team is a named // set of conversations that the tab strip can be narrowed to. // @@ -106,6 +106,7 @@ const wallTailCap = 40 // wallView is everything the painter needs, and nothing it may go and get. type wallView struct { + openTabs bool // team is the active team's id, "" for All, and teams is every team in // the order the Teams row draws them. team string @@ -353,6 +354,7 @@ type wallState struct { // (walltail.go's [app.wallFrontMoved]). frontVer wallFrontVer on bool + openTabs bool focus int scroll int cols int diff --git a/internal/tui3/walldock.go b/internal/tui3/walldock.go index a1c0d9107b..510ed1c045 100644 --- a/internal/tui3/walldock.go +++ b/internal/tui3/walldock.go @@ -61,16 +61,14 @@ const dockFloor = 2 const dockLabel = tabWallWord // dockWallWord is what the hint slot says while the pointer rests on the -// strip's own door to the wall (`▦ All`), and the dock's `▦ All` says -// [dockChatsWord], which is the same sentence: they are the same door. +// strip's and dock's `▦ All` buttons, which share the current tab scope. // // The hint names conversations open here because saved sessions belong to // Home and Sessions, while this button shows this window's tiles side by side. -const dockWallWord = "The grid of all your conversations" + hintSegment + wallOpenKey +const dockWallWord = "The grid of tabs in this view" -// dockChatsWord is what the hint slot says while the pointer rests on the -// dock's `▦ All`. -const dockChatsWord = dockWallWord +// dockChatsWord names the broader conversation library in navigation hints. +const dockChatsWord = "The grid of all your conversations" // dockCell is one conversation's cell as it was drawn: its column and its tab. type dockCell struct { @@ -318,7 +316,7 @@ func (a *app) dockHoverWords() string { } case hoverDockLabel, hoverDockWall: if (a.hot.kind == hoverDockLabel && a.dock.label.pressable()) || (a.hot.kind == hoverDockWall && a.dock.wall.pressable()) { - return dockChatsWord + return dockWallWord } case hoverDockCell: for _, cell := range a.dock.cells { @@ -402,7 +400,7 @@ func (a *app) dockPress(x, y int) (tea.Cmd, bool) { return nil, false } if a.dock.label.holds(x) || a.dock.wall.holds(x) { - return a.openWall(), true + return a.openTabsWall(), true } for _, cell := range a.dock.cells { if !cell.span.holds(x) { diff --git a/internal/tui3/walldock_test.go b/internal/tui3/walldock_test.go index a9d4155ae7..f5f3d5666d 100644 --- a/internal/tui3/walldock_test.go +++ b/internal/tui3/walldock_test.go @@ -163,14 +163,14 @@ func TestDockHoverNamesAndPressGoes(t *testing.T) { } } a.setHover(a.dock.wall.from, y) - if row := screenLines(a)[y]; !strings.HasPrefix(row, " "+dockChatsWord) { + if row := screenLines(a)[y]; !strings.HasPrefix(row, " "+dockWallWord) { t.Fatalf("the hovered wall mark: %q", row) } a.setHover(a.dock.label.from, y) if a.hot.kind != hoverDockLabel { t.Fatalf("the hover on the word is %+v", a.hot) } - if row := screenLines(a)[y]; !strings.HasPrefix(row, " "+dockChatsWord) { + if row := screenLines(a)[y]; !strings.HasPrefix(row, " "+dockWallWord) { t.Fatalf("the hovered word: %q", row) } for _, cell := range a.dock.cells { @@ -292,8 +292,8 @@ func TestDockHoverHintPerCellKind(t *testing.T) { if got := dockCellHint(here); got != "Shipping the parser"+hintSegment+"you are here" { t.Fatalf("here: %q", got) } - if dockChatsWord != dockWallWord { - t.Fatalf("the word and the glyph say %q", dockChatsWord) + if dockChatsWord == dockWallWord || !strings.Contains(dockWallWord, "tabs in this view") { + t.Fatal("the library and All hints do not distinguish their scopes") } } diff --git a/internal/tui3/walldoor_test.go b/internal/tui3/walldoor_test.go index d6b5b8369d..25b953fe53 100644 --- a/internal/tui3/walldoor_test.go +++ b/internal/tui3/walldoor_test.go @@ -79,7 +79,7 @@ func TestTabWallDoorHover(t *testing.T) { if !strings.Contains(cell, "\x1b[48;") || cell == ansi.Cut(rest, door.from, door.to) { t.Fatalf("the hovered door wears no ground: %q", cell) } - if got := a.dockHoverWords(); got != dockWallWord || !strings.Contains(got, "all your conversations") || !strings.HasSuffix(got, wallOpenKey) { + if got := a.dockHoverWords(); got != dockWallWord || !strings.Contains(got, "tabs in this view") { t.Fatalf("the hint slot says %q", got) } t.Logf("120 columns at rest:\n%q\nhovered:\n%q", plain(rest), plain(lit)) @@ -112,6 +112,68 @@ func TestTabWallDoorOpensAndCloses(t *testing.T) { } } +func TestTabWallDoorShowsOpenTabsInsteadOfSavedConversations(t *testing.T) { + a, _ := catalogLab(t, 5) + a.width, a.height = 120, 40 + _ = a.tabsRow(a.width) + command, took := a.tabPress(a.wall.door.from+1, tabStripRow) + if !took { + t.Fatal("All did not take the click") + } + spend(t, a, command) + tiles := a.wallShown(a.now()) + if len(tiles) != 3 { + t.Fatalf("All should show the three open tabs, got %d cards", len(tiles)) + } + if frame := plain(strings.Join(a.wallFrame(a.width, a.height), "\n")); !strings.Contains(frame, "open tabs") || strings.Contains(frame, "all saved conversations") { + t.Fatal("All did not label its open-tab scope") + } + for _, tile := range tiles { + if strings.HasPrefix(tile.name, "Saved conversation") { + t.Fatalf("saved conversation leaked into open tabs: %q", tile.name) + } + } +} + +func TestTabWallDoorKeepsTheSelectedTeamWhenOpeningACard(t *testing.T) { + a, harbor, orbit := menuApp(t) + _ = a.tabsRow(a.width) + command, _ := a.tabPress(a.wall.door.from+1, tabStripRow) + spend(t, a, command) + tiles := a.wallShown(a.now()) + if len(tiles) != 2 || a.wall.activeID != harbor { + t.Fatalf("All lost the selected team's two tabs: cards=%d team=%q", len(tiles), a.wall.activeID) + } + if frame := plain(strings.Join(a.wallFrame(a.width, a.height), "\n")); !strings.Contains(frame, "open tabs · in harbor") { + t.Fatal("All did not name the selected team in its scope") + } + for _, tile := range tiles { + if teamHolds(mustTeam(t, a, orbit), tile.tab.key) { + t.Fatal("another team's chat appeared in this team's All view") + } + } + spend(t, a, a.wallOpen(tiles, 1)) + if a.wall.on || a.teamViews.id != harbor || a.wall.activeID != harbor { + t.Fatal("opening a team card lost its tab scope") + } +} + +func TestTabWallDoorDropsAClosedTab(t *testing.T) { + a := wallDoorApp(t, 120) + for _, tab := range a.tabList() { + if !tab.here { + spend(t, a, a.tabDismiss(tab)) + break + } + } + _ = a.tabsRow(a.width) + command, _ := a.tabPress(a.wall.door.from+1, tabStripRow) + spend(t, a, command) + if got := len(a.wallShown(a.now())); got != 2 { + t.Fatalf("All retained a closed tab: got %d cards, want 2", got) + } +} + // THE DOOR GOES WITH THE ROW, WORD AND GLYPH TOGETHER: ` ▦ All ` wherever it // is drawn and nothing under [tabWallFrom], never a lone glyph; the count of // hidden tabs stays the last thing on it. The row is exactly the frame's width. diff --git a/internal/tui3/wallview.go b/internal/tui3/wallview.go index e5d0eed2d6..53e0db6298 100644 --- a/internal/tui3/wallview.go +++ b/internal/tui3/wallview.go @@ -439,6 +439,9 @@ func wallTitleRow(pal palette, g wallGlyphs, v wallView, width, inset, y int) (s mark, run = "#", "*" } sub := wallOpenHereWord + if v.openTabs { + sub = "open tabs" + } teamName := "" if i := v.teamRow(v.team); i >= 0 { teamName = v.teams[i].name From 8cffffca7ac0678a320829528ec43f777299a4cd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 12:14:52 -0400 Subject: [PATCH 2/8] Keep screenshot launches guarded and use rendered count grammar --- internal/e2e/open_tabs_wall_e2e_test.go | 6 +++--- internal/tui3/wallview.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/e2e/open_tabs_wall_e2e_test.go b/internal/e2e/open_tabs_wall_e2e_test.go index c5279c0f24..26076fc878 100644 --- a/internal/e2e/open_tabs_wall_e2e_test.go +++ b/internal/e2e/open_tabs_wall_e2e_test.go @@ -3,10 +3,10 @@ package e2e import ( + "context" "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -68,7 +68,7 @@ func TestAllShowsTheCurrentTabsOnTheTerminal(t *testing.T) { t.Helper() cleanChatCapture(t, r, stage) if hook := os.Getenv("CODEAF_E2E_SCREENSHOT_HOOK"); hook != "" { - if out, err := exec.Command(hook, r.name, stage).CombinedOutput(); err != nil { + if out, err := guardedCommand(t, context.Background(), home, os.Environ(), hook, r.name, stage).CombinedOutput(); err != nil { t.Fatalf("screenshot: %v: %s", err, out) } } @@ -105,7 +105,7 @@ func TestAllShowsTheCurrentTabsOnTheTerminal(t *testing.T) { r.waitFor(10*time.Second, "Product") r.keys("Enter") r.mouseClick(138, 3) - team := r.waitFor(20*time.Second, "open tabs", "in Product", "2 conversations") + team := r.waitFor(20*time.Second, "open tabs", "in Product", keyedWord("2", "conversations")) if !strings.Contains(team, "in Product") || !strings.Contains(team, "2 conversations") { t.Fatal("All did not show the selected team's two tabs") } diff --git a/internal/tui3/wallview.go b/internal/tui3/wallview.go index 53e0db6298..73adbea58c 100644 --- a/internal/tui3/wallview.go +++ b/internal/tui3/wallview.go @@ -430,8 +430,8 @@ func wallInset(width, c, tileW int) int { // a count growing a digit moves only what is left of it. The count waiting on // a person is a button: it goes to the next one, as ? does. // -// The title says that the grid contains all saved conversations. Team overlays -// never filter this library; the counts describe the cards currently shown. +// The title identifies the saved library or current tab strip, including the +// selected team for scoped tabs; counts describe the cards currently shown. func wallTitleRow(pal palette, g wallGlyphs, v wallView, width, inset, y int) (string, []wallHit) { mark := "▦" run := "⠿" From 3bcb8a5e9233012a5f45078048bda9b89f074684 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 12:15:18 -0400 Subject: [PATCH 3/8] Guard the screenshot terminal option command --- internal/e2e/open_tabs_wall_e2e_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/e2e/open_tabs_wall_e2e_test.go b/internal/e2e/open_tabs_wall_e2e_test.go index 26076fc878..6737397f48 100644 --- a/internal/e2e/open_tabs_wall_e2e_test.go +++ b/internal/e2e/open_tabs_wall_e2e_test.go @@ -49,7 +49,7 @@ func TestAllShowsTheCurrentTabsOnTheTerminal(t *testing.T) { } stopRecoveryEngineOnCleanup(t, nil, home, workspace) r := startDefault(t, "open-tabs-wall", home, workspace, 140, 42, "chat", "--session", members[0].File, "--model", model, "--one-model", "--debug") - if out, err := exec.Command("tmux", "set-option", "-t", r.name, "status", "off").CombinedOutput(); err != nil { + if out, err := guardedCommand(t, context.Background(), home, os.Environ(), "tmux", "set-option", "-t", r.name, "status", "off").CombinedOutput(); err != nil { t.Fatalf("terminal status: %v: %s", err, out) } r.lit("Reply with only OPEN_TABS_READY. Do not change files.") From 7553b343c70d4ef3a183a938e66990c12bb33270 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 12:19:20 -0400 Subject: [PATCH 4/8] Expect current-tab scope in the All navigation hint --- internal/tui3/topnav_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go index d07333c4cc..dcc16e1006 100644 --- a/internal/tui3/topnav_test.go +++ b/internal/tui3/topnav_test.go @@ -369,7 +369,7 @@ func TestEveryNavWordSaysWhatItOpens(t *testing.T) { } drive(t, a, tea.MouseMotionMsg{X: a.wall.door.from, Y: tabStripRow}) all := a.footHint(a.width) - if !strings.Contains(all, "grid of all your conversations") || strings.Contains(all, placeFor(pageChats).about()) { + if !strings.Contains(all, "grid of tabs in this view") || strings.Contains(all, placeFor(pageChats).about()) { t.Fatalf("`▦ All` does not say it is the grid of open tabs: %q", all) } } From d2f20a17fdac7944906cd5cad17b4e4022d99a77 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 13:06:06 -0400 Subject: [PATCH 5/8] Scope the All wall's copy and empty state to open tabs The in-wall All chip now says the grid holds the tabs in this view rather than saved conversations, and an empty scoped grid says no conversations are open here instead of promising saved ones will appear. wallEmptyText is shared by the live grid and the currently unused wallEmptyRow, so the two stay in step. The dock test now proves the label and the mark each open the wall scoped to open tabs, and the render test pins the scoped empty line. Assisted-by: CodeAF (deepseek-v4.1-flash-1017) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/tui3/wallbar.go | 16 ++++++++++------ internal/tui3/walldock_test.go | 8 ++++---- internal/tui3/wallview.go | 16 ++++++++++++++-- internal/tui3/wallview_test.go | 4 ++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/internal/tui3/wallbar.go b/internal/tui3/wallbar.go index 00ad595efc..0882fb01a9 100644 --- a/internal/tui3/wallbar.go +++ b/internal/tui3/wallbar.go @@ -376,6 +376,9 @@ func wallHint(v wallView, ascii bool) string { // tab steps through the teams rather than naming one, so no key is // offered for a single segment. if h.id == "" { + if v.openTabs { + return "Show the tabs in this view" + } return "Show all saved conversations" } // The team's segment counts what is open here, and the hint says what @@ -496,18 +499,19 @@ func wallNewTeamHint(marked int) string { return "Select conversations for a new team" } -// wallEmptyRow is the whisper an empty wall draws, with the way back beside it -// as a button, centred. +// wallEmptyRow is the whisper an empty grid draws, with the way back beside it +// as a button, centred, in the scope it was opened. func wallEmptyRow(pal palette, v wallView, width, y int) (string, []wallHit) { + word := wallEmptyText(v) btn := wallButton{act: wallActBack, label: wallKeysFor(pal.ascii).back + " Back", key: "esc"} - ww, bw := ansi.StringWidth(wallEmptyWord), wallButtonW(btn) + ww, bw := ansi.StringWidth(word), wallButtonW(btn) if ww+3+bw > width { - word := ansi.Truncate(wallEmptyWord, width, "") - return strings.Repeat(" ", (width-ansi.StringWidth(word))/2) + pal.dim(word), nil + cut := ansi.Truncate(word, width, "") + return strings.Repeat(" ", (width-ansi.StringWidth(cut))/2) + pal.dim(cut), nil } left := (width - ww - 3 - bw) / 2 s, _, hits := wallLay(pal, []wallButton{btn}, v.hover, left+ww+3, y, 1) - return strings.Repeat(" ", left) + pal.dim(wallEmptyWord) + " " + s, hits + return strings.Repeat(" ", left) + pal.dim(word) + " " + s, hits } // wallNoneRow is what a narrowed grid with nothing in it says, centred on diff --git a/internal/tui3/walldock_test.go b/internal/tui3/walldock_test.go index f5f3d5666d..33a9f58dbe 100644 --- a/internal/tui3/walldock_test.go +++ b/internal/tui3/walldock_test.go @@ -193,14 +193,14 @@ func TestDockHoverNamesAndPressGoes(t *testing.T) { } _ = frame(a) y = dockRowY(t, a) - if _, took := a.dockPress(a.dock.label.from+2, y); !took || !a.wall.on { - t.Fatal("the word did not open the wall") + if _, took := a.dockPress(a.dock.label.from+2, y); !took || !a.wall.on || !a.wall.openTabs { + t.Fatal("the word did not open the wall on the open tabs") } a.closeWall() _ = frame(a) y = dockRowY(t, a) - if _, took := a.dockPress(a.dock.wall.from, y); !took || !a.wall.on { - t.Fatal("the wall's mark did not open the wall") + if _, took := a.dockPress(a.dock.wall.from, y); !took || !a.wall.on || !a.wall.openTabs { + t.Fatal("the wall's mark did not open the wall on the open tabs") } // A press on the blank between the keys and the dock is nothing. a.closeWall() diff --git a/internal/tui3/wallview.go b/internal/tui3/wallview.go index 73adbea58c..478f63bdcc 100644 --- a/internal/tui3/wallview.go +++ b/internal/tui3/wallview.go @@ -110,9 +110,21 @@ const wallFreshSettle = 600 * time.Millisecond // wallMadeFor is how long the chip row says a team was just made. const wallMadeFor = 2 * time.Second -// wallEmptyWord is the whisper an empty wall draws beside its way back. +// wallEmptyWord is the whisper an empty library draws beside its way back. const wallEmptyWord = "Conversations you save will appear here" +// wallNoTabsWord is the same whisper for All, where saving is not what fills +// the grid: the tabs in this view are. +const wallNoTabsWord = "No conversations open in this view" + +// wallEmptyText is what an empty grid is missing, in the scope it was opened. +func wallEmptyText(v wallView) string { + if v.openTabs { + return wallNoTabsWord + } + return wallEmptyWord +} + // wallBox is one border's glyphs. type wallBox struct{ tl, tr, bl, br, h, v string } @@ -306,7 +318,7 @@ func renderWall(pal palette, v wallView, width, height int) ([]string, []wallHit y := wallGridTop + (gridEnd-wallGridTop-1)/2 row, h := wallNoneRow(pal, v, width, y) if v.filter == "" && v.team == "" { - word := wallEmptyWord + word := wallEmptyText(v) if v.loading { word = "Loading conversations" } diff --git a/internal/tui3/wallview_test.go b/internal/tui3/wallview_test.go index f46ced890c..8ade30a8cb 100644 --- a/internal/tui3/wallview_test.go +++ b/internal/tui3/wallview_test.go @@ -301,6 +301,10 @@ func TestWallRenderStates(t *testing.T) { if !strings.Contains(wallPlainFrame(empty), wallEmptyWord) { t.Fatalf("the empty frame says nothing") } + scoped, _ := renderWall(pal, wallView{openTabs: true}, 80, 24) + if got := wallPlainFrame(scoped); !strings.Contains(got, wallNoTabsWord) || strings.Contains(got, wallEmptyWord) { + t.Fatalf("the empty open-tabs grid says the library line: %q", got) + } v.filtering, v.filter = true, "port" rows, _ = renderWall(pal, v, 120, 40) From 5ad8bfa3fd2a10718be7d6ef258affcf8005d877 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 13:20:44 -0400 Subject: [PATCH 6/8] Make open tabs a property of the open wall closeWall now clears wall.openTabs with wall.on, so no reader after a close can mistake a closed scoped wall for an open one. wallOpen still reads the scope before it closes the wall, which is where the team is kept. Assisted-by: CodeAF (deepseek-v4.1-flash-1017) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/tui3/wall.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index 9ad2d49893..5ec26b238d 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -142,6 +142,7 @@ func (a *app) closeWall() { a.tabView = a.wall.returnView } a.wall.on = false + a.wall.openTabs = false a.wall.catalogGen++ a.wall.catalogBusy, a.wall.savedReading = false, false a.wall.selecting = false From 37e57e8f010116fec551570de55749ddbe9848be Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 13:40:14 -0400 Subject: [PATCH 7/8] Open an overview unpicked A pick outlived a close that was not esc, so a card picked in the wall left the next overview in selection mode - and in the All scope it could be a card nobody had picked there. Every overview now opens with nothing picked. Assisted-by: CodeAF (deepseek-v4.1-flash-1017) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/tui3/wall.go | 7 +++-- internal/tui3/walldoor_test.go | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index 5ec26b238d..df05f6a975 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -109,9 +109,10 @@ func (a *app) openWallFor(openTabs bool) tea.Cmd { a.wall.places = nil a.wall.zoomAt = time.Time{} a.wall.ptrIn, a.wall.rehover = false, false - if a.wall.marked == nil { - a.wall.marked = map[string]bool{} - } + // A PICK BELONGS TO THE WALL IT WAS MADE IN: this grid is a new one, and in + // the All scope it may not even hold the card that was picked, so a kept key + // would leave the grid in selection mode over cards nobody chose. + a.wall.marked = map[string]bool{} tiles := a.wallShown(now) a.wall.focus = 0 keys := make([]string, 0, len(tiles)) diff --git a/internal/tui3/walldoor_test.go b/internal/tui3/walldoor_test.go index 25b953fe53..091393955b 100644 --- a/internal/tui3/walldoor_test.go +++ b/internal/tui3/walldoor_test.go @@ -158,6 +158,56 @@ func TestTabWallDoorKeepsTheSelectedTeamWhenOpeningACard(t *testing.T) { } } +// A PICK MADE IN THE WALL DOES NOT SURVIVE LEAVING IT: the next overview opens +// unpicked, so its first press on a card opens it. Marks used to outlive a close +// that was not esc, which was invisible while every open showed the same +// library; All draws the strip's cards, and a kept key there leaves the grid in +// selection mode over a card nobody chose in it. +func TestTabWallDoorOpensUnpickedAfterAPickWasLeftInIt(t *testing.T) { + a, _ := catalogLab(t, 5) + a.width, a.height = 120, 40 + _ = a.tabsRow(a.width) + if command, took := a.tabPress(a.wall.door.from+1, tabStripRow); !took { + t.Fatal("All did not take the click") + } else { + spend(t, a, command) + } + _ = a.wallFrame(a.width, a.height) + picked := a.wallShown(a.now())[0].tab.key + body := wallHitFor(t, a, wallHitTile, 0) + a.wallMotion(body.x0+4, body.y0+2) + _ = a.wallFrame(a.width, a.height) + wallClick(t, a, wallHitFor(t, a, wallHitSelect, 0)) + if !a.wall.marked[picked] { + t.Fatalf("the pick did not take: %v", a.wall.marked) + } + // Leaving by the strip is not esc: nothing about it is a decision to keep + // the pick, and the person has not asked to remember one. + a.closeWall() + _ = a.tabsRow(a.width) + if command, took := a.tabPress(a.wall.door.from+1, tabStripRow); !took { + t.Fatal("All did not take the second click") + } else { + spend(t, a, command) + } + _ = a.wallFrame(a.width, a.height) + tiles := a.wallShown(a.now()) + for i, tile := range tiles { + if tile.tab.key != picked { + continue + } + if tile.marked { + t.Fatal("All opened wearing the pick from the wall before it") + } + wallClick(t, a, wallHitFor(t, a, wallHitTile, i)) + if a.wall.on { + t.Fatal("the first press on a card picked it instead of opening it") + } + return + } + t.Fatalf("All lost the conversation that was picked: %d cards", len(tiles)) +} + func TestTabWallDoorDropsAClosedTab(t *testing.T) { a := wallDoorApp(t, 120) for _, tab := range a.tabList() { From ec5fb0d84256941fbe4f580fc5283a4daaa4d9a7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 13:46:07 -0400 Subject: [PATCH 8/8] Name the All door as the tabs in view in the manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit places.md still promised all saved conversations from ▦ All, and keys.md listed alt+v, /wall and the strip door as one thing. They are two views since this change. Assisted-by: CodeAF (deepseek-v4.1-flash-1017) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/manual/chat/keys.md | 3 ++- internal/manual/chat/places.md | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index a43120df32..ed2faa0ecb 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1870,7 +1870,8 @@ reads `[m] send to main · [esc] cancel`, because reviving live work would dupli ## Keys in the settings panel and the other panels -**Conversations view** (`alt+v`, `/wall`, or `▦ All` on the strip or under the box): the +**Conversations view** (`alt+v` and `/wall` open the saved library, `▦ All` on the strip or +under the box opens the tabs in the view you are in; the keys below are the same either way): the arrows move the focus · `enter` opens · `space` picks · `x` closes a view (the work keeps running) · `m` its teams · `s` a new team · `e` the shown team's settings · `tab` or `1` to `9` show a team · `/` filters · `?` lists every key, and each of its rows is a button. The diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index d12ad0d9c4..a2ed41f635 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -46,7 +46,9 @@ page instead, so the word never leads nowhere. It is not a room. It is the lit word while you are in a conversation, because that is where the tab strip of your chats is drawn; `tab` and `shift+tab` step over it (they walk the rooms), and it has no count. Its hint says `every conversation, one at a time`, which is how it differs from -`▦ All` on the strip: that one is the grid of all saved conversations. `esc` still goes to home, as on every place. +`▦ All` on the strip: that one is the grid of the conversations open in the view you are in, +all at once. `/wall` and `alt+v` open the library of saved conversations instead, with the +teams beside it. `esc` still goes to home, as on every place. `alt+k` is a different key: it opens the chats switcher to choose *which* conversation, while `chats` on the bar goes straight back to the one you were in. @@ -149,7 +151,9 @@ back. **The hint line says what the word opens and its key** while the pointer rests on it: `alt+2 teams · the teams you hand work to`, `alt+3 chats · every conversation, one at a time`. On `more ▾` it says `more · the places this row has no room for`; on the strip's -`▦ All` it says `The grid of all your conversations · alt+v`. +`▦ All`, and on the same door under the box, it says `The grid of tabs in this view`. +`/wall` and `alt+v` open the library of saved conversations instead, so neither hint +promises saved ones. **A resize drops that hint.** Widening the window so `more ▾` is no longer drawn clears the hover, and the hint line stops saying `more · the places this row has no room for`