mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
commit
6e09411289
58 files changed
+2282
-364
No files matched your search
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user