feat(ui): reveal collapsed sidebar on configurable hover (#3678)

This commit is contained in:
José Almeida
2026-09-15 11:43:47 +02:00
committed by GitHub
parent f6bf42c662
commit b10ed80fa2
50 changed files with 2234 additions and 16 deletions
@@ -32,3 +32,14 @@ func TestMapUserSettingsStateIncludesResolveSessionHostnames(t *testing.T) {
t.Fatalf("resolveSessionHostnames = %#v, want true", state["resolveSessionHostnames"])
}
}
func TestMapUserSettingsStateIncludesSidebarHoverSettings(t *testing.T) {
var response userdto.UserSettingsResponse
if err := json.Unmarshal([]byte(`{"settings":{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}}`), &response); err != nil {
t.Fatal(err)
}
state := mapUserSettingsState(response, "workspace-1")
if state["sidebarHoverEnabled"] != false || state["sidebarHoverDelayMs"] != 0 {
t.Fatalf("hover boot fields: %v, %v", state["sidebarHoverEnabled"], state["sidebarHoverDelayMs"])
}
}
@@ -733,6 +733,8 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
"simplified": settings.SystemMetricsDisplay.Simplified,
},
"appStatusBarEnabled": settings.AppStatusBarEnabled,
"sidebarHoverEnabled": settings.SidebarHoverEnabled,
"sidebarHoverDelayMs": settings.SidebarHoverDelayMs,
"resolveSessionHostnames": settings.ResolveSessionHostnames,
"appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder),
"quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace,
@@ -646,6 +646,7 @@ func userSettingsDomain() DomainDescriptor {
"default_utility_agent_id", "default utility agent", "string", "default_utility_model", "default utility model", "string", "default_utility_agent_profile_id", "default utility profile", "string",
"keyboard_shortcuts", "keyboard shortcuts", "object", "terminal_link_behavior", "terminal link behavior", "string", "terminal_font_family", "terminal font family", "string",
"terminal_font_size", "terminal font size", "integer", "changes_panel_layout", "changes panel layout", "string", "last_seen_display", "last seen display", "string",
"sidebar_hover_enabled", "sidebar hover enabled", "boolean", "sidebar_hover_delay_ms", "sidebar hover delay", "integer",
"system_metrics_display", "system metrics display", "object", "app_status_bar_enabled", "status bar", "boolean", "resolve_session_hostnames", "resolve hostnames", "boolean",
"app_status_bar_order", "status bar order", "object", "quick_chat_tab_order_by_workspace", "quick chat tab order", "object", "kanban_hidden_step_ids", "hidden kanban steps", "object",
"workflow_ids_with_auto_hide_empty_steps", "auto-hide workflows", "array", "kanban_sort", "kanban board sort", "string", "kanban_priority_filter_tokens", "kanban priority filter", "array",
@@ -144,6 +144,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
LastSeenDisplay: req.LastSeenDisplay,
SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay),
AppStatusBarEnabled: req.AppStatusBarEnabled,
SidebarHoverEnabled: req.SidebarHoverEnabled,
SidebarHoverDelayMs: req.SidebarHoverDelayMs,
ResolveSessionHostnames: req.ResolveSessionHostnames,
AppStatusBarOrder: req.AppStatusBarOrder,
QuickChatTabOrderByWorkspace: req.QuickChatTabOrderByWorkspace,
+6
View File
@@ -76,6 +76,8 @@ type UserSettingsDTO struct {
LastSeenDisplay string `json:"last_seen_display"`
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
SidebarHoverEnabled bool `json:"sidebar_hover_enabled"`
SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"`
ResolveSessionHostnames bool `json:"resolve_session_hostnames"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
@@ -188,6 +190,8 @@ type UpdateUserSettingsRequest struct {
LastSeenDisplay *string `json:"last_seen_display,omitempty"`
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"`
AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"`
SidebarHoverEnabled *bool `json:"sidebar_hover_enabled,omitempty"`
SidebarHoverDelayMs *int `json:"sidebar_hover_delay_ms,omitempty"`
ResolveSessionHostnames *bool `json:"resolve_session_hostnames,omitempty"`
AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"`
QuickChatTabOrderByWorkspace *map[string][]string `json:"quick_chat_tab_order_by_workspace,omitempty"`
@@ -384,6 +388,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
LastSeenDisplay: models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
SystemMetricsDisplay: settings.SystemMetricsDisplay,
AppStatusBarEnabled: settings.AppStatusBarEnabled,
SidebarHoverEnabled: settings.SidebarHoverEnabled,
SidebarHoverDelayMs: settings.SidebarHoverDelayMs,
ResolveSessionHostnames: settings.ResolveSessionHostnames,
AppStatusBarOrder: settings.AppStatusBarOrder,
QuickChatTabOrderByWorkspace: settings.QuickChatTabOrderByWorkspace,
@@ -0,0 +1,47 @@
package handlers
import (
"net/http"
"testing"
)
// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3
func TestSidebarHoverSettingsRoundTrip(t *testing.T) {
router := newTestUserSettingsRouter(t)
read := func() map[string]any {
return readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodGet, ""))
}
defaults := read()
if defaults["sidebar_hover_enabled"] != true || defaults["sidebar_hover_delay_ms"] != float64(500) {
t.Fatalf("hover defaults = %v, %v", defaults["sidebar_hover_enabled"], defaults["sidebar_hover_delay_ms"])
}
for _, body := range []string{`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, `{"app_status_bar_enabled":true}`} {
readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, body))
}
got := read()
if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(0) {
t.Fatalf("hover round trip = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"])
}
readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_delay_ms":5000}`))
got = read()
if got["sidebar_hover_enabled"] != false || got["sidebar_hover_delay_ms"] != float64(5000) {
t.Fatal("delay patch reset toggle or did not persist")
}
}
func TestSidebarHoverSettingsRejectInvalidWrites(t *testing.T) {
for _, value := range []string{"-1", "5001", "1.5", `""`, `"500"`} {
t.Run(value, func(t *testing.T) {
router := newTestUserSettingsRouter(t)
before := threadSettingsRequest(t, router, http.MethodGet, "").Body.String()
response := threadSettingsRequest(t, router, http.MethodPatch, `{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":`+value+`}`)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d: %s", response.Code, response.Body.String())
}
after := threadSettingsRequest(t, router, http.MethodGet, "").Body.String()
if before != after {
t.Fatal("invalid request changed saved settings")
}
})
}
}
@@ -170,6 +170,8 @@ type UserSettings struct {
LastSeenDisplay string `json:"last_seen_display"` // "absolute" | "relative"
SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
SidebarHoverEnabled bool `json:"sidebar_hover_enabled"`
SidebarHoverDelayMs int `json:"sidebar_hover_delay_ms"`
ResolveSessionHostnames bool `json:"resolve_session_hostnames"`
AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"`
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
@@ -104,6 +104,8 @@ type UpdateUserSettingsRequest struct {
LastSeenDisplay *string
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch
AppStatusBarEnabled *bool
SidebarHoverEnabled *bool
SidebarHoverDelayMs *int
ResolveSessionHostnames *bool
AppStatusBarOrder *models.AppStatusBarOrder
QuickChatTabOrderByWorkspace *map[string][]string
@@ -348,6 +350,9 @@ func taskCreateLastUsedPatchEmpty(patch models.TaskCreateLastUsed) bool {
// applyBasicSettings copies simple (non-validated) fields from req to settings.
func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
if err := applySidebarHoverSettings(settings, req); err != nil {
return err
}
if err := applyWorkspaceAndTaskListPreferences(settings, req); err != nil {
return err
}
@@ -1125,6 +1130,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
"system_metrics_display": settings.SystemMetricsDisplay,
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"sidebar_hover_enabled": settings.SidebarHoverEnabled,
"sidebar_hover_delay_ms": settings.SidebarHoverDelayMs,
"resolve_session_hostnames": settings.ResolveSessionHostnames,
"app_status_bar_order": settings.AppStatusBarOrder,
"kanban_hidden_step_ids": settings.KanbanHiddenStepIDs,
@@ -0,0 +1,19 @@
package service
import (
"fmt"
"github.com/kandev/kandev/internal/user/models"
)
func applySidebarHoverSettings(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
if req.SidebarHoverDelayMs != nil && (*req.SidebarHoverDelayMs < 0 || *req.SidebarHoverDelayMs > 5000) {
return fmt.Errorf("sidebar_hover_delay_ms must be between 0 and 5000")
}
if req.SidebarHoverEnabled != nil {
settings.SidebarHoverEnabled = *req.SidebarHoverEnabled
}
if req.SidebarHoverDelayMs != nil {
settings.SidebarHoverDelayMs = *req.SidebarHoverDelayMs
}
return nil
}
@@ -0,0 +1,31 @@
package service
import (
"context"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/user/models"
"go.uber.org/zap"
"testing"
)
func TestSidebarHoverSettingsPublishAndReject(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatal(err)
}
bus := &recordingEventBus{}
repo := &recordingUserRepository{getSettings: &models.UserSettings{SidebarHoverEnabled: true, SidebarHoverDelayMs: 500}}
svc := NewService(repo, bus, log)
invalid := 5001
if _, err = svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{SidebarHoverDelayMs: &invalid}); err == nil {
t.Fatal("accepted invalid delay")
}
if repo.preservingInput != nil || len(bus.publishedEvents) != 0 {
t.Fatal("invalid delay persisted or broadcast")
}
svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{SidebarHoverEnabled: false, SidebarHoverDelayMs: 0})
data := bus.publishedEvents[0].Data.(map[string]interface{})
if data["sidebar_hover_enabled"] != false || data["sidebar_hover_delay_ms"] != 0 {
t.Fatalf("hover event fields: %v, %v", data["sidebar_hover_enabled"], data["sidebar_hover_delay_ms"])
}
}
@@ -0,0 +1,41 @@
package store
import (
"encoding/json"
"testing"
)
func TestSidebarHoverStoredDefaults(t *testing.T) {
for _, tc := range []struct {
raw string
enabled bool
delay float64
}{
{`{}`, true, 500},
{`{"sidebar_hover_delay_ms":"invalid"}`, true, 500},
{`{"sidebar_hover_delay_ms":0.5}`, true, 500},
{`{"sidebar_hover_delay_ms":null}`, true, 500},
{`{"sidebar_hover_enabled":false,"sidebar_hover_delay_ms":0}`, false, 0},
{`{"sidebar_hover_delay_ms":1200}`, true, 1200},
{`{"sidebar_hover_delay_ms":-1}`, true, 500},
{`{"sidebar_hover_delay_ms":5001}`, true, 500},
} {
t.Run(tc.raw, func(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: tc.raw}, DefaultUserID)
if err != nil {
t.Fatal(err)
}
raw, err := marshalUserSettingsPayload(settings)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err = json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if got["sidebar_hover_enabled"] != tc.enabled || got["sidebar_hover_delay_ms"] != tc.delay {
t.Fatalf("hover stored = %v, %v", got["sidebar_hover_enabled"], got["sidebar_hover_delay_ms"])
}
})
}
}
@@ -671,6 +671,8 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
"system_metrics_display": settings.SystemMetricsDisplay,
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"sidebar_hover_enabled": settings.SidebarHoverEnabled,
"sidebar_hover_delay_ms": settings.SidebarHoverDelayMs,
"resolve_session_hostnames": settings.ResolveSessionHostnames,
"app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder),
"quick_chat_tab_order_by_workspace": quickChatTabOrderByWorkspace,
@@ -760,6 +762,8 @@ func defaultUserSettings(userID string) *models.UserSettings {
SidebarTaskColorAutomation: models.DefaultSidebarTaskColorAutomation(),
SidebarTaskColors: map[string]*string{},
AppStatusBarEnabled: false,
SidebarHoverEnabled: true,
SidebarHoverDelayMs: 500,
ResolveSessionHostnames: false,
AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}),
QuickChatTabOrderByWorkspace: map[string][]string{},
@@ -855,6 +859,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
LastSeenDisplay json.RawMessage `json:"last_seen_display"`
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled *bool `json:"app_status_bar_enabled"`
SidebarHoverEnabled *bool `json:"sidebar_hover_enabled"`
SidebarHoverDelayMs json.RawMessage `json:"sidebar_hover_delay_ms"`
ResolveSessionHostnames *bool `json:"resolve_session_hostnames"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
QuickChatTabOrderByWorkspace map[string][]string `json:"quick_chat_tab_order_by_workspace"`
@@ -1018,6 +1024,13 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
settings.TerminalFontFamily = payload.TerminalFontFamily
settings.TerminalFontSize = payload.TerminalFontSize
settings.SystemMetricsDisplay = payload.SystemMetricsDisplay
if payload.SidebarHoverEnabled != nil {
settings.SidebarHoverEnabled = *payload.SidebarHoverEnabled
}
var hoverDelay *int
if json.Unmarshal(payload.SidebarHoverDelayMs, &hoverDelay) == nil && hoverDelay != nil && *hoverDelay >= 0 && *hoverDelay <= 5000 {
settings.SidebarHoverDelayMs = *hoverDelay
}
if payload.AppStatusBarEnabled != nil {
settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled
}