Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) != "",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...),
Expand Down
5 changes: 4 additions & 1 deletion cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions cmd/codeaf/chatv3_view.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions cmd/codeaf/chatv3_view_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1785-restore-last-view.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions internal/config/viewstate.go
Original file line number Diff line number Diff line change
@@ -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)
}
45 changes: 45 additions & 0 deletions internal/config/viewstate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
15 changes: 9 additions & 6 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand Down
Loading
Loading