diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go index a4a5f4b..8de20f6 100644 --- a/internal/web/settings_handlers.go +++ b/internal/web/settings_handlers.go @@ -50,6 +50,14 @@ func (s *Server) handlePostSettings(c *gin.Context) { if req.AWURL == "" { req.AWURL = s.settings.AWURL } + // Likewise preserve ambient fields when a caller omits them, so a partial + // POST never clobbers a configured ambient dial. + if req.AmbientMode == "" { + req.AmbientMode = s.settings.AmbientMode + } + if req.AmbientCadenceSecs <= 0 { + req.AmbientCadenceSecs = s.settings.AmbientCadenceSecs + } s.settingsMu.Unlock() if apply == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) diff --git a/internal/web/settings_handlers_test.go b/internal/web/settings_handlers_test.go index 3b078ff..65b20b6 100644 --- a/internal/web/settings_handlers_test.go +++ b/internal/web/settings_handlers_test.go @@ -6,8 +6,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "keel/internal/harness" "keel/internal/settings" ) @@ -220,3 +222,30 @@ func TestBrowseBadDirIs400(t *testing.T) { t.Fatalf("status = %d, want 400", w.Code) } } + +func TestPostSettingsPreservesBlankAmbientFields(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + current := settings.Settings{AIBackend: "claude", AmbientMode: "status", AmbientCadenceSecs: 120} + s.SetSettings(path, current, func(settings.Settings) error { return nil }) + + // A POST that omits the ambient fields must not blank them. + body := `{"ai_backend":"claude"}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.Router().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + saved, err := settings.Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if saved.AmbientMode != "status" || saved.AmbientCadenceSecs != 120 { + t.Fatalf("ambient fields not preserved: mode=%q cadence=%d", saved.AmbientMode, saved.AmbientCadenceSecs) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b90ff02..5c911e3 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -190,3 +190,18 @@ input[type="checkbox"] { } @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } + +.ambient-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 8px 0; + padding: 10px 14px; + border-radius: 10px; + background: #3a2a00; + color: #ffd479; + border: 1px solid #6b4f00; +} +.ambient-banner .ambient-line { font-weight: 600; } +.ambient-banner .ambient-actions { display: flex; gap: 8px; flex-shrink: 0; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 36e57cf..3ced7fd 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -381,10 +381,27 @@ function renderOffscreen(m) { if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start'); } +// updateAmbient shows the always-on drift banner whenever the server reports an +// ambient coaching line, regardless of the active mode. It clears when empty. +function updateAmbient(env) { + const el = document.getElementById('ambientBanner'); + const line = (env && env.ambient) || ''; + if (!line) { el.hidden = true; el.innerHTML = ''; return; } + el.hidden = false; + el.innerHTML = `⚠ ${esc(line)} + + + + `; + document.getElementById('ambientSnooze').onclick = () => post('/ambient/snooze'); + document.getElementById('ambientFocus').onclick = () => post('/mode/focus/start'); +} + // route dispatches the state envelope to the active mode's renderer. On a mode // switch it resets the focus in-place trackers so stale DOM never leaks across. function route(env) { const am = (env && env.active_mode) || ''; + updateAmbient(env); if (am !== renderedMode) { renderedMode = am; renderedState = null; @@ -420,6 +437,14 @@ function openSettings() { + + + +