Skip to content
Merged
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
25 changes: 25 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -2964,3 +2964,28 @@ ordering guarantee, not a merge of caches written by separate processes.

Restart fixtures drain their owned scheduled writes before discarding process
memory. No timeout, response limit or request/retry budget changes.

## Navigation and first-message response

Home paints its last completed reading and refreshes the world, memory ledger,
spending, deliverables and folder availability off the UI loop. At most **one**
Home reading is outstanding across visits; a late result schedules the current
visit's read rather than replacing its draft or selection. A single completed
reading is retained across visits. Startup can still take the initial reading
before the event loop starts. Page-entry money refreshes also run off the loop.

The first-message opening screen retains the submitted string and displays at
most **width × height × 4 bytes** before sanitizing and wrapping, then clips to
the viewport. Opening failure and cancellation leave the original composers
intact. No model request or session is created by merely opening a new tab.
`navigation_latency_test.go` pins blocked-reader responsiveness, stale results,
and first-message acknowledgement before session creation completes.

`TestNavigationLatency` drives the built binary in a real terminal and records
keypress-to-Home, new-tab, return, and first-message visibility. It separates
ordinary setup from a controlled **800 ms** engine stall and records short-answer
visibility separately from the UI acknowledgement. Polling is **5 ms** plus
command overhead; timing is diagnostic, not a machine-dependent unit-test gate.
Every live model role is pinned to `deepseek/deepseek-v4.1-flash` and call logs
are audited. Raw measurements can be kept outside the repository through
`CODEAF_E2E_EVIDENCE_DIR`.
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1902-snappy-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: show first messages immediately and refresh Home behind the screen
pr: 1902
surface: [chat, docs]
invalidates:
- "Sending from Home or the new-chat page left the starter visible until session creation finished. A first-message screen now acknowledges the send while opening runs in the background, before any model token arrives."
- "Every Home visit and beat synchronously read conversation, memory, spending and folder data. Home now paints its last completed reading and refreshes those inputs off the UI loop, coalescing reads across visits."
---

Page-entry spending refreshes also run beside navigation. Failed or cancelled
opens preserve drafts and attachments. Real-terminal timing coverage separates
navigation, message acknowledgement, an 800 ms backend stall and live-model
answer visibility; measurements and model receipts stay outside the repository.
196 changes: 196 additions & 0 deletions internal/e2e/navigation_latency_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//go:build e2e

package e2e

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/remote"
)

// Timings include actual keyboard delivery, terminal rendering and framebuffer
// observation. rig.keys' 120ms settling sleep is deliberately outside the meter.
func TestNavigationLatency(t *testing.T) {
requireTmuxAndKey(t)
const model = "deepseek/deepseek-v4.1-flash"
if e2eModel != model {
t.Fatal("pin every live role to " + model)
}
seed := newHome(t, map[string]any{"model.talk": model, "models.fallbacks": "", "model_pool": "off", "daily_budget_usd": 0})
ws := newWorkspace(t, "navigation-latency", false)
home, err := os.MkdirTemp("", "afnav-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(home) })
raw, err := os.ReadFile(filepath.Join(seed, "config.json"))
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(home, "config.json"), raw, 0600); err != nil {
t.Fatal(err)
}
r := startWithEnv(t, []string{config.APIKeyEnv + "=" + liveKey(t), "CODEAF_BASE_URL=https://openrouter.ai/api/v1"}, "navigation_latency", home, ws, 140, 42, "chat", "--model", model, "--one-model")
r.skipSetup(t)
sum := sha256.Sum256([]byte(filepath.Clean(ws)))
socket := filepath.Join(home, "v3", "hosts", hex.EncodeToString(sum[:8]), "host.sock")
sockets, err := filepath.Glob(filepath.Join(home, "v3", "hosts", "*", "*.sock"))
if err != nil || len(sockets) != 1 {
t.Fatalf("expected the test's one engine socket, got %d", len(sockets))
}
socket = sockets[0]
conn, err := net.Dial("unix", socket)
if err != nil {
t.Fatal(err)
}
self, err := remote.AskHost(conn, remote.WhoIs{})
conn.Close()
if err != nil {
t.Fatal(err)
}
if self.PID <= 1 {
t.Fatal("the fixture engine did not identify its process")
}
// This PID comes only from the fixture's private socket, never process search.
t.Cleanup(func() { _ = syscall.Kill(self.PID, syscall.SIGCONT); _ = syscall.Kill(self.PID, syscall.SIGTERM) })
results := map[string][]float64{}
send := func(keys ...string) {
t.Helper()
args := append([]string{"send-keys", "-t", r.name}, keys...)
if out, err := exec.Command("tmux", args...).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
wait := func(name string, began time.Time, within time.Duration, ready func(string) bool) string {
t.Helper()
for time.Since(began) < within {
screen := r.capture()
if ready(screen) {
results[name] = append(results[name], float64(time.Since(began))/float64(time.Millisecond))
return screen
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("%s did not draw within %s:\n%s", name, within, r.capture())
return ""
}
chat := func(s string) bool {
return strings.Contains(s, "space space home") && !strings.Contains(s, "first message starts")
}
homeUp := func(s string) bool { return strings.Contains(s, "[Home]") }
startUp := func(s string) bool { return strings.Contains(s, "first message starts the conversation") }
// Leave the initial greeting so the measured chat-return predicate is exact.
r.lit("/status")
r.keys("Enter")
r.keys("Escape")
for i := 0; i < 20; i++ {
began := time.Now()
send("Space", "Space")
wait("home", began, 10*time.Second, homeUp)
began = time.Now()
send("Escape")
wait("home_return", began, 10*time.Second, chat)
began = time.Now()
send("C-t")
wait("new_tab", began, 10*time.Second, startUp)
began = time.Now()
send("Escape")
wait("tab_return", began, 10*time.Second, chat)
text := fmt.Sprintf("typing_latency_%02d", i)
began = time.Now()
send("-l", text)
wait("typing", began, 10*time.Second, func(screen string) bool {
lines := strings.Split(screen, "\n")
return strings.Contains(strings.Join(lines[max(len(lines)-8, 0):], "\n"), text)
})
send("C-u")
wait("typing_clear", time.Now(), 10*time.Second, func(screen string) bool { return !strings.Contains(screen, text) })
send("-l", "/settings")
wait("settings_command", time.Now(), 10*time.Second, func(screen string) bool { return strings.Contains(screen, "/settings") })
began = time.Now()
send("Enter")
wait("settings", began, 10*time.Second, func(screen string) bool { return strings.Contains(screen, "[Settings]") })
began = time.Now()
send("Escape")
wait("settings_return", began, 10*time.Second, chat)
}
for _, slow := range []bool{false, true} {
for trial := 0; trial < 10; trial++ {
where := "new_tab"
if trial%2 == 1 {
where = "home"
r.keys("Space", "Space")
} else {
r.keys("C-t")
}
answer := fmt.Sprintf("NAV_OK_%t_%d", slow, trial)
prompt := "Say exactly " + answer + ". Do not use tools."
r.lit(prompt)
if slow {
if err := syscall.Kill(self.PID, syscall.SIGSTOP); err != nil {
t.Fatal(err)
}
time.AfterFunc(800*time.Millisecond, func() { _ = syscall.Kill(self.PID, syscall.SIGCONT) })
}
kind := "normal_"
if slow {
kind = "delayed_"
}
began := time.Now()
send("Enter")
screen := wait(kind+where+"_message", began, 10*time.Second, func(s string) bool {
lines := strings.Split(s, "\n")
for _, line := range lines[:max(len(lines)-8, 0)] {
if x := strings.Index(line, prompt); x >= 0 && x < 12 {
return true
}
}
return false
})
if dir := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); dir != "" {
os.MkdirAll(dir, 0755)
os.WriteFile(filepath.Join(dir, fmt.Sprintf("%s%s-%d.txt", kind, where, trial)), []byte(screen), 0644)
}
// A complete short answer is measured separately from the UI receipt. It
// includes provider latency and is diagnostic, never a UI-performance gate.
wait(kind+"answer", began, 3*time.Minute, func(s string) bool { return cleanChatHasAnswer(s, answer) })
wait(kind+"idle", time.Now(), 15*time.Second, chat)
}
}
cleanChatAuditModels(t, home, model, "navigation")
usage, err := os.ReadFile(filepath.Join(home, "v3", "usage.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(usage), "\n") {
var row struct {
TTFT float64 `json:"ttft_ms"`
}
if json.Unmarshal([]byte(line), &row) == nil && row.TTFT > 0 {
results["provider_ttft"] = append(results["provider_ttft"], row.TTFT)
}
}

raw, err = json.MarshalIndent(results, "", " ")
if err != nil {
t.Fatal(err)
}
t.Logf("terminal latency milliseconds (5ms polling plus command overhead):\n%s", raw)
if dir := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); dir != "" {
if err = os.WriteFile(filepath.Join(dir, "timings.json"), raw, 0644); err != nil {
t.Fatal(err)
}
}
}
8 changes: 8 additions & 0 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -2461,3 +2461,11 @@ stop its work or release an agent still held by this window. Such a conversation
remains in Home history and can reopen here; it must not claim another window
holds it. The other-window label is reserved for a conversation actually held
elsewhere.

## Home opens immediately while its readings refresh

`space` twice and `/home` show the last completed Home reading immediately.
Conversations, memory changes, spending, deliverables and folder availability
refresh in the background. The first visit can show the page before its rows
arrive. Typing and navigation remain available during that refresh; a reading
from a previous visit cannot replace the current visit's selection or draft.
11 changes: 11 additions & 0 deletions internal/manual/chat/sessions-and-rewind.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,14 @@ rather than taking over an empty conversation that could acquire membership duri

`Conversation unavailable` on a team card means its saved transcript is missing. A missing
file is not recreated silently; add a new conversation and choose it as manager explicitly.

## Starting a new chat without waiting for the first token

Sending a first message from Home or the new-chat page (`+` or `ctrl+t`) shows
its own `new chat` screen immediately, with your message and
`opening conversation… · esc cancels`. The conversation opens in the background;
the message reaches its new conversation only after opening succeeds. This
screen acknowledges your action before the model starts answering. The previous
chat keeps its draft and attachments. Escape cancels the pending opening;
typing also cancels it and edits the original draft. An opening failure returns
to that draft with the error, so your message is not lost or sent to the old chat.
9 changes: 9 additions & 0 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,9 @@ type (
type app struct {
conversationRequest uint64
conversationOpening bool
conversationPreview *conversationPreview
homeReading homeReading
homeReadingPending bool
hostReplayLoading bool
hostReplayPending []followingMsg
hostReplayWaiting bool
Expand Down Expand Up @@ -4441,7 +4444,13 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, cmd
}
if a.conversationOpening {
previewing := a.conversationPreview != nil
a.cancelConversationOpening()
// The preview has a different layout from the page beneath it. The
// cancelling click must not activate that page's old hit targets.
if previewing {
return a, nil
}
}
a.ptr.still = false
a.clearPlaceRowHover()
Expand Down
4 changes: 3 additions & 1 deletion internal/tui3/chatstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ func (a *app) startChatEnter() tea.Cmd {
return nil
}
page := a.liveComposer()
return a.renewLater(a.startSay, func() tea.Cmd {
cmd := a.renewLater(a.startSay, func() tea.Cmd {
a.startBack = startBack{}
a.startKept = composerState{}
// The start page was the greeting; opening its conversation must not
Expand All @@ -444,6 +444,8 @@ func (a *app) startChatEnter() tea.Cmd {
a.putComposer(page)
return a.enterLine()
})
a.previewConversation(page.box.String())
return cmd
}

// startChatKey is the page's claim on the keyboard, and it is four keys wide.
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1389,7 +1389,7 @@ func TestTheFrameDrawsThePageThatWasOpenedLast(t *testing.T) {
t.Fatalf("the settings panel is not what the frame draws:\n%s", frame)
}
// Home over the panel: the frame must change hands, not merely add a flag.
a.openHome()
spend(t, a, a.openHome())
home, _, _ := a.frame()
if strings.Contains(plain(home), tabProviders) {
t.Fatalf("the settings panel is still being drawn under home:\n%s", home)
Expand Down
Loading
Loading