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..6737397f48 --- /dev/null +++ b/internal/e2e/open_tabs_wall_e2e_test.go @@ -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") +} 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/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` 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/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) } } diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index aeb98cce80..df05f6a975 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,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)) @@ -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 @@ -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) } @@ -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, @@ -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) } @@ -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)) 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/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..33a9f58dbe 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 { @@ -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() @@ -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..091393955b 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,118 @@ 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") + } +} + +// 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() { + 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..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" } @@ -430,8 +442,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 := "⠿" @@ -439,6 +451,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 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)