feat: notify user when a kandev update is detected (#1924)

* feat: notify user when a kandev update is detected

Adds a background-poller-driven update notification with a saved
preference for delivery channel (desktop / in-app banner / both) and
an enable/disable switch on the Updates settings page.

Backend:
- internal/system/updates: NotifySettings + NotifyStore persist the
  enable/channel preference on the existing install-wide settings
  table; notifyIfNewUpdate broadcasts a WS event the first time a
  newer release is observed, deduped durably via kandev_meta so a
  restart or repeat poll doesn't re-announce the same version.
- New GET/PUT /api/v1/system/updates/notification-settings endpoints.
- pkg/websocket: new system.update_available action.

Desktop (Tauri):
- native_notifications.rs: task_id is now optional (Option<String>,
  required only for session-scoped events) and the event allowlist
  accepts system.update_available: alongside the existing session
  events, so the update notification can use the native bridge.

Frontend:
- New UpdateNotificationsCard on the Updates settings page (Switch +
  channel Select), wired through the shared settings-save-provider
  floating Save/Discard action.
- useUpdateAvailableToast bridges the WS event to an in-app toast
  and/or the native/browser desktop notification, per the saved
  channel, deduped per version.

* docs: document the update notification channel setting

* fix: hydrate update notification settings and broadcast delivery ack

- Only mark a release notification as delivered when the broadcaster
  reports at least one connected client, closing a race where an
  update could be silently dropped before the first client connects.
- Hydrate updateNotificationSettings through the settings boot payload
  (loadSettingsInitialState -> hydrateState) instead of a component-
  level fetch, per the boot-payload -> store -> component data flow.
  Widen the hydration types (HydrationState) so callers can hydrate a
  partial system slice without fabricating the other system fields.
  UpdateNotificationsCard now only reads/writes the subscribed store
  state.
- Fix module-scope settings-fetch cache leaking across tests in
  use-update-available-toast.test.ts so the fetch-failure test
  actually exercises the catch/fallback path.
- Add API-client contract tests for fetchUpdateNotificationSettings
  and saveUpdateNotificationSettings (GET cache policy, PUT method
  and body).

* test: cover delayed hydration and boot-payload mapping for update notifications
This commit is contained in:
Clément Fiere
2026-07-24 23:40:24 +02:00
committed by GitHub
parent 2d950a72fb
commit f85b648d97
40 changed files with 1675 additions and 44 deletions
+14
View File
@@ -29,6 +29,7 @@ import (
// Event bus
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
ws "github.com/kandev/kandev/pkg/websocket"
// GitHub integration
azuredevopspkg "github.com/kandev/kandev/internal/azuredevops"
@@ -781,6 +782,19 @@ func startGatewayAndServe(
gateway.Hub.SetSystemMetricsInterestTracker(systemSvc.Metrics)
systemSvc.Metrics.SetExecutionProvider(lifecycleMetricProvider{manager: lifecycleMgr})
}
if systemSvc.Updates != nil {
systemSvc.Updates.SetBroadcaster(func(msg *ws.Message) bool {
// GetClientCount is checked first so a truly empty hub never
// enqueues onto the broadcast channel; between this check and
// the send a client could still disconnect, so this reports
// eligibility, not a delivery guarantee.
if gateway.Hub.GetClientCount() == 0 {
return false
}
gateway.Hub.Broadcast(msg)
return true
})
}
systemSvc.StartBackground(ctx)
addCleanup(func() error { systemSvc.StopBackground(); return nil })
gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log)
+16
View File
@@ -14,6 +14,7 @@ const (
metaKeyLatestVersion = "latest_version"
metaKeyLatestVersionURL = "latest_version_url"
metaKeyLatestVersionCheckedAt = "latest_version_checked_at"
metaKeyNotifiedUpdateVersion = "notified_update_version"
)
const metaTableDDL = `
@@ -110,6 +111,21 @@ func ReadLatestVersion(db *sqlx.DB) (string, string, time.Time, error) {
return version, url, checkedAt, nil
}
// WriteNotifiedUpdateVersion records the release tag that the update
// notification was last sent for. This is independent of
// latest_version/ReadLatestVersion so a user who dismisses/misses a
// notification isn't re-notified for the same release on every subsequent
// poll tick or backend restart — only when a *newer* tag appears.
func WriteNotifiedUpdateVersion(db *sqlx.DB, version string) error {
return writeKey(db, metaKeyNotifiedUpdateVersion, version)
}
// ReadNotifiedUpdateVersion returns the release tag last notified about, or
// "" when no update notification has been sent yet.
func ReadNotifiedUpdateVersion(db *sqlx.DB) (string, error) {
return readKey(db, metaKeyNotifiedUpdateVersion)
}
// hasUserTables returns true when the DB contains at least one table that is
// not part of the SQLite internal schema and not kandev_meta itself. This
// distinguishes a genuinely fresh DB from a pre-meta DB being upgraded.
@@ -144,6 +144,48 @@ func TestWriteAndReadLatestVersion_RoundtripAndUpsert(t *testing.T) {
}
}
// TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert verifies the
// separate "last version we notified the user about" key persists
// independently of the polled latest_version, and upserts cleanly across
// restarts (backed by the same DB row) so re-notification is deduped per
// version rather than per process lifetime.
func TestWriteAndReadNotifiedUpdateVersion_RoundtripAndUpsert(t *testing.T) {
db := memSQLiteDB(t)
if err := ensureMetaTable(db); err != nil {
t.Fatalf("ensureMetaTable: %v", err)
}
v, err := ReadNotifiedUpdateVersion(db)
if err != nil {
t.Fatalf("ReadNotifiedUpdateVersion fresh: %v", err)
}
if v != "" {
t.Errorf("expected empty value on fresh DB, got %q", v)
}
if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil {
t.Fatalf("WriteNotifiedUpdateVersion: %v", err)
}
v, err = ReadNotifiedUpdateVersion(db)
if err != nil {
t.Fatalf("ReadNotifiedUpdateVersion after write: %v", err)
}
if v != "v1.2.3" {
t.Errorf("version = %q, want %q", v, "v1.2.3")
}
if err := WriteNotifiedUpdateVersion(db, "v1.2.4"); err != nil {
t.Fatalf("WriteNotifiedUpdateVersion overwrite: %v", err)
}
v, err = ReadNotifiedUpdateVersion(db)
if err != nil {
t.Fatalf("ReadNotifiedUpdateVersion after overwrite: %v", err)
}
if v != "v1.2.4" {
t.Errorf("version after overwrite = %q, want %q", v, "v1.2.4")
}
}
// TestReadLatestVersion_PartialPersistedState verifies the read tolerates
// only a subset of the three keys being present (which can happen if a
// previous write was interrupted).
@@ -26,3 +26,22 @@ func TestPostgresLatestVersionMetaRoundTrip(t *testing.T) {
t.Fatalf("meta round-trip = (%q, %q, %s)", version, url, gotCheckedAt)
}
}
func TestPostgresNotifiedUpdateVersionMetaRoundTrip(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
if err := ensureMetaTable(db); err != nil {
t.Fatalf("ensure meta table: %v", err)
}
if err := WriteNotifiedUpdateVersion(db, "v1.2.3"); err != nil {
t.Fatalf("write notified update version: %v", err)
}
version, err := ReadNotifiedUpdateVersion(db)
if err != nil {
t.Fatalf("read notified update version: %v", err)
}
if version != "v1.2.3" {
t.Fatalf("notified update version round-trip = %q", version)
}
}
+5 -1
View File
@@ -96,9 +96,11 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
log.Error("Failed to initialize system settings store", zap.Error(err))
}
var metricsSvc *metrics.Service
updatesOpts := []updates.Option{updates.WithHomeDir(homeDir), updates.WithJobs(tracker)}
if settingsStore != nil {
metricsStore := metrics.NewStore(settingsStore)
metricsSvc = metrics.NewService(metricsStore, metrics.NewCollector())
updatesOpts = append(updatesOpts, updates.WithNotifyStore(updates.NewNotifyStore(settingsStore)))
}
return &Service{
@@ -109,7 +111,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
Backups: backupsSvc,
Logs: logs.NewService(logDir, logFile, log),
Metrics: metricsSvc,
Updates: updates.NewService(pool, build.Version, nil, log, updates.WithHomeDir(homeDir), updates.WithJobs(tracker)),
Updates: updates.NewService(pool, build.Version, nil, log, updatesOpts...),
Restart: restart.NewManagerFromEnv(),
}
}
@@ -147,6 +149,8 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
g.GET("/updates", updates.HandleGet(s.Updates))
g.POST("/updates/check", updates.HandleCheck(s.Updates))
g.POST("/updates/apply", updates.HandleApply(s.Updates))
g.GET("/updates/notification-settings", updates.HandleGetNotifySettings(s.Updates))
g.PUT("/updates/notification-settings", updates.HandleSaveNotifySettings(s.Updates))
g.GET("/restart-capability", restart.HandleCapability(s.Restart))
g.POST("/restart", restart.HandleRequest(s.Restart))
@@ -80,3 +80,38 @@ func HandleApply(svc *Service) gin.HandlerFunc {
c.JSON(http.StatusAccepted, ApplyResponse{JobID: jobID})
}
}
// HandleGetNotifySettings returns the persisted update-notification
// preferences (enable/disable + desktop/in_view/both channel).
func HandleGetNotifySettings(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.GetNotifySettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
}
// HandleSaveNotifySettings validates and persists the update-notification
// preferences. An unknown channel or malformed body is a 400.
func HandleSaveNotifySettings(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
var body NotifySettings
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
saved, err := svc.SaveNotifySettings(c.Request.Context(), body)
if errors.Is(err, ErrNotifyStoreUnavailable) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, saved)
}
}
@@ -0,0 +1,127 @@
package updates
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
)
func newNotifyRouter(svc *Service) *gin.Engine {
r := gin.New()
api := r.Group("/api/v1/system")
api.GET("/updates/notification-settings", HandleGetNotifySettings(svc))
api.PUT("/updates/notification-settings", HandleSaveNotifySettings(svc))
return r
}
func newServiceWithNotifyStore(t *testing.T) *Service {
t.Helper()
pool := newTestPool(t)
notifyStore := newTestNotifyStore(t)
return NewService(pool, "v1.0.0", nil, logger.Default(), WithNotifyStore(notifyStore))
}
func TestHandleGetNotifySettings_ReturnsDefaults(t *testing.T) {
svc := newServiceWithNotifyStore(t)
r := newNotifyRouter(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var got NotifySettings
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got != DefaultNotifySettings() {
t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings())
}
}
func TestHandleSaveNotifySettings_PersistsAndReturnsNormalized(t *testing.T) {
svc := newServiceWithNotifyStore(t)
r := newNotifyRouter(svc)
body, _ := json.Marshal(NotifySettings{Enabled: false, Channel: NotifyChannelInView})
req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var got NotifySettings
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Enabled || got.Channel != NotifyChannelInView {
t.Errorf("got %+v", got)
}
// Persisted: a fresh GET reflects the save.
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates/notification-settings", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
var got2 NotifySettings
if err := json.Unmarshal(w2.Body.Bytes(), &got2); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got2 != got {
t.Errorf("GET after save = %+v, want %+v", got2, got)
}
}
func TestHandleSaveNotifySettings_InvalidChannelReturns400(t *testing.T) {
svc := newServiceWithNotifyStore(t)
r := newNotifyRouter(svc)
body, _ := json.Marshal(map[string]string{"channel": "smoke-signal"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestHandleSaveNotifySettings_MalformedJSONReturns400(t *testing.T) {
svc := newServiceWithNotifyStore(t)
r := newNotifyRouter(svc)
req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestHandleSaveNotifySettings_NoNotifyStoreReturns500(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default()) // no WithNotifyStore
r := newNotifyRouter(svc)
body, _ := json.Marshal(NotifySettings{Enabled: true, Channel: NotifyChannelBoth})
req := httptest.NewRequest(http.MethodPut, "/api/v1/system/updates/notification-settings", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,169 @@
package updates
import (
"context"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/persistence"
ws "github.com/kandev/kandev/pkg/websocket"
)
// capturingBroadcaster records every message handed to it so tests can
// assert whether (and what) the service broadcast, and can simulate "no
// client connected" by setting eligible=false.
type capturingBroadcaster struct {
messages []*ws.Message
eligible bool
}
func (c *capturingBroadcaster) broadcast(msg *ws.Message) bool {
c.messages = append(c.messages, msg)
return c.eligible
}
// newNotifyTestService wires a Service against a stub GitHub release server
// plus a fresh NotifyStore (defaults: enabled, both channels) and a
// capturing broadcaster, for tests that assert on the notify-on-new-update
// side effect of Check()/fetchAndPersist.
func newNotifyTestService(t *testing.T, tag, url string) (svc *Service, bc *capturingBroadcaster, notifyStore *NotifyStore) {
t.Helper()
pool := newTestPool(t)
srv, _ := newStubGitHub(t, tag, url)
notifyStore = newTestNotifyStore(t)
svc = NewService(pool, "v1.0.0", srv.Client(), logger.Default(), WithNotifyStore(notifyStore))
svc.SetReleaseURL(srv.URL)
bc = &capturingBroadcaster{eligible: true}
svc.SetBroadcaster(bc.broadcast)
return svc, bc, notifyStore
}
func TestService_Check_BroadcastsUpdateAvailable_WhenEnabled(t *testing.T) {
svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1")
if _, err := svc.Check(context.Background()); err != nil {
t.Fatalf("Check: %v", err)
}
if len(bc.messages) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(bc.messages))
}
if bc.messages[0].Action != ws.ActionUpdateAvailable {
t.Errorf("action = %q, want %q", bc.messages[0].Action, ws.ActionUpdateAvailable)
}
}
func TestService_Check_NoBroadcast_WhenNotifyDisabled(t *testing.T) {
svc, bc, notifyStore := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1")
if _, err := notifyStore.SaveSettings(context.Background(), NotifySettings{Enabled: false, Channel: NotifyChannelBoth}); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if _, err := svc.Check(context.Background()); err != nil {
t.Fatalf("Check: %v", err)
}
if len(bc.messages) != 0 {
t.Fatalf("expected no broadcast while disabled, got %d", len(bc.messages))
}
}
func TestService_Check_NoBroadcast_WhenNoUpdateAvailable(t *testing.T) {
// current == latest -> not an update.
svc, bc, _ := newNotifyTestService(t, "v1.0.0", "https://example/v1.0.0")
if _, err := svc.Check(context.Background()); err != nil {
t.Fatalf("Check: %v", err)
}
if len(bc.messages) != 0 {
t.Fatalf("expected no broadcast when already up to date, got %d", len(bc.messages))
}
}
func TestService_Check_DedupesRepeatedBroadcastForSameVersion(t *testing.T) {
svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1")
// Simulate two separate poll ticks of the same release (fetchAndPersist
// is what both Check() and the poller call; using it directly avoids the
// unrelated 30s manual-check rate limiter tested elsewhere).
if _, err := svc.fetchAndPersist(context.Background()); err != nil {
t.Fatalf("fetchAndPersist 1: %v", err)
}
if _, err := svc.fetchAndPersist(context.Background()); err != nil {
t.Fatalf("fetchAndPersist 2: %v", err)
}
if len(bc.messages) != 1 {
t.Fatalf("expected exactly 1 broadcast across repeated polls of the same version, got %d", len(bc.messages))
}
}
func TestService_Check_DoesNotPersistNotified_WhenNoClientEligible(t *testing.T) {
// Regression test for the immediate on-Start poller tick landing before
// any WS client has connected: Hub.Broadcast has no delivery guarantee,
// so a release must not be marked notified until a broadcast reports at
// least one eligible client — otherwise the first real client to
// connect would never see the one-time notification for that release.
svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1")
bc.eligible = false
if _, err := svc.fetchAndPersist(context.Background()); err != nil {
t.Fatalf("fetchAndPersist (no clients): %v", err)
}
if len(bc.messages) != 1 {
t.Fatalf("expected the broadcast attempt to still happen, got %d messages", len(bc.messages))
}
if notified, err := persistence.ReadNotifiedUpdateVersion(svc.pool.Reader()); err != nil {
t.Fatalf("ReadNotifiedUpdateVersion: %v", err)
} else if notified != "" {
t.Fatalf("expected no version marked notified while ineligible, got %q", notified)
}
// A later tick with a client now connected must still deliver it.
bc.eligible = true
if _, err := svc.fetchAndPersist(context.Background()); err != nil {
t.Fatalf("fetchAndPersist (client connected): %v", err)
}
if len(bc.messages) != 2 {
t.Fatalf("expected a retried broadcast once eligible, got %d messages", len(bc.messages))
}
if notified, err := persistence.ReadNotifiedUpdateVersion(svc.pool.Reader()); err != nil {
t.Fatalf("ReadNotifiedUpdateVersion: %v", err)
} else if notified != "v1.0.1" {
t.Fatalf("expected v1.0.1 marked notified after eligible retry, got %q", notified)
}
}
func TestService_Check_NilBroadcaster_DoesNotPanic(t *testing.T) {
pool := newTestPool(t)
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
svc.SetReleaseURL(srv.URL)
if _, err := svc.Check(context.Background()); err != nil {
t.Fatalf("Check: %v", err)
}
}
func TestService_Check_ConcurrentChecks_DoNotDoubleBroadcast(t *testing.T) {
// Regression test for a race between fetchAndPersist calls that land
// concurrently (e.g. a manual Check racing the poller's tick): only one
// of them should win the notified-version dedup and broadcast.
svc, bc, _ := newNotifyTestService(t, "v1.0.1", "https://example/v1.0.1")
const n = 8
done := make(chan struct{}, n)
for range n {
go func() {
defer func() { done <- struct{}{} }()
svc.notifyIfNewUpdate(context.Background(), "v1.0.1", "https://example/v1.0.1")
}()
}
for range n {
<-done
}
if got := len(bc.messages); got != 1 {
t.Fatalf("expected exactly 1 broadcast across %d concurrent notifyIfNewUpdate calls, got %d", n, got)
}
}
@@ -0,0 +1,44 @@
package updates
import "fmt"
// Update notification channels. "desktop" delivers an OS-level notification
// (native via the Tauri shell, or a browser Notification when running in a
// plain tab); "in_view" renders an in-app banner/toast that only appears
// while Kandev is open; "both" delivers both.
const (
NotifyChannelDesktop = "desktop"
NotifyChannelInView = "in_view"
NotifyChannelBoth = "both"
)
// NotifySettings controls whether — and how — the user is notified when the
// background updates poller (see poller.go) detects a new release.
type NotifySettings struct {
Enabled bool `json:"enabled"`
Channel string `json:"channel"`
}
// DefaultNotifySettings returns the value used whenever nothing has been
// saved yet (fresh installs, and existing installs on their first read after
// this feature ships): notify on both channels. Once a user saves a
// preference via NotifyStore, that choice — including disabling the feature
// — persists across restarts and future releases.
func DefaultNotifySettings() NotifySettings {
return NotifySettings{Enabled: true, Channel: NotifyChannelBoth}
}
// NormalizeNotifySettings fills in a missing channel with the default and
// rejects anything that isn't a known channel value.
func NormalizeNotifySettings(in NotifySettings) (NotifySettings, error) {
out := in
if out.Channel == "" {
out.Channel = DefaultNotifySettings().Channel
}
switch out.Channel {
case NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth:
default:
return NotifySettings{}, fmt.Errorf("invalid notification channel %q", in.Channel)
}
return out, nil
}
@@ -0,0 +1,45 @@
package updates
import "testing"
func TestDefaultNotifySettings(t *testing.T) {
got := DefaultNotifySettings()
if !got.Enabled {
t.Errorf("expected Enabled=true by default")
}
if got.Channel != NotifyChannelBoth {
t.Errorf("channel = %q, want %q", got.Channel, NotifyChannelBoth)
}
}
func TestNormalizeNotifySettings_FillsDefaultChannel(t *testing.T) {
got, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: ""})
if err != nil {
t.Fatalf("NormalizeNotifySettings: %v", err)
}
if got.Channel != NotifyChannelBoth {
t.Errorf("channel = %q, want default %q", got.Channel, NotifyChannelBoth)
}
}
func TestNormalizeNotifySettings_AcceptsKnownChannels(t *testing.T) {
for _, channel := range []string{NotifyChannelDesktop, NotifyChannelInView, NotifyChannelBoth} {
got, err := NormalizeNotifySettings(NotifySettings{Enabled: false, Channel: channel})
if err != nil {
t.Fatalf("NormalizeNotifySettings(%q): %v", channel, err)
}
if got.Channel != channel {
t.Errorf("channel = %q, want %q", got.Channel, channel)
}
if got.Enabled {
t.Errorf("expected Enabled=false to be preserved")
}
}
}
func TestNormalizeNotifySettings_RejectsUnknownChannel(t *testing.T) {
_, err := NormalizeNotifySettings(NotifySettings{Enabled: true, Channel: "carrier-pigeon"})
if err == nil {
t.Fatal("expected error for unknown channel")
}
}
@@ -0,0 +1,52 @@
package updates
import (
"context"
"encoding/json"
"fmt"
systemsettings "github.com/kandev/kandev/internal/system/settings"
)
const notifySettingsKey = "update_notifications"
// NotifyStore persists NotifySettings on top of the generic install-wide
// settings table (see internal/system/settings), following the same
// shape as metrics.Store.
type NotifyStore struct {
settings *systemsettings.Store
}
func NewNotifyStore(settings *systemsettings.Store) *NotifyStore {
return &NotifyStore{settings: settings}
}
func (s *NotifyStore) GetSettings(ctx context.Context) (NotifySettings, error) {
raw, found, err := s.settings.Get(ctx, notifySettingsKey)
if err != nil {
return NotifySettings{}, err
}
if !found {
return DefaultNotifySettings(), nil
}
var settings NotifySettings
if err := json.Unmarshal(raw, &settings); err != nil {
return NotifySettings{}, err
}
return NormalizeNotifySettings(settings)
}
func (s *NotifyStore) SaveSettings(ctx context.Context, settings NotifySettings) (NotifySettings, error) {
normalized, err := NormalizeNotifySettings(settings)
if err != nil {
return NotifySettings{}, err
}
data, err := json.Marshal(normalized)
if err != nil {
return NotifySettings{}, err
}
if err := s.settings.Save(ctx, notifySettingsKey, data); err != nil {
return NotifySettings{}, fmt.Errorf("save update notification settings: %w", err)
}
return normalized, nil
}
@@ -0,0 +1,67 @@
package updates
import (
"context"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/db"
systemsettings "github.com/kandev/kandev/internal/system/settings"
)
func newTestNotifyStore(t *testing.T) *NotifyStore {
t.Helper()
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
settingsStore, err := systemsettings.NewStore(db.NewPool(conn, conn))
if err != nil {
t.Fatalf("new settings store: %v", err)
}
return NewNotifyStore(settingsStore)
}
func TestNotifyStore_GetSettings_DefaultsWhenUnset(t *testing.T) {
store := newTestNotifyStore(t)
got, err := store.GetSettings(context.Background())
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if got != DefaultNotifySettings() {
t.Errorf("got %+v, want default %+v", got, DefaultNotifySettings())
}
}
func TestNotifyStore_SaveAndGet_Roundtrip(t *testing.T) {
store := newTestNotifyStore(t)
ctx := context.Background()
saved, err := store.SaveSettings(ctx, NotifySettings{Enabled: false, Channel: NotifyChannelDesktop})
if err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if saved.Enabled || saved.Channel != NotifyChannelDesktop {
t.Errorf("saved = %+v", saved)
}
got, err := store.GetSettings(ctx)
if err != nil {
t.Fatalf("GetSettings after save: %v", err)
}
if got != saved {
t.Errorf("got %+v after save, want %+v", got, saved)
}
}
func TestNotifyStore_SaveSettings_RejectsInvalidChannel(t *testing.T) {
store := newTestNotifyStore(t)
_, err := store.SaveSettings(context.Background(), NotifySettings{Enabled: true, Channel: "smoke-signal"})
if err == nil {
t.Fatal("expected error for invalid channel")
}
}
@@ -20,6 +20,7 @@ import (
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/system/jobs"
ws "github.com/kandev/kandev/pkg/websocket"
)
// PollInterval is the cadence at which the background goroutine polls GitHub.
@@ -54,6 +55,11 @@ var ErrNoUpdateAvailable = errors.New("no update available")
// launched and not yet completed, so a second apply is refused.
var ErrApplyInProgress = errors.New("a self-update is already in progress")
// ErrNotifyStoreUnavailable indicates that SaveNotifySettings was called on
// a Service constructed without WithNotifyStore (e.g. the system settings
// table failed to initialize at boot).
var ErrNotifyStoreUnavailable = errors.New("update notification settings are not available")
// devVersion is the sentinel used by the ldflags-injected current version
// when no release tag was baked in (i.e. local dev builds).
const devVersion = "dev"
@@ -106,6 +112,25 @@ type Service struct {
now func() time.Time
applyRun applyRunner
// notifyStore persists whether/how the user wants to be told about a
// newly detected update (see notify_settings.go, notify_store.go). Nil
// when the caller didn't wire one (e.g. some tests) — treated as
// DefaultNotifySettings().
notifyStore *NotifyStore
// broadcast delivers a WS notification to connected clients when a new
// update is detected and notifications are enabled. Nil-safe: when unset,
// notifyIfNewUpdate is a no-op beyond the settings/dedup check.
broadcast Broadcaster
// notifyMu serializes the read-check-broadcast-write sequence in
// notifyIfNewUpdate. fetchAndPersist can run concurrently from a manual
// Check() and the poller's tick (Check is rate-limited against itself,
// but not against a poller tick landing in the same instant), so without
// this the notified-version read and write could interleave and fire the
// broadcast twice for the same release.
notifyMu sync.Mutex
// applyStartedAt holds the unix-nano timestamp of the last self-update launch
// (0 = none in flight). It guards against two concurrent /updates/apply calls
// each launching a helper that would race the reinstall/restart, and
@@ -136,6 +161,21 @@ type Service struct {
// the poller and Check() without spinning up an httptest server.
type Fetcher func(ctx context.Context) (tag, url string, err error)
// Broadcaster delivers a WS notification message to connected clients. Kept
// as a bare function type (rather than depending on gateway/websocket.Hub or
// a user-store package) so this package stays free of unrelated wiring —
// callers close over whatever targeting (broadcast-to-all, broadcast-to-user)
// makes sense for their deployment.
//
// The returned bool reports only whether at least one client was connected
// (eligible to receive) at broadcast time — Hub.Broadcast has no per-client
// ack, so this cannot guarantee actual receipt (a client can still drop the
// connection between the eligibility check and the send). It is precise
// enough to close the common gap: the poller's immediate on-Start tick can
// run before any WS client has connected, which would otherwise mark a
// release "notified" that nobody was ever eligible to see.
type Broadcaster func(msg *ws.Message) (eligible bool)
// Option customises Service construction without growing NewService's public
// parameter list.
type Option func(*Service)
@@ -173,6 +213,15 @@ func WithApplyRunner(r applyRunner) Option {
}
}
// WithNotifyStore wires the persisted update-notification preferences
// (enable/disable + desktop/in_view/both channel). Without it,
// notifyIfNewUpdate falls back to DefaultNotifySettings().
func WithNotifyStore(store *NotifyStore) Option {
return func(s *Service) {
s.notifyStore = store
}
}
// NewService constructs the updates service. When httpClient is nil, a fresh
// client carrying defaultClientTimeout is allocated — http.DefaultClient has
// no timeout, so handing it the poller would let a stalled socket hang a
@@ -239,6 +288,34 @@ func (s *Service) SetReleaseURL(url string) {
s.mu.Unlock()
}
// SetBroadcaster wires the WS broadcaster used to announce a newly detected
// update. Constructed post-hoc (rather than via Option) because the WS hub
// is composed after this service in the current wiring order — see
// internal/backendapp/main.go.
func (s *Service) SetBroadcaster(b Broadcaster) {
s.mu.Lock()
s.broadcast = b
s.mu.Unlock()
}
// GetNotifySettings returns the persisted update-notification preferences,
// or DefaultNotifySettings() when no NotifyStore was wired.
func (s *Service) GetNotifySettings(ctx context.Context) (NotifySettings, error) {
if s.notifyStore == nil {
return DefaultNotifySettings(), nil
}
return s.notifyStore.GetSettings(ctx)
}
// SaveNotifySettings validates and persists the update-notification
// preferences. Returns an error if no NotifyStore was wired.
func (s *Service) SaveNotifySettings(ctx context.Context, settings NotifySettings) (NotifySettings, error) {
if s.notifyStore == nil {
return NotifySettings{}, ErrNotifyStoreUnavailable
}
return s.notifyStore.SaveSettings(ctx, settings)
}
// Get returns the last-known state from kandev_meta without contacting
// GitHub. Safe to call on every page load.
func (s *Service) Get() (UpdatesResponse, error) {
@@ -358,9 +435,63 @@ func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error)
s.log.Warn("updates: persist latest version failed", zap.Error(werr))
return UpdatesResponse{}, werr
}
s.notifyIfNewUpdate(ctx, tag, releaseURL)
return s.buildResponse(tag, releaseURL, now), nil
}
// notifyIfNewUpdate broadcasts a WS notification the first time a given
// release tag is observed as newer than the running binary, provided the
// user hasn't disabled update notifications. Dedup is durable (kandev_meta),
// not in-memory, so a backend restart doesn't re-announce a release the user
// already saw. Disabling notifications does not mark the version as
// notified, so re-enabling later still surfaces the current release.
func (s *Service) notifyIfNewUpdate(ctx context.Context, tag, releaseURL string) {
if !s.updateAvailable(tag) {
return
}
s.mu.Lock()
broadcast := s.broadcast
s.mu.Unlock()
if broadcast == nil {
return
}
settings, err := s.GetNotifySettings(ctx)
if err != nil {
s.log.Warn("updates: read notify settings failed", zap.Error(err))
return
}
if !settings.Enabled {
return
}
s.notifyMu.Lock()
defer s.notifyMu.Unlock()
notified, err := persistence.ReadNotifiedUpdateVersion(s.pool.Reader())
if err != nil {
s.log.Warn("updates: read notified version failed", zap.Error(err))
return
}
if notified == tag {
return
}
msg, err := ws.NewNotification(ws.ActionUpdateAvailable, map[string]interface{}{
"version": tag,
"url": releaseURL,
})
if err != nil {
s.log.Warn("updates: build notification message failed", zap.Error(err))
return
}
if !broadcast(msg) {
// No client was connected to receive this — don't mark the release
// notified. The next poll tick (or a manual "Check now") will retry
// once at least one client is connected.
return
}
if werr := persistence.WriteNotifiedUpdateVersion(s.pool.Writer(), tag); werr != nil {
s.log.Warn("updates: persist notified version failed", zap.Error(werr))
}
}
func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse {
install, _ := s.detectInstallState()
return s.buildResponseFrom(install, latest, url, checkedAt)
+1
View File
@@ -223,6 +223,7 @@ const (
ActionExecutorPrepareProgress = "executor.prepare.progress"
ActionExecutorPrepareCompleted = "executor.prepare.completed"
ActionSystemMetricsUpdated = "system.metrics.updated"
ActionUpdateAvailable = "system.update_available"
ActionAgentProfileDeleted = "agent.profile.deleted"
ActionAgentProfileCreated = "agent.profile.created"
@@ -15,7 +15,12 @@ pub struct NativeNotificationRequest {
pub event_id: String,
pub title: String,
pub body: String,
pub task_id: String,
/// Present for session-scoped events (waiting-for-input, failed).
/// Absent for account/app-scoped events such as an available update,
/// which has no associated task. `#[serde(default)]` accepts payloads
/// that omit the key entirely (e.g. `JSON.stringify` drops `undefined`).
#[serde(default)]
pub task_id: Option<String>,
pub session_id: Option<String>,
}
@@ -83,25 +88,40 @@ impl NativeNotificationState {
}
}
/// Event identities this bridge will display. Session-scoped events require
/// a non-empty task_id (they're gated on task context); the update-available
/// event has no task and must omit it.
const SESSION_EVENT_PREFIXES: [&str; 4] = [
"session.waiting_for_input:",
"session.failed:",
"session.turn_finished:",
"session.clarification_requested:",
];
const UPDATE_AVAILABLE_PREFIX: &str = "system.update_available:";
fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> {
if !request.event_id.starts_with("session.turn_finished:")
&& !request
.event_id
.starts_with("session.clarification_requested:")
&& !request.event_id.starts_with("session.failed:")
{
let is_session_event = SESSION_EVENT_PREFIXES
.iter()
.any(|prefix| request.event_id.starts_with(prefix));
let is_update_event = request.event_id.starts_with(UPDATE_AVAILABLE_PREFIX);
if !is_session_event && !is_update_event {
return Err("unsupported native notification event".to_string());
}
if request.event_id.len() > 256
|| request.title.is_empty()
|| request.title.len() > 160
|| request.body.len() > 1000
|| request.task_id.is_empty()
|| request.task_id.len() > 256
|| request.session_id.as_ref().is_some_and(|id| id.len() > 256)
|| request.task_id.as_ref().is_some_and(|id| id.len() > 256)
{
return Err("invalid native notification payload".to_string());
}
if is_session_event {
match request.task_id.as_deref() {
Some(id) if !id.is_empty() => {}
_ => return Err("invalid native notification payload".to_string()),
}
}
Ok(())
}
@@ -159,11 +179,21 @@ mod tests {
event_id: event_id.to_string(),
title: "Agent needs your answer".to_string(),
body: "The agent asked a question.".to_string(),
task_id: task_id.to_string(),
task_id: Some(task_id.to_string()),
session_id: Some("session-1".to_string()),
}
}
fn update_available_request(event_id: &str) -> NativeNotificationRequest {
NativeNotificationRequest {
event_id: event_id.to_string(),
title: "Update available".to_string(),
body: "Kandev v1.2.3 is available.".to_string(),
task_id: None,
session_id: None,
}
}
#[test]
fn event_identity_is_delivered_at_most_once() {
let state = NativeNotificationState::default();
@@ -184,6 +214,16 @@ mod tests {
assert!(state.claim("session.failed:0"));
}
#[test]
fn bridge_rejects_events_outside_supported_notification_types() {
let request = request("office.inbox_item:item-1", "task-1");
assert_eq!(
validate_request(&request),
Err("unsupported native notification event".to_string())
);
}
#[test]
fn bridge_accepts_semantic_session_events() {
for event_id in [
@@ -194,6 +234,33 @@ mod tests {
}
}
#[test]
fn bridge_accepts_update_available_events_without_task_id() {
let request = update_available_request("system.update_available:v1.2.3");
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn bridge_rejects_session_events_missing_task_id() {
let mut request = request("session.waiting_for_input:session-1", "task-1");
request.task_id = None;
assert_eq!(
validate_request(&request),
Err("invalid native notification payload".to_string())
);
}
#[test]
fn update_available_identity_is_delivered_at_most_once() {
let state = NativeNotificationState::default();
let request = update_available_request("system.update_available:v1.2.3");
assert!(state.claim(&request.event_id));
assert!(!state.claim(&request.event_id));
}
#[test]
fn denied_delivery_does_not_claim_the_event() {
let state = NativeNotificationState::default();
@@ -0,0 +1,126 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import type { AppState } from "@/lib/state/store";
import { UpdateNotificationsCard } from "./update-notifications-card";
const mockSave = vi.fn();
const TOGGLE_NAME = "Enable update notifications";
const DIRTY_ATTRIBUTE = "data-settings-dirty";
vi.mock("@/lib/api/domains/system-api", () => ({
saveUpdateNotificationSettings: (...args: unknown[]) => mockSave(...args),
}));
function renderCard(initial?: { enabled: boolean; channel: string }) {
const initialState: Record<string, unknown> | undefined = initial
? { system: { updateNotificationSettings: initial } }
: undefined;
return render(
<StateProvider initialState={initialState}>
<ToastProvider>
<SettingsSaveProvider>
<UpdateNotificationsCard />
</SettingsSaveProvider>
</ToastProvider>
</StateProvider>,
);
}
function StoreCapture({ onStore }: { onStore: (store: StoreApi<AppState>) => void }) {
onStore(useAppStoreApi());
return null;
}
function renderCardCapturingStore() {
let store: StoreApi<AppState> | undefined;
const utils = render(
<StateProvider>
<ToastProvider>
<SettingsSaveProvider>
<StoreCapture onStore={(s) => (store = s)} />
<UpdateNotificationsCard />
</SettingsSaveProvider>
</ToastProvider>
</StateProvider>,
);
if (!store) throw new Error("store was not captured");
return { ...utils, store };
}
beforeEach(() => {
vi.clearAllMocks();
mockSave.mockImplementation((settings) => Promise.resolve(settings));
});
afterEach(() => {
cleanup();
});
describe("UpdateNotificationsCard", () => {
it("renders the saved enable toggle and channel from hydrated state", async () => {
renderCard({ enabled: true, channel: "desktop" });
const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME });
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(screen.getByText("Desktop notification")).toBeTruthy();
});
it("falls back to the backend default without fetching when no hydrated state is available", async () => {
renderCard();
const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME });
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(screen.getByText("Both")).toBeTruthy();
});
it("hides the channel selector once disabled and persists through the floating Save action", async () => {
renderCard({ enabled: true, channel: "both" });
const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME });
fireEvent.click(toggle);
expect(screen.queryByLabelText("Update notification channel")).toBeNull();
expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: false, channel: "both" }));
await waitFor(() => expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"));
});
it("marks the trigger dirty on channel change and persists the new channel on save", async () => {
renderCard({ enabled: true, channel: "in_view" });
await screen.findByText("In-app banner");
const trigger = screen.getByRole("combobox", { name: "Update notification channel" });
fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("option", { name: "Both" }));
expect(screen.getByText("Both")).toBeTruthy();
expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("true");
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ enabled: true, channel: "both" }));
await waitFor(() => expect(trigger.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"));
});
it("adopts the boot-payload value once hydration lands after the initial render", async () => {
const { store } = renderCardCapturingStore();
const toggle = await screen.findByRole("switch", { name: TOGGLE_NAME });
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(screen.getByText("Both")).toBeTruthy();
act(() => {
store.getState().setSystemUpdateNotificationSettings({ enabled: true, channel: "desktop" });
});
await waitFor(() => expect(screen.getByText("Desktop notification")).toBeTruthy());
expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false");
});
});
@@ -0,0 +1,133 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
import { useAppStore } from "@/components/state-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import { saveUpdateNotificationSettings } from "@/lib/api/domains/system-api";
import type { UpdateNotificationChannel, UpdateNotificationSettings } from "@/lib/types/system";
// Matches the backend default (DefaultNotifySettings). Only used while the
// initial settings fetch is pending (no SSR-hydrated value was available).
const DEFAULT_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" };
const CHANNEL_OPTIONS: Array<{ value: UpdateNotificationChannel; label: string; help: string }> = [
{
value: "desktop",
label: "Desktop notification",
help: "Shows a native OS notification (or a browser notification when this isn't the desktop app).",
},
{
value: "in_view",
label: "In-app banner",
help: "Shows an in-app banner only while Kandev is open in this window.",
},
{
value: "both",
label: "Both",
help: "Shows both a desktop notification and an in-app banner.",
},
];
function useUpdateNotificationSettingsDraft() {
const stored = useAppStore((s) => s.system.updateNotificationSettings);
const setStored = useAppStore((s) => s.setSystemUpdateNotificationSettings);
const { toast } = useToast();
const [saved, setSaved] = useState<UpdateNotificationSettings>(stored ?? DEFAULT_SETTINGS);
const [draft, setDraft] = useState<UpdateNotificationSettings>(saved);
// Boot-payload hydration (see settings-routes.tsx: loadSettingsInitialState)
// resolves asynchronously and may land after this component's first
// render. Sync draft/saved from the store exactly once, the first time a
// real value shows up, so an in-flight boot fetch doesn't get shadowed by
// DEFAULT_SETTINGS. Never resync afterward so in-progress edits survive
// unrelated store updates (including this hook's own save round-trip).
const hydratedFromStoreRef = useRef(stored !== null);
useEffect(() => {
if (hydratedFromStoreRef.current || stored === null) return;
hydratedFromStoreRef.current = true;
setSaved(stored);
setDraft(stored);
}, [stored]);
const revision = JSON.stringify(draft);
const isDirty = revision !== JSON.stringify(saved);
useSettingsSaveContributor({
id: "system-update-notifications",
revision,
isDirty,
save: async () => {
const submitted = draft;
const persisted = await saveUpdateNotificationSettings(submitted);
setSaved(persisted);
setDraft(persisted);
setStored(persisted);
toast({ title: "Update notification settings saved", variant: "success" });
},
discard: () => setDraft(saved),
});
return { draft, saved, setDraft, isDirty };
}
export function UpdateNotificationsCard() {
const { draft, saved, setDraft, isDirty } = useUpdateNotificationSettingsDraft();
const activeChannel = CHANNEL_OPTIONS.find((option) => option.value === draft.channel);
return (
<SettingsCard isDirty={isDirty} data-testid="update-notifications-card">
<CardHeader>
<CardTitle>Update notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Notify me about new releases</div>
<p className="text-sm text-muted-foreground">
Kandev checks GitHub for new releases every few hours in the background. When a newer
version is found, show a one-time notification for that release below.
</p>
</div>
<Switch
checked={draft.enabled}
data-settings-dirty={draft.enabled !== saved.enabled}
onCheckedChange={(enabled) => setDraft({ ...draft, enabled })}
aria-label="Enable update notifications"
className="cursor-pointer"
/>
</div>
{draft.enabled && (
<div className="flex items-center gap-2">
<Select
value={draft.channel}
onValueChange={(channel) =>
setDraft({ ...draft, channel: channel as UpdateNotificationChannel })
}
>
<SelectTrigger
className="w-56 cursor-pointer"
data-settings-dirty={draft.channel !== saved.channel}
aria-label="Update notification channel"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="cursor-pointer">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">{activeChannel?.help}</p>
</div>
)}
</CardContent>
</SettingsCard>
);
}
@@ -0,0 +1,9 @@
"use client";
import { useUpdateAvailableToast } from "@/hooks/use-update-available-toast";
/** Mounts the update-available toast hook inside the ToastProvider tree. */
export function UpdateAvailableToastBridge() {
useUpdateAvailableToast();
return null;
}
@@ -0,0 +1,62 @@
import { test, expect } from "../../fixtures/test-base";
test.describe("Update notifications settings", () => {
test("channel change persists through the floating Save action and survives reload", async ({
testPage,
}) => {
test.setTimeout(30_000);
await testPage.goto("/settings/system/updates");
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
const card = testPage.getByTestId("update-notifications-card");
await expect(card).toBeVisible();
// Fresh worker-scoped backend: defaults to enabled + "both" (see
// DefaultNotifySettings in internal/system/updates/notify_settings.go).
const toggle = card.getByRole("switch", { name: "Enable update notifications" });
await expect(toggle).toHaveAttribute("aria-checked", "true");
const channelTrigger = card.getByRole("combobox", { name: "Update notification channel" });
await expect(channelTrigger).toHaveText("Both");
await channelTrigger.click();
await testPage.getByRole("option", { name: "Desktop notification" }).click();
await expect(channelTrigger).toHaveText("Desktop notification");
await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "true");
await testPage.getByRole("button", { name: "Save changes" }).click();
await expect(channelTrigger).toHaveAttribute("data-settings-dirty", "false", {
timeout: 10_000,
});
await testPage.reload();
await expect(testPage.getByTestId("update-notifications-card")).toBeVisible();
await expect(
testPage
.getByTestId("update-notifications-card")
.getByRole("combobox", { name: "Update notification channel" }),
).toHaveText("Desktop notification");
});
test("disabling hides the channel selector", async ({ testPage }) => {
test.setTimeout(30_000);
await testPage.goto("/settings/system/updates");
const card = testPage.getByTestId("update-notifications-card");
const toggle = card.getByRole("switch", { name: "Enable update notifications" });
await expect(toggle).toBeVisible();
await toggle.click();
await expect(card.getByLabel("Update notification channel")).toHaveCount(0);
await testPage.getByRole("button", { name: "Save changes" }).click();
await expect(toggle).toHaveAttribute("data-settings-dirty", "false", { timeout: 10_000 });
await testPage.reload();
const reloadedToggle = testPage
.getByTestId("update-notifications-card")
.getByRole("switch", { name: "Enable update notifications" });
await expect(reloadedToggle).toHaveAttribute("aria-checked", "false");
});
});
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import type { UpdateAvailableNotification } from "@/lib/state/slices/ui/types";
import type { UpdateNotificationSettings } from "@/lib/types/system";
import type { useUpdateAvailableToast as UseUpdateAvailableToastHook } from "./use-update-available-toast";
let mockNotification: UpdateAvailableNotification | null = null;
let mockSettings: UpdateNotificationSettings | null = null;
const mockClearNotification = vi.fn();
const mockSetSettings = vi.fn();
const mockToast = vi.fn();
const mockNativeIsAvailable = vi.fn(() => false);
const mockNativeShow = vi.fn().mockResolvedValue("shown");
const mockFetchSettings = vi.fn();
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
updateAvailableNotification: mockNotification,
setUpdateAvailableNotification: mockClearNotification,
system: { updateNotificationSettings: mockSettings },
setSystemUpdateNotificationSettings: mockSetSettings,
}),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mockToast }),
}));
vi.mock("@/lib/desktop/native-notification-client", () => ({
nativeNotifications: {
isAvailable: () => mockNativeIsAvailable(),
show: (request: unknown) => mockNativeShow(request),
},
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchUpdateNotificationSettings: (...args: unknown[]) => mockFetchSettings(...args),
}));
let useUpdateAvailableToast: typeof UseUpdateAvailableToastHook;
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("useUpdateAvailableToast", () => {
beforeEach(async () => {
vi.clearAllMocks();
// The hook module caches in-flight settings fetches in a module-scoped
// variable (`settingsRequest`) so concurrent hook instances share one
// request. Reset the module and re-import per test so that cache
// doesn't leak a resolved/rejected promise from a previous test into
// this one (e.g. the fetch-failure test below would otherwise silently
// reuse a prior test's already-resolved fetch and never exercise the
// catch branch).
vi.resetModules();
mockNativeIsAvailable.mockReturnValue(false);
mockNotification = null;
mockSettings = null;
// Dynamic import is required here (not a static one): vi.resetModules()
// above clears Vitest's module registry, and only a fresh import()
// after that call picks up a new module instance with the module-scoped
// `settingsRequest` cache reset to null.
({ useUpdateAvailableToast } = await import("./use-update-available-toast"));
});
it("does nothing when there is no notification", () => {
renderHook(() => useUpdateAvailableToast());
expect(mockToast).not.toHaveBeenCalled();
expect(mockClearNotification).not.toHaveBeenCalled();
});
it("shows an in-app toast when the channel is in_view", async () => {
mockSettings = { enabled: true, channel: "in_view" };
mockNotification = { version: "v1.2.3" };
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Update available",
description: expect.stringContaining("v1.2.3"),
}),
);
expect(mockNativeShow).not.toHaveBeenCalled();
expect(mockClearNotification).toHaveBeenCalledWith(null);
});
it("shows a native notification (not a toast) when the channel is desktop and native is available", async () => {
mockSettings = { enabled: true, channel: "desktop" };
mockNotification = { version: "v1.2.3" };
mockNativeIsAvailable.mockReturnValue(true);
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockToast).not.toHaveBeenCalled();
expect(mockNativeShow).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "system.update_available:v1.2.3",
title: "Update available",
}),
);
});
it("shows both a toast and a native notification when the channel is both", async () => {
mockSettings = { enabled: true, channel: "both" };
mockNotification = { version: "v1.2.3" };
mockNativeIsAvailable.mockReturnValue(true);
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockToast).toHaveBeenCalledTimes(1);
expect(mockNativeShow).toHaveBeenCalledTimes(1);
});
it("does not notify on any channel when notifications are disabled", async () => {
mockSettings = { enabled: false, channel: "both" };
mockNotification = { version: "v1.2.3" };
mockNativeIsAvailable.mockReturnValue(true);
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockToast).not.toHaveBeenCalled();
expect(mockNativeShow).not.toHaveBeenCalled();
expect(mockClearNotification).toHaveBeenCalledWith(null);
});
it("fetches settings lazily when not yet loaded, then caches them in the store", async () => {
mockSettings = null;
mockFetchSettings.mockResolvedValue({ enabled: true, channel: "in_view" });
mockNotification = { version: "v1.2.3" };
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockFetchSettings).toHaveBeenCalledTimes(1);
expect(mockSetSettings).toHaveBeenCalledWith({ enabled: true, channel: "in_view" });
expect(mockToast).toHaveBeenCalledTimes(1);
});
it("falls back to both channels when the settings fetch fails", async () => {
mockSettings = null;
mockFetchSettings.mockRejectedValue(new Error("network error"));
mockNotification = { version: "v1.2.3" };
renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
await flushMicrotasks();
expect(mockFetchSettings).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledTimes(1);
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({ description: expect.stringContaining("v1.2.3") }),
);
// The fallback must not be persisted to the store - a later notification
// should still see settings as unset and retry the real fetch.
expect(mockSetSettings).not.toHaveBeenCalled();
});
it("deduplicates repeated notifications for the same version", async () => {
mockSettings = { enabled: true, channel: "in_view" };
mockNotification = { version: "v1.2.3" };
const { rerender } = renderHook(() => useUpdateAvailableToast());
await flushMicrotasks();
expect(mockToast).toHaveBeenCalledTimes(1);
mockToast.mockClear();
mockClearNotification.mockClear();
mockNotification = { version: "v1.2.3" };
rerender();
await flushMicrotasks();
expect(mockToast).not.toHaveBeenCalled();
expect(mockClearNotification).toHaveBeenCalledWith(null);
});
});
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { nativeNotifications } from "@/lib/desktop/native-notification-client";
import { fetchUpdateNotificationSettings } from "@/lib/api/domains/system-api";
import type { UpdateNotificationSettings } from "@/lib/types/system";
// Matches the backend default (DefaultNotifySettings in
// internal/system/updates/notify_settings.go): used only when the settings
// fetch itself fails, so a transient network hiccup doesn't silently
// swallow an update notification the user would otherwise want.
const FALLBACK_SETTINGS: UpdateNotificationSettings = { enabled: true, channel: "both" };
let settingsRequest: Promise<UpdateNotificationSettings> | null = null;
function loadSettings(): Promise<UpdateNotificationSettings> {
if (!settingsRequest) {
settingsRequest = fetchUpdateNotificationSettings({ cache: "no-store" }).catch((err) => {
// Allow the next notification to retry the fetch instead of being
// stuck on the fallback (and never re-checking the user's real saved
// preference) for the rest of the session.
settingsRequest = null;
throw err;
});
}
return settingsRequest;
}
/**
* Watches for a newly detected Kandev release (pushed over WS as
* `system.update_available`, see lib/ws/handlers/system-events.ts) and
* notifies the user according to their saved channel preference
* (desktop / in_view / both — see the Updates settings page). Mount once
* inside ToastProvider, alongside the other notification bridges.
*/
export function useUpdateAvailableToast() {
const notification = useAppStore((s) => s.updateAvailableNotification);
const clearNotification = useAppStore((s) => s.setUpdateAvailableNotification);
const settings = useAppStore((s) => s.system.updateNotificationSettings);
const setSettings = useAppStore((s) => s.setSystemUpdateNotificationSettings);
const { toast } = useToast();
const shownRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!notification) return;
if (shownRef.current.has(notification.version)) {
clearNotification(null);
return;
}
shownRef.current.add(notification.version);
void (async () => {
let current: UpdateNotificationSettings;
if (settings) {
current = settings;
} else {
try {
current = await loadSettings();
setSettings(current);
} catch {
// Don't cache the fallback in the store — a future notification
// should still see `settings` as unset and retry the fetch.
current = FALLBACK_SETTINGS;
}
}
if (!current.enabled) return;
const title = "Update available";
const body = `Kandev ${notification.version} is available. See Settings > System > Updates.`;
if (current.channel === "in_view" || current.channel === "both") {
toast({ title, description: body });
}
if (current.channel === "desktop" || current.channel === "both") {
if (nativeNotifications.isAvailable()) {
void nativeNotifications
.show({ eventId: `system.update_available:${notification.version}`, title, body })
.catch(() => undefined);
} else if (typeof Notification !== "undefined" && Notification.permission === "granted") {
new Notification(title, { body });
}
}
})();
clearNotification(null);
}, [notification, settings, setSettings, toast, clearNotification]);
}
@@ -27,6 +27,8 @@ import {
fetchSystemJob,
fetchRestartCapability,
requestRestart,
fetchUpdateNotificationSettings,
saveUpdateNotificationSettings,
adoptStorageGoCache,
analyzeStorage,
deleteStorageQuarantine,
@@ -391,3 +393,26 @@ describe("storage maintenance", () => {
expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored");
});
});
describe("update notification settings", () => {
it("fetchUpdateNotificationSettings GETs the notification-settings endpoint and bypasses cache", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: true, channel: "desktop" }));
const settings = await fetchUpdateNotificationSettings({ cache: "force-cache" });
expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`);
expect(method()).toBe("GET");
expect(lastCall().init?.cache).toBe("no-store");
expect(settings).toEqual({ enabled: true, channel: "desktop" });
});
it("saveUpdateNotificationSettings PUTs the settings body to the same endpoint", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: false, channel: "in_view" }));
const persisted = await saveUpdateNotificationSettings({ enabled: false, channel: "in_view" });
expect(lastCall().url).toBe(`${BASE}/updates/notification-settings`);
expect(method()).toBe("PUT");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
enabled: false,
channel: "in_view",
});
expect(persisted).toEqual({ enabled: false, channel: "in_view" });
});
});
+20
View File
@@ -8,6 +8,7 @@ import type {
LogFileInfo,
LogTailResponse,
UpdatesResponse,
UpdateNotificationSettings,
JobAcceptResponse,
RestartCapability,
RestartResponse,
@@ -177,6 +178,25 @@ export function applyUpdate(
});
}
export function fetchUpdateNotificationSettings(
options?: ApiRequestOptions,
): Promise<UpdateNotificationSettings> {
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
...options,
cache: "no-store",
});
}
export function saveUpdateNotificationSettings(
settings: UpdateNotificationSettings,
options?: ApiRequestOptions,
): Promise<UpdateNotificationSettings> {
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
...options,
init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(settings) },
});
}
// --- Restart ------------------------------------------------------------
export function fetchRestartCapability(options?: ApiRequestOptions): Promise<RestartCapability> {
@@ -4,7 +4,8 @@ export type NativeNotificationRequest = {
eventId: string;
title: string;
body: string;
taskId: string;
/** Absent for account/app-scoped events (e.g. an available update) that have no task. */
taskId?: string;
sessionId?: string | null;
};
+8 -7
View File
@@ -16,6 +16,7 @@ import {
defaultSystemState,
} from "./slices";
import { getStoredQuickChatNames } from "@/lib/local-storage";
import type { HydrationState } from "./store";
import { migrateView } from "./slices/ui/ui-slice";
export const defaultState = {
@@ -115,7 +116,7 @@ export type DefaultState = typeof defaultState;
function mergeCodeHostFields(
d: DefaultState,
s: Partial<DefaultState>,
s: HydrationState,
): Pick<
DefaultState,
| "taskMRs"
@@ -142,7 +143,7 @@ function mergeCodeHostFields(
};
}
function mergeQuickChatState(initialState: Partial<DefaultState>): DefaultState["quickChat"] {
function mergeQuickChatState(initialState: HydrationState): DefaultState["quickChat"] {
const quickChat = { ...defaultState.quickChat, ...initialState.quickChat };
if (!initialState.quickChat?.sessions) return quickChat;
@@ -160,7 +161,7 @@ function mergeQuickChatState(initialState: Partial<DefaultState>): DefaultState[
};
}
function mergeSidebarViewState(initialState: Partial<DefaultState>): DefaultState["sidebarViews"] {
function mergeSidebarViewState(initialState: HydrationState): DefaultState["sidebarViews"] {
const sidebarViews = { ...defaultState.sidebarViews, ...initialState.sidebarViews };
const userSettings = initialState.userSettings;
const serverViews = userSettings?.sidebarViews?.map(migrateView) ?? [];
@@ -177,7 +178,7 @@ function mergeSidebarViewState(initialState: Partial<DefaultState>): DefaultStat
}
function mergeSidebarTaskPrefsState(
initialState: Partial<DefaultState>,
initialState: HydrationState,
): DefaultState["sidebarTaskPrefs"] {
const sidebarTaskPrefs = {
...defaultState.sidebarTaskPrefs,
@@ -200,7 +201,7 @@ function mergeSidebarTaskPrefsState(
}
function mergeReviewPRSelectionState(
initialState: Partial<DefaultState>,
initialState: HydrationState,
): DefaultState["reviewPRSelection"] {
return {
...defaultState.reviewPRSelection,
@@ -212,11 +213,11 @@ function mergeReviewPRSelectionState(
};
}
function mergeSessionFailureNotification(initialState: Partial<DefaultState>) {
function mergeSessionFailureNotification(initialState: HydrationState) {
return initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification;
}
export function mergeInitialState(initialState?: Partial<DefaultState>): DefaultState {
export function mergeInitialState(initialState?: HydrationState): DefaultState {
if (!initialState) return defaultState;
return {
...defaultState,
@@ -346,3 +346,31 @@ describe("hydrateState — sidebar views from user settings", () => {
});
});
});
describe("hydrateState — system slice", () => {
it("leaf-merges update notification settings without clobbering other system fields", () => {
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
draft.system.info = { version: "v1.0.0" } as unknown as AppState["system"]["info"];
hydrateState(draft, {
system: {
updateNotificationSettings: { enabled: false, channel: "desktop" },
},
});
});
expect(result.system.updateNotificationSettings).toEqual({
enabled: false,
channel: "desktop",
});
expect(result.system.info).toEqual({ version: "v1.0.0" });
});
it("leaves the system slice untouched when the caller supplies no system fields", () => {
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
hydrateState(draft, {});
});
expect(result.system).toEqual(defaultState.system);
});
});
+16 -9
View File
@@ -1,5 +1,5 @@
import type { Draft } from "immer";
import type { AppState } from "../store";
import type { AppState, HydrationState } from "../store";
import { migrateView } from "../slices/ui/ui-slice";
import { getStoredQuickChatNames } from "@/lib/local-storage";
import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies";
@@ -51,7 +51,7 @@ function mergeKanbanTasks(
}
/** Hydrate kanban and workspace slices. */
function hydrateKanbanAndWorkspace(draft: Draft<AppState>, state: Partial<AppState>): void {
function hydrateKanbanAndWorkspace(draft: Draft<AppState>, state: HydrationState): void {
if (state.kanban) {
// Merge tasks by ID with timestamp comparison to avoid overwriting fresher WS data
const { tasks, ...kanbanRest } = state.kanban;
@@ -67,7 +67,7 @@ function hydrateKanbanAndWorkspace(draft: Draft<AppState>, state: Partial<AppSta
}
/** Hydrate settings slices, preserving loading states. */
function hydrateSettings(draft: Draft<AppState>, state: Partial<AppState>): void {
function hydrateSettings(draft: Draft<AppState>, state: HydrationState): void {
if (state.executors) deepMerge(draft.executors, state.executors);
if (state.settingsAgents) deepMerge(draft.settingsAgents, state.settingsAgents);
if (state.agentDiscovery) deepMerge(draft.agentDiscovery, state.agentDiscovery);
@@ -117,7 +117,7 @@ function bridgeSidebarViewsFromUserSettings(
/** Hydrate session slices, protecting active sessions. */
function hydrateSession(
draft: Draft<AppState>,
state: Partial<AppState>,
state: HydrationState,
activeSessionId: string | null,
forceMergeSessionId: string | null,
): void {
@@ -173,7 +173,7 @@ function hydrateSession(
/** Hydrate session runtime slices (volatile state). */
function hydrateSessionRuntime(
draft: Draft<AppState>,
state: Partial<AppState>,
state: HydrationState,
activeSessionId: string | null,
forceMergeSessionId: string | null,
): void {
@@ -224,7 +224,7 @@ function hydrateSessionRuntime(
}
/** Hydrate UI slices without overwriting active connection state. */
export function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): void {
export function hydrateUI(draft: Draft<AppState>, state: HydrationState): void {
if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel);
if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel);
if (state.diffs) deepMerge(draft.diffs, state.diffs);
@@ -271,7 +271,7 @@ export function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): voi
*/
export function hydrateState(
draft: Draft<AppState>,
state: Partial<AppState>,
state: HydrationState,
options: HydrationOptions = {},
): void {
const {
@@ -296,15 +296,22 @@ export function hydrateState(
Object.assign(draft.office, state.office);
}
// Feature flags — overwrite whole map. SSR is authoritative; the backend
// Feature flags - overwrite whole map. SSR is authoritative; the backend
// is the only source of truth for what's enabled in this deployment.
if (state.features) {
Object.assign(draft.features, state.features);
}
// System slice - shallow-merge whichever fields the caller supplied.
// `system` aggregates many independently-fetched fields (info, diskUsage,
// updates, updateNotificationSettings, ...); callers only ever provide the
// subset they fetched, so use the same leaf-level deepMerge as the other
// multi-field slices above rather than overwriting the whole object.
if (state.system) deepMerge(draft.system, state.system);
}
/** Hydrate GitHub slices, preserving loading states. */
function hydrateGitHub(draft: Draft<AppState>, state: Partial<AppState>): void {
function hydrateGitHub(draft: Draft<AppState>, state: HydrationState): void {
if (state.githubStatus) mergeWithLoading(draft.githubStatus, state.githubStatus);
if (state.taskPRs) deepMerge(draft.taskPRs, state.taskPRs);
if (state.azureDevOpsTaskPullRequests) {
@@ -9,6 +9,7 @@ export const defaultSystemState: SystemSliceState = {
backups: { items: [], loaded: false },
logs: { files: [], tail: [], tailLoaded: false },
updates: null,
updateNotificationSettings: null,
jobs: {},
metrics: null,
storage: { overview: null, runs: [], quarantine: [] },
@@ -55,6 +56,10 @@ export const createSystemSlice: StateCreator<
set((draft) => {
draft.system.updates = updates;
}),
setSystemUpdateNotificationSettings: (settings) =>
set((draft) => {
draft.system.updateNotificationSettings = settings;
}),
upsertSystemJob: (job) =>
set((draft) => {
draft.system.jobs[job.id] = job;
@@ -5,6 +5,7 @@ import type {
SnapshotInfo,
LogFileInfo,
UpdatesResponse,
UpdateNotificationSettings,
SystemJob,
SystemMetricsSnapshot,
StorageMaintenanceRun,
@@ -33,6 +34,7 @@ export type SystemSliceState = {
backups: SystemBackupsState;
logs: SystemLogsState;
updates: UpdatesResponse | null;
updateNotificationSettings: UpdateNotificationSettings | null;
jobs: SystemJobsMap;
metrics: SystemMetricsSnapshot | null;
storage: {
@@ -51,6 +53,7 @@ export type SystemSliceActions = {
setSystemLogs: (files: LogFileInfo[]) => void;
setSystemLogTail: (lines: string[]) => void;
setSystemUpdates: (updates: UpdatesResponse) => void;
setSystemUpdateNotificationSettings: (settings: UpdateNotificationSettings) => void;
upsertSystemJob: (job: SystemJob) => void;
clearSystemJob: (jobId: string) => void;
setSystemMetricsSnapshot: (snapshot: SystemMetricsSnapshot) => void;
+8
View File
@@ -104,6 +104,11 @@ export type TaskDeletedNotification = {
reason?: string;
};
export type UpdateAvailableNotification = {
version: string;
url?: string;
};
export type BottomTerminalState = {
isOpen: boolean;
pendingCommand: string | null;
@@ -154,6 +159,8 @@ export type UISliceState = {
sessionFailureNotification: SessionFailureNotification | null;
/** Set when the focused task is deleted live, so a toast can explain why. */
taskDeletedNotification: TaskDeletedNotification | null;
/** Set when the background updates poller reports a newly detected release. */
updateAvailableNotification: UpdateAvailableNotification | null;
bottomTerminal: BottomTerminalState;
sidebarViews: SidebarSliceState;
/** Parent task IDs whose subtasks are collapsed in the sidebar. Tab-scoped (sessionStorage). */
@@ -219,6 +226,7 @@ export type UISliceActions = {
setQuickChatInitialPrompt: (sessionId: string, prompt?: string) => void;
setSessionFailureNotification: (n: SessionFailureNotification | null) => void;
setTaskDeletedNotification: (n: TaskDeletedNotification | null) => void;
setUpdateAvailableNotification: (n: UpdateAvailableNotification | null) => void;
toggleBottomTerminal: () => void;
openBottomTerminalWithCommand: (command: string) => void;
clearBottomTerminalCommand: () => void;
+5
View File
@@ -84,6 +84,7 @@ export const defaultUIState: UISliceState = {
quickChat: { isOpen: false, sessions: [], activeSessionId: null },
sessionFailureNotification: null,
taskDeletedNotification: null,
updateAvailableNotification: null,
bottomTerminal: { isOpen: false, pendingCommand: null },
sidebarViews: createDefaultSidebarState(),
collapsedSubtaskParents: [],
@@ -256,6 +257,10 @@ function buildNotificationActions(set: ImmerSet) {
set((draft) => {
draft.taskDeletedNotification = n;
}),
setUpdateAvailableNotification: (n: UISlice["updateAvailableNotification"]) =>
set((draft) => {
draft.updateAvailableNotification = n;
}),
};
}
+15 -3
View File
@@ -243,6 +243,7 @@ export type AppState = {
quickChat: (typeof defaultUIState)["quickChat"];
sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"];
taskDeletedNotification: (typeof defaultUIState)["taskDeletedNotification"];
updateAvailableNotification: (typeof defaultUIState)["updateAvailableNotification"];
bottomTerminal: (typeof defaultUIState)["bottomTerminal"];
sidebarViews: (typeof defaultUIState)["sidebarViews"];
collapsedSubtaskParents: (typeof defaultUIState)["collapsedSubtaskParents"];
@@ -294,7 +295,7 @@ export type AppState = {
resetLinearIssueWatches: () => void;
// Actions from all slices
hydrate: (state: Partial<AppState>, options?: HydrationOptions) => void;
hydrate: (state: HydrationState, options?: HydrationOptions) => void;
setActiveWorkspace: (workspaceId: string | null) => void;
setWorkspaces: (workspaces: WorkspaceState["items"]) => void;
setActiveWorkflow: (workflowId: string | null) => void;
@@ -402,6 +403,7 @@ export type AppState = {
setQuickChatInitialPrompt: UIA["setQuickChatInitialPrompt"];
setSessionFailureNotification: (n: UISliceTypes.SessionFailureNotification | null) => void;
setTaskDeletedNotification: (n: UISliceTypes.TaskDeletedNotification | null) => void;
setUpdateAvailableNotification: (n: UISliceTypes.UpdateAvailableNotification | null) => void;
toggleBottomTerminal: () => void;
openBottomTerminalWithCommand: (command: string) => void;
clearBottomTerminalCommand: () => void;
@@ -613,7 +615,17 @@ export type AppState = {
AutomationsSliceActions &
PluginsSliceActions;
export function createAppStore(initialState?: Partial<AppState>) {
// Most callers hydrate a fully-shaped slice per top-level key (see
// mergeInitialState / hydrateState), but `system` is a grab-bag of many
// independently-fetched fields (info, diskUsage, updates, ...). Callers that
// only have one piece of it (e.g. update notification settings from the
// settings boot payload) must be able to pass a partial `system` object
// without fabricating placeholder values for the rest.
export type HydrationState = Omit<Partial<AppState>, "system"> & {
system?: Partial<AppState["system"]>;
};
export function createAppStore(initialState?: HydrationState) {
const merged = mergeInitialState(initialState);
return createStore<AppState>()(
@@ -661,5 +673,5 @@ export function createAppStore(initialState?: Partial<AppState>) {
export type StoreProviderProps = {
children: React.ReactNode;
initialState?: Partial<AppState>;
initialState?: HydrationState;
};
+6
View File
@@ -149,6 +149,11 @@ export type SystemErrorPayload = {
code?: string;
};
export type UpdateAvailablePayload = {
version: string;
url?: string;
};
export type WorkspacePayload = {
id: string;
name: string;
@@ -484,6 +489,7 @@ export type BackendMessageMap = OfficeBackendMessageMap &
"system.error": BackendMessage<"system.error", SystemErrorPayload>;
"system.job.update": BackendMessage<"system.job.update", import("./system").SystemJob>;
"system.metrics.updated": BackendMessage<"system.metrics.updated", SystemMetricsSnapshot>;
"system.update_available": BackendMessage<"system.update_available", UpdateAvailablePayload>;
"workspace.created": BackendMessage<"workspace.created", WorkspacePayload>;
"workspace.updated": BackendMessage<"workspace.updated", WorkspacePayload>;
"workspace.deleted": BackendMessage<"workspace.deleted", WorkspacePayload>;
+7
View File
@@ -79,6 +79,13 @@ export interface UpdatesResponse {
manual_commands?: string[];
}
export type UpdateNotificationChannel = "desktop" | "in_view" | "both";
export interface UpdateNotificationSettings {
enabled: boolean;
channel: UpdateNotificationChannel;
}
export interface InstallState {
running_as_service: boolean;
managed_service: boolean;
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { BackendMessageMap } from "@/lib/types/backend";
import { registerSystemEventsHandlers } from "./system-events";
function makeStore() {
const state = {
setUpdateAvailableNotification: vi.fn(),
} as unknown as AppState;
return { getState: () => state } as unknown as StoreApi<AppState>;
}
function makeMessage(
payload: BackendMessageMap["system.update_available"]["payload"],
): BackendMessageMap["system.update_available"] {
return {
id: "msg-1",
type: "notification",
action: "system.update_available",
payload,
timestamp: new Date().toISOString(),
} as BackendMessageMap["system.update_available"];
}
describe("system.update_available handler", () => {
it("stores the notification so the toast bridge hook can consume it", () => {
const store = makeStore();
const handler = registerSystemEventsHandlers(store)["system.update_available"]!;
handler(makeMessage({ version: "v1.2.3", url: "https://example/v1.2.3" }));
expect(store.getState().setUpdateAvailableNotification).toHaveBeenCalledWith({
version: "v1.2.3",
url: "https://example/v1.2.3",
});
});
});
@@ -16,5 +16,10 @@ export function registerSystemEventsHandlers(store: StoreApi<AppState>): WsHandl
"system.metrics.updated": (message) => {
store.getState().setSystemMetricsSnapshot(message.payload);
},
"system.update_available": (message) => {
// Persisted transiently (see slices/ui) so useUpdateAvailableToast can
// consume it and decide desktop/in-view delivery per saved settings.
store.getState().setUpdateAvailableNotification(message.payload);
},
};
}
+2
View File
@@ -10,6 +10,7 @@ import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider";
import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher";
import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge";
import { TaskDeletedToastBridge } from "@/components/task-deleted-toast-bridge";
import { UpdateAvailableToastBridge } from "@/components/update-available-toast-bridge";
import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge";
import { ThemeProvider } from "@/components/theme-provider";
import { ToastProvider } from "@/components/toast-provider";
@@ -31,6 +32,7 @@ export function AppShell({ children }: AppShellProps) {
<SonnerToaster richColors position="top-right" />
<SessionFailureToastBridge />
<TaskDeletedToastBridge />
<UpdateAvailableToastBridge />
<SidebarViewsSyncBridge />
<LogBufferBridge />
<CommandRegistryProvider>
+11
View File
@@ -108,6 +108,16 @@ describe("buildSettingsInitialStateForRoute", () => {
expect(loaded.userSettings?.loaded).toBe(true);
expect(failed.userSettings).toBeUndefined();
});
it("maps the update notification settings from the boot payload", () => {
const state = buildState({
updateNotificationSettings: { enabled: false, channel: "desktop" },
});
expect(state.system).toEqual({
updateNotificationSettings: { enabled: false, channel: "desktop" },
});
});
});
describe("renderSettingsRoute", () => {
@@ -140,6 +150,7 @@ function buildState(
availableAgents: [],
availableTools: [],
userSettingsResponse: null,
updateNotificationSettings: null,
...overrides,
});
}
+28 -13
View File
@@ -56,6 +56,7 @@ import { LogViewer } from "@/components/settings/system/log-viewer";
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
import { UIStateCard } from "@/components/settings/system/ui-state-card";
import { UpdatesCard } from "@/components/settings/system/updates-card";
import { UpdateNotificationsCard } from "@/components/settings/system/update-notifications-card";
import { VersionSummaryCard } from "@/components/settings/system/version-summary-card";
import { TerminalSettings } from "@/components/settings/terminal-settings";
import { VoiceModeSettings } from "@/components/settings/voice-mode-settings";
@@ -67,6 +68,7 @@ import {
} from "@/components/plugins/plugin-error-boundary";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
import { listWorkflows } from "@/lib/api/domains/kanban-api";
import { fetchUpdateNotificationSettings } from "@/lib/api/domains/system-api";
import {
fetchUserSettings,
listAgentDiscovery,
@@ -84,7 +86,7 @@ import {
resolveSettingsActiveWorkspaceId,
} from "@/lib/routing/route-bootstrap";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { AppState } from "@/lib/state/store";
import type { HydrationState } from "@/lib/state/store";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import type {
ListWorkspacesResponse,
@@ -95,7 +97,7 @@ import type {
WorkflowTemplate,
Workspace,
} from "@/lib/types/http";
import type { LicenseEntry } from "@/lib/types/system";
import type { LicenseEntry, UpdateNotificationSettings } from "@/lib/types/system";
type RouteRenderer = () => ReactNode;
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
@@ -116,6 +118,7 @@ type SettingsInitialStateData = {
availableAgents: Awaited<ReturnType<typeof listAvailableAgents>>["agents"];
availableTools: NonNullable<Awaited<ReturnType<typeof listAvailableAgents>>["tools"]>;
userSettingsResponse: UserSettingsResponse | null;
updateNotificationSettings: UpdateNotificationSettings | null;
};
const licenseEntries = licenses as LicenseEntry[];
@@ -400,6 +403,7 @@ function renderUpdatesRoute() {
description="Current vs latest release plus the full kandev changelog."
>
<UpdatesCard />
<UpdateNotificationsCard />
</SystemPageShell>
);
}
@@ -440,16 +444,24 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
return null;
}
async function loadSettingsInitialState(): Promise<Partial<AppState>> {
const [workspaces, executors, agents, discovery, available, userSettingsResponse] =
await Promise.all([
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })),
listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })),
listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })),
listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
]);
async function loadSettingsInitialState(): Promise<HydrationState> {
const [
workspaces,
executors,
agents,
discovery,
available,
userSettingsResponse,
updateNotificationSettings,
] = await Promise.all([
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })),
listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })),
listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })),
listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
fetchUpdateNotificationSettings({ cache: "no-store" }).catch(() => null),
]);
return buildSettingsInitialStateForRoute({
workspaces: workspaces.workspaces,
@@ -459,6 +471,7 @@ async function loadSettingsInitialState(): Promise<Partial<AppState>> {
availableAgents: available.agents,
availableTools: available.tools ?? [],
userSettingsResponse,
updateNotificationSettings,
});
}
@@ -470,7 +483,8 @@ export function buildSettingsInitialStateForRoute({
availableAgents,
availableTools,
userSettingsResponse,
}: SettingsInitialStateData): Partial<AppState> {
updateNotificationSettings,
}: SettingsInitialStateData): HydrationState {
const workspaceItems = workspaces.map(mapWorkspaceItem);
const activeWorkspaceId = resolveSettingsActiveWorkspaceId(
workspaceItems,
@@ -497,6 +511,7 @@ export function buildSettingsInitialStateForRoute({
loaded: true,
},
settingsData: { executorsLoaded: true, agentsLoaded: true },
system: { updateNotificationSettings },
...(mappedUserSettings.loaded
? {
userSettings: {
+2
View File
@@ -239,6 +239,8 @@ Never delete a managed task directory merely because its database row looks term
The backend contacts the public GitHub Releases API once at startup and every six hours, with a 30-second HTTP timeout, and persists the last successful result. **Check now** performs a synchronous request and permits one manual check per process every 30 seconds. Offline or rate-limited installations continue to show cached state.
**Settings > System > Updates > Update notifications** controls whether a newly detected release triggers a notification, and on which channel: **Desktop notification** (native OS notification, or a browser notification when not running the desktop app), **In-app banner**, or **Both**. Defaults to enabled on both channels; disabling stops delivery without losing the underlying update check. Each release only notifies once per install.
Before any update, finish or stop active sessions, create and export a database backup plus its master key, preserve unpushed Git work, and read release notes.
- Desktop: use **Settings > System > Updates** when signed updater assets are available; otherwise install the new desktop package.