Skip to content
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1939-all-current-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 117 additions & 0 deletions internal/e2e/open_tabs_wall_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//go:build e2e

package e2e

import (
"context"
"encoding/json"
"fmt"
"os"
"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 := 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.")
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 := guardedCommand(t, context.Background(), home, os.Environ(), 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", 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")
}
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")
}
2 changes: 1 addition & 1 deletion internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | | `<what to ask the manager>` | 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 |
Expand Down
64 changes: 31 additions & 33 deletions internal/manual/chat/conversations-and-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/manual/chat/places.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/chattabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/topnav_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
33 changes: 26 additions & 7 deletions internal/tui3/wall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -86,20 +94,25 @@ 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
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))
Expand Down Expand Up @@ -130,6 +143,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
Expand All @@ -147,8 +161,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)
}
Expand Down Expand Up @@ -207,6 +220,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,
Expand Down Expand Up @@ -239,6 +253,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)
}
Expand Down Expand Up @@ -524,7 +541,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))
Expand Down
Loading
Loading