From 4303f512b0a07bdb85b95b157839f3a13937f339 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:04:48 -0400 Subject: [PATCH 1/4] feat: restore the last local chat and place on startup --- cmd/codeaf/chatv3.go | 30 ++++++--- cmd/codeaf/chatv3_local.go | 5 +- cmd/codeaf/chatv3_surface.go | 10 +++ cmd/codeaf/chatv3_view.go | 29 +++++++++ cmd/codeaf/chatv3_view_test.go | 50 +++++++++++++++ internal/config/viewstate.go | 82 +++++++++++++++++++++++++ internal/config/viewstate_test.go | 45 ++++++++++++++ internal/manual/chat/home.md | 15 +++-- internal/manual/chat/starting-codeaf.md | 28 ++++++++- internal/manual/chat_test.go | 2 + internal/tui3/app.go | 13 +++- internal/tui3/pages.go | 43 +++++++++++++ internal/tui3/tui3.go | 6 ++ internal/tui3/viewstate_test.go | 77 +++++++++++++++++++++++ 14 files changed, 416 insertions(+), 19 deletions(-) create mode 100644 cmd/codeaf/chatv3_view.go create mode 100644 cmd/codeaf/chatv3_view_test.go create mode 100644 internal/config/viewstate.go create mode 100644 internal/config/viewstate_test.go create mode 100644 internal/tui3/viewstate_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 00d05f2034..d4e90f0fab 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -267,6 +267,12 @@ func openChatV3(name string, args []string, pickSession bool) error { // way it is shown on the surface's own notice line below rather than printed // into a terminal the surface is about to take over. entryNotice := "" + launchDir, _ := os.Getwd() + savedView := v3SavedView(config.ProfileDir(), launchDir, *file, *once, pickSession) + bootSession := *file + if savedView.Session != "" { + bootSession = savedView.Session + } if workspace, take := v3HostRoad(v3HostChoice{ noHost: *noHost, once: strings.TrimSpace(*once) != "", @@ -278,15 +284,19 @@ func openChatV3(name string, args []string, pickSession bool) error { debug: trace.Enabled(), setup: !v3MachineIsSetUp(), }); take { + if savedView.Workspace != "" { + workspace = savedView.Workspace + } err := openChatV3Local(localLaunch{ - workspace: workspace, - session: strings.TrimSpace(*file), - model: strings.TrimSpace(*model), - level: level, - once: strings.TrimSpace(*once), - pick: pickSession, - shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""), - restart: restart, + workspace: workspace, + session: strings.TrimSpace(bootSession), + restorePlace: savedView.Place, + model: strings.TrimSpace(*model), + level: level, + once: strings.TrimSpace(*once), + pick: pickSession, + shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""), + restart: restart, }) var taken *hostShapeTaken var unreachable *hostUnreachable @@ -337,7 +347,8 @@ func openChatV3(name string, args []string, pickSession bool) error { Budget: chatBudget(*maxHours, *maxCost), } boot := seed - boot.Session = *file + boot.Session = bootSession + boot.Workspace = savedView.Workspace launch, err := openV3Launch(proc, boot) if err != nil { return err @@ -486,6 +497,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // than to this door, and [runSurface] (chatv3_surface.go) is where every // door gets them. err = runSurface(ctx, tui3.Options{ + RestorePlace: savedView.Place, Agent: agent, Build: buildinfo.String(), UnreadProfileKeys: append([]string(nil), proc.UnreadProfileKeys...), diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index d6a143ae63..f7677ae467 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -44,6 +44,8 @@ import ( // been resolved by the rule that decided to come here ([v3HostRoad]) rather // than parsed out of a flag. type localLaunch struct { + // restorePlace accompanies an implicit saved session, never --session. + restorePlace string // workspace is the directory whose host holds this conversation, absolute // and resolved — the same answer [openV3Launch] would have reached, so both // doors agree about which project this terminal is in. @@ -274,7 +276,8 @@ func openChatV3Local(launch localLaunch) error { // at all (its world is not an answer yet by the first frame, which is what // [app.landHome] refuses on), and a launch that NAMED a conversation or asked // for the picker means that one, exactly as the in-process door reads it. - options.Landing = strings.TrimSpace(launch.session) == "" && !launch.pick + options.Landing = (strings.TrimSpace(launch.session) == "" || launch.restorePlace != "") && !launch.pick + options.RestorePlace = launch.restorePlace // AND THE CONVERSATION THIS LAUNCH COULD NOT HAVE, POINTED AT AND ARMED. // It is "" on every ordinary launch; it is set only by the refusal above, // and the surface lands on home with that row under the cursor diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 809f6d3f61..d9b6c179e3 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -6,6 +6,7 @@ import ( "os" "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/config" internalenv "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" @@ -43,6 +44,15 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + // The original launch directory owns the bookmark even after navigation + // opens another workspace. Far-machine views never replace local history. + if options.Host == "" { + if launchDir, err := os.Getwd(); err == nil { + options.SaveView = func(view config.ViewState) error { + return config.WriteViewState(options.ProfileDir, launchDir, view) + } + } + } revision := surfaceRevision() executable, executableErr := surfaceRunningExecutable() curl := codeupdate.CurlCommand diff --git a/cmd/codeaf/chatv3_view.go b/cmd/codeaf/chatv3_view.go new file mode 100644 index 0000000000..46bbdfc359 --- /dev/null +++ b/cmd/codeaf/chatv3_view.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "strings" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// v3SavedView is only consulted after the far-machine doors have forked. +// Explicit requests and headless work always keep their own session choice. +func v3SavedView(profileDir, launchDir, explicit, once string, pick bool) config.ViewState { + if strings.TrimSpace(explicit) != "" || strings.TrimSpace(once) != "" || pick { + return config.ViewState{} + } + view, ok := config.ViewStateAt(profileDir, launchDir) + if !ok { + return config.ViewState{} + } + transcript, err := os.Stat(view.Session) + if err != nil || !transcript.Mode().IsRegular() { + return config.ViewState{} + } + workspace, err := os.Stat(view.Workspace) + if err != nil || !workspace.IsDir() { + return config.ViewState{} + } + return view +} diff --git a/cmd/codeaf/chatv3_view_test.go b/cmd/codeaf/chatv3_view_test.go new file mode 100644 index 0000000000..234b0b97a0 --- /dev/null +++ b/cmd/codeaf/chatv3_view_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func TestSavedViewRestoresChosenSessionAndObeysExplicitDoors(t *testing.T) { + profile, launch, workspace := t.TempDir(), t.TempDir(), t.TempDir() + chosen := filepath.Join(workspace, "chosen.jsonl") + for _, name := range []string{chosen, filepath.Join(workspace, "newer.jsonl")} { + if err := os.WriteFile(name, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + view := config.ViewState{Session: chosen, Workspace: workspace, Place: "home"} + if err := config.WriteViewState(profile, launch, view); err != nil { + t.Fatal(err) + } + if got := v3SavedView(profile, launch, "", "", false); got.Session != chosen || got.Place != "home" { + t.Fatalf("did not restore selected chat: %+v", got) + } + for _, door := range []struct { + explicit, once string + pick bool + }{{explicit: "asked.jsonl"}, {once: "hello"}, {pick: true}} { + if got := v3SavedView(profile, launch, door.explicit, door.once, door.pick); got.Session != "" { + t.Fatalf("saved view overrode explicit door: %+v", door) + } + } + if err := os.Remove(chosen); err != nil { + t.Fatal(err) + } + if got := v3SavedView(profile, launch, "", "", false); got.Session != "" { + t.Fatal("missing chat was restored") + } + if err := os.WriteFile(chosen, nil, 0o600); err != nil { + t.Fatal(err) + } + view.Workspace = filepath.Join(workspace, "gone") + if err := config.WriteViewState(profile, launch, view); err != nil { + t.Fatal(err) + } + if got := v3SavedView(profile, launch, "", "", false); got.Session != "" { + t.Fatal("missing workspace was restored") + } +} diff --git a/internal/config/viewstate.go b/internal/config/viewstate.go new file mode 100644 index 0000000000..ce4c9f5177 --- /dev/null +++ b/internal/config/viewstate.go @@ -0,0 +1,82 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" +) + +// ViewState remembers navigation, not running work or a temporary decision. +// Place is the registry's word rather than its numeric id, so adding a room +// cannot turn an older bookmark into a different room. +type ViewState struct { + Version int `json:"version"` + Session string `json:"session"` + Workspace string `json:"workspace"` + Place string `json:"place"` +} + +// viewStatePath scopes a bookmark to the directory the terminal launched in. +// Switching projects inside the window must not change that bookmark's key. +func viewStatePath(profileDir, launchDir string) string { + if resolved, err := filepath.EvalSymlinks(launchDir); err == nil { + launchDir = resolved + } + key := sha256.Sum256([]byte(filepath.Clean(launchDir))) + return ProfilePath(profileDir, filepath.Join("views", hex.EncodeToString(key[:])+".json")) +} + +// ViewStateAt treats missing, damaged and future records as no saved view. +// A bookmark is optional navigation and must never prevent startup. +func ViewStateAt(profileDir, launchDir string) (ViewState, bool) { + f, err := os.Open(viewStatePath(profileDir, launchDir)) + if err != nil { + return ViewState{}, false + } + defer f.Close() + var view ViewState + err = json.NewDecoder(io.LimitReader(f, 16*1024)).Decode(&view) + if err != nil || view.Version != 1 || !filepath.IsAbs(view.Session) || !filepath.IsAbs(view.Workspace) || view.Place == "" { + return ViewState{}, false + } + return view, true +} + +// WriteViewState replaces one small record atomically. Concurrent terminals +// keep whole records, with the last navigation or shutdown taking precedence. +func WriteViewState(profileDir, launchDir string, view ViewState) error { + // An explicit --session may have been relative to the launch directory. + // Its saved identity must still name the same file after a later startup. + var err error + if view.Session, err = filepath.Abs(view.Session); err != nil { + return err + } + if view.Workspace, err = filepath.Abs(view.Workspace); err != nil { + return err + } + view.Version = 1 + data, err := json.Marshal(view) + if err != nil { + return err + } + path := viewStatePath(profileDir, launchDir) + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".view-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(data); err != nil { + _ = f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) +} diff --git a/internal/config/viewstate_test.go b/internal/config/viewstate_test.go new file mode 100644 index 0000000000..eb3dc60e02 --- /dev/null +++ b/internal/config/viewstate_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestViewStateKeepsLaunchDirectoriesAndProfilesSeparate(t *testing.T) { + profile, launch := t.TempDir(), t.TempDir() + view := ViewState{Session: "/saved/transcript.jsonl", Workspace: "/project", Place: "settings"} + if err := WriteViewState(profile, launch, view); err != nil { + t.Fatal(err) + } + got, ok := ViewStateAt(profile, launch) + view.Version = 1 + if !ok || got != view { + t.Fatalf("got %+v, found %v", got, ok) + } + for _, scope := range [][2]string{{profile, t.TempDir()}, {t.TempDir(), launch}} { + if _, found := ViewStateAt(scope[0], scope[1]); found { + t.Fatal("another launch directory or profile inherited the view") + } + } + info, err := os.Stat(viewStatePath(profile, launch)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("bookmark permissions: %v, %v", info, err) + } +} + +func TestViewStateIgnoresDamagedAndFutureRecords(t *testing.T) { + profile, launch := t.TempDir(), t.TempDir() + path := viewStatePath(profile, launch) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + for _, data := range []string{"{", `{ "version": 2, "session": "/a", "workspace": "/b", "place": "chat" }`, `{ "version": 1, "session": "relative", "workspace": "/b", "place": "chat" }`} { + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + if _, found := ViewStateAt(profile, launch); found { + t.Fatalf("accepted %s", data) + } + } +} diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 63df9ea87c..03dc8724de 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -657,7 +657,7 @@ conversation. There is no separate Search tab or `/search` command. ## Why did a dashboard open when I started codeaf — home greets you -**Home is the first thing you see when you open codeaf.** The conversation your launch +**Without a saved view, home can be the first thing you see when you open codeaf.** The conversation your launch would have opened is loaded and waiting underneath it: `esc` drops straight into it. In effect the launch is the launch you always had, with home already open on top of it. @@ -666,13 +666,15 @@ were`, wearing `here` — so the first frame already answers "where am I". `↑` the column stays there (see *Where the cursor starts*); `esc` goes on with what you were doing. -Nothing about *which* conversation opens is changed by this. The door picks it exactly as it -always did — this directory's most recently spoken-in chat, or a fresh one — before home is -drawn at all. +An ordinary restart restores the chat and main place last left from this launch directory. +If you left home, it opens over that chat. If you left chat or another place, that view +opens instead. Without a usable bookmark, the door picks this directory's most recently +spoken-in chat, or a fresh one, before deciding whether to greet you with home. It greets you only when it has something to say. All of these have to be true: - You opened codeaf **without naming a conversation**. `codeaf` or `codeaf chat`. +- There is no usable saved view to restore. - The machine holds **a conversation other than the one this launch opened**. Somewhere else to go, in other words. - It is a real terminal session — not `--once`, not `--host`. @@ -682,7 +684,8 @@ typing searches the rest of the saved conversations. ## Skip the home screen — launching straight into a conversation -Four ways, and each of them is you saying which conversation you mean: +Leaving the window on chat makes the next ordinary launch from the same directory +restore chat directly. These explicit launches also choose their own startup view: | What you run | What you get | |---|---| @@ -691,7 +694,7 @@ Four ways, and each of them is you saying which conversation you mean: | `codeaf chat --once "text"` | replies printed with no surface; one reply normally, or every landing-woken reply when `--yolo` has a budget | | `codeaf --host ` | the far machine's session, no greeting — `space` `space` opens that machine's home | -And on a machine with only one conversation — a first run — home does not greet you. +Without a saved view, a machine with only one conversation — a first run — does not greet you with home. There is no setting for this and no flag to turn it off: whether home greets you follows from how you launched and what the machine holds, both of which answer themselves. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 030ce40e72..9eb804e8e9 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -54,11 +54,37 @@ moved by hand: If you have seen “CodeAF” as the name of a coding harness in a benchmark table, that is a different program and nothing here talks to it. +## Restore the last view after closing, quitting or restarting codeaf + +Bare `codeaf` and `codeaf chat` reopen the active conversation and main place +you left: chat, home, teams, sessions, spend, settings, standing or memory. The +bookmark belongs to your profile and the directory you launched from. If you +open another project's chat inside that window, restarting from the original +launch directory restores that chat too. A different launch directory has its +own bookmark. The last local window to navigate or shut down wins when several +windows were launched from the same directory. + +Navigation is saved when it changes and again on shutdown, including closing +the terminal. This works both through the local background engine and with +`--no-host`. It restores the view; it does not restart a stopped task or send an +unsent message. Existing draft recovery still applies. + +Temporary dialogs, searches, scroll positions, selected settings rows and task +drill-ins reset. A task drill-in returns to its main place. Other open tabs are +not reopened. First-run setup and a chat held by another window keep their +usual setup or takeover screen. + +`--session `, `codeaf resume`, `--once`, `--host` and `--at` use their +explicit startup behavior. Remote launches do not overwrite the local +bookmark. A missing chat or workspace, or an unreadable bookmark, falls back to +the usual startup choice. Without a saved view, home greets a terminal launch +when there is another conversation on the machine; otherwise chat opens. + ## Starting it | What you type | What you get | | --- | --- | -| `codeaf` | the home screen, over this directory's most recent conversation | +| `codeaf` | the last chat and place left from this launch directory; without a saved view, the usual greeting | | `codeaf chat` | the same thing | | `codeaf chat --session ` | that conversation, straight in, no home screen | | `codeaf resume` | the chat, opened on the picker of earlier conversations | diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 2d6a1acdfd..98fd393f9c 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -30,6 +30,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { question string page string }{ + {"restore the last view after restarting codeaf", "starting-codeaf"}, + {"why does codeaf reopen a random chat when I close and open it", "starting-codeaf"}, // C12: Key troubleshooting must reach the self-contained restart section. {"I changed or unset my API key in the shell but codeaf still uses the old one", "starting-codeaf"}, {"your key was not accepted for this model", "starting-codeaf"}, diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 4c15c31b7b..f6e85f25bd 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -877,6 +877,10 @@ type ( ) type app struct { + // Navigation is kept independently of transcripts and temporary overlays. + saveView func(config.ViewState) error + keptView config.ViewState + restoreViewCmd tea.Cmd conversationRequest uint64 conversationOpening bool hostReplayLoading bool @@ -3242,7 +3246,10 @@ func newApp(ctx context.Context, opts Options) *app { // It reads the surface it opens over — which conversation this window is in, // so the cursor can open on it — and it must be able to see that the picker // already took the frame, because a launch gets one greeting (home.go). - a.landHome() + a.saveView = opts.SaveView + if !a.restorePlace(opts.RestorePlace) { + a.landHome() + } // AND A LAUNCH THAT MET A LOCK LANDS ON THE ROW IT COULD NOT OPEN, armed, so // one enter continues that conversation here rather than leaving somebody // with a second one they did not ask for (takeover.go). @@ -3429,7 +3436,7 @@ func (a *app) Init() tea.Cmd { a.news.waitRing(), a.leaving.waitRing(), a.landedBell.waitRing(), // AND THE TEAMS' FIRST READ, when the seam held nothing to load above // (teamseam.go); nil on every local launch. - a.teamsWrite()} + a.teamsWrite(), a.restoreViewCmd} if a.warmEmptyProviders != nil { warm := a.warmEmptyProviders standing = append(standing, func() tea.Msg { @@ -3494,6 +3501,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } model, cmd := a.update(msg) + a.keepView(false) // A USAGE NOTICE THE LAST FRAME DREW IS RECORDED HERE, on the loop and once: // the frame may only note that it drew it (view.go), because the door's // record is a write to disk. @@ -8521,6 +8529,7 @@ func (a *app) orchestrateEvent(ev session.Event) tea.Cmd { // conversation it is holding — ending the ones this process runs, detaching from // the ones a host runs (keeper.go's [app.leaveEverything]). func (a *app) quit() tea.Cmd { + a.keepView(true) if a.creditHookStop != nil { a.creditHookStop() a.creditHookStop = nil diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 1e32e167f4..6d98f8ee2b 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1,10 +1,12 @@ package tui3 import ( + "log" "strings" "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" "github.com/charmbracelet/x/ansi" ) @@ -36,6 +38,47 @@ import ( // cutlery drawer. type page uint8 +// restorePlace goes through the same room doors as navigation, including their +// initial reads and clocks. Unknown words let the ordinary greeting decide. +func (a *app) restorePlace(word string) bool { + if word == "" || a.hosted() || a.pickSession || a.takeOverAt != "" { + return false + } + if word == "chat" { + a.dismissWelcome() + return true + } + for _, room := range placeRegistry { + if room.word() == word && room.id() != pageChats { + a.dismissWelcome() + a.restoreViewCmd = a.showPage(room.id()) + return true + } + } + return false +} + +// keepView writes only when navigation changes, plus once on shutdown so the +// last window closed wins. Failed writes are retried on navigation or shutdown, +// never on every streamed token from a conversation that cannot save a file. +func (a *app) keepView(force bool) { + if a.saveView == nil || a.hosted() || a.file == "" || a.workspace == "" { + return + } + word := "chat" + if room := a.showing(); room != nil { + word = room.word() + } + view := config.ViewState{Session: a.file, Workspace: a.workspace, Place: word} + if !force && view == a.keptView { + return + } + if err := a.saveView(view); err != nil { + log.Printf("keep last view: %v", err) + } + a.keptView = view +} + const ( // pageNone is THE CONVERSATION — no place at all. It is the zero value on // purpose: a window that has just opened is sitting in a chat, and every diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 80a39973b4..a5053758a2 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -430,6 +430,10 @@ type CodexFlow interface { // Options configures one surface. type Options struct { + // RestorePlace is the saved registry word for an ordinary local launch. + // SaveView is absent on remote surfaces and tests unless explicitly supplied. + RestorePlace string + SaveView func(config.ViewState) error // Agent is the conversation this surface shows. Required. Agent Agent // EngineRoad says the conversation is in a daemon on this machine. It is @@ -1384,6 +1388,8 @@ func Run(ctx context.Context, opts Options) error { program = append(program, tea.WithWindowSize(opts.Width, opts.Height)) } surface := newApp(ctx, opts) + // The loop has stopped before this final write, including context failures. + defer surface.keepView(true) defer listenForServiceModels(surface, opts.SubscribeServiceModels)() p := tea.NewProgram(surface, program...) // AND THE ENGINE IS GIVEN SOMEWHERE TO PUT ITS NEWS, and the loop a door to diff --git a/internal/tui3/viewstate_test.go b/internal/tui3/viewstate_test.go new file mode 100644 index 0000000000..f3c14b8a34 --- /dev/null +++ b/internal/tui3/viewstate_test.go @@ -0,0 +1,77 @@ +package tui3 + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func TestRestartRestoresEveryMainPlace(t *testing.T) { + for _, room := range placeRegistry { + if room.id() == pageChats { + continue + } + t.Run(room.word(), func(t *testing.T) { + profile, launch := t.TempDir(), t.TempDir() + opts := Options{Agent: &fakeAgent{model: "m"}, ProfileDir: profile, + Workspace: launch, SessionFile: filepath.Join(launch, "chosen.jsonl"), + SaveView: func(view config.ViewState) error { return config.WriteViewState(profile, launch, view) }} + first := newApp(context.Background(), opts) + first.showPage(room.id()) + first.quit() + saved, ok := config.ViewStateAt(profile, launch) + if !ok || saved.Place != room.word() { + t.Fatalf("shutdown saved %+v, found %v", saved, ok) + } + opts.RestorePlace, opts.SessionFile = saved.Place, saved.Session + second := newApp(context.Background(), opts) + if !second.at(room.id()) || second.file != saved.Session || second.welcome.open { + t.Fatalf("restart: page %v, file %s, welcome %v", second.page, second.file, second.welcome.open) + } + second.quit() + }) + } +} + +func TestSavedChatSkipsGreetingAndNavigationWritesOnlyOnChange(t *testing.T) { + profile, workspace := t.TempDir(), t.TempDir() + var saved []config.ViewState + a := newApp(context.Background(), Options{Agent: &fakeAgent{model: "m"}, ProfileDir: profile, + Workspace: workspace, SessionFile: filepath.Join(workspace, "chosen.jsonl"), Landing: true, + RestorePlace: "chat", SaveView: func(view config.ViewState) error { saved = append(saved, view); return nil }}) + if a.showing() != nil || a.welcome.open { + t.Fatal("saved chat was covered by a greeting") + } + a.Update(key("x")) + a.Update(key("y")) + if len(saved) != 1 || saved[0].Place != "chat" { + t.Fatalf("typing rewrote navigation: %+v", saved) + } + a.showPage(pageSettings) + a.Update(key("left")) + if len(saved) != 2 || saved[1].Place != "settings" { + t.Fatalf("navigation did not save settings: %+v", saved) + } + a.quit() + if len(saved) != 3 || saved[2].Place != "settings" { + t.Fatalf("shutdown did not take precedence: %+v", saved) + } +} + +func TestRestorePlaceLeavesExplicitAndRemoteViewsAlone(t *testing.T) { + for _, setup := range []func(*app){func(a *app) { a.pickSession = true }, func(a *app) { a.host = "devbox" }, func(a *app) { a.takeOverAt = "/held.jsonl" }} { + a := newTestApp(&fakeAgent{model: "m"}) + setup(a) + if a.restorePlace("settings") { + t.Fatal("restore covered an explicit, remote or takeover view") + } + a.quit() + } + a := newTestApp(&fakeAgent{model: "m"}) + if a.restorePlace("a-future-place") { + t.Fatal("unknown saved place was accepted") + } + a.quit() +} From 71446454e8a14a9c8e8c367ad06ea17764751f87 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:07:20 -0400 Subject: [PATCH 2/4] fix: arm restored home launch clocks once --- internal/tui3/pages.go | 6 ++++++ internal/tui3/viewstate_test.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 6d98f8ee2b..be8997acd6 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -52,6 +52,12 @@ func (a *app) restorePlace(word string) bool { if room.word() == word && room.id() != pageChats { a.dismissWelcome() a.restoreViewCmd = a.showPage(room.id()) + // Home's launch clocks and card reads are already armed by Init, + // just as on the ordinary greeting. Starting the door's commands + // too would leave two recurring ticks on the same generation. + if a.at(pageHome) { + a.restoreViewCmd = nil + } return true } } diff --git a/internal/tui3/viewstate_test.go b/internal/tui3/viewstate_test.go index f3c14b8a34..05d165214b 100644 --- a/internal/tui3/viewstate_test.go +++ b/internal/tui3/viewstate_test.go @@ -30,6 +30,9 @@ func TestRestartRestoresEveryMainPlace(t *testing.T) { if !second.at(room.id()) || second.file != saved.Session || second.welcome.open { t.Fatalf("restart: page %v, file %s, welcome %v", second.page, second.file, second.welcome.open) } + if second.at(pageHome) && second.restoreViewCmd != nil { + t.Fatal("home restoration armed a second set of launch clocks") + } second.quit() }) } From 5edff2eee50b8b6f3946560529c7325ac22e0b2c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:12:36 -0400 Subject: [PATCH 3/4] fix: recognize current completion batches and document startup override --- cmd/codeaf/chatv3.go | 2 +- internal/manual/chat/starting-codeaf.md | 2 +- internal/tui3/atcompletion_regression_test.go | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index d4e90f0fab..59992c105c 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -90,7 +90,7 @@ func openChatV3(name string, args []string, pickSession bool) error { flags := commandFlags(name) model := flags.String("model", "", "model slug for this session; beats the configured default") once := flags.String("once", "", "run one message non-interactively, print the reply, and exit") - file := flags.String("session", "", "session transcript to resume; empty resumes this directory's most recent") + file := flags.String("session", "", "session transcript to resume; overrides the saved local view") noCompact := flags.Bool("no-compact", false, "never compact automatically") yolo := flags.Bool("yolo", false, "run every tool without asking: the approval default becomes allow") reasoning := flags.String("reasoning", "", "reasoning override: auto (inherit), low, medium, high, xhigh or max; off is an alias for auto") diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 9eb804e8e9..7a344eed11 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -146,7 +146,7 @@ stale is running. | --- | --- | | `--model ` | start on a particular model instead of the configured default | | `--reasoning ` | how hard the model is asked to think: `off`, `low`, `medium` or `high` | -| `--session ` | open a particular conversation file instead of the most recent | +| `--session ` | open that conversation instead of restoring the saved view | | `--host ` | run the conversation on another machine over ssh | | `--once ""` | send one message and print its replies — normally it then exits; with `--yolo` and a budget it stays until handed-over work is home or the limit ends it | | `--no-compact` | never shorten the conversation automatically | diff --git a/internal/tui3/atcompletion_regression_test.go b/internal/tui3/atcompletion_regression_test.go index f7aae6d01f..65f59fec3a 100644 --- a/internal/tui3/atcompletion_regression_test.go +++ b/internal/tui3/atcompletion_regression_test.go @@ -1198,11 +1198,18 @@ func completionRecentCommands(cmd tea.Cmd) []tea.Cmd { if strings.Contains(name, ".loadMentionRecents.") { return []tea.Cmd{cmd} } - if !strings.Contains(name, ".Batch.") { + // Bubble Tea 2.0.8 builds Batch through a generic compactCmds helper. + // Admit both spellings, then check the message type: Sequence shares that + // helper, and its commands must not be mistaken for a batch of readers. + if !strings.Contains(name, ".Batch.") && !strings.Contains(name, ".compactCmds[") { + return nil + } + batch, ok := cmd().(tea.BatchMsg) + if !ok { return nil } var out []tea.Cmd - for _, child := range cmd().(tea.BatchMsg) { + for _, child := range batch { out = append(out, completionRecentCommands(child)...) } return out From aed72d6959145f9a161a886cee4865203494ada5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:16:24 -0400 Subject: [PATCH 4/4] docs: record startup view restoration in PR 1785 --- .../changes/unreleased/1785-restore-last-view.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/1785-restore-last-view.md diff --git a/docs/changes/unreleased/1785-restore-last-view.md b/docs/changes/unreleased/1785-restore-last-view.md new file mode 100644 index 0000000000..dad8fae48c --- /dev/null +++ b/docs/changes/unreleased/1785-restore-last-view.md @@ -0,0 +1,16 @@ +--- +kind: added +title: Ordinary local launches restore the last chat and place +pr: 1785 +surface: [chat] +invalidates: + - "Bare codeaf and codeaf chat chose this directory's recent chat and could greet with home regardless of the view left behind. They now restore the active chat and main place saved for the same profile and launch directory." + - "Recent-completion concurrency tests recognized only the older Bubble Tea Batch closure name. They now recognize the pinned version's compactCmds helper and check the returned message type, so their scheduled readers are actually driven." +--- + +The bookmark is saved atomically on navigation and shutdown, for both the local +background engine and in-process chat. Explicit session, picker, headless and +remote launches keep their startup behavior. Missing conversations or workspaces +fall back to ordinary startup; setup and takeover screens retain precedence. +Temporary overlays, searches, scroll positions, task drill-ins and additional +tabs reset. The manual describes the restored view and its limits.