Merge remote-tracking branch 'origin/main-v2' into fix/transcript-reader-transaction

Resolve overlaps with #9540/#9541/#9542: keep both the reader-transaction
exports (revalidateTail, noteLayoutTransient source) and the pre-paint live
tail repair (pinLiveTailBeforePaint, userResizeRevision, live-region
minHeight); union the transcript test gate; re-measure the bundle budgets on
the merged tree (gzip 452.529 KiB, raw 2432.400 KiB).
This commit is contained in:
SivanCola committed 2026-08-29 03:06:21 +08:00
commit 6e09411289
58 files changed
+2282 -364

No files matched your search

+9
View File
@@ -18,6 +18,15 @@ branch.
readable on old sessions and are stripped from new provider context.
Old `--preset`/`--profile` compatibility no-ops are unchanged.
- **Remote connect wizard host picker:** Step 1's host field now opens the
saved SSH connections through an explicit chevron dropdown on the input's
right edge instead of the old focus-triggered popup. The dropdown lists
every saved connection unfiltered, appends non-standard ports to each row,
leads with a "saved SSH connections" caption, and closes on pick, arrow
toggle, Escape (before the Escape that exits the wizard), or an outside
pointer press. The arrow is hidden while no hosts are saved and disabled
while a connection is busy.
### Fixed
- **Compact MCP discovery:** `use_capability(action=list)` now returns one
+6 -1
View File
@@ -140,6 +140,8 @@ type App struct {
catalogLifecycleMu sync.Mutex
catalogCancel context.CancelFunc
catalogDone chan struct{}
catalogRebuildMu sync.Mutex
catalogRebuild *sessionCatalogRebuildFlight
catalogRebuilding atomic.Bool
shuttingDown atomic.Bool
// catalogReconcileJobs coalesces both the legacy pre-scan and catalog scan.
@@ -149,6 +151,9 @@ type App struct {
catalogReconcileJobs map[string]*desktopCatalogReconcileJob
// Test-only deterministic boundary, set before concurrent requests.
catalogReconcileHook func(sessioncatalog.DirectoryTarget)
// catalogRebuildJoinHook is test-only: it proves concurrent Wails callers
// joined the published rebuild flight before its completion was released.
catalogRebuildJoinHook func()
// projectTreeCatalogRefreshHook is test-only: it proves runtime-only
// navigation never falls back to the broad catalog refresh path.
projectTreeCatalogRefreshHook func()
@@ -520,7 +525,7 @@ func (a *App) startup(ctx context.Context) {
a.mu.Unlock()
go a.restoreOrBuildTabs()
a.registerHistoryIndexEvents()
a.startSessionCatalog(false)
a.startSessionCatalog()
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
a.goSafe("sendStartupPing", a.sendStartupPing)
a.goSafe("flushMetrics", a.flushMetrics)
+125 -29
View File
@@ -15,6 +15,7 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -31,19 +32,23 @@ import (
const credentialProxyProviderName = "reasonix-desktop-proxy"
type credProxyRoute struct {
proxy *httputil.ReverseProxy
model string
proxy *httputil.ReverseProxy
model string
ref string
apiKeyEnv string
provider string
}
// credentialProxy is the desktop-side key holder: a loopback HTTP endpoint
// that authenticates requests by virtual token and forwards them to the real
// provider with the real key. One instance serves the whole app.
type credentialProxy struct {
mu sync.Mutex
ln net.Listener
server *http.Server
port int
routes map[string]*credProxyRoute
mu sync.Mutex
updateMu sync.Mutex
ln net.Listener
server *http.Server
port int
routes map[string]*credProxyRoute
}
func (p *credentialProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -110,28 +115,66 @@ func rewriteJSONModel(body []byte, model string) []byte {
return out
}
func (p *credentialProxy) setRoute(token string, upstream *url.URL, apiKey, model, kind string) {
if kind == "" {
kind = "openai"
func (p *credentialProxy) setRoute(token, ref string, upstream *url.URL, apiKey, model, kind string) {
p.updateMu.Lock()
defer p.updateMu.Unlock()
p.setRouteLocked(token, ref, proxyUpstream{url: upstream, apiKey: apiKey, model: model, kind: kind})
}
func (p *credentialProxy) resolveAndSetRoute(token, ref string, resolve func() (proxyUpstream, error)) (proxyUpstream, error) {
p.updateMu.Lock()
defer p.updateMu.Unlock()
up, err := resolve()
if err != nil {
return proxyUpstream{}, err
}
p.setRouteLocked(token, ref, up)
return up, nil
}
func (p *credentialProxy) setRouteLocked(token, ref string, up proxyUpstream) {
if up.kind == "" {
up.kind = "openai"
}
proxy := &httputil.ReverseProxy{FlushInterval: -1}
proxy.Rewrite = func(req *httputil.ProxyRequest) {
req.SetURL(upstream)
req.SetURL(up.url)
for _, header := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "Via"} {
req.Out.Header.Del(header)
}
if kind == "anthropic" {
if up.kind == "anthropic" {
req.Out.Header.Del("Authorization")
req.Out.Header.Set("x-api-key", apiKey)
req.Out.Header.Set("x-api-key", up.apiKey)
req.Out.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Out.Header.Del("x-api-key")
req.Out.Header.Set("Authorization", "Bearer "+apiKey)
req.Out.Header.Set("Authorization", "Bearer "+up.apiKey)
}
}
p.mu.Lock()
defer p.mu.Unlock()
p.routes[token] = &credProxyRoute{proxy: proxy, model: model}
p.routes[token] = &credProxyRoute{
proxy: proxy, model: up.model, ref: ref,
apiKeyEnv: strings.TrimSpace(up.apiKeyEnv), provider: strings.TrimSpace(up.provider),
}
}
type credentialRouteRegistration struct {
token string
ref string
}
func (p *credentialProxy) routeRegistrationsLocked() []credentialRouteRegistration {
p.mu.Lock()
registrations := make([]credentialRouteRegistration, 0, len(p.routes))
for token, route := range p.routes {
if route != nil && strings.TrimSpace(route.ref) != "" {
registrations = append(registrations, credentialRouteRegistration{token: token, ref: route.ref})
}
}
p.mu.Unlock()
sort.Slice(registrations, func(i, j int) bool { return registrations[i].token < registrations[j].token })
return registrations
}
func (p *credentialProxy) close() {
@@ -248,10 +291,12 @@ type credentialProxyRouteInfo struct {
// proxyUpstream is the resolved desktop-side provider a route forwards to.
type proxyUpstream struct {
apiKey string
url *url.URL
model string
kind string
apiKey string
url *url.URL
model string
kind string
apiKeyEnv string
provider string
}
// resolveProxyProvider resolves a desktop model ref into the upstream the
@@ -281,7 +326,10 @@ func resolveProxyProvider(cfg *config.Config, ref string) (proxyUpstream, error)
if kind == "" {
kind = "openai"
}
return proxyUpstream{apiKey: apiKey, url: upstream, model: entry.Model, kind: kind}, nil
return proxyUpstream{
apiKey: apiKey, url: upstream, model: entry.Model, kind: kind,
apiKeyEnv: entry.APIKeyEnv, provider: entry.Name,
}, nil
}
// registerCredentialProxyRoute binds one workspace token to the current
@@ -317,14 +365,6 @@ func (a *App) desktopModelForWorkspace(hostID, workspace string) string {
}
func (a *App) applyCredentialProxyModel(hostID, workspace, ref string) (credentialProxyRouteInfo, error) {
cfg, err := config.Load()
if err != nil {
return credentialProxyRouteInfo{}, err
}
up, err := resolveProxyProvider(cfg, ref)
if err != nil {
return credentialProxyRouteInfo{}, err
}
port, err := a.credentialProxyPort()
if err != nil {
return credentialProxyRouteInfo{}, err
@@ -341,10 +381,66 @@ func (a *App) applyCredentialProxyModel(hostID, workspace, ref string) (credenti
if err != nil {
return credentialProxyRouteInfo{}, err
}
proxy.setRoute(token, up.url, up.apiKey, up.model, up.kind)
up, err := proxy.resolveAndSetRoute(token, ref, func() (proxyUpstream, error) {
cfg, err := config.Load()
if err != nil {
return proxyUpstream{}, err
}
return resolveProxyProvider(cfg, ref)
})
if err != nil {
return credentialProxyRouteInfo{}, err
}
return credentialProxyRouteInfo{token: token, model: up.model, kind: up.kind, port: port}, nil
}
// saveProviderCredential persists a provider key and synchronously refreshes
// every route that captured its previous value before returning to the UI.
func (a *App) saveProviderCredential(apiKeyEnv, value string) (string, error) {
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
value = strings.TrimSpace(value)
if err := upsertDotEnv(apiKeyEnv, value); err != nil {
return "", err
}
if value == "" {
a.revokeCredentialProxyRoutesByCredential(apiKeyEnv)
} else {
a.refreshCredentialProxyRoutes()
}
return providerCredentialSourceNotice(apiKeyEnv, value), nil
}
// refreshCredentialProxyRoutes rebuilds every registered model-token route.
// updateMu covers credential resolution through route replacement, so an
// older save cannot capture a stale key and publish it after a newer save.
func (a *App) refreshCredentialProxyRoutes() {
a.credProxyMu.Lock()
proxy := a.credProxy
a.credProxyMu.Unlock()
if proxy == nil {
return
}
proxy.updateMu.Lock()
defer proxy.updateMu.Unlock()
// Credential saves can run inside a user-config edit transaction (for
// example while installing provider access). Use the non-migrating loader
// so route refresh never tries to reacquire that transaction's file lock.
cfg, err := config.LoadForRootReadOnly(".")
if err != nil {
log.Printf("[remote] credProxy: credential route refresh skipped: %v", err)
return
}
for _, registration := range proxy.routeRegistrationsLocked() {
up, err := resolveProxyProvider(cfg, registration.ref)
if err != nil {
proxy.revokeRouteLocked(registration.token)
log.Printf("[remote] credProxy: invalid credential route revoked: %v", err)
continue
}
proxy.setRouteLocked(registration.token, registration.ref, up)
}
}
// credentialModeView returns the host entry's normalized credential mode for
// views ("" reads as "remote" — the default).
func credentialModeView(h config.RemoteHostEntry) string {
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"reasonix/internal/config"
)
func TestSaveProviderCredentialRefreshesRouteWhileConfigEditLocked(t *testing.T) {
isolateDesktopUserDirs(t)
const keyEnv = "TEST_PROXY_CONFIG_LOCK_KEY"
setDesktopTestCredential(t, keyEnv, "sk-before-lock")
auth := make(chan string, 1)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth <- r.Header.Get("Authorization")
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
modelRef, _ := configureCredentialProxyTestModels(t, upstream.URL, keyEnv)
a := &App{}
t.Cleanup(a.closeCredentialProxy)
route, err := a.applyCredentialProxyModel("box", "~/app", modelRef)
if err != nil {
t.Fatal(err)
}
if err := func() error {
unlock := config.LockUserConfigEdits()
defer unlock()
_, err := a.saveProviderCredential(keyEnv, "sk-after-lock")
return err
}(); err != nil {
t.Fatal(err)
}
requestCredentialProxy(t, route.port, route.token)
if got := <-auth; got != "Bearer sk-after-lock" {
t.Fatalf("refreshed upstream auth = %q, want rotated credential", got)
}
}
+60
View File
@@ -0,0 +1,60 @@
package main
import "strings"
// revokeRoutes serializes revocation with credential resolution and route
// publication. A route update that already started must finish before its
// matching token is removed, so it cannot republish stale credentials after
// the user-facing clear or provider-removal operation returns.
func (p *credentialProxy) revokeRoutes(match func(*credProxyRoute) bool) {
p.updateMu.Lock()
defer p.updateMu.Unlock()
p.mu.Lock()
defer p.mu.Unlock()
for token, route := range p.routes {
if route != nil && match(route) {
delete(p.routes, token)
}
}
}
// revokeRouteLocked removes one route while the caller holds updateMu.
func (p *credentialProxy) revokeRouteLocked(token string) {
p.mu.Lock()
delete(p.routes, token)
p.mu.Unlock()
}
func (a *App) revokeCredentialProxyRoutesByCredential(apiKeyEnv string) {
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
if apiKeyEnv == "" {
return
}
a.credProxyMu.Lock()
proxy := a.credProxy
a.credProxyMu.Unlock()
if proxy != nil {
proxy.revokeRoutes(func(route *credProxyRoute) bool { return route.apiKeyEnv == apiKeyEnv })
}
}
func (a *App) revokeCredentialProxyRoutesByProvider(names []string) {
targets := make(map[string]struct{}, len(names))
for _, name := range names {
if name = strings.TrimSpace(name); name != "" {
targets[name] = struct{}{}
}
}
if len(targets) == 0 {
return
}
a.credProxyMu.Lock()
proxy := a.credProxy
a.credProxyMu.Unlock()
if proxy != nil {
proxy.revokeRoutes(func(route *credProxyRoute) bool {
_, remove := targets[route.provider]
return remove
})
}
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/config"
)
func TestClearProviderKeyRevokesCredentialProxyRoute(t *testing.T) {
isolateDesktopUserDirs(t)
const keyEnv = "TEST_PROXY_CLEAR_KEY"
setDesktopTestCredential(t, keyEnv, "sk-before-clear")
var upstreamCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamCalls.Add(1)
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
firstRef, _ := configureCredentialProxyTestModels(t, upstream.URL, keyEnv)
a := &App{}
t.Cleanup(a.closeCredentialProxy)
route, err := a.applyCredentialProxyModel("box", "~/app", firstRef)
if err != nil {
t.Fatal(err)
}
if status := credentialProxyStatus(t, route.port, route.token); status != http.StatusOK {
t.Fatalf("initial route status = %d, want 200", status)
}
if err := a.ClearProviderKey(keyEnv); err != nil {
t.Fatal(err)
}
if status := credentialProxyStatus(t, route.port, route.token); status != http.StatusUnauthorized {
t.Fatalf("cleared route status = %d, want 401", status)
}
if got := upstreamCalls.Load(); got != 1 {
t.Fatalf("revoked token reached upstream: calls=%d, want 1", got)
}
}
func TestDeleteProviderRevokesOnlyItsCredentialProxyRoutes(t *testing.T) {
isolateDesktopUserDirs(t)
const keyEnv = "TEST_PROXY_PROVIDER_DELETE_KEY"
setDesktopTestCredential(t, keyEnv, "sk-shared")
var upstreamCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
upstreamCalls.Add(1)
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
firstRef, secondRef := configureCredentialProxyTestModels(t, upstream.URL, keyEnv)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Desktop.ProviderAccess = []string{"proxy-first", "proxy-second"}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
a := &App{}
t.Cleanup(a.closeCredentialProxy)
first, err := a.applyCredentialProxyModel("box", "~/app", firstRef)
if err != nil {
t.Fatal(err)
}
second, err := a.applyCredentialProxyModel("box", "~/app", secondRef)
if err != nil {
t.Fatal(err)
}
for _, route := range []credentialProxyRouteInfo{first, second} {
if status := credentialProxyStatus(t, route.port, route.token); status != http.StatusOK {
t.Fatalf("initial route status = %d, want 200", status)
}
}
if err := a.DeleteProvider("proxy-first"); err != nil {
t.Fatal(err)
}
if status := credentialProxyStatus(t, first.port, first.token); status != http.StatusUnauthorized {
t.Fatalf("deleted provider route status = %d, want 401", status)
}
if status := credentialProxyStatus(t, second.port, second.token); status != http.StatusOK {
t.Fatalf("remaining provider route status = %d, want 200", status)
}
if got := upstreamCalls.Load(); got != 3 {
t.Fatalf("upstream calls = %d, want 3", got)
}
}
func TestCredentialProxyRevocationSerializesWithRoutePublish(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
parsed := mustParseURL(t, upstream.URL)
p := &credentialProxy{routes: map[string]*credProxyRoute{}}
resolving := make(chan struct{})
release := make(chan struct{})
published := make(chan error, 1)
go func() {
_, err := p.resolveAndSetRoute("virtual-tok", "provider/model", func() (proxyUpstream, error) {
close(resolving)
<-release
return proxyUpstream{
url: parsed, apiKey: "sk-old", model: "model",
kind: "openai", apiKeyEnv: "TEST_KEY", provider: "provider",
}, nil
})
published <- err
}()
<-resolving
revoked := make(chan struct{})
go func() {
p.revokeRoutes(func(route *credProxyRoute) bool { return route.apiKeyEnv == "TEST_KEY" })
close(revoked)
}()
close(release)
if err := <-published; err != nil {
t.Fatal(err)
}
<-revoked
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/v1/chat/completions", strings.NewReader(`{"model":"model"}`))
req.Header.Set("Authorization", "Bearer virtual-tok")
recorder := httptest.NewRecorder()
p.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", recorder.Code)
}
}
func credentialProxyStatus(t *testing.T, port int, token string) int {
t.Helper()
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), strings.NewReader(`{"model":"placeholder"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
+144 -4
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"slices"
"strings"
"sync"
"testing"
"reasonix/internal/config"
@@ -52,7 +53,7 @@ func TestCredentialProxyAuthSwap(t *testing.T) {
t.Fatal(err)
}
const token = "virtual-tok"
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "", "")
a.credProxy.setRoute(token, "", mustParseURL(t, upstream.URL), "sk-real-key", "", "")
proxyURL := fmt.Sprintf("http://127.0.0.1:%d/v1/chat", port)
do := func(auth string) (int, string) {
@@ -117,7 +118,7 @@ func TestCredentialProxyRewritesRequestModel(t *testing.T) {
t.Fatal(err)
}
const token = "virtual-tok"
a.credProxy.setRoute(token, mustParseURL(t, upstream.URL), "sk-real-key", "deepseek-v4-pro", "openai")
a.credProxy.setRoute(token, "", mustParseURL(t, upstream.URL), "sk-real-key", "deepseek-v4-pro", "openai")
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), strings.NewReader(`{"model":"deepseek-v4-flash","messages":[]}`))
if err != nil {
@@ -151,7 +152,7 @@ func TestCredentialProxyRejectsUnreadableOrOversizeBodies(t *testing.T) {
}))
defer upstream.Close()
p := &credentialProxy{routes: map[string]*credProxyRoute{}}
p.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "model", "openai")
p.setRoute("virtual-tok", "", mustParseURL(t, upstream.URL), "sk-real-key", "model", "openai")
request := func(body io.ReadCloser, contentLength int64) int {
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/v1/chat/completions", body)
@@ -177,6 +178,9 @@ func TestCredentialProxyRejectsUnreadableOrOversizeBodies(t *testing.T) {
// desktop keeps the same route while distinct workspaces stay isolated.
func TestCredentialProxyTokenStableAcrossRestarts(t *testing.T) {
seedBridgeTestHost(t, "box")
const keyEnv = "TEST_PROXY_TOKEN_STABILITY_KEY"
setDesktopTestCredential(t, keyEnv, "sk-test")
configureCredentialProxyTestModels(t, "https://example.invalid/v1", keyEnv)
a1 := &App{}
t.Cleanup(a1.closeCredentialProxy)
i1, err := a1.registerCredentialProxyRoute("box", "~/app")
@@ -226,6 +230,9 @@ func TestCredentialProxyModelTokensKeepRoutesImmutable(t *testing.T) {
func TestCredentialProxyReconnectRegistersTrackedWorkspaces(t *testing.T) {
seedBridgeTestHost(t, "box")
const keyEnv = "TEST_PROXY_RECONNECT_KEY"
setDesktopTestCredential(t, keyEnv, "sk-test")
configureCredentialProxyTestModels(t, "https://example.invalid/v1", keyEnv)
app := &App{}
t.Cleanup(app.closeCredentialProxy)
mgr := newDesktopRemoteManager(app)
@@ -352,7 +359,7 @@ func TestCredentialProxyAnthropicAuthShape(t *testing.T) {
if err != nil {
t.Fatal(err)
}
a.credProxy.setRoute("virtual-tok", mustParseURL(t, upstream.URL), "sk-real-key", "", "anthropic")
a.credProxy.setRoute("virtual-tok", "", mustParseURL(t, upstream.URL), "sk-real-key", "", "anthropic")
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/messages", port), strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
@@ -422,3 +429,136 @@ func TestCredentialModeConfigRoundTrip(t *testing.T) {
t.Fatalf("bogus mode normalized to %q", n)
}
}
// TestSaveProviderCredentialRefreshesEveryModelRoute: old model tokens can
// remain active in detached/background controllers, so rotating a provider key
// must update all registered routes rather than only the workspace's latest
// model.
func TestSaveProviderCredentialRefreshesEveryModelRoute(t *testing.T) {
isolateDesktopUserDirs(t)
const keyEnv = "TEST_PROXY_REFRESH_KEY"
setDesktopTestCredential(t, keyEnv, "sk-before-rotation")
auth := make(chan string, 2)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth <- r.Header.Get("Authorization")
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
firstRef, secondRef := configureCredentialProxyTestModels(t, upstream.URL, keyEnv)
a := &App{}
t.Cleanup(a.closeCredentialProxy)
first, err := a.applyCredentialProxyModel("box", "~/app", firstRef)
if err != nil {
t.Fatal(err)
}
second, err := a.applyCredentialProxyModel("box", "~/app", secondRef)
if err != nil {
t.Fatal(err)
}
if first.token == second.token {
t.Fatal("different models shared one route token")
}
if _, err := a.saveProviderCredential(keyEnv, "sk-after-rotation"); err != nil {
t.Fatal(err)
}
for _, route := range []credentialProxyRouteInfo{first, second} {
requestCredentialProxy(t, route.port, route.token)
if got := <-auth; got != "Bearer sk-after-rotation" {
t.Fatalf("refreshed upstream auth = %q, want rotated credential", got)
}
}
}
// TestCredentialProxySerializesResolveAndPublish pins the latest-save-wins
// boundary: a newer route update queued while an older credential resolution
// is in progress must publish last. Channel gates make the overlap exact and
// avoid scheduler sleeps.
func TestCredentialProxySerializesResolveAndPublish(t *testing.T) {
auth := make(chan string, 1)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth <- r.Header.Get("Authorization")
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
parsed := mustParseURL(t, upstream.URL)
p := &credentialProxy{routes: map[string]*credProxyRoute{}}
oldResolving := make(chan struct{})
releaseOld := make(chan struct{})
newCalling := make(chan struct{})
errs := make(chan error, 2)
var updates sync.WaitGroup
updates.Add(2)
go func() {
defer updates.Done()
_, err := p.resolveAndSetRoute("virtual-tok", "provider/model", func() (proxyUpstream, error) {
close(oldResolving)
<-releaseOld
return proxyUpstream{url: parsed, apiKey: "sk-older", model: "model", kind: "openai"}, nil
})
errs <- err
}()
<-oldResolving
go func() {
defer updates.Done()
close(newCalling)
_, err := p.resolveAndSetRoute("virtual-tok", "provider/model", func() (proxyUpstream, error) {
return proxyUpstream{url: parsed, apiKey: "sk-newer", model: "model", kind: "openai"}, nil
})
errs <- err
}()
<-newCalling
close(releaseOld)
updates.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/v1/chat/completions", strings.NewReader(`{"model":"model"}`))
req.Header.Set("Authorization", "Bearer virtual-tok")
recorder := httptest.NewRecorder()
p.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
if got := <-auth; got != "Bearer sk-newer" {
t.Fatalf("final upstream auth = %q, want the newer credential", got)
}
}
func configureCredentialProxyTestModels(t *testing.T, baseURL, keyEnv string) (string, string) {
t.Helper()
const firstRef = "proxy-first/model-a"
const secondRef = "proxy-second/model-b"
cfg := config.Default()
cfg.DefaultModel = firstRef
cfg.Providers = []config.ProviderEntry{
{Name: "proxy-first", Kind: "openai", BaseURL: baseURL, Model: "model-a", APIKeyEnv: keyEnv},
{Name: "proxy-second", Kind: "openai", BaseURL: baseURL, Model: "model-b", APIKeyEnv: keyEnv},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save credential proxy test config: %v", err)
}
return firstRef, secondRef
}
func requestCredentialProxy(t *testing.T, port int, token string) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", port), strings.NewReader(`{"model":"placeholder"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript-browser": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs",
"test:transcript-reader-browser": "node bench/transcript-reader-transaction.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:diagnostics && pnpm test:transcript",
@@ -138,23 +138,15 @@ console.log("\nbundle budgets");
// The final 0.266 KiB retains jump ownership through paint-ready instead of
// allowing a native scrollend to release it. Keep only 0.213 KiB headroom;
// native validation hosts and test fixtures stay outside the production graph.
// Reader transactions, geometry revisions, and generation-fenced writer
// requests add 2.657 KiB gzip (+0.59%) to that exact baseline. This is the
// production ownership state machine; replay fixtures and native hosts remain
// outside the bundle. The measured path is 450.244 KiB; retain 0.256 KiB of
// bounded toolchain headroom. Cross-frame tail-write confirmation and its
// single LAST/quiet fallback plus the accepted-extent baseline bring the exact
// path to 450.765 KiB. Retain only 0.035 KiB of build-hash headroom rather than
// weakening either retry bound. Logical-anchor screen displacement protection
// brings the measured path to 450.876 KiB while closing the same-scrollTop
// WKWebView range-swap gap. The bounded post-correction observation window
// measures 450.960 KiB. Synchronous idle-reader anchoring plus the pre-paint
// reader sample and stale-overscan-anchor fence measure 451.199 KiB; retain
// only 0.101 KiB of bounded toolchain headroom. Notification volume plus
// per-source loudness normalization adds another measured 0.243 KiB on the
// updated main-v2 baseline. Retain bounded build/toolchain headroom for the
// combined path.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 451.8 : 451.8;
// Notification volume plus per-source loudness normalization moves current
// main-v2 from 447.639 to 447.882 KiB gzip (+0.243 KiB). Retain 0.118 KiB of
// bounded build/toolchain headroom.
// Pre-paint live-tail stabilization and its monotonic live-footer guard then
// add 0.695 KiB after extracting ownership modules below repolint's source
// ceilings. Surface- and resize-scoped floor fencing adds another 0.113 KiB;
// the combined path measures 448.692 KiB. Retain 0.108 KiB of bounded
// build/toolchain headroom.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 448.8 : 448.8;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
@@ -232,22 +224,15 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// raw/toolchain headroom for both owners.
// The same transcript transaction measures 2413.012 KiB raw (+0.28%) against
// the 2406.204 KiB baseline. Retain 0.188 KiB of bounded headroom.
// The reader/geometry/writer contract brings the measured raw path to
// 2424.562 KiB while leaving all browser replay and native validation code
// outside the production graph. The bounded tail range confirmation brings
// this to 2425.341 KiB with the accepted-extent baseline. The reader-to-tail
// layout-safe handoff measures 2425.420 KiB (+0.079 KiB) and prevents
// WKWebView from contracting the measured mount window during ownership
// transfer. The logical-anchor drift guard brings that path to 2425.651 KiB;
// the post-correction observation window measures 2425.969 KiB. The idle
// manual-anchor handoff and reader pre-paint anchor eligibility checks bring
// the measured path to 2426.921 KiB. The notification-volume control adds
// another measured 1.696 KiB raw on current main-v2; retain bounded headroom
// for the combined production graph.
// The stabilized-shrink extent acceptance (gesture travel proof plus the
// reader-transaction diagnostic and its bench hook) adds a measured 0.554 KiB
// raw on the reader transaction path, bringing it to 2429.754 KiB against the
// 2429.2 KiB baseline; retain 0.046 KiB of bounded headroom.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_429.8 : 2_429.8;
// The notification-volume control adds one persisted master gain, per-source
// loudness trims, and its accessible Settings surface. Current main-v2 moves
// from 2413.183 to 2414.879 KiB raw (+1.696 KiB); retain 0.121 KiB of bounded
// headroom.
// Owner-lifecycle reasoning disclosure, pre-paint tail pinning, and the live
// footer growth floor then add 2.390 KiB after extracting ownership modules
// below repolint's source ceilings. Lifecycle fencing adds 0.258 KiB; the
// combined path measures 2417.526 KiB. Retain 0.074 KiB while preventing
// phase-boundary reverse flashes and cross-surface floor leaks.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_417.6 : 2_417.6;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+1
View File
@@ -41,6 +41,7 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"stream-delta-batch.test.ts": "test:stream",
"use-controller-stream-progress.test.ts": "test:stream",
"transcript-virtuoso-index.test.ts": "test:transcript",
"transcript-live-turn-stability.test.tsx": "test:transcript",
"transcript-scroll-release.test.ts": "test:transcript",
"transcript-virtualization.test.tsx": "test:transcript",
"nested-scroll-handoff.test.ts": "test:transcript",
@@ -91,6 +91,17 @@ eq(
false,
"suppresses React's recoverable concurrent-render diagnostic",
);
const supersededRemoteStatusError = new Error('remote tab "remote-1" status was superseded by newer runtime state');
eq(
shouldReportGlobalCrashEvent({ defaultPrevented: false, reason: supersededRemoteStatusError }),
false,
"suppresses superseded remote status errors delivered through PromiseRejectionEvent.reason",
);
eq(
shouldReportGlobalCrashEvent({ defaultPrevented: false, reason: new Error("remote status transport failed") }),
true,
"reports unrelated PromiseRejectionEvent reasons",
);
eq(
shouldReportGlobalCrashEvent({
defaultPrevented: false,
@@ -0,0 +1,36 @@
import { JSDOM } from "jsdom";
import { act } from "react";
export function installTranscriptRecoveryRaceDom() {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => void frames.delete(id);
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;
const flushFrames = async () => {
const pending = [...frames.entries()];
frames.clear();
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
};
return { dom, flushFrames };
}
@@ -218,6 +218,17 @@ await render({
});
ok(Boolean(document.querySelector(".reasoning__body")), "auto mode opens reasoning while it streams");
await render({
kind: "assistant",
id: "a-auto",
text: "answer",
reasoning: "live thought",
streaming: true,
reasoningComplete: true,
});
ok(Boolean(document.querySelector(".reasoning__body")), "auto mode keeps reasoning open after its first answer token while the turn streams");
ok(!document.querySelector(".reasoning-summary"), "active turn does not replace full reasoning with a summary");
await render({
kind: "assistant",
id: "a-auto",
@@ -195,6 +195,12 @@ ok(railItems().every((item) => !item.className.includes("--done")), "no step is
ok(document.querySelectorAll(".remote-wizard__seg").length === 3, "auth, download, and credential mode use segmented sliders");
const hostInput = document.querySelector<HTMLInputElement>(".remote-wizard__suggest input");
ok(Boolean(hostInput), "config step shows the host input");
ok(
hostInput?.closest("label") === null &&
hostInput?.labels?.length === 1 &&
hostInput.labels[0]?.textContent?.trim() === "Host",
"host input uses an exact explicit label",
);
ok(document.activeElement === hostInput, "opening the dialog focuses the first field");
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
@@ -228,19 +234,52 @@ ok(document.activeElement === hostInput, "Tab from the last action wraps to the
await Promise.resolve();
});
}
// ── Saved-host suggestion: focus → dropdown → prefill ──
// ── Saved-host suggestion: arrow toggle → dropdown → prefill ──
const toggleArrow = () => document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-toggle");
ok(Boolean(toggleArrow()), "host field exposes the saved-connections arrow");
ok(toggleArrow()?.closest("label") === null, "saved-connections arrow stays outside the host label");
ok(toggleArrow()?.getAttribute("aria-haspopup") === "menu", "arrow advertises its saved-host menu");
ok(toggleArrow()?.getAttribute("aria-expanded") === "false", "arrow starts collapsed");
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
await Promise.resolve();
});
const suggestion = document.querySelector<HTMLButtonElement>(".remote-wizard__suggest-list button");
ok(Boolean(suggestion), "focusing the host input lists saved SSH connections");
ok(!document.querySelector(".remote-wizard__suggest-list"), "focusing the host input alone no longer opens the list");
await act(async () => {
suggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
toggleArrow()?.click();
await Promise.resolve();
});
ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "clicking the arrow lists saved SSH connections");
ok(toggleArrow()?.getAttribute("aria-expanded") === "true", "arrow reflects the expanded state");
ok((document.querySelector(".remote-wizard__suggest-head")?.textContent ?? "").toLowerCase().includes("ssh"), "dropdown leads with the saved-connections caption");
ok(document.querySelector(".remote-wizard__suggest-list")?.getAttribute("role") === "menu", "saved hosts use menu semantics");
const menuItems = [...document.querySelectorAll<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]')];
ok(menuItems.length === savedHosts.length, "every saved host is exposed as a menu item");
ok(document.activeElement === menuItems[0], "opening the menu moves focus to the first saved host");
await act(async () => {
menuItems[0]?.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await Promise.resolve();
});
ok(document.activeElement === menuItems[1], "ArrowDown moves focus to the next saved host");
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await Promise.resolve();
});
ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the keyboard-opened menu");
ok(document.activeElement === toggleArrow(), "Escape restores focus to the saved-host arrow");
await act(async () => {
toggleArrow()?.click();
await Promise.resolve();
});
const suggestion = document.querySelector<HTMLButtonElement>('.remote-wizard__suggest-list [role="menuitem"]');
await act(async () => {
suggestion?.click();
await Promise.resolve();
});
ok(hostInput?.value === "192.168.1.10", "picking a suggestion prefills the host");
ok(!document.querySelector(".remote-wizard__suggest-list"), "picking a suggestion closes the list");
ok(document.activeElement === hostInput, "picking a suggestion restores focus to the host input");
const keyInput = [...document.querySelectorAll<HTMLInputElement>("input")].find((i) => i.value.includes("id_ed25519"));
ok(Boolean(keyInput), "saved key auth switches the form to key mode with the identity file");
await act(async () => {
@@ -251,36 +290,90 @@ ok(tape.includes("PickRemoteIdentityFile"), "identity-file action uses the nativ
ok(keyInput?.value === "/home/dev/.ssh/id_wizard", "native picker returns the absolute identity-file path");
{
const listButtons = () => [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")];
await act(async () => {
if (hostInput) setInput(hostInput, "");
await Promise.resolve();
});
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
toggleArrow()?.click();
await Promise.resolve();
});
const listed = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("pw-box"));
const listed = listButtons().find((b) => b.textContent?.includes("pw-box"));
await act(async () => {
listed?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
listed?.click();
await Promise.resolve();
});
const passwordInput = document.querySelector<HTMLInputElement>(".remote-wizard__field input[type='password']");
ok((passwordInput?.placeholder ?? "").toLowerCase().includes("saved") || (passwordInput?.placeholder ?? "").includes("已保存"), "saved password host keeps a keep-existing placeholder");
// Typed text no longer filters the list: reopen with a filled host field
// and every saved connection must still be offered.
await act(async () => {
if (hostInput) setInput(hostInput, "");
if (hostInput) setInput(hostInput, "10.0.0.8");
await Promise.resolve();
});
await act(async () => {
hostInput?.dispatchEvent(new dom.window.Event("focusin", { bubbles: true }));
hostInput?.dispatchEvent(new dom.window.Event("focus", { bubbles: false }));
toggleArrow()?.click();
await Promise.resolve();
});
const gpuSuggestion = [...document.querySelectorAll<HTMLButtonElement>(".remote-wizard__suggest-list button")].find((b) => b.textContent?.includes("gpu-box"));
{
const labels = listButtons().map((b) => b.textContent ?? "");
ok(labels.some((l) => l.includes("gpu-box")) && labels.some((l) => l.includes("pw-box")), "typing in the host field does not filter the dropdown");
}
await act(async () => {
gpuSuggestion?.dispatchEvent(new dom.window.Event("mousedown", { bubbles: true, cancelable: true }));
hostInput?.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
await Promise.resolve();
});
ok(Boolean(document.querySelector(".remote-wizard__suggest-list")), "a pointer press on the host field keeps the list open");
await act(async () => {
document.body.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
await Promise.resolve();
});
ok(!document.querySelector(".remote-wizard__suggest-list"), "a pointer press outside the field closes the list");
await act(async () => {
toggleArrow()?.click();
await Promise.resolve();
});
await act(async () => {
document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await Promise.resolve();
});
ok(!document.querySelector(".remote-wizard__suggest-list"), "Escape closes the open list");
ok(!tape.includes("close"), "Escape with the list open keeps the wizard open");
ok(document.activeElement === toggleArrow(), "Escape from a saved host restores focus to the arrow");
await act(async () => {
toggleArrow()?.click();
await Promise.resolve();
});
await act(async () => {
toggleArrow()?.click();
await Promise.resolve();
});
ok(!document.querySelector(".remote-wizard__suggest-list"), "clicking the open arrow toggles the list shut");
// No saved hosts at all: no arrow, no list.
{
const restored = useRemoteStore.getState().hosts.slice();
await act(async () => {
useRemoteStore.getState().setHosts([]);
await Promise.resolve();
});
ok(!document.querySelector(".remote-wizard__suggest-toggle"), "no saved hosts hides the arrow entirely");
await act(async () => {
useRemoteStore.getState().setHosts(restored);
await Promise.resolve();
});
ok(Boolean(document.querySelector(".remote-wizard__suggest-toggle")), "the arrow returns once hosts exist again");
}
await act(async () => {
toggleArrow()?.click();
await Promise.resolve();
});
const gpuSuggestion = listButtons().find((b) => b.textContent?.includes("gpu-box"));
await act(async () => {
gpuSuggestion?.click();
await Promise.resolve();
});
ok(hostInput?.value === "192.168.1.10", "picking gpu-box from the reopened list restores its host");
}
// ── Next: first connect fails; the wizard stays on the connecting step ──
await act(async () => {
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { sessionCatalogNotice } from "../lib/sessionCatalogPresentation";
import type { SessionCatalogStatus } from "../lib/sessionCatalogTypes";
const status = (overrides: Partial<SessionCatalogStatus> = {}): SessionCatalogStatus => ({
state: "ready",
revision: 1,
indexed: 10,
total: 10,
repairPending: 0,
canRebuild: false,
...overrides,
});
assert.deepEqual(
sessionCatalogNotice(status({ state: "opening", indexed: 0, total: 0 })),
"working",
"opening without a known total uses the generic working label",
);
assert.deepEqual(
sessionCatalogNotice(status({ state: "rebuilding", indexed: 7, total: 10, canRebuild: true })),
"working",
"rebuilding preserves known progress and never offers another rebuild",
);
assert.deepEqual(
sessionCatalogNotice(status({ repairPending: 1, canRebuild: true })),
"working",
"automatic repair never exposes the manual rebuild action",
);
assert.equal(sessionCatalogNotice(status()), null, "healthy ready catalogs render no notice");
assert.deepEqual(
sessionCatalogNotice(status({ state: "degraded", canRebuild: true })),
"rebuild",
"a degraded catalog can expose the explicit repair action",
);
assert.deepEqual(
sessionCatalogNotice(status({ lastError: "private backend detail", canRebuild: true })),
"rebuild",
"backend errors select a generic failed presentation without exposing the detail",
);
assert.deepEqual(
sessionCatalogNotice(status({ state: "degraded", canRebuild: undefined })),
"failed",
"older backends without canRebuild fail closed",
);
console.log("session catalog presentation tests passed");
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
let generation = 0;
const statusWriteIsAllowed = (candidateGeneration: number, rebuildFailed: boolean) => !rebuildFailed && candidateGeneration === generation;
const renderedStates: string[] = [];
let resolveRemoteStatus: ((state: string) => void) | undefined;
const remoteStatus = new Promise<string>((resolve) => {
resolveRemoteStatus = resolve;
});
const remoteGeneration = generation;
const lateRemoteWrite = remoteStatus.then((state) => {
if (statusWriteIsAllowed(remoteGeneration, false)) renderedStates.push(state);
});
// The Wails rebuild rejects after the finished event has already started its
// status read. Restoring the retryable failure must fence out that late read.
generation += 1;
renderedStates.push("degraded");
resolveRemoteStatus?.("opening");
await lateRemoteWrite;
assert.deepEqual(
renderedStates,
["degraded"],
"a late pre-failure status response cannot replace the restored retry state",
);
const postFailureGeneration = generation;
if (statusWriteIsAllowed(postFailureGeneration, true)) renderedStates.push("opening");
assert.deepEqual(
renderedStates,
["degraded"],
"status reads started after a failed rebuild cannot replace its retry state during watcher startup",
);
const retryGeneration = generation;
if (statusWriteIsAllowed(retryGeneration, false)) renderedStates.push("ready");
assert.deepEqual(renderedStates, ["degraded", "ready"], "a new rebuild re-enables authoritative status writes");
const projectTreeSource = readFileSync(new URL("../components/ProjectTree.tsx", import.meta.url), "utf8");
assert.match(
projectTreeSource,
/catch \{\s*catalogRebuildFailedRef\.current = true; catalogStatusGenerationRef\.current \+= 1; setCatalogStatus\(catalogStatus\)/,
"ProjectTree invalidates older status reads before restoring a failed rebuild",
);
assert.match(
projectTreeSource,
/GetSessionCatalogStatus\(\)[\s\S]*!catalogRebuildFailedRef\.current && catalogStatusGeneration === catalogStatusGenerationRef\.current/,
"ProjectTree fences every event-driven status write behind rebuild ownership",
);
assert.match(
projectTreeSource,
/GetProjectTreeSnapshot\(\)[\s\S]*!catalogRebuildFailedRef\.current && catalogStatusGeneration === catalogStatusGenerationRef\.current/,
"ProjectTree fences snapshot status writes behind the same rebuild ownership",
);
console.log("session catalog status ownership tests passed");
@@ -111,6 +111,23 @@ console.log("\nsubagent progress card");
}
});
ok(!!document.querySelector(".tool__subagent-preview .md"), "auto mode expands reasoning when the card mounts mid-stream");
const responding = makeItem("responding");
responding.id = "task-reasoning";
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: responding })));
await flushTimers();
});
ok(!!document.querySelector(".tool__subagent-preview"), "auto mode keeps the subagent card open after reasoning starts responding");
ok(!!document.querySelector(".tool__subagent-preview .md"), "auto mode keeps completed subagent reasoning expanded while the task runs");
const completed = makeItem("completed");
completed.id = "task-reasoning";
await act(async () => {
root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: completed })));
await flushTimers();
});
ok(!document.querySelector(".tool__subagent-preview"), "auto mode collapses the untouched subagent card after the task settles");
await act(async () => root.unmount());
dom.window.close();
hydrateReasoningDisplayMode("summary", true);
@@ -5,11 +5,11 @@ import {
buildTranscriptRows,
buildTurnModels,
EMPTY_FOLDS,
splitTranscriptLiveRows,
userRowKey,
type BuildRowsOptions,
type TranscriptLiveFlags,
} from "../lib/transcriptRows";
import { resolveLiveTurnGrowthFloor, splitTranscriptLiveRows } from "../lib/transcriptLiveTurn";
let passed = 0;
let failed = 0;
@@ -51,6 +51,10 @@ function rowsFor(items: Item[], live?: TranscriptLiveFlags, running = false) {
}
const keys = (rows: readonly { key: string }[]) => rows.map((row) => row.key).join(",");
check(resolveLiveTurnGrowthFloor(3, 5, 420, null) === 420, "adding live rows carries the previous painted height into the next commit");
check(resolveLiveTurnGrowthFloor(5, 5, 420, null) === null, "steady live rows do not invent a geometry floor");
check(resolveLiveTurnGrowthFloor(5, 4, 420, 420) === 420, "an active floor survives an unrelated intermediate commit");
// ── Settled transcript: everything is history ────────────────────────────────
{
const { models, rows } = rowsFor([
@@ -0,0 +1,137 @@
// Run: tsx src/__tests__/transcript-live-turn-stability.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import {
buildTranscriptRows,
buildTurnModels,
EMPTY_FOLDS,
type BuildRowsOptions,
} from "../lib/transcriptRows";
import { useTranscriptLiveTurnStability } from "../lib/useTranscriptLiveTurnStability";
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
import type { Item } from "../lib/useController";
let passed = 0;
let failed = 0;
function check(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\ntranscript live-turn stability lifecycle");
const dom = new JSDOM(`<!doctype html><html><body>
<div id="root"></div><div id="resize-root"></div>
<div id="scroll">
<div class="transcript__live-region"><div class="transcript__live-content"></div></div>
</div>
</body></html>`, { pretendToBeVisual: true });
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const scrollElement = document.getElementById("scroll") as HTMLElement;
const liveRegion = scrollElement.querySelector<HTMLElement>(".transcript__live-region")!;
const liveContent = scrollElement.querySelector<HTMLElement>(".transcript__live-content")!;
let naturalHeight = 0;
const rect = (height: number): DOMRect => ({
x: 0, y: 0, top: 0, right: 800, bottom: height, left: 0, width: 800, height, toJSON: () => ({}),
});
liveContent.getBoundingClientRect = () => rect(naturalHeight);
liveRegion.getBoundingClientRect = () => rect(naturalHeight);
const options: BuildRowsOptions = {
folds: EMPTY_FOLDS,
foldPreference: "auto",
hasOlderHistory: false,
creationMode: false,
turnForUser: () => 0,
};
function fixture(answerCount: number) {
const items: Item[] = [{ kind: "user", id: "u1", text: "question" }];
for (let index = 0; index < answerCount; index += 1) {
items.push({
kind: "assistant",
id: `a${index + 1}`,
text: `answer ${index + 1}`,
reasoning: "",
streaming: index === 0,
});
}
const live = { id: "a1", hasAnswerText: true, hasReasoning: false };
const turnModels = buildTurnModels(items, live, true, false);
return { turnModels, rows: buildTranscriptRows(turnModels, options) };
}
const tailOwnedRef = { current: false };
function Probe({ surfaceKey, userResizeRevision, answerCount }: {
surfaceKey: string;
userResizeRevision: number;
answerCount: number;
}) {
const { turnModels, rows } = fixture(answerCount);
const { liveMinHeight } = useTranscriptLiveTurnStability({
turnModels,
rows,
liveId: "a1",
running: true,
stabilityKey: `${surfaceKey}:${userResizeRevision}`,
scrollElement,
hydrating: false,
tailOwnedRef,
pinLiveTailBeforePaint: () => false,
});
return <output data-floor={liveMinHeight ?? "none"} />;
}
const root = createRoot(document.getElementById("root")!);
async function render(surfaceKey: string, userResizeRevision: number, answerCount: number, height: number) {
naturalHeight = height;
await act(async () => root.render(
<Probe surfaceKey={surfaceKey} userResizeRevision={userResizeRevision} answerCount={answerCount} />,
));
return document.querySelector("output")?.getAttribute("data-floor");
}
await render("tab-a:1", 0, 1, 640);
check(await render("tab-a:1", 0, 2, 240) === "640", "row growth holds the previous live height");
check(await render("tab-b:1", 0, 1, 360) === "none", "an active tab switch drops the previous surface floor");
check(await render("tab-b:1", 0, 2, 180) === "360", "the new surface establishes its own independent floor");
check(await render("tab-b:1", 1, 1, 140) === "none", "an explicit user collapse releases the held floor immediately");
let resizeIntent: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
function ResizeIntentProbe() {
resizeIntent = useTranscriptScrollArbiter();
return <output id="resize-revision" data-revision={resizeIntent.userResizeRevision} />;
}
const resizeRoot = createRoot(document.getElementById("resize-root")!);
await act(async () => resizeRoot.render(<ResizeIntentProbe />));
await act(async () => resizeIntent?.beginUserResize());
check(
document.getElementById("resize-revision")?.getAttribute("data-revision") === "1",
"the shared user-resize owner advances the live-height revision",
);
await act(async () => root.unmount());
await act(async () => resizeRoot.unmount());
dom.window.close();
if (failed > 0) {
console.error(`\n${failed} live-turn stability lifecycle test(s) failed; ${passed} passed.`);
process.exit(1);
}
console.log(`\n${passed} live-turn stability lifecycle tests passed.`);
@@ -246,7 +246,81 @@ const warningTurn: Item[] = [
}
}
// ── Phase 3: expanded reasoning pins its parent process fold ─────────────────
// ── Phase 3: auto reasoning follows the active turn, not just its phase ──────
{
const harness = await createTranscriptHarness({ reasoningDisplayMode: "auto" });
const container = harness.container;
try {
const runningItems: Item[] = [
{ kind: "user", id: "u-auto-owner", text: "inspect" },
{
kind: "assistant",
id: "a-auto-owner",
text: "",
reasoning: "long live reasoning stays mounted across the first answer token",
streaming: true,
reasoningComplete: false,
},
];
await render(harness, runningItems, {
running: true,
live: {
id: "a-auto-owner",
text: "",
reasoning: "long live reasoning stays mounted across the first answer token",
reasoningComplete: false,
},
});
await harness.waitFor(
() => Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .reasoning__stream-text")),
"auto reasoning to open while its turn runs",
);
await render(harness, runningItems, {
running: true,
live: {
id: "a-auto-owner",
text: "first answer token",
reasoning: "long live reasoning stays mounted across the first answer token",
reasoningComplete: true,
},
});
await harness.waitFor(
() => Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")),
"completed reasoning to remain expanded while its turn still runs",
);
ok(
container.querySelector(".turn-collapse__reasoning-phase")?.getAttribute("data-transcript-layout-variant") === "reasoning-expanded",
"first answer token keeps the auto reasoning layout expanded",
);
ok(!container.querySelector(".turn-collapse__body .reasoning-summary"), "active turn does not replace full reasoning with a summary");
const toolItems: Item[] = [
runningItems[0],
{ ...runningItems[1], text: "first answer token", streaming: false, reasoningComplete: true } as Item,
{ kind: "tool", id: "t-auto-owner", name: "read_file", args: "{}", status: "running", readOnly: true },
];
await render(harness, toolItems, { running: true });
await harness.waitFor(() => container.querySelectorAll(".turn-collapse").length === 2, "tool work to open a second process segment");
ok(container.querySelectorAll(".turn-collapse--open").length === 2, "all untouched process segments stay open while their turn runs");
ok(Boolean(container.querySelector(".turn-collapse__reasoning-phase--open .md")), "tool dispatch does not collapse reasoning from the same active turn");
const settledItems: Item[] = toolItems.map((item) => item.kind === "tool"
? { ...item, status: "done" }
: item);
await render(harness, settledItems, { running: false });
await harness.waitFor(
() => container.querySelectorAll(".turn-collapse--open").length === 0,
"auto process segments to collapse after the owning turn settles",
);
ok(!container.querySelector(".turn-collapse__body"), "settled turn releases the expanded process layout once");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Phase 4: expanded reasoning pins its parent process fold ─────────────────
{
const harness = await createTranscriptHarness({ reasoningDisplayMode: "expanded" });
const container = harness.container;
@@ -341,7 +415,7 @@ const warningTurn: Item[] = [
}
}
// ── Phase 4: summaries disabled ─────────────────────────────────────────────
// ── Phase 5: summaries disabled ─────────────────────────────────────────────
{
const harness = await createTranscriptHarness({
storage: { "reasonix-process-fold": "expanded", "reasonix-reasoning-summary": "0" },
@@ -1,6 +1,5 @@
// Run: tsx src/__tests__/transcript-recovery-race.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
@@ -11,6 +10,7 @@ import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { buildTranscriptRows, buildTurnModels, EMPTY_FOLDS, transcriptRowMeasurementVersion, type TranscriptRow } from "../lib/transcriptRows";
import type { Item } from "../lib/useController";
import { installTranscriptRaceClock } from "./helpers/transcriptRaceClock";
import { installTranscriptRecoveryRaceDom } from "./helpers/transcriptRecoveryRaceDom";
let passed = 0;
let failed = 0;
@@ -27,39 +27,10 @@ function check(condition: unknown, label: string) {
console.log("\ntranscript recovery races");
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => void frames.delete(id);
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;
const { dom, flushFrames } = installTranscriptRecoveryRaceDom();
const { advanceClock, restore: restoreClock } = installTranscriptRaceClock(dom.window as unknown as Window);
async function flushFrames() {
const pending = [...frames.entries()];
frames.clear();
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
}
// Runtime capture of every imperative scroll write (Phase 0 probe).
const scrollWrites: TranscriptScrollWriteRecord[] = [];
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
@@ -198,6 +169,28 @@ scrollElement.scrollTop = 400;
await act(async () => arbiter?.atBottomStateChange(false));
check(arbiter?.isAtBottom === true, "physical bottom overrides a stale Virtuoso atBottom=false report");
// A live-footer structural commit (answer -> tool) can expose the new native
// extent before Virtuoso reports its footer height. Tail ownership repairs the
// offset synchronously so WebView2 never paints the clamped intermediate frame.
scrollExtent = 700;
scrollElement.scrollTop = 477;
scrollToCalls = 0;
await act(async () => arbiter?.pinLiveTailBeforePaint());
check(
scrollElement.scrollTop === 600 && scrollToCalls === 1,
"a claimed live tail pins the new native extent before paint",
);
await act(async () => arbiter?.releaseTailFollow());
scrollExtent = 800;
scrollElement.scrollTop = 500;
scrollToCalls = 0;
await act(async () => arbiter?.pinLiveTailBeforePaint());
check(
scrollElement.scrollTop === 500 && scrollToCalls === 0,
"a manual reader is never moved by live-tail commit stabilization",
);
await act(async () => arbiter?.reset());
// A nested code/tool scrollport owns the wheel until it reaches its edge.
// Capturing the event on Transcript must not release tail-follow early.
const nestedScroller = dom.window.document.createElement("div");
@@ -94,5 +94,18 @@ assert.equal(resolveToolCardDefaultOpen({
startedAt: 1,
},
}, 0, "auto"), true, "auto mode follows live subagent reasoning");
assert.equal(resolveToolCardDefaultOpen({
...runningTask,
status: "running",
subagentProgress: {
phase: "responding",
reasoning: "working",
text: "first response token",
notice: "",
lastActivityAt: 2,
truncated: false,
startedAt: 1,
},
}, 0, "auto"), true, "auto mode keeps a subagent card open for its full running lifecycle");
console.log("transcript row geometry tests passed");
@@ -304,6 +304,42 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
eq(reconcileFoldEntries(closed ?? EMPTY_FOLDS, settledStates, "auto", false), null, "steady state reconciles to no change");
}
{
// The first answer token completes the reasoning phase but not the active
// turn. Its row must keep the expanded geometry until turn_done, otherwise
// the live footer loses the full reasoning height in one commit.
const activeModels = buildTurnModels([
{ kind: "user", id: "u-auto-geometry", text: "inspect" },
{
kind: "assistant",
id: "a-auto-geometry",
text: "first answer token",
reasoning: "long reasoning",
streaming: false,
reasoningComplete: true,
},
{ kind: "tool", id: "t-auto-geometry", name: "read_file", args: "{}", status: "running", readOnly: true },
], undefined, true);
const activeRows = buildTranscriptRows(activeModels, rowOptions(EMPTY_FOLDS, "expanded"));
const activeReasoning = activeRows.find((row) => row.kind === "reasoning");
eq(activeReasoning?.layoutVariant, "reasoning-expanded", "active turn keeps completed reasoning geometry expanded");
const settledModels = buildTurnModels([
{ kind: "user", id: "u-auto-geometry", text: "inspect" },
{
kind: "assistant",
id: "a-auto-geometry",
text: "first answer token",
reasoning: "long reasoning",
streaming: false,
reasoningComplete: true,
},
], undefined, false);
const settledRows = buildTranscriptRows(settledModels, rowOptions(EMPTY_FOLDS, "expanded"));
const settledReasoning = settledRows.find((row) => row.kind === "reasoning");
eq(settledReasoning?.layoutVariant, "reasoning-summary", "settled turn returns auto reasoning geometry to its summary");
}
{
// Expanded reasoning pins only reasoning-bearing folds across completion.
const running = buildTurnModels(fixture.slice(0, 7), { id: "a2", hasAnswerText: true, hasReasoning: false, reasoningComplete: true }, true);
@@ -30,7 +30,7 @@ export function AssistantReasoningPanel({
const running = item.streaming && !item.reasoningComplete;
const followsWhileStreaming = displayMode === "auto" || displayMode === "expanded" || expandWhileStreaming;
const keepExpanded = displayMode === "expanded";
const [open, setOpen] = useState(defaultExpanded || keepExpanded || (followsWhileStreaming && running));
const [open, setOpen] = useState(defaultExpanded || keepExpanded || (followsWhileStreaming && item.streaming));
const bodyRef = useRef<HTMLDivElement>(null);
const userOverridden = useRef(false);
const previousStreaming = useRef(item.streaming);
@@ -47,11 +47,11 @@ export function AssistantReasoningPanel({
previousMode.current = displayMode;
if (modeChanged) {
userOverridden.current = false;
setOpen(defaultExpanded || keepExpanded || (followsWhileStreaming && !complete));
setOpen(defaultExpanded || keepExpanded || (followsWhileStreaming && item.streaming));
} else if (item.streaming) {
if (!wasStreaming) userOverridden.current = false;
if (defaultExpanded || keepExpanded) setOpen(true);
else if (!userOverridden.current) setOpen(followsWhileStreaming && !complete);
else if (!userOverridden.current && followsWhileStreaming) setOpen(true);
} else if ((complete && !wasComplete) || wasStreaming) {
if (!defaultExpanded && !keepExpanded && !userOverridden.current) setOpen(false);
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { useComposerCommandCatalog } from "../lib/useComposerCommandCatalog";
import { guidanceIsInFlight, guidanceNeedsRetry, guidanceTextMatches, kickIdleGuidance, markGuidanceQueued } from "../lib/composerGuidance";
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { sessionTurnsLabel } from "../lib/sessionCatalogPresentation";
import { sessionTurnsLabel } from "../lib/sessionTurnsPresentation";
import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts";
import { fallbackCopyText } from "../lib/clipboard";
@@ -11,32 +11,44 @@ import { StreamingReasoningText } from "./StreamingReasoningText";
import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext";
import { resolveReasoningLayoutVariant } from "../lib/transcriptRowGeometry";
export function InlineAssistantReasoning({ item, onManualOpen }: { item: AssistantItem; onManualOpen?: () => void }) {
export function InlineAssistantReasoning({
item,
autoFollowActive,
onManualOpen,
}: {
item: AssistantItem;
autoFollowActive?: boolean;
onManualOpen?: () => void;
}) {
const t = useT();
const beginUserResize = useTranscriptUserResizeIntent();
const live = useContext(LiveStreamContext);
const displayMode = useReasoningDisplayMode();
const shown = live?.id === item.id ? { reasoning: live.reasoning, streaming: true, reasoningComplete: live.reasoningComplete } : item;
const running = shown.streaming && !shown.reasoningComplete;
const [open, setOpen] = useState(displayMode === "expanded" || (displayMode === "auto" && running));
const followActive = autoFollowActive ?? shown.streaming;
const [open, setOpen] = useState(displayMode === "expanded" || (displayMode === "auto" && followActive));
const userOverridden = useRef(false);
const previousRunning = useRef(running);
const previousFollowActive = useRef(followActive);
const previousMode = useRef(displayMode);
useEffect(() => {
const modeChanged = previousMode.current !== displayMode;
const wasRunning = previousRunning.current;
const wasFollowActive = previousFollowActive.current;
previousMode.current = displayMode;
previousRunning.current = running;
previousFollowActive.current = followActive;
if (modeChanged) {
userOverridden.current = false;
setOpen(displayMode === "expanded" || (displayMode === "auto" && running));
setOpen(displayMode === "expanded" || (displayMode === "auto" && followActive));
} else if (running && !wasRunning && (displayMode === "auto" || displayMode === "expanded")) {
userOverridden.current = false;
setOpen(true);
} else if (displayMode === "auto" && !running && wasRunning && !userOverridden.current) {
} else if (displayMode === "auto" && !followActive && wasFollowActive && !userOverridden.current) {
setOpen(false);
}
}, [displayMode, running]);
}, [displayMode, followActive, running]);
const toggle = useCallback(() => {
beginUserResize();
userOverridden.current = true;
@@ -47,7 +59,7 @@ export function InlineAssistantReasoning({ item, onManualOpen }: { item: Assista
if (!reasoning) return null;
const layoutVariant = open
? "reasoning-expanded"
: resolveReasoningLayoutVariant(displayMode, running) ?? "reasoning-heading-only";
: resolveReasoningLayoutVariant(displayMode, followActive) ?? "reasoning-heading-only";
return (
<div
className={`turn-collapse__reasoning-phase${open ? " turn-collapse__reasoning-phase--open" : ""}`}
@@ -6,7 +6,7 @@
// (#8657/#8688). Virtuoso tracks the footer height itself and includes it in
// totalListHeightChanged, which drives the scroll coordinator's tail-follow.
import { memo, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { memo, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import type { TranscriptRow } from "../lib/transcriptRows";
import { useT } from "../lib/i18n";
import { useTick, workStatusLabel } from "../lib/workStatus";
@@ -34,6 +34,7 @@ export const LiveTurnRegion = memo(function LiveTurnRegion({
tabId,
scrollElement,
onPointerDownCapture,
minHeight,
}: {
rows: readonly TranscriptRow[];
renderRow: (row: TranscriptRow) => ReactNode;
@@ -46,13 +47,18 @@ export const LiveTurnRegion = memo(function LiveTurnRegion({
tabId?: string;
scrollElement: HTMLElement | null;
onPointerDownCapture?: (event: ReactPointerEvent<HTMLElement>) => void;
minHeight?: number;
}) {
const overlayRevision = rows.map((row) => String(row.key)).join("|");
const regionStyle = minHeight !== undefined
? { minHeight: `${minHeight}px` } satisfies CSSProperties
: undefined;
return (
<div
className={`transcript__live-region${overlay ? " transcript__live-region--overlay" : ""}`}
data-live-region="true"
aria-hidden={overlay || undefined}
style={regionStyle}
onPointerDownCapture={onPointerDownCapture}
>
<div className="transcript__live-content">
+19 -18
View File
@@ -6,6 +6,7 @@ import { asArray } from "../lib/array";
import { useToast } from "../lib/toast";
import { app } from "../lib/bridge";
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
import { sessionCatalogNotice } from "../lib/sessionCatalogPresentation";
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeTopicPageSignature, projectTreeTopicRecoveryCopyCount, projectTreeWithoutTopic, projectTreeWithTopicTitle, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
export * from "../lib/projectTreeTopic";
import { arrangeClassicProjectTree, arrangeWorkbenchTree, classicTopicWindow, CLASSIC_TOPIC_PREVIEW_LIMIT, splitPinnedProjectTree, type PinnedTreeSections } from "../lib/projectTreePresentation";
@@ -229,7 +230,7 @@ export function ProjectTree({
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
});
const rebuildingCatalogRef = useRef(false);
const catalogStatusGenerationRef = useRef(0), rebuildingCatalogRef = useRef(false), catalogRebuildFailedRef = useRef(false);
const [topicPageState, setTopicPageState] = useState<Record<string, { nextCursor?: string; loading: boolean }>>({});
const topicPageStateRef = useRef(topicPageState);
const updateTopicPageState = useCallback((key: string, next: { nextCursor?: string; loading: boolean }) => {
@@ -413,7 +414,7 @@ export function ProjectTree({
// Preserve already loaded pages by project key while reconciling pins, so a
// metadata refresh does not collapse or blank the sidebar.
const refresh = useCallback(async (options?: ProjectTreeRefreshOptions) => {
const reloadRequestedProjects = (projects: ProjectNode[]) => reloadProjectTreeTopics(projects, options, loadProjectTopicsRef.current);
const reloadRequestedProjects = (projects: ProjectNode[]) => reloadProjectTreeTopics(projects, options, loadProjectTopicsRef.current), catalogStatusGeneration = catalogStatusGenerationRef.current;
try {
const snapshot = await app.GetProjectTreeSnapshot();
const rev = snapshot.revision ?? 0, empty = treeRef.current.length === 0;
@@ -423,7 +424,7 @@ export function ProjectTree({
}
if (projectTreeRevisionIsFresh(latestRevisionRef.current, rev)) latestRevisionRef.current = Math.max(latestRevisionRef.current, rev);
const projects = asArray(snapshot.projects);
setCatalogStatus(snapshot.catalog);
if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(snapshot.catalog);
setTree((current) => applyRuntimeProjection(projects.map((project) => {
const previous = current.find((node) => node.key === project.key);
// Topic pages reload asynchronously. Keep the last painted children
@@ -449,18 +450,18 @@ export function ProjectTree({
});
const rebuildSessionCatalog = useCallback(async () => {
if (rebuildingCatalogRef.current || catalogStatus.canRebuild === false) return;
rebuildingCatalogRef.current = true;
if (rebuildingCatalogRef.current || catalogStatus.canRebuild !== true) return;
rebuildingCatalogRef.current = true; catalogRebuildFailedRef.current = false; catalogStatusGenerationRef.current += 1;
setCatalogStatus({ ...catalogStatus, state: "rebuilding", canRebuild: false });
try {
await app.RebuildSessionCatalog();
showToast(t("projectTree.rebuildCatalog"), "info");
void refresh();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error", { durationMs: 6000 });
await app.RebuildSessionCatalog(); catalogStatusGenerationRef.current += 1;
await refresh();
} catch {
catalogRebuildFailedRef.current = true; catalogStatusGenerationRef.current += 1; setCatalogStatus(catalogStatus);
} finally {
rebuildingCatalogRef.current = false;
}
}, [catalogStatus.canRebuild, refresh, showToast, t]);
}, [catalogStatus, refresh]);
useEffect(() => {
treeRef.current = tree;
@@ -490,7 +491,8 @@ export function ProjectTree({
}
latestRevisionRef.current = Math.max(latestRevisionRef.current, event.revision);
if (event.reason === "metadata") setOrganizationRevision((current) => Math.max(current, event.revision));
void app.GetSessionCatalogStatus().then(setCatalogStatus).catch(() => {});
const catalogStatusGeneration = catalogStatusGenerationRef.current;
void app.GetSessionCatalogStatus().then((status) => { if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(status); }).catch(() => {});
if (treeRef.current.length === 0) { void refresh(); return; } // race: event before shell
const affected = asArray(event.roots);
for (const project of treeRef.current) {
@@ -499,7 +501,6 @@ export function ProjectTree({
if (projectTreeEventAffectsFolder(project, affected)) void loadProjectTopics(project);
}
}), [expanded, loadProjectTopics, refresh]);
// Debounce query/timeFilter reloads so typing does not stampede the catalog.
// Dependency is the project-shell signature, not tree: topic page loads
// rewrite children and would otherwise re-arm this effect in a loop.
@@ -2166,6 +2167,7 @@ export function ProjectTree({
// Reset topic index counter and visible topics collector before each render.
topicIndexRef.current = 0;
visibleTopicsCollectorRef.current = [];
const catalogNotice = sessionCatalogNotice(catalogStatus);
return (
<div className="project-tree">
@@ -2180,12 +2182,11 @@ export function ProjectTree({
/>
</label>
)}
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0 || (catalogStatus.unindexedTargetCount ?? 0) > 0 || Boolean(catalogStatus.lastError)) && (
{catalogNotice && (
<div className="project-tree__catalog-progress" role="status">
<span>{catalogStatus.lastError
? t("projectTree.rebuildCatalog")
: t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}</span>
{catalogStatus.canRebuild !== false && catalogStatus.state !== "rebuilding" && (
<span>{catalogNotice !== "working" ? `${t("projectTree.indexing")} — ${t("task.state.failed")}` : catalogStatus.total <= 0 ? t("projectTree.indexing")
: t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total })}</span>
{catalogNotice === "rebuild" && (
<button type="button" className="project-tree__catalog-rebuild" onClick={() => void rebuildSessionCatalog()}>
{t("projectTree.rebuildCatalog")}
</button>
@@ -103,6 +103,53 @@
.remote-wizard__suggest {
position: relative;
}
.remote-wizard__host-box {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
/* Outranks the .remote-wizard__form input padding shorthand; the input
reserves room for the absolute toggle arrow on its right edge. */
.remote-wizard__suggest .remote-wizard__host-box input {
flex: 1;
min-width: 0;
padding-right: 32px;
}
.remote-wizard__suggest-toggle {
position: absolute;
top: 0;
bottom: 0;
right: 4px;
margin-block: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--fg-faint);
transition: transform 0.15s ease;
cursor: pointer;
}
.remote-wizard__suggest-toggle:hover:not(:disabled) {
color: var(--fg-dim);
}
.remote-wizard__suggest-toggle:disabled {
opacity: 0.55;
cursor: default;
}
.remote-wizard__suggest-toggle--open {
transform: rotate(180deg);
}
.remote-wizard__suggest-head {
padding: 6px 10px 4px;
color: var(--fg-dim);
font-size: var(--text-xs);
}
.remote-wizard__suggest-list {
position: absolute;
top: 100%;
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, FileText, Folder, Plus } from "lucide-react";
import { Check, ChevronDown, FileText, Folder, Plus } from "lucide-react";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { useRemoteStore, waitForRemoteConnection } from "../store/remote";
@@ -9,6 +9,8 @@ import type { RemoteDirEntry, RemoteHostInput, RemoteHostView } from "../lib/typ
type WizardStep = "config" | "connecting" | "workspace";
const STEP_ORDER: WizardStep[] = ["config", "connecting", "workspace"];
const HOST_INPUT_ID = "remote-wizard-host-input";
const HOST_MENU_ID = "remote-wizard-host-menu";
const blankInput: RemoteHostInput = {
label: "",
@@ -71,7 +73,7 @@ export function RemoteConnectWizard({
const [form, setForm] = useState<RemoteHostInput>(blankInput);
const [authMode, setAuthMode] = useState<"password" | "key">("password");
const [pickedHostId, setPickedHostId] = useState<string | null>(null);
const [hostFocus, setHostFocus] = useState(false);
const [hostListOpen, setHostListOpen] = useState(false);
const [hostId, setHostId] = useState("");
const [connectErr, setConnectErr] = useState("");
const [startPath, setStartPath] = useState("~");
@@ -86,6 +88,10 @@ export function RemoteConnectWizard({
const [error, setError] = useState("");
const dialogRef = useRef<HTMLDivElement>(null);
const hostInputRef = useRef<HTMLInputElement>(null);
const hostToggleRef = useRef<HTMLButtonElement>(null);
const portInputRef = useRef<HTMLInputElement>(null);
const suggestRef = useRef<HTMLDivElement>(null);
const pendingHostMenuFocusRef = useRef<"first" | "last" | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const listRequestRef = useRef(0);
const host = hosts.find((h) => h.id === hostId) ?? null;
@@ -116,14 +122,42 @@ export function RemoteConnectWizard({
listRequestRef.current += 1;
}, []);
useLayoutEffect(() => {
const edge = pendingHostMenuFocusRef.current;
if (!hostListOpen || !edge) return;
pendingHostMenuFocusRef.current = null;
const items = Array.from(
suggestRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
);
(edge === "first" ? items[0] : items[items.length - 1])?.focus();
}, [hostListOpen, hosts.length]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
event.stopPropagation();
// With the saved-host list open, the first Escape closes the list;
// only the next one exits the wizard.
if (hostListOpen) {
setHostListOpen(false);
hostToggleRef.current?.focus();
return;
}
onClose();
return;
}
if (
event.key === "Tab" &&
hostListOpen &&
document.activeElement instanceof HTMLElement &&
document.activeElement.getAttribute("role") === "menuitem"
) {
event.preventDefault();
setHostListOpen(false);
(event.shiftKey ? hostToggleRef.current : portInputRef.current)?.focus();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
'input:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])',
@@ -144,18 +178,25 @@ export function RemoteConnectWizard({
};
document.addEventListener("keydown", onKey, { capture: true });
return () => document.removeEventListener("keydown", onKey, { capture: true });
}, [busy, onClose]);
}, [busy, hostListOpen, onClose]);
// The saved-host dropdown only closes on explicit dismissal: a pick, the
// arrow, Escape, or a pointer press outside the host field wrapper.
useEffect(() => {
if (!hostListOpen) return;
const onPointerDown = (event: PointerEvent) => {
const wrapper = suggestRef.current;
if (wrapper && event.target && !wrapper.contains(event.target as Node)) {
setHostListOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [hostListOpen]);
const set = <K extends keyof RemoteHostInput>(key: K, value: RemoteHostInput[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const suggestions =
form.host.trim() === ""
? hosts
: hosts.filter(
(h) => h.host.includes(form.host.trim()) || h.label.includes(form.host.trim()),
);
const pickSaved = (saved: RemoteHostView) => {
setForm({
label: saved.label,
@@ -171,7 +212,8 @@ export function RemoteConnectWizard({
});
setAuthMode(saved.identityFile ? "key" : "password");
setPickedHostId(saved.id);
setHostFocus(false);
setHostListOpen(false);
hostInputRef.current?.focus();
};
const openDir = async (id: string, path: string) => {
@@ -351,38 +393,101 @@ export function RemoteConnectWizard({
<>
<div className="remote-wizard__form">
<div className="remote-wizard__field-row">
<div className="remote-wizard__suggest">
<label className="remote-wizard__field">
<span>{t("remote.host.host")}</span>
<input
ref={hostInputRef}
value={form.host}
disabled={busy}
autoComplete="off"
placeholder={t("remoteWizard.hostPlaceholder")}
onFocus={() => setHostFocus(true)}
onBlur={() => setHostFocus(false)}
onChange={(event) => {
set("host", event.target.value);
setPickedHostId(null);
<div className="remote-wizard__suggest" ref={suggestRef}>
<div className="remote-wizard__field">
<label htmlFor={HOST_INPUT_ID}>{t("remote.host.host")}</label>
<div className="remote-wizard__host-box">
<input
id={HOST_INPUT_ID}
ref={hostInputRef}
value={form.host}
disabled={busy}
autoComplete="off"
placeholder={t("remoteWizard.hostPlaceholder")}
onChange={(event) => {
set("host", event.target.value);
setPickedHostId(null);
}}
/>
{hosts.length > 0 ? (
<button
ref={hostToggleRef}
type="button"
className={`remote-wizard__suggest-toggle${hostListOpen ? " remote-wizard__suggest-toggle--open" : ""}`}
disabled={busy}
aria-haspopup="menu"
aria-expanded={hostListOpen}
aria-controls={HOST_MENU_ID}
title={t("remoteWizard.suggestions")}
onClick={() => {
if (hostListOpen) {
pendingHostMenuFocusRef.current = null;
setHostListOpen(false);
return;
}
pendingHostMenuFocusRef.current = "first";
setHostListOpen(true);
}}
onKeyDown={(event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
pendingHostMenuFocusRef.current = event.key === "ArrowDown" ? "first" : "last";
if (hostListOpen) {
const items = Array.from(
suggestRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
);
pendingHostMenuFocusRef.current = null;
(event.key === "ArrowDown" ? items[0] : items[items.length - 1])?.focus();
} else {
setHostListOpen(true);
}
}}
>
<ChevronDown size={14} aria-hidden="true" />
</button>
) : null}
</div>
</div>
{hostListOpen && hosts.length > 0 ? (
<div
className="remote-wizard__suggest-list"
role="menu"
id={HOST_MENU_ID}
aria-label={t("remoteWizard.suggestions")}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowDown"
? (current + 1) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
}}
/>
</label>
{hostFocus && suggestions.length > 0 ? (
<div className="remote-wizard__suggest-list" role="listbox" aria-label={t("remoteWizard.suggestions")}>
{suggestions.map((saved) => (
>
<div className="remote-wizard__suggest-head" aria-hidden="true">{t("remoteWizard.suggestions")}</div>
{hosts.map((saved) => (
<button
key={saved.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
pickSaved(saved);
}}
role="menuitem"
tabIndex={-1}
onClick={() => pickSaved(saved)}
>
<span className="remote-wizard__suggest-label">{saved.label}</span>
<span className="remote-wizard__suggest-detail">
{saved.user ? `${saved.user}@` : ""}
{saved.host}
{saved.port && saved.port !== 22 ? `:${saved.port}` : ""}
</span>
</button>
))}
@@ -392,6 +497,7 @@ export function RemoteConnectWizard({
<label className="remote-wizard__field">
<span>{t("remote.host.port")}</span>
<input
ref={portInputRef}
type="text"
inputMode="numeric"
placeholder="22"
+11 -6
View File
@@ -215,8 +215,10 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
// All tools default to collapsed. Sub-agent tools open while running so the
// user sees nested calls; they collapse when done. Reasoning (AssistantMessage)
// also opens while streaming and closes on finish.
// stays open for the same owner lifecycle instead of collapsing between the
// reasoning and response/tool phases.
const subagentReasoningRunning = sp?.phase === "reasoning";
const subagentActive = Boolean(sp) && item.status === "running";
const liveFollow = reasoningDisplayMode === "auto" || reasoningDisplayMode === "expanded";
const defaultOpen = resolveToolCardDefaultOpen(item, nested.length, reasoningDisplayMode);
const [userOpen, setUserOpen] = useState<boolean | null>(null);
@@ -228,31 +230,34 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
// The sub-agent reasoning preview opens as a one-line summary; the full
// Markdown only mounts after the user expands the reasoning section.
const [subagentReasoningOpen, setSubagentReasoningOpen] = useState(
() => reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentReasoningRunning),
() => reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentActive),
);
const subagentReasoningUserOverridden = useRef(false);
const previousSubagentReasoningRunning = useRef(subagentReasoningRunning);
const previousSubagentActive = useRef(subagentActive);
const previousReasoningDisplayMode = useRef(reasoningDisplayMode);
useEffect(() => {
const modeChanged = previousReasoningDisplayMode.current !== reasoningDisplayMode;
const wasRunning = previousSubagentReasoningRunning.current;
const wasActive = previousSubagentActive.current;
previousReasoningDisplayMode.current = reasoningDisplayMode;
previousSubagentReasoningRunning.current = subagentReasoningRunning;
previousSubagentActive.current = subagentActive;
if (modeChanged) {
subagentReasoningUserOverridden.current = false;
setSubagentReasoningOpen(reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentReasoningRunning));
setSubagentReasoningOpen(reasoningDisplayMode === "expanded" || (reasoningDisplayMode === "auto" && subagentActive));
return;
}
if (subagentReasoningRunning && !wasRunning) {
if ((subagentActive && !wasActive) || (subagentReasoningRunning && !wasRunning)) {
subagentReasoningUserOverridden.current = false;
if (liveFollow) setSubagentReasoningOpen(true);
return;
}
if (reasoningDisplayMode !== "auto") return;
if (!subagentReasoningRunning && wasRunning && !subagentReasoningUserOverridden.current) {
if (!subagentActive && wasActive && !subagentReasoningUserOverridden.current) {
setSubagentReasoningOpen(false);
}
}, [reasoningDisplayMode, subagentReasoningRunning]);
}, [liveFollow, reasoningDisplayMode, subagentActive, subagentReasoningRunning]);
// Lazy-load full tool data from the backend when the card is expanded and
// the in-memory copy was archived for memory efficiency.
const [fullData, setFullData] = useState<{ args: string; output?: string; execution?: ToolItem["execution"] } | null>(null);
+15 -15
View File
@@ -25,16 +25,16 @@ import {
foldMapWithToggle,
foldSegmentStates,
reconcileFoldEntries,
splitTranscriptLiveRows,
EMPTY_FOLDS,
NO_LIVE,
type AssistantItem,
type FoldMap,
type ToolItem,
type TranscriptLiveFlags,
type TranscriptRow,
transcriptRowMeasurementVersion,
} from "../lib/transcriptRows";
import { assistantAnswerOnly } from "../lib/transcriptLiveTurn";
import { useTranscriptLiveTurnStability } from "../lib/useTranscriptLiveTurnStability";
import { createTranscriptMeasuredSizes, type TranscriptSynthesizedSizes } from "../lib/transcriptMeasuredSizes";
import {
transcriptRowLayoutVariant,
@@ -94,9 +94,6 @@ const READER_MOUNT_CORRIDOR_VIEWPORTS = 7;
// non-overlapping ranges without an anchor; large sessions keep a bounded corridor.
const READER_FULL_MOUNT_ROW_LIMIT = 1_000;
function assistantAnswerOnly(item: AssistantItem): AssistantItem {
return { ...item, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
}
export function Transcript({
items,
live: liveProp,
@@ -245,9 +242,10 @@ export function Transcript({
atBottomStateChange,
deliverScroll,
scrollToBottom,
pinLiveTailBeforePaint,
followGrowingTail,
revalidateTail,
beginUserResize,
beginUserResize, userResizeRevision,
scrollToDataIndex, beginQuestionJump, finishQuestionJump,
releaseTailFollow,
setMode: setScrollMode,
@@ -444,14 +442,10 @@ export function Transcript({
}),
[turnModels, folds, foldPreference, hasOlderHistory, creationMode, turnForUser, hasCheckpointForTurn, reasoningDisplayMode, subcallsByParent],
);
// The active (streaming) turn renders as the list's in-flow Footer, outside
// the measured size tree: the list only ever owns static, bounded rows, so
// streaming never churns Virtuoso's measurements or scroll anchoring
// (#8657/#8688).
const liveSplit = useMemo(
() => splitTranscriptLiveRows(turnModels, rows, liveId, running),
[turnModels, rows, liveId, running],
);
const { liveSplit, liveMinHeight } = useTranscriptLiveTurnStability({
turnModels, rows, liveId, running, stabilityKey: `${layoutSurfaceKey}:${userResizeRevision}`,
scrollElement, hydrating, tailOwnedRef: stick, pinLiveTailBeforePaint,
});
// Keep the load-older affordance in Virtuoso's measured Header slot so an
// older page is a true data prepend, rather than an insertion after row 0.
const virtualRows = useMemo(
@@ -676,7 +670,11 @@ export function Transcript({
case "reasoning":
return (
<div className="turn-collapse__body">
<InlineAssistantReasoning item={row.item} onManualOpen={() => handleReasoningManualOpen(row.segmentKey)} />
<InlineAssistantReasoning
item={row.item}
autoFollowActive={row.autoFollowActive}
onManualOpen={() => handleReasoningManualOpen(row.segmentKey)}
/>
</div>
);
case "tool":
@@ -884,6 +882,7 @@ export function Transcript({
showStatus: liveSplit.liveActive,
overlay: !liveSplit.liveActive,
turnStartAt,
minHeight: liveMinHeight ?? undefined,
onPointerDownCapture: selectionRetention.onPointerDownCapture,
}
: null,
@@ -902,6 +901,7 @@ export function Transcript({
heldLiveRows,
liveSplit.liveActive,
liveSplit.liveRows,
liveMinHeight,
loadingOlderHistory,
contentRevision,
nativeScrollbarDragging,
@@ -35,6 +35,7 @@ export type TranscriptVirtuosoContext = {
showStatus: boolean;
overlay: boolean;
turnStartAt?: number;
minHeight?: number;
onPointerDownCapture: (event: ReactPointerEvent<HTMLElement>) => void;
};
olderHistory: null | {
@@ -146,7 +147,7 @@ function TranscriptVirtuosoHeader({ context }: { context: TranscriptVirtuosoCont
function TranscriptVirtuosoFooter({ context }: { context: TranscriptVirtuosoContext }) {
const live = context.liveRegion;
if (!live || (live.rows.length === 0 && !live.showStatus)) return null;
return <LiveTurnRegion rows={live.rows} renderRow={live.renderRow} showStatus={live.showStatus} overlay={live.overlay} turnStartAt={live.turnStartAt} tabId={context.tabId} scrollElement={context.scrollElement} onPointerDownCapture={live.onPointerDownCapture} />;
return <LiveTurnRegion rows={live.rows} renderRow={live.renderRow} showStatus={live.showStatus} overlay={live.overlay} turnStartAt={live.turnStartAt} tabId={context.tabId} scrollElement={context.scrollElement} onPointerDownCapture={live.onPointerDownCapture} minHeight={live.minHeight} />;
}
export const TRANSCRIPT_VIRTUOSO_COMPONENTS: Components<TranscriptRow, TranscriptVirtuosoContext> = {
+5 -5
View File
@@ -789,13 +789,13 @@ export function reportCrash(label: string, err: unknown, extra?: string) {
type GlobalCrashEventLike = Pick<Event, "defaultPrevented"> & {
message?: unknown;
error?: unknown;
reason?: unknown;
filename?: unknown;
lineno?: unknown;
colno?: unknown;
};
const RESIZE_OBSERVER_LOOP_MESSAGE_RE =
/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications\.?)$/;
const RESIZE_OBSERVER_LOOP_MESSAGE_RE = /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications\.?)$/;
const OPAQUE_SCRIPT_ERROR_MESSAGE = "Script error.";
function globalCrashEventMessages(e: GlobalCrashEventLike): string[] {
const messages: string[] = [];
@@ -804,7 +804,7 @@ function globalCrashEventMessages(e: GlobalCrashEventLike): string[] {
if (trimmed) messages.push(trimmed);
};
if (typeof e.message === "string") pushMessage(e.message);
const error = e.error;
const error = e.error ?? e.reason;
if (typeof error === "string") pushMessage(error);
if (error && typeof error === "object" && "message" in error) {
const msg = (error as { message?: unknown }).message;
@@ -815,8 +815,8 @@ function globalCrashEventMessages(e: GlobalCrashEventLike): string[] {
export function shouldReportGlobalCrashEvent(e: GlobalCrashEventLike): boolean {
if (e.defaultPrevented) return false;
if (globalCrashEventMessages(e).some((message) => RESIZE_OBSERVER_LOOP_MESSAGE_RE.test(message))) return false;
if (globalCrashEventMessages(e).some((message) => /Minified React error #520\b/.test(message))) return false;
if (globalCrashEventMessages(e).some((message) => RESIZE_OBSERVER_LOOP_MESSAGE_RE.test(message) ||
/Minified React error #520\b/.test(message) || message.includes("status was superseded by"))) return false;
if (isWailsRuntimeOnlyCrashEvent(e)) return false;
return true;
}
@@ -57,7 +57,7 @@ export function makeMockSessionCatalogBindings(cloneProjectTree: () => ProjectNo
...project,
children: asArray(project.children).filter((topic) => Boolean(topic.pinned)),
})),
catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0, sourceCount: 4, unindexedTargetCount: 0, canRebuild: true },
catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0, sourceCount: 4, unindexedTargetCount: 0, canRebuild: false },
indexed: 4,
total: 4,
indexingDone: true,
@@ -70,7 +70,7 @@ export function makeMockSessionCatalogBindings(cloneProjectTree: () => ProjectNo
?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] };
},
async GetSessionCatalogStatus() {
return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0, sourceCount: 4, unindexedTargetCount: 0, canRebuild: true };
return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0, sourceCount: 4, unindexedTargetCount: 0, canRebuild: false };
},
async RebuildSessionCatalog() {},
};
@@ -1,12 +1,13 @@
import type { Translator } from "./i18n";
import type { SessionCatalogStatus } from "./sessionCatalogTypes";
type SessionTurns = {
turns?: number;
turnsState?: string;
};
export type SessionCatalogNotice = "working" | "failed" | "rebuild";
export function sessionTurnsLabel(session: SessionTurns, t: Translator): string {
if (session.turnsState === "unknown") return t("history.indexing");
if (typeof session.turns === "number") return t("composer.sessionTurns", { n: session.turns });
return "";
export function sessionCatalogNotice(status: SessionCatalogStatus): SessionCatalogNotice | null {
const working = status.state === "opening"
|| status.state === "rebuilding"
|| status.repairPending > 0
|| (status.unindexedTargetCount ?? 0) > 0;
if (working) return "working";
if (status.state === "degraded" || status.lastError) return status.canRebuild === true ? "rebuild" : "failed";
return null;
}
@@ -0,0 +1,12 @@
import type { Translator } from "./i18n";
type SessionTurns = {
turns?: number;
turnsState?: string;
};
export function sessionTurnsLabel(session: SessionTurns, t: Translator): string {
if (session.turnsState === "unknown") return t("history.indexing");
if (typeof session.turns === "number") return t("composer.sessionTurns", { n: session.turns });
return "";
}
@@ -0,0 +1,62 @@
import { userRowKey, type AssistantItem, type TranscriptRow, type TurnModel } from "./transcriptRows";
// The streaming turn renders in Virtuoso's Footer, outside its measured size
// tree. Keep the active user's row in history and preserve every later row's
// visual order in the live region.
export interface TranscriptLiveSplit {
historyRows: TranscriptRow[];
liveRows: TranscriptRow[];
/** True while the live region may need to render its status row. */
liveActive: boolean;
}
export function assistantAnswerOnly(item: AssistantItem): AssistantItem {
return { ...item, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
}
export function resolveLiveTurnGrowthFloor(
previousRowCount: number,
nextRowCount: number,
previousHeight: number,
heldHeight: number | null,
): number | null {
if (nextRowCount <= previousRowCount || previousHeight <= 0) return heldHeight;
return Math.max(previousHeight, heldHeight ?? 0);
}
function firstRowKeyForModel(model: TurnModel): string | undefined {
for (const segment of model.segments) {
if (segment.displayItems.length > 0) return `ph:${segment.key}`;
const outside = segment.outsideItems[0];
if (!outside) continue;
return outside.kind === "extension" ? `x:${outside.id}` : outside.kind === "notice" ? `n:${outside.id}` : `a:${outside.id}`;
}
return undefined;
}
export function splitTranscriptLiveRows(
models: readonly TurnModel[],
rows: readonly TranscriptRow[],
liveId: string | undefined,
running: boolean,
): TranscriptLiveSplit {
let activeIndex = -1;
if (liveId) activeIndex = models.findIndex((model) => model.turnItems.some((item) => item.id === liveId));
if (activeIndex < 0 && running && models.length > 0) activeIndex = models.length - 1;
if (activeIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: false };
const active = models[activeIndex];
const activeUser = active.user;
if (!activeUser) {
const firstKey = firstRowKeyForModel(active);
const firstIndex = firstKey ? rows.findIndex((row) => row.key === firstKey) : -1;
if (!firstKey || firstIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: true };
return { historyRows: rows.slice(0, firstIndex), liveRows: rows.slice(firstIndex), liveActive: true };
}
const userIndex = rows.findIndex((row) => row.key === userRowKey(activeUser.id));
if (userIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: false };
return {
historyRows: rows.slice(0, userIndex + 1),
liveRows: rows.slice(userIndex + 1),
liveActive: true,
};
}
@@ -73,6 +73,7 @@ export function resolveToolCardDefaultOpen(
const liveFollow = reasoningDisplayMode === "auto" || reasoningDisplayMode === "expanded";
const keepSubagentReasoningExpanded = reasoningDisplayMode === "expanded" && Boolean(item.subagentProgress?.reasoning);
return (nestedCount > 0 && item.status === "running")
|| (liveFollow && Boolean(item.subagentProgress) && item.status === "running")
|| (liveFollow && subagentReasoningRunning)
|| keepSubagentReasoningExpanded;
}
+11 -62
View File
@@ -156,6 +156,8 @@ export interface SegmentModel {
displayItems: Item[];
/** Turn-level: the turn renders anything outside its folds. */
hasOutsideContent: boolean;
/** Disclosure ownership: every untouched segment stays open until its turn settles. */
foldActive: boolean;
hasRunningWork: boolean;
durationMs: number;
/** "full" carries the work-duration label; earlier segments only list counts. */
@@ -257,6 +259,7 @@ export function buildTurnModels(
const isLastSegment = segmentIndex === segments.length - 1;
const displayItems = foldDisplayItems(segment.processItems, live, hideReasoning);
const turnActive = model.isActive && isLastSegment;
const hasRunningWork = segmentHasRunningWork(displayItems, turnActive, live);
return {
// Duplicate raw ids occur in imported/merged histories. Derive the
// disambiguator from stable turn/item identity rather than occurrence
@@ -273,7 +276,8 @@ export function buildTurnModels(
outsideItems: segment.outsideItems,
displayItems,
hasOutsideContent: turnHasOutsideContent,
hasRunningWork: segmentHasRunningWork(displayItems, turnActive, live),
foldActive: model.isActive || hasRunningWork,
hasRunningWork,
durationMs: isLastSegment ? turnWorkDurationMs(model.turnItems) : 0,
labelStyle: isLastSegment ? "full" : "counts",
turnActive,
@@ -303,10 +307,10 @@ export type FoldMap = ReadonlyMap<string, FoldEntry>;
export const EMPTY_FOLDS: FoldMap = new Map();
export function defaultFoldOpen(
segment: { hasOutsideContent: boolean; hasRunningWork: boolean; keepReasoningExpanded?: boolean },
segment: { hasOutsideContent: boolean; hasRunningWork: boolean; foldActive?: boolean; keepReasoningExpanded?: boolean },
preference: ProcessFoldPreference,
): boolean {
return preference === "expanded" || segment.keepReasoningExpanded === true || !segment.hasOutsideContent || segment.hasRunningWork;
return preference === "expanded" || segment.keepReasoningExpanded === true || !segment.hasOutsideContent || segment.foldActive === true || segment.hasRunningWork;
}
export interface FoldSegmentState {
@@ -324,7 +328,7 @@ export function foldSegmentStates(models: readonly TurnModel[], keepReasoningExp
out.push({
key: segment.key,
hasOutsideContent: segment.hasOutsideContent,
hasRunningWork: segment.hasRunningWork,
hasRunningWork: segment.foldActive,
keepReasoningExpanded: keepReasoningExpanded && segment.displayItems.some((item) => item.kind === "assistant"),
});
}
@@ -442,7 +446,7 @@ type TranscriptRowContent =
| { kind: "older-history"; key: string }
| { kind: "user"; key: string; item: UserItem; turn: number | undefined }
| { kind: "process-header"; key: string; segment: SegmentModel; open: boolean }
| { kind: "reasoning"; key: string; item: AssistantItem; segmentKey: string }
| { kind: "reasoning"; key: string; item: AssistantItem; segmentKey: string; autoFollowActive?: boolean }
| { kind: "tool"; key: string; item: ToolItem }
| { kind: "tool-batch"; key: string; items: ToolItem[] }
| { kind: "tool-group"; key: string; items: ToolItem[]; groupKind: ToolGroupKind }
@@ -623,9 +627,10 @@ function processBodyRows(
key: `r:${it.id}`,
item: it as AssistantItem,
segmentKey: segment.key,
autoFollowActive: segment.foldActive,
layoutVariant: resolveReasoningLayoutVariant(
reasoningDisplayMode,
Boolean((it as AssistantItem).streaming && !(it as AssistantItem).reasoningComplete),
segment.foldActive,
) ?? "reasoning-summary",
});
break;
@@ -704,62 +709,6 @@ export function buildTranscriptRows(models: readonly TurnModel[], options: Build
return rows;
}
// ── Live turn split ───────────────────────────────────────────────────────────
// The streaming ("live") turn renders as the virtual list's in-flow Footer —
// inside the scroller but outside the measured size tree — so the list only
// ever owns static, bounded rows. The active turn is the one owning the live
// assistant item; while running with no live item yet it is the last turn.
// Everything from the active turn onward (except its user message, which
// stays as the history tail) moves to the live region, preserving the visual
// order for later steers/messages.
export interface TranscriptLiveSplit {
historyRows: TranscriptRow[];
liveRows: TranscriptRow[];
/** True while a turn is active — the live region may render a status row even when liveRows is empty. */
liveActive: boolean;
}
function firstRowKeyForModel(model: TurnModel): string | undefined {
for (const segment of model.segments) {
if (segment.displayItems.length > 0) return `ph:${segment.key}`;
const outside = segment.outsideItems[0];
if (!outside) continue;
return outside.kind === "extension" ? `x:${outside.id}` : outside.kind === "notice" ? `n:${outside.id}` : `a:${outside.id}`;
}
return undefined;
}
export function splitTranscriptLiveRows(
models: readonly TurnModel[],
rows: readonly TranscriptRow[],
liveId: string | undefined,
running: boolean,
): TranscriptLiveSplit {
let activeIndex = -1;
if (liveId) {
activeIndex = models.findIndex((model) => model.turnItems.some((item) => item.id === liveId));
}
if (activeIndex < 0 && running && models.length > 0) activeIndex = models.length - 1;
if (activeIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: false };
const active = models[activeIndex];
if (!active.user) {
// Prelude turn (no user message): split at the turn's first row. A turn
// with no rendered rows yet keeps the whole list as history.
const firstKey = firstRowKeyForModel(active);
const firstIndex = firstKey ? rows.findIndex((row) => row.key === firstKey) : -1;
if (!firstKey || firstIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: true };
return { historyRows: rows.slice(0, firstIndex), liveRows: rows.slice(firstIndex), liveActive: true };
}
const userIndex = rows.findIndex((row) => row.key === userRowKey(active.user!.id));
if (userIndex < 0) return { historyRows: [...rows], liveRows: [], liveActive: false };
return {
historyRows: rows.slice(0, userIndex + 1),
liveRows: rows.slice(userIndex + 1),
liveActive: true,
};
}
// ── Measurement / identity helpers ────────────────────────────────────────────
/** Ballpark row heights; Virtuoso replaces them with real measurements on mount. */
@@ -41,6 +41,8 @@ export type TranscriptTailSettle = {
cancel: () => void;
/** Mark a layout-transient window and arm its bounded idle expiry. */
noteLayoutTransient: (source?: TranscriptGeometryChangeSource) => void;
/** Repair an already-owned live tail before a structural Footer commit paints. */
pinLiveTailBeforePaint: () => boolean;
};
/**
@@ -181,6 +183,17 @@ export function createTranscriptTailSettle({
armLayoutTransientIdle();
};
const pinLiveTailBeforePaint = () => {
if (!scrollRef.current || modeRef.current !== "tail-follow") return false;
geometryRevisionRef.current += 1;
noteLayoutTransient();
scrollToTail("auto", CAPTURE_TRANSCRIPT_SCROLL_DIAGNOSTICS
? { source: "tail-content-changed", phase: "initial" }
: undefined);
schedule(false, "tail-content-changed");
return true;
};
const schedule = (jump: boolean, source?: TranscriptScrollDiagnosticSource) => {
const scrollElement = scrollRef.current;
if (!scrollElement) {
@@ -320,5 +333,5 @@ export function createTranscriptTailSettle({
tailSettleFrame = requestAnimationFrame(tick);
};
return { scrollToTail, schedule, cancel, noteLayoutTransient };
return { scrollToTail, schedule, cancel, noteLayoutTransient, pinLiveTailBeforePaint };
}
+4 -2
View File
@@ -433,7 +433,9 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
});
await replayMissingPrompt(status, replay.some((event) => event.kind === "approval_request" || event.kind === "ask_request"), expectedConnectionGeneration);
if (replay.some((event) => event.kind === "turn_done")) {
void refreshStatus();
// A status poll losing the revision race is benign; the SSE feed
// and the running-state watchdog converge the surface anyway.
void refreshStatus().catch(() => undefined);
void reconcileHistory().catch(() => undefined);
}
return;
@@ -500,7 +502,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
}
setTranscript((s) => reducer(s, { type: "event", e: event, remote: true }));
if (event.kind === "turn_done") {
void refreshStatus();
void refreshStatus().catch(() => undefined);
void reconcileHistory().catch(() => undefined);
}
});
@@ -0,0 +1,108 @@
import { useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { transcriptRowLayoutVariant } from "./transcriptRowGeometry";
import { transcriptRowMeasurementVersion, type TranscriptRow, type TurnModel } from "./transcriptRows";
import { resolveLiveTurnGrowthFloor, splitTranscriptLiveRows } from "./transcriptLiveTurn";
export function useTranscriptLiveTurnStability({
turnModels,
rows,
liveId,
running,
stabilityKey,
scrollElement,
hydrating,
tailOwnedRef,
pinLiveTailBeforePaint,
}: {
turnModels: readonly TurnModel[];
rows: readonly TranscriptRow[];
liveId: string | undefined;
running: boolean;
stabilityKey: string;
scrollElement: HTMLElement | null;
hydrating: boolean;
tailOwnedRef: RefObject<boolean>;
pinLiveTailBeforePaint: () => boolean;
}) {
const liveSplit = useMemo(
() => splitTranscriptLiveRows(turnModels, rows, liveId, running),
[turnModels, rows, liveId, running],
);
const liveGeometryRevision = useMemo(
() => liveSplit.liveRows.map((row) => [
String(row.key),
row.kind,
transcriptRowLayoutVariant(row),
transcriptRowMeasurementVersion(row),
].join("\u0000")).join("\u0001"),
[liveSplit.liveRows],
);
const previousRowCountRef = useRef(liveSplit.liveRows.length);
const previousHeightRef = useRef(0);
const stabilityEpochRef = useRef(stabilityKey);
const epochMatches = stabilityEpochRef.current === stabilityKey;
const [heldMinHeight, setHeldMinHeight] = useState<number | null>(null);
const heldMinHeightRef = useRef(heldMinHeight);
const activeHeldMinHeight = epochMatches ? heldMinHeight : null;
heldMinHeightRef.current = activeHeldMinHeight;
const liveMinHeight = epochMatches
? resolveLiveTurnGrowthFloor(
previousRowCountRef.current,
liveSplit.liveRows.length,
previousHeightRef.current,
activeHeldMinHeight,
)
: null;
// The answer/tool boundary must repair an already-owned tail in the same
// commit, before Virtuoso publishes its asynchronous Footer measurement.
useLayoutEffect(() => {
if (stabilityEpochRef.current !== stabilityKey) {
stabilityEpochRef.current = stabilityKey;
previousHeightRef.current = 0;
if (heldMinHeight !== null) setHeldMinHeight(null);
}
if (!liveSplit.liveActive) {
previousRowCountRef.current = 0;
previousHeightRef.current = 0;
if (heldMinHeight !== null) setHeldMinHeight(null);
return;
}
const region = scrollElement?.querySelector<HTMLElement>(".transcript__live-region");
const content = region?.querySelector<HTMLElement>(".transcript__live-content");
const floor = resolveLiveTurnGrowthFloor(
previousRowCountRef.current,
liveSplit.liveRows.length,
previousHeightRef.current,
activeHeldMinHeight,
);
previousRowCountRef.current = liveSplit.liveRows.length;
if (region && content) {
const naturalHeight = content.getBoundingClientRect().height;
if (floor !== null && naturalHeight + 0.5 < floor) {
if (activeHeldMinHeight !== floor) setHeldMinHeight(floor);
previousHeightRef.current = floor;
} else {
if (activeHeldMinHeight !== null && naturalHeight + 0.5 >= activeHeldMinHeight) setHeldMinHeight(null);
previousHeightRef.current = Math.max(naturalHeight, region.getBoundingClientRect().height);
}
}
if (!hydrating && tailOwnedRef.current) pinLiveTailBeforePaint();
}, [heldMinHeight, hydrating, liveGeometryRevision, liveSplit.liveActive, liveSplit.liveRows, pinLiveTailBeforePaint, scrollElement, stabilityKey, tailOwnedRef]);
useLayoutEffect(() => {
const content = scrollElement?.querySelector<HTMLElement>(".transcript__live-content");
if (!content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (stabilityEpochRef.current !== stabilityKey) return;
const naturalHeight = content.getBoundingClientRect().height;
const held = heldMinHeightRef.current;
if (held !== null && naturalHeight + 0.5 >= held) setHeldMinHeight(null);
if (held === null) previousHeightRef.current = naturalHeight;
});
observer.observe(content);
return () => observer.disconnect();
}, [liveGeometryRevision, scrollElement, stabilityKey]);
return { liveSplit, liveMinHeight };
}
@@ -101,6 +101,7 @@ export function useTranscriptScrollArbiter({
onItemMeasuredRef.current = onItemMeasured;
const [isAtBottom, setIsAtBottom] = useState(true);
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
const [userResizeRevision, setUserResizeRevision] = useState(0);
const writerRef = useRef<ReturnType<typeof createTranscriptScrollWriter> | null>(null);
writerRef.current ??= createTranscriptScrollWriter({
virtuosoRef, scrollRef, modeRef, generationRef, ownershipEpochRef, geometryRevisionRef,
@@ -342,6 +343,8 @@ export function useTranscriptScrollArbiter({
dispatch({ type: "JUMP_TO_BOTTOM", behavior });
}, [dispatch]);
const pinLiveTailBeforePaint = useCallback(() => tailSettle.pinLiveTailBeforePaint(), [tailSettle]);
// Reaches a terminal state for a recovery the arbiter itself ends (done /
// expired / scroller gone). Preemption cancels go through
// cancelInFlightRecovery instead, driven by the reducer's CANCEL command.
@@ -650,6 +653,7 @@ export function useTranscriptScrollArbiter({
}, [geometryController]);
const beginUserResize = useCallback(() => {
setUserResizeRevision((revision) => revision + 1);
dispatch({ type: "USER_RESIZE_BEGIN" });
if (resizeSettleFrameRef.current !== null) cancelAnimationFrame(resizeSettleFrameRef.current);
const generation = generationRef.current;
@@ -786,10 +790,10 @@ export function useTranscriptScrollArbiter({
virtuosoRef, scrollRef, scrollElement, layoutTransientRef,
itemSize, nativeScrollbarDragging, readerTransactionActive, pinnedRef, isAtBottom, modeRef,
scrollerRef, setMode, reset, writeOffset,
scrollToBottom, followGrowingTail, revalidateTail, scrollToDataIndex,
scrollToBottom, pinLiveTailBeforePaint, followGrowingTail, revalidateTail, scrollToDataIndex,
beginQuestionJump: questionJumpOwnership.begin,
finishQuestionJump: questionJumpOwnership.finish,
finishProgrammaticScroll, releaseTailFollow, beginUserResize,
finishProgrammaticScroll, releaseTailFollow, beginUserResize, userResizeRevision,
atBottomStateChange, deliverScroll, onWheelIntent,
onTouchStartIntent, onTouchMoveIntent, onTouchEndIntent,
onKeyScrollIntent, onPointerDownIntent, onNestedScrollIntent,
+6 -4
View File
@@ -165,10 +165,9 @@ func TestStopSessionCatalogWaitsForExplicitReconcile(t *testing.T) {
}
app.requestSessionCatalogReconcile(dir)
<-started
stopped := make(chan struct{})
stopped := make(chan bool, 1)
go func() {
app.stopSessionCatalog(time.Second)
close(stopped)
stopped <- app.stopSessionCatalog(time.Second)
}()
select {
case <-stopped:
@@ -177,7 +176,10 @@ func TestStopSessionCatalogWaitsForExplicitReconcile(t *testing.T) {
}
close(release)
select {
case <-stopped:
case clean := <-stopped:
if !clean {
t.Fatal("catalog stop reported an incomplete drain after reconcile released")
}
case <-time.After(time.Second):
t.Fatal("catalog stop did not drain its explicit reconcile")
}
+2
View File
@@ -619,6 +619,7 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(names []string) error {
if err != nil {
return err
}
a.revokeCredentialProxyRoutesByProvider(plan.targets)
return a.applyProviderRemovalRuntime(affected, fallbackRef, "provider access")
}
@@ -662,5 +663,6 @@ func (a *App) deleteProvidersAndRetargetTabs(rawNames []string) error {
if err != nil {
return err
}
a.revokeCredentialProxyRoutesByProvider(plan.targets)
return a.applyProviderRemovalRuntime(affected, fallbackRef, "provider")
}
+36
View File
@@ -3,7 +3,9 @@ package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -563,6 +565,40 @@ func TestRemoteStatusRefreshRejectsOutOfOrderSnapshot(t *testing.T) {
}
}
// TestRemoteTabStatusSupersededRaceReturnsSentinel: when an SSE-derived frame
// advances the tab revision while a /status poll is in flight, RemoteTabStatus
// must fail with the superseded sentinel — a benign stale snapshot, distinct
// from transport failures — instead of an opaque error that surfaces as a
// crash report.
func TestRemoteTabStatusSupersededRaceReturnsSentinel(t *testing.T) {
client := &http.Client{}
a := &App{remoteTabs: map[string]*remoteTab{
"remote-1": {id: "remote-1", client: client, gen: 4, state: "ready"},
}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// The serve streams a turn_started between reservation and recording:
// the frame handler advances the revision mid-poll.
a.reserveRemoteTabStatusSequence("remote-1", client, 4)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"running":false,"pendingPrompt":false}`))
}))
t.Cleanup(server.Close)
a.remoteTabMu.Lock()
a.remoteTabs["remote-1"].base = server.URL
a.remoteTabMu.Unlock()
_, err := a.RemoteTabStatus("remote-1")
if err == nil {
t.Fatal("superseded status poll returned nil error")
}
if !errors.Is(err, errRemoteTabStatusSuperseded) {
t.Fatalf("superseded race error = %v, want errRemoteTabStatusSuperseded", err)
}
if want := `remote tab "remote-1" status was superseded by newer runtime state`; err.Error() != want {
t.Fatalf("superseded race message = %q, want %q", err.Error(), want)
}
}
func TestRemoteStatusRefreshRejectsSnapshotOlderThanTurnDone(t *testing.T) {
client := &http.Client{}
a := &App{remoteTabs: map[string]*remoteTab{
+8 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
@@ -13,6 +14,12 @@ import (
"reasonix/internal/agent"
)
// errRemoteTabStatusSuperseded marks the benign lost race where a /status
// response lands after SSE-derived runtime state already advanced the tab
// revision. Adoption was correctly skipped; callers (watchdog, close policy)
// merely skip the snapshot instead of surfacing a crash.
var errRemoteTabStatusSuperseded = errors.New("status was superseded by newer runtime state")
type RemoteTabSnapshot struct {
History json.RawMessage `json:"history"`
Context json.RawMessage `json:"context,omitempty"`
@@ -144,7 +151,7 @@ func (a *App) RemoteTabStatus(tabID string) (json.RawMessage, error) {
status, err := serveGet(ctx, client, serveURL(base, "/status?runtime=1"))
if err == nil {
if !a.recordRemoteTabSessionStatus(tabID, client, gen, statusSeq, status) {
return nil, fmt.Errorf("remote tab %q status was superseded by newer runtime state", tabID)
return nil, fmt.Errorf("remote tab %q %w", tabID, errRemoteTabStatusSuperseded)
}
}
return status, err
+30 -27
View File
@@ -55,6 +55,7 @@ type SessionCatalogStatus struct {
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
RepairPending int64 `json:"repairPending"`
CanRebuild bool `json:"canRebuild"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
}
@@ -136,12 +137,14 @@ func flushDesktopDerivedCatalogs(ctx context.Context) error {
func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus {
return SessionCatalogStatus{
State: string(status.State),
Mode: string(status.Mode),
Revision: status.Revision,
Indexed: status.Indexed,
Total: status.Total,
RepairPending: status.RepairPending,
State: string(status.State),
Mode: string(status.Mode),
Revision: status.Revision,
Indexed: status.Indexed,
Total: status.Total,
RepairPending: status.RepairPending,
CanRebuild: status.RepairPending == 0 && (status.State == sessioncatalog.StateDegraded ||
(status.State == sessioncatalog.StateReady && strings.TrimSpace(status.LastError) != "")),
LastError: status.LastError,
QuarantinedPath: status.QuarantinedPath,
}
@@ -151,16 +154,21 @@ func (a *App) currentSessionCatalogStatus() SessionCatalogStatus {
if a == nil {
return SessionCatalogStatus{State: string(sessioncatalog.StateDegraded), Mode: string(sessioncatalog.ModeMemory)}
}
if catalog := a.sessionCatalog.Load(); catalog != nil {
status := sessionCatalogStatus(catalog.Status())
if a.catalogRebuilding.Load() {
status.State = string(sessioncatalog.StateRebuilding)
status.CanRebuild = false
}
return status
}
if a.catalogRebuilding.Load() {
return SessionCatalogStatus{State: string(sessioncatalog.StateRebuilding)}
}
if catalog := a.sessionCatalog.Load(); catalog != nil {
return sessionCatalogStatus(catalog.Status())
}
return SessionCatalogStatus{State: string(sessioncatalog.StateOpening)}
}
func (a *App) startSessionCatalog(rebuild bool) {
func (a *App) startSessionCatalog() {
if a == nil || a.shuttingDown.Load() {
return
}
@@ -173,20 +181,18 @@ func (a *App) startSessionCatalog(rebuild bool) {
done := make(chan struct{})
a.catalogCancel = cancel
a.catalogDone = done
a.catalogRebuilding.Store(rebuild)
a.catalogLifecycleMu.Unlock()
history.RegisterSessionPersistObserver(desktopSessionCatalogPersistObserverKey, desktopSessionCatalogPersistObserver{app: a})
go func() {
defer close(done)
defer a.catalogRebuilding.Store(false)
a.runSessionCatalog(ctx, rebuild)
a.runSessionCatalog(ctx)
}()
}
func (a *App) stopSessionCatalog(timeout time.Duration) {
func (a *App) stopSessionCatalog(timeout time.Duration) bool {
if a == nil {
return
return true
}
a.catalogLifecycleMu.Lock()
cancel := a.catalogCancel
@@ -208,29 +214,26 @@ func (a *App) stopSessionCatalog(timeout time.Duration) {
reconcileDone = append(reconcileDone, job.done)
}
a.catalogReconcileMu.Unlock()
stopped := true
for _, done := range reconcileDone {
if !waitChannelBefore(done, deadline) {
stopped = false
break
}
}
if catalog != nil {
remaining := max(time.Until(deadline), 0)
ctx, closeCancel := context.WithTimeout(context.Background(), remaining)
_ = catalog.Close(ctx)
err := catalog.Close(ctx)
closeCancel()
}
if done != nil {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
if err != nil {
stopped = false
}
}
if done != nil && !waitChannelBefore(done, deadline) {
stopped = false
}
return stopped
}
func waitChannelBefore(done <-chan struct{}, deadline time.Time) bool {
+8 -19
View File
@@ -15,7 +15,7 @@ import (
"reasonix/internal/taskcatalog"
)
func (a *App) runSessionCatalog(ctx context.Context, rebuild bool) {
func (a *App) runSessionCatalog(ctx context.Context) {
path := sessioncatalog.DefaultPath()
freshGeneration := false
if strings.TrimSpace(path) != "" {
@@ -29,11 +29,6 @@ func (a *App) runSessionCatalog(ctx context.Context, rebuild bool) {
for _, project := range projects.Projects {
taskcatalog.RegisterSharedProject(project.Root, projectDisplayName(project))
}
if rebuild {
if _, err := sessioncatalog.Rebuild(ctx, path, targets); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("desktop: rebuild session catalog", "err", err)
}
}
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
Path: path,
OnRevision: func(revision uint64, roots []string, reason string) {
@@ -72,10 +67,8 @@ func (a *App) runSessionCatalog(ctx context.Context, rebuild bool) {
return
}
reconcileGroup.Go(func() error {
if !rebuild {
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
if err := catalog.ReconcileDirectory(ctx, target); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: reconcile session catalog directory", "dir", target.Path, "err", err)
@@ -84,16 +77,14 @@ func (a *App) runSessionCatalog(ctx context.Context, rebuild bool) {
})
}
_ = reconcileGroup.Wait()
if rebuild {
catalog.MarkRepairReason("manual_rebuild")
} else if freshGeneration {
if freshGeneration {
catalog.MarkRepairReason("generation_upgrade")
}
a.retargetOpenTabsToContinuations()
a.runSessionCatalogRefreshLoop(ctx, catalog, rebuild)
a.runSessionCatalogRefreshLoop(ctx, catalog)
}
func (a *App) runSessionCatalogRefreshLoop(ctx context.Context, catalog *sessioncatalog.Catalog, rebuild bool) {
func (a *App) runSessionCatalogRefreshLoop(ctx context.Context, catalog *sessioncatalog.Catalog) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
@@ -103,10 +94,8 @@ func (a *App) runSessionCatalogRefreshLoop(ctx context.Context, catalog *session
slog.Debug("desktop: refresh session catalog metadata", "err", err)
}
for _, target := range a.sessionCatalogTargets() {
if !rebuild {
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
catalog.RequestReconcile(target)
// Count sweep rides the periodic reconcile tick; it only moves
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"errors"
"time"
"reasonix/internal/sessioncatalog"
)
var (
errSessionCatalogStopTimeout = errors.New("session catalog did not stop before the rebuild deadline")
errSessionCatalogPanic = errors.New("session catalog rebuild failed unexpectedly")
)
type sessionCatalogRebuildFlight struct {
done chan struct{}
err error
}
// RebuildSessionCatalog owns the bounded, one-shot rebuild transaction. The
// replacement watcher is always an ordinary watcher and never owns rebuilding.
func (a *App) RebuildSessionCatalog() error {
return a.rebuildSessionCatalog(5 * time.Second)
}
func (a *App) rebuildSessionCatalog(stopTimeout time.Duration) (err error) {
if a == nil || a.shuttingDown.Load() {
return errors.New("application is shutting down")
}
a.catalogRebuildMu.Lock()
if flight := a.catalogRebuild; flight != nil {
a.catalogRebuildMu.Unlock()
if a.catalogRebuildJoinHook != nil {
a.catalogRebuildJoinHook()
}
<-flight.done
return flight.err
}
flight := &sessionCatalogRebuildFlight{done: make(chan struct{})}
a.catalogRebuild = flight
a.catalogRebuilding.Store(true)
a.catalogRebuildMu.Unlock()
defer func() {
panicValue := recover()
if panicValue != nil {
flight.err = errSessionCatalogPanic
} else {
flight.err = err
}
a.catalogRebuildMu.Lock()
a.catalogRebuild = nil
close(flight.done)
a.catalogRebuildMu.Unlock()
if panicValue != nil {
panic(panicValue)
}
}()
err = a.runSessionCatalogRebuild(stopTimeout)
return err
}
func (a *App) runSessionCatalogRebuild(stopTimeout time.Duration) error {
status := a.currentSessionCatalogStatus()
finishedRevision := status.Revision
defer func() {
if !a.shuttingDown.Load() {
// Arm the ordinary watcher before releasing the single-flight gate so
// another Wails rebuild cannot enter the stop/start handoff gap.
a.startSessionCatalog()
}
a.catalogRebuilding.Store(false)
if !a.shuttingDown.Load() {
a.emitProjectTreeChangedV2(finishedRevision, nil, "catalog_rebuild_finished")
}
}()
a.emitProjectTreeChangedV2(status.Revision, nil, "catalog_rebuild_started")
// Rebuild must not race the old SQLite handle on Windows, where publishing
// the atomic replacement can fail while that handle is still closing.
if !a.stopSessionCatalog(stopTimeout) {
return errSessionCatalogStopTimeout
}
replacement, err := sessioncatalog.RebuildWithRevisionFloor(
a.bootContext(), sessioncatalog.DefaultPath(), a.sessionCatalogTargets(), status.Revision,
)
if err == nil {
finishedRevision = max(finishedRevision, replacement.Revision)
}
return err
}
@@ -0,0 +1,96 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
func TestConcurrentRebuildSessionCatalogCallersShareFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
app := NewApp()
app.startSessionCatalog()
_ = waitForSessionCatalogForTest(t, app, nil)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
projectRoot := filepath.Join(t.TempDir(), "broken-project")
if err := addProject(projectRoot, "Broken project"); err != nil {
t.Fatal(err)
}
badTarget := desktopSessionDir(projectRoot)
if err := os.MkdirAll(filepath.Dir(badTarget), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(badTarget, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
reconcileStarted := make(chan struct{})
reconcileRelease := make(chan struct{})
released := false
t.Cleanup(func() {
if !released {
close(reconcileRelease)
}
})
app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) {
close(reconcileStarted)
<-reconcileRelease
}
if !app.requestSessionCatalogReconcile(dir) {
t.Fatal("explicit reconcile was not scheduled")
}
<-reconcileStarted
app.ctx = context.Background()
rebuildStarted := make(chan struct{})
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
if name != "project-tree:changed-v2" || len(payload) != 1 {
return
}
event, ok := payload[0].(ProjectTreeChangedV2)
if ok && event.Reason == "catalog_rebuild_started" {
close(rebuildStarted)
}
}
joined := make(chan struct{})
app.catalogRebuildJoinHook = func() { close(joined) }
leaderDone := make(chan error, 1)
go func() { leaderDone <- app.rebuildSessionCatalog(3 * time.Second) }()
<-rebuildStarted
followerDone := make(chan error, 1)
go func() { followerDone <- app.rebuildSessionCatalog(3 * time.Second) }()
<-joined
select {
case err := <-followerDone:
t.Fatalf("concurrent rebuild returned before the owner completed: %v", err)
default:
}
close(reconcileRelease)
released = true
leaderErr := <-leaderDone
followerErr := <-followerDone
if leaderErr == nil || followerErr == nil {
t.Fatalf("concurrent rebuild errors = leader %v, follower %v; want shared failure", leaderErr, followerErr)
}
if followerErr.Error() != leaderErr.Error() || !strings.Contains(leaderErr.Error(), "not a directory") {
t.Fatalf("concurrent rebuild errors = leader %q, follower %q; want the same rebuild result",
leaderErr, followerErr)
}
if app.catalogRebuilding.Load() {
t.Fatal("shared failed rebuild left rebuilding set")
}
}
@@ -0,0 +1,267 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
func waitForSessionCatalogForTest(t *testing.T, app *App, previous *sessioncatalog.Catalog) *sessioncatalog.Catalog {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
catalog := app.sessionCatalog.Load()
if catalog != nil && catalog != previous {
return catalog
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("session catalog was not published before the deadline")
return nil
}
func assertSessionCatalogWatcherRunning(t *testing.T, app *App) {
t.Helper()
app.catalogLifecycleMu.Lock()
cancel := app.catalogCancel
done := app.catalogDone
app.catalogLifecycleMu.Unlock()
if cancel == nil || done == nil {
t.Fatal("session catalog watcher is not armed")
}
select {
case <-done:
t.Fatal("session catalog watcher exited instead of entering the refresh loop")
default:
}
}
func TestSessionCatalogRebuildingStatusPreservesPublishedCounts(t *testing.T) {
isolateDesktopUserDirs(t)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
path := filepath.Join(dir, "counted.jsonl")
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := catalog.UpsertSession(context.Background(), sessioncatalog.SessionRecord{
Path: path, Directory: dir, Scope: "global", TopicID: "counted",
Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.sessionCatalog.Store(catalog)
published := sessionCatalogStatus(catalog.Status())
app.catalogRebuilding.Store(true)
t.Cleanup(func() {
app.catalogRebuilding.Store(false)
app.sessionCatalog.CompareAndSwap(catalog, nil)
_ = catalog.Close(context.Background())
})
status := app.currentSessionCatalogStatus()
if status.State != string(sessioncatalog.StateRebuilding) {
t.Fatalf("state = %q, want rebuilding", status.State)
}
if status.Indexed != published.Indexed || status.Total != published.Total || status.Revision != published.Revision {
t.Fatalf("rebuilding snapshot = %d/%d@%d, want published %d/%d@%d",
status.Indexed, status.Total, status.Revision, published.Indexed, published.Total, published.Revision)
}
if status.CanRebuild {
t.Fatal("an active rebuild must fail closed for another rebuild request")
}
app.sessionCatalog.Store(nil)
status = app.currentSessionCatalogStatus()
if status.State != string(sessioncatalog.StateRebuilding) || status.Total != 0 || status.CanRebuild {
t.Fatalf("catalog-close rebuild status = %+v, want unknown progress and no rebuild action", status)
}
}
func TestSessionCatalogStatusCanRebuildJSONContract(t *testing.T) {
ready := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateReady, Mode: sessioncatalog.ModeDisk})
degraded := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateDegraded, Mode: sessioncatalog.ModeMemory})
failed := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateReady, LastError: "catalog unavailable"})
opening := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateOpening, LastError: "still opening"})
closed := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateClosed, LastError: "closed"})
repairing := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateDegraded, RepairPending: 1})
if ready.CanRebuild || !degraded.CanRebuild || !failed.CanRebuild || opening.CanRebuild || closed.CanRebuild || repairing.CanRebuild {
t.Fatalf("canRebuild policy ready/degraded/failed/opening/closed/repairing = %v/%v/%v/%v/%v/%v",
ready.CanRebuild, degraded.CanRebuild, failed.CanRebuild, opening.CanRebuild, closed.CanRebuild, repairing.CanRebuild)
}
for _, status := range []SessionCatalogStatus{ready, degraded, failed, opening, closed, repairing} {
body, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"canRebuild":`) {
t.Fatalf("Wails status omitted canRebuild: %s", body)
}
}
}
func TestRebuildSessionCatalogReturnsWithOrdinaryWatcherRunning(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
app.startSessionCatalog()
oldCatalog := waitForSessionCatalogForTest(t, app, nil)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
for range 32 {
if err := oldCatalog.SyncMetadata(context.Background(), nil, nil); err != nil {
t.Fatal(err)
}
}
oldRevision := oldCatalog.Status().Revision
app.ctx = context.Background()
events := make(chan ProjectTreeChangedV2, 8)
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
if name != "project-tree:changed-v2" || len(payload) != 1 {
return
}
if event, ok := payload[0].(ProjectTreeChangedV2); ok {
events <- event
}
}
if err := app.RebuildSessionCatalog(); err != nil {
t.Fatal(err)
}
if app.catalogRebuilding.Load() {
t.Fatal("RebuildSessionCatalog returned while rebuilding was still true")
}
assertSessionCatalogWatcherRunning(t, app)
newCatalog := waitForSessionCatalogForTest(t, app, oldCatalog)
if status := newCatalog.Status(); status.State != sessioncatalog.StateReady {
t.Fatalf("replacement watcher status = %q, want ready", status.State)
}
if revision := newCatalog.Status().Revision; revision < oldRevision {
t.Fatalf("replacement watcher revision = %d, want at least previous revision %d", revision, oldRevision)
}
deadline := time.After(3 * time.Second)
for {
select {
case event := <-events:
if event.Reason != "catalog_rebuild_finished" {
continue
}
if event.Revision < oldRevision {
t.Fatalf("finished event revision = %d, want at least previous revision %d", event.Revision, oldRevision)
}
goto finishedEventObserved
case <-deadline:
t.Fatal("catalog rebuild finished event was not published")
}
}
finishedEventObserved:
if app.catalogRebuilding.Load() {
t.Fatal("the long-lived watcher took ownership of rebuilding")
}
}
func TestRebuildSessionCatalogFailureKeepsProjectionAndRestartsWatcher(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "keep.jsonl")
body := []byte(`{"role":"user","content":"keep me"}` + "\n")
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
ID: agent.BranchID(path), Scope: "global", TopicID: "keep", TopicTitle: "Keep",
}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.startSessionCatalog()
oldCatalog := waitForSessionCatalogForTest(t, app, nil)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
if err := oldCatalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
if _, ok, err := oldCatalog.GetTopic(context.Background(), sessioncatalog.TopicKey{Scope: "global", TopicID: "keep"}); err != nil || !ok {
t.Fatalf("seed topic missing before failed rebuild: ok=%v err=%v", ok, err)
}
metaPath := agent.BranchMetaPath(path)
var metaBody []byte
var err error
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
metaBody, err = os.ReadFile(metaPath)
if err == nil && strings.Contains(string(metaBody), `"schema_version": 2`) {
break
}
time.Sleep(5 * time.Millisecond)
}
if !strings.Contains(string(metaBody), `"schema_version": 2`) {
t.Fatalf("listing sidecar did not settle before rebuild: %v: %s", err, metaBody)
}
projectRoot := filepath.Join(t.TempDir(), "broken-project")
if err := addProject(projectRoot, "Broken project"); err != nil {
t.Fatal(err)
}
badTarget := desktopSessionDir(projectRoot)
if err := os.MkdirAll(filepath.Dir(badTarget), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(badTarget, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := app.RebuildSessionCatalog(); err == nil {
t.Fatal("expected rebuild to fail for a session target that is a regular file")
}
if app.catalogRebuilding.Load() {
t.Fatal("failed rebuild left rebuilding set")
}
assertSessionCatalogWatcherRunning(t, app)
newCatalog := waitForSessionCatalogForTest(t, app, oldCatalog)
if _, ok, err := newCatalog.GetTopic(context.Background(), sessioncatalog.TopicKey{Scope: "global", TopicID: "keep"}); err != nil || !ok {
t.Fatalf("failed rebuild lost the existing projection: ok=%v err=%v", ok, err)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(after) != string(body) {
t.Fatalf("failed rebuild modified the authoritative transcript: got %q want %q", after, body)
}
metaAfter, err := os.ReadFile(metaPath)
if err != nil {
t.Fatal(err)
}
if string(metaAfter) != string(metaBody) {
t.Fatalf("failed rebuild modified the authoritative sidecar: got %q want %q", metaAfter, metaBody)
}
}
func TestRebuildSessionCatalogDoesNotRestartDuringShutdown(t *testing.T) {
app := NewApp()
app.shuttingDown.Store(true)
if err := app.RebuildSessionCatalog(); err == nil {
t.Fatal("rebuild during shutdown must fail")
}
app.catalogLifecycleMu.Lock()
cancel, done := app.catalogCancel, app.catalogDone
app.catalogLifecycleMu.Unlock()
if cancel != nil || done != nil || app.catalogRebuilding.Load() {
t.Fatal("shutdown rebuild armed catalog lifecycle state")
}
}
@@ -0,0 +1,76 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
func TestRebuildSessionCatalogDoesNotReplaceBeforeOldReconcileStops(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
app := NewApp()
app.startSessionCatalog()
oldCatalog := waitForSessionCatalogForTest(t, app, nil)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
catalogPath := sessioncatalog.DefaultPath()
before, err := os.Stat(catalogPath)
if err != nil {
t.Fatal(err)
}
started := make(chan struct{})
release := make(chan struct{})
finished := make(chan struct{})
app.catalogReconcileHook = func(sessioncatalog.DirectoryTarget) {
close(started)
<-release
}
app.catalogReconcileDoneHook = func(sessioncatalog.DirectoryTarget) { close(finished) }
if !app.requestSessionCatalogReconcile(dir) {
t.Fatal("explicit reconcile was not scheduled")
}
<-started
stopTimeout := 25 * time.Millisecond
err = app.rebuildSessionCatalog(stopTimeout)
if !errors.Is(err, errSessionCatalogStopTimeout) {
close(release)
t.Fatalf("rebuild error = %v, want stop deadline error", err)
}
if app.catalogRebuilding.Load() {
close(release)
t.Fatal("timed-out rebuild left rebuilding set")
}
after, err := os.Stat(catalogPath)
if err != nil {
close(release)
t.Fatal(err)
}
if !os.SameFile(before, after) {
close(release)
t.Fatal("rebuild replaced the live projection before the old reconcile stopped")
}
if backups, err := filepath.Glob(catalogPath + ".replaced-*"); err != nil || len(backups) != 0 {
close(release)
t.Fatalf("replacement backups after aborted rebuild = %v, err = %v", backups, err)
}
close(release)
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("old reconcile did not finish after release")
}
assertSessionCatalogWatcherRunning(t, app)
_ = waitForSessionCatalogForTest(t, app, oldCatalog)
}
-15
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"path/filepath"
"sort"
"strings"
@@ -698,20 +697,6 @@ func (a *App) GetSessionCatalogStatus() SessionCatalogStatus {
return a.currentSessionCatalogStatus()
}
func (a *App) RebuildSessionCatalog() error {
if a == nil || a.shuttingDown.Load() {
return errors.New("application is shutting down")
}
if !a.catalogRebuilding.CompareAndSwap(false, true) {
return nil
}
go func() {
a.stopSessionCatalog(250 * time.Millisecond)
a.startSessionCatalog(true)
}()
return nil
}
// ListProjectTree is the one-release compatibility wrapper. It composes only
// catalog pages and project shells; it never migrates, scans, or decodes a
// session synchronously.
+3 -10
View File
@@ -1837,15 +1837,6 @@ func (a *App) activeWorkspaceRoot() string {
return "."
}
func (a *App) saveProviderCredential(apiKeyEnv, value string) (string, error) {
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
value = strings.TrimSpace(value)
if err := upsertDotEnv(apiKeyEnv, value); err != nil {
return "", err
}
return providerCredentialSourceNotice(apiKeyEnv, value), nil
}
func providerCredentialSourceNotice(apiKeyEnv, value string) string {
return ""
}
@@ -3194,7 +3185,8 @@ func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error {
// ClearProviderKey removes a provider secret from Reasonix's global .env
// and rebuilds so the provider immediately becomes unauthenticated.
func (a *App) ClearProviderKey(apiKeyEnv string) error {
if strings.TrimSpace(apiKeyEnv) == "" {
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
if apiKeyEnv == "" {
return fmt.Errorf("this provider has no api_key_env set")
}
if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
@@ -3203,6 +3195,7 @@ func (a *App) ClearProviderKey(apiKeyEnv string) error {
if err := removeDotEnv(apiKeyEnv); err != nil {
return err
}
a.revokeCredentialProxyRoutesByCredential(apiKeyEnv)
if err := a.rebuildSetting("provider key"); err != nil {
if _, ok := a.deferredRebuildWarning("provider key", err); ok {
return nil
+2 -2
View File
@@ -73,7 +73,7 @@ func waitForTopicDirMarker(t *testing.T, dir, marker string) {
func waitForCatalogTopic(t *testing.T, app *App, scope, workspaceRoot, topicID string) []ProjectNode {
t.Helper()
app.startSessionCatalog(false)
app.startSessionCatalog()
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
@@ -764,7 +764,7 @@ func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
app := NewApp()
// Filename recovery folds into the root ordinary row; History keeps the
// physical recovery file reachable as another saved version.
app.startSessionCatalog(false)
app.startSessionCatalog()
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
nodes := waitForCatalogTreeCondition(t, app, "filename recovery folded into one ordinary row", func(nodes []ProjectNode) bool {
for _, folder := range nodes {
+29 -2
View File
@@ -634,6 +634,14 @@ func (c *Catalog) finishDirectoryScan(ctx context.Context, target DirectoryTarge
// sidecars are never changed or removed by this operation. The live database
// stays in place until a fully-populated replacement is validated and swapped.
func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) {
return RebuildWithRevisionFloor(ctx, path, targets, 0)
}
// RebuildWithRevisionFloor preserves the caller's revision epoch while
// atomically replacing the disposable projection. Desktop clients retain
// revision fences across the rebuild, so a replacement must never publish a
// lower revision than the catalog they already rendered.
func RebuildWithRevisionFloor(ctx context.Context, path string, targets []DirectoryTarget, revisionFloor uint64) (Status, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath()
}
@@ -642,6 +650,13 @@ func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Statu
if err != nil {
return Status{}, err
}
if err := setCatalogRevisionFloor(ctx, catalog.db, revisionFloor); err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = catalog.Close(closeCtx)
cancel()
return Status{}, err
}
catalog.rememberRevision(revisionFloor)
for _, target := range targets {
if err := catalog.ReconcileDirectory(ctx, target); err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -662,6 +677,9 @@ func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Statu
Migrations: sessionMigrations(),
RetainBackup: true,
}, func(ctx context.Context, db *sql.DB) error {
if err := setCatalogRevisionFloor(ctx, db, revisionFloor); err != nil {
return err
}
// Populate through a catalog that owns this temporary database handle
// without starting background repair workers.
temp := &Catalog{
@@ -670,8 +688,9 @@ func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Statu
writeQueued: map[string]SessionRecord{},
directoryLocks: map[string]*sync.Mutex{},
stop: make(chan struct{}),
status: Status{State: StateReady, Mode: ModeDisk, Path: path},
status: Status{State: StateReady, Mode: ModeDisk, Path: path, Revision: revisionFloor},
}
temp.revision.Store(revisionFloor)
temp.workerCtx, temp.workerCancel = context.WithCancel(ctx)
defer temp.workerCancel()
for _, target := range targets {
@@ -687,7 +706,7 @@ func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Statu
// Open the published replacement briefly for a status snapshot, then close.
catalog, err := Open(ctx, Options{Path: path, DisableRepair: true})
if err != nil {
return Status{State: StateReady, Mode: ModeDisk, Path: path}, nil
return Status{State: StateReady, Mode: ModeDisk, Path: path, Revision: revisionFloor}, nil
}
status := catalog.Status()
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -696,6 +715,14 @@ func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Statu
return status, nil
}
func setCatalogRevisionFloor(ctx context.Context, db *sql.DB, revisionFloor uint64) error {
if db == nil || revisionFloor == 0 {
return nil
}
_, err := db.ExecContext(ctx, `UPDATE catalog_state SET revision=? WHERE id=1 AND revision<?`, revisionFloor, revisionFloor)
return err
}
// Inspect is read-only. It never migrates, repairs, quarantines, or rewrites a
// catalog, making it suitable for `reasonix doctor sessions`.
func Inspect(ctx context.Context, path string) (Status, error) {