From 96caf4a8a7dbce88c9d835b58a13af0bf557447d Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 13 Oct 2026 12:59:10 +0200 Subject: [PATCH] fix(master): keep the scroll position when toggling auto mode on the overview The mode switch was a plain form POST answered with 303 to /, so the browser navigated to a fresh page at the top. The form now also posts via htmx and the handler returns the services fragment for htmx requests, so the list is swapped in place; search and machine filter stay applied. A refused auto answers 409 with HX-Redirect to the service page, since htmx does not swap 4xx responses. Without JS the form still posts and redirects as before. #services gets overflow-anchor:none. htmx inserts the new list before removing the old one, and Chrome anchored scrolling on the focused switch, so on narrow screens the page jumped to the bottom after a click, and after any SSE refresh while a switch had focus. Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ --- internal/master/web/server.go | 14 +++++++ internal/master/web/server_test.go | 41 +++++++++++++++++++++ internal/master/web/static/app.css | 3 ++ internal/master/web/templates/overview.html | 2 +- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/master/web/server.go b/internal/master/web/server.go index 11d8e06..0fa0eb6 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -628,6 +628,10 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) { } var nu *model.NotUpdatableError if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) { + if isHTMX(r) { + // htmx does not swap a 409; the service page shows the reason. + w.Header().Set("HX-Redirect", "/services/"+strconv.FormatInt(id, 10)) + } s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".") return } else if err != nil { @@ -641,6 +645,11 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) { } s.broker.Publish(events.Services) if r.PostFormValue("return") == "overview" { + if isHTMX(r) { + // Swap the list in place instead of redirecting, so the page keeps its scroll position. + s.servicesFragment(w, r) + return + } http.Redirect(w, r, "/", http.StatusSeeOther) return } @@ -881,6 +890,11 @@ func (s *Server) fail(w http.ResponseWriter, err error) { http.Error(w, "internal error", http.StatusInternalServerError) } +// isHTMX reports whether r was sent by htmx rather than by a plain form submit. +func isHTMX(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} + // agentSnippet is the compose file shown once when an agent is added. func agentSnippet(cfg Config, token string) string { return fmt.Sprintf(`services: diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 34a06d2..94128cf 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -707,6 +707,47 @@ func TestSetMode_ReturnToOverview(t *testing.T) { assert.Equal(t, "auto", store.modeOf(1)) } +func postModeHTMX(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) { + t.Helper() + form.Set("csrf_token", "csrf") + req, err := http.NewRequest(http.MethodPost, srv.URL+"/services/"+id+"/mode", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + resp, err := noRedirectClient().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp, string(body) +} + +func TestSetMode_HTMXFromOverviewReturnsServicesFragment(t *testing.T) { + srv, store, _, broker, _ := newTestEnv(t) + sub, cancel := broker.Subscribe() + defer cancel() + + resp, body := postModeHTMX(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}}) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "no redirect, so the page keeps its scroll position") + assert.Empty(t, resp.Header.Get("Location")) + assert.NotContains(t, body, "This agent{{end}}
-
+ {{if eq .UpdateMode "auto"}}