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
}
@@ -96,3 +96,17 @@ describe("AppSidebarHeader", () => {
expect(setWorkspacePickerOpen).toHaveBeenCalledWith(false);
});
});
it("offers permanent expansion while displaying the hover-revealed workspace picker", () => {
const toggle = vi.fn();
render(
<TooltipProvider>
<WorkspaceScopeProvider>
<AppSidebarHeader collapsed={false} hoverRevealed onToggleCollapse={toggle} />
</WorkspaceScopeProvider>
</TooltipProvider>,
);
expect(screen.getByTestId("workspace-picker")).toBeTruthy();
screen.getByRole("button", { name: "Expand sidebar" }).click();
expect(toggle).toHaveBeenCalledOnce();
});
@@ -13,19 +13,27 @@ import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker";
type AppSidebarHeaderProps = {
collapsed: boolean;
hoverRevealed?: boolean;
onToggleCollapse: () => void;
};
const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer";
export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) {
export function AppSidebarHeader({
collapsed,
hoverRevealed = false,
onToggleCollapse,
}: AppSidebarHeaderProps) {
const { t } = useTranslation();
const toggleLabel = t(hoverRevealed ? "sidebar:expandSidebar" : "sidebar:collapseSidebar");
const ToggleIcon = hoverRevealed ? IconLayoutSidebarLeftExpand : IconLayoutSidebarLeftCollapse;
const workspaces = useAppStore((s) => s.workspaces);
const startupPage = useAppStore((s) => s.userSettings.startupPage);
// The global WORKSPACE_PICKER shortcut opens this instance (and only this
// one) through the store; the mobile sheet keeps its own local open state.
const pickerOpen = useAppStore((s) => s.appSidebar.workspacePickerOpen);
const setPickerOpen = useAppStore((s) => s.setWorkspacePickerOpen);
const pickerProps = hoverRevealed ? {} : { open: pickerOpen, onOpenChange: setPickerOpen };
const mode = useOfficeModeState();
const activeWorkspace = workspaces.items.find(
(workspace) => workspace.id === workspaces.activeId,
@@ -62,6 +70,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
variant="ghost"
size="icon"
className={COLLAPSE_BUTTON_CLASS}
data-sidebar-toggle
onClick={onToggleCollapse}
aria-label={t("sidebar:expandSidebar")}
>
@@ -99,20 +108,22 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
<span aria-hidden className="shrink-0 select-none text-muted-foreground/30">
/
</span>
<AppSidebarWorkspacePicker open={pickerOpen} onOpenChange={setPickerOpen} />
{/* Hover already provides an anchor; the global picker action persistently expands the rail. */}
<AppSidebarWorkspacePicker {...pickerProps} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={COLLAPSE_BUTTON_CLASS}
data-sidebar-toggle
onClick={onToggleCollapse}
aria-label={t("sidebar:collapseSidebar")}
aria-label={toggleLabel}
>
<IconLayoutSidebarLeftCollapse className="h-4 w-4" />
<ToggleIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("sidebar:collapseSidebar")}</TooltipContent>
<TooltipContent side="top">{toggleLabel}</TooltipContent>
</Tooltip>
</div>
);
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { APP_SIDEBAR_EXPANDED_WIDTH } from "./app-sidebar-constants";
@@ -112,6 +112,7 @@ const storeState = {
workspaces: {
activeId: undefined as string | undefined,
},
userSettings: { sidebarHoverEnabled: true, sidebarHoverDelayMs: 500 },
appSidebar: {
collapsed: false,
sectionExpanded: {
@@ -164,6 +165,8 @@ function renderSidebar() {
return render(sidebar());
}
const SIDEBAR = "app-sidebar";
const COLLAPSED_ATTRIBUTE = "data-collapsed";
const TASKS_SECTION = "tasks-section";
const INTEGRATIONS_SECTION = "integrations-section";
const OFFICE_WORK_SECTION = "office-navigation-section-work";
@@ -193,13 +196,13 @@ describe("AppSidebar", () => {
it("renders the expanded nav inside a clipped animation layer", () => {
renderSidebar();
expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("false");
expect(screen.getByTestId(SIDEBAR).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("false");
expect(screen.getByTestId(TASKS_SECTION)).toBeTruthy();
expect(screen.getByTestId("projects-section")).toBeTruthy();
expect(screen.getByTestId("agents-section")).toBeTruthy();
expect(screen.queryByTestId("settings-section")).toBeNull();
expect(screen.getByTestId("app-sidebar-content").classList).toContain("overflow-hidden");
expect(screen.getByTestId("app-sidebar").classList).not.toContain("overflow-hidden");
expect(screen.getByTestId(SIDEBAR).classList).not.toContain("overflow-hidden");
});
it("renders office navigation without kanban-only sections in office mode", () => {
@@ -250,8 +253,8 @@ describe("AppSidebar", () => {
it("renders collapsed when store reports collapsed=true", () => {
storeState.appSidebar.collapsed = true;
renderSidebar();
expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("true");
expect(screen.getByTestId(TASKS_SECTION).getAttribute("data-collapsed")).toBe("true");
expect(screen.getByTestId(SIDEBAR).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true");
expect(screen.getByTestId(TASKS_SECTION).getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true");
});
it("invokes toggleAppSidebar when the header collapse button is clicked", () => {
@@ -352,3 +355,39 @@ describe("AppSidebar before the workspace resolves", () => {
expect(screen.queryByTestId(OFFICE_WORK_SECTION)).toBeNull();
});
});
// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.2
describe("sidebar hover reveal", () => {
beforeEach(() => {
resetSidebarState();
vi.useFakeTimers();
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: !query.includes("max-width"),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
storeState.appSidebar.collapsed = true;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("reveals at 500 ms without expanding the layout reservation or saved state", () => {
renderSidebar();
const panel = screen.getByTestId(SIDEBAR);
fireEvent.pointerOver(panel, { pointerType: "mouse" });
act(() => vi.advanceTimersByTime(499));
expect(panel.style.width).toBe("56px");
act(() => vi.advanceTimersByTime(1));
expect(panel.style.width).toBe(`${APP_SIDEBAR_EXPANDED_WIDTH}px`);
expect(screen.getByTestId("app-sidebar-layout").style.width).toBe("56px");
expect(panel.getAttribute(COLLAPSED_ATTRIBUTE)).toBe("true");
expect(storeState.toggleAppSidebar).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { useEnsureWorkspaceWorkflows } from "@/hooks/use-workflows";
import { useOfficeModeState } from "@/hooks/use-in-office";
import type { WorkspaceMode } from "@/components/workspace-scope-provider";
import { useOfficeWorkspaceData } from "@/hooks/use-office-workspace-data";
import { useSidebarHoverReveal } from "@/hooks/domains/sidebar/use-sidebar-hover-reveal";
import { cn } from "@/lib/utils";
import {
APP_SIDEBAR_COLLAPSED_WIDTH,
@@ -180,6 +181,14 @@ export function AppSidebar() {
const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar);
const setWidth = useAppStore((s) => s.setAppSidebarWidth);
const pathname = usePathname();
const enabled = useAppStore((s) => s.userSettings.sidebarHoverEnabled);
const delayMs = useAppStore((s) => s.userSettings.sidebarHoverDelayMs);
const hover = useSidebarHoverReveal({ collapsed, pathname, enabled, delayMs });
const visuallyCollapsed = collapsed && !hover.revealed;
const handleToggleCollapse = () => {
hover.dismiss();
toggleCollapsed();
};
const mode = useOfficeModeState();
const [isResizing, setIsResizing] = useState(false);
@@ -244,6 +253,9 @@ export function AppSidebar() {
style={{ width: targetWidth }}
>
<aside
ref={hover.ref}
{...hover.handlers}
data-hover-revealed={hover.revealed ? "true" : "false"}
data-testid="app-sidebar"
data-collapsed={collapsed ? "true" : "false"}
className={cn(
@@ -256,15 +268,26 @@ export function AppSidebar() {
? "transition-none"
: "transition-[width] duration-300 ease-out motion-reduce:transition-none",
)}
style={{ width: targetWidth }}
style={{ width: visuallyCollapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth }}
>
<AppSidebarHeader collapsed={collapsed} onToggleCollapse={toggleCollapsed} />
<AppSidebarHeader
collapsed={visuallyCollapsed}
hoverRevealed={hover.revealed}
onToggleCollapse={handleToggleCollapse}
/>
<div
data-testid="app-sidebar-content"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<AppSidebarNavigation collapsed={collapsed} mode={mode} settingsMode={settingsMode} />
<AppSidebarFooter collapsed={collapsed} onToggleSettingsMode={handleToggleSettingsMode} />
<AppSidebarNavigation
collapsed={visuallyCollapsed}
mode={mode}
settingsMode={settingsMode}
/>
<AppSidebarFooter
collapsed={visuallyCollapsed}
onToggleSettingsMode={handleToggleSettingsMode}
/>
</div>
{!collapsed && <AppSidebarResizeHandle onMouseDown={handleResize} />}
</aside>
@@ -1,13 +1,14 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconActivity, IconGitBranch } from "@tabler/icons-react";
import { IconActivity, IconGitBranch, IconLayoutSidebar } from "@tabler/icons-react";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import type { AppearanceState } from "./appearance-settings-state";
import { SidebarHoverSettingsCard } from "./sidebar-hover-settings-card";
import { AppStatusBarSettingsCard } from "./app-status-bar-settings-card";
import { SettingsCard } from "./settings-card";
import { SettingsSection } from "./settings-section";
@@ -70,6 +71,13 @@ export function AppearanceAccountSections({
const { t } = useTranslation();
return (
<>
<Separator />
<SettingsSection
icon={<IconLayoutSidebar className="h-5 w-5" />}
title={t("settings:sidebarHoverTitle")}
>
<SidebarHoverSettingsCard draft={draft} saved={saved} updateDraft={updateDraft} />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconActivity className="h-5 w-5" />}
@@ -36,3 +36,28 @@ describe("Threads appearance draft", () => {
});
});
});
// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3
it("saves hover false and zero independently and rebases clean fields", () => {
const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings);
expect(saved).toMatchObject({ sidebarHoverEnabled: true, sidebarHoverDelayMs: "500" });
const draft = { ...saved, sidebarHoverEnabled: false, sidebarHoverDelayMs: "0" };
expect(buildAppearanceUserSettingsPatch(draft, saved)).toEqual({
sidebar_hover_enabled: false,
sidebar_hover_delay_ms: 0,
});
expect(appearanceRevision(draft)).not.toBe(appearanceRevision(saved));
const remote = { ...saved, sidebarHoverDelayMs: "1200", appStatusBarEnabled: true };
expect(rebaseAppearanceDraft(draft, saved, remote)).toEqual({
...draft,
appStatusBarEnabled: true,
});
expect(rebaseAppearanceDraft(saved, saved, remote)).toEqual(remote);
});
it.each(["", "-1", "5001", "0.5", "abc"])("rejects invalid delay draft %s", (value) => {
const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings);
expect(() =>
buildAppearanceUserSettingsPatch({ ...saved, sidebarHoverDelayMs: value }, saved),
).toThrow();
});
@@ -10,6 +10,8 @@ export type AppearanceState = {
startupPage: UserSettingsState["startupPage"];
changesPanelLayout: UserSettingsState["changesPanelLayout"];
appStatusBarEnabled: boolean;
sidebarHoverEnabled: boolean;
sidebarHoverDelayMs: string;
showMetrics: boolean;
simplifiedMetrics: boolean;
};
@@ -20,11 +22,18 @@ export function createAppearanceSavedState(
richOutputAnimationsEnabled: boolean,
userSettings: Pick<
UserSettingsState,
"appStatusBarEnabled" | "changesPanelLayout" | "startupPage" | "systemMetricsDisplay"
| "appStatusBarEnabled"
| "changesPanelLayout"
| "startupPage"
| "systemMetricsDisplay"
| "sidebarHoverEnabled"
| "sidebarHoverDelayMs"
>,
): AppearanceState {
return {
theme,
sidebarHoverEnabled: userSettings.sidebarHoverEnabled,
sidebarHoverDelayMs: String(userSettings.sidebarHoverDelayMs),
appStatusBarEnabled: userSettings.appStatusBarEnabled,
// Per-device, but drafted and saved with account settings under one control.
settingsMenuMode,
@@ -40,7 +49,16 @@ export function buildAppearanceUserSettingsPatch(
submitted: AppearanceState,
saved: AppearanceState,
): UserSettingsUpdatePayload {
const delay = parseSidebarHoverDelay(submitted.sidebarHoverDelayMs);
// i18n-exempt: unreachable invariant guard; the contributor presents localized validation.
if (delay === null) throw new Error("Invalid sidebar hover delay");
const patch: UserSettingsUpdatePayload = {};
if (submitted.sidebarHoverEnabled !== saved.sidebarHoverEnabled) {
patch.sidebar_hover_enabled = submitted.sidebarHoverEnabled;
}
if (delay !== Number(saved.sidebarHoverDelayMs)) {
patch.sidebar_hover_delay_ms = delay;
}
if (submitted.startupPage !== saved.startupPage) {
patch.startup_page = submitted.startupPage;
}
@@ -75,6 +93,8 @@ export function rebaseAppearanceDraft(
: nextSaved[field];
return {
theme: rebase("theme"),
sidebarHoverEnabled: rebase("sidebarHoverEnabled"),
sidebarHoverDelayMs: rebase("sidebarHoverDelayMs"),
settingsMenuMode: rebase("settingsMenuMode"),
richOutputAnimationsEnabled: rebase("richOutputAnimationsEnabled"),
startupPage: rebase("startupPage"),
@@ -88,6 +108,8 @@ export function rebaseAppearanceDraft(
export function appearanceRevision(state: AppearanceState): string {
return JSON.stringify([
state.theme,
state.sidebarHoverEnabled,
state.sidebarHoverDelayMs,
state.settingsMenuMode,
state.richOutputAnimationsEnabled,
state.startupPage,
@@ -97,3 +119,9 @@ export function appearanceRevision(state: AppearanceState): string {
state.simplifiedMetrics,
]);
}
export function parseSidebarHoverDelay(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const delay = Number(value);
return Number.isInteger(delay) && delay >= 0 && delay <= 5000 ? delay : null;
}
@@ -397,3 +397,26 @@ describe("AppearanceSettings status bar preference", () => {
verifyNewerSaveResponseWinsOverOlderLiveUpdate,
);
});
it("validates hover drafts, preserves them after a failed save, and discards", async () => {
renderAppearance();
const delay = screen.getByRole("spinbutton", { name: "Hover delay (ms)" });
fireEvent.change(delay, { target: { value: "" } });
expect(
(screen.getByRole("button", { name: SAVE_CHANGES_LABEL }) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.change(delay, { target: { value: "750" } });
apiMocks.updateUserSettings.mockRejectedValueOnce(new Error("Save failed"));
fireEvent.click(screen.getByRole("button", { name: SAVE_CHANGES_LABEL }));
await waitFor(() =>
expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ sidebar_hover_delay_ms: 750 }),
);
await waitFor(() =>
expect((screen.getByRole("button", { name: "Retry save" }) as HTMLButtonElement).disabled).toBe(
false,
),
);
expect((delay as HTMLInputElement).value).toBe("750");
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect((delay as HTMLInputElement).value).toBe("500");
});
@@ -50,6 +50,7 @@ import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
import {
appearanceRevision,
parseSidebarHoverDelay,
buildAppearanceUserSettingsPatch,
createAppearanceSavedState,
rebaseAppearanceDraft,
@@ -299,10 +300,14 @@ function useAppearanceSaveContributor({
const previewRichOutputAnimations = useAppStore((state) => state.previewRichOutputAnimations);
const commitRichOutputAnimations = useAppStore((state) => state.commitRichOutputAnimations);
const restoreRichOutputAnimations = useAppStore((state) => state.restoreRichOutputAnimations);
const { t } = useTranslation();
const delayValid = parseSidebarHoverDelay(draft.sidebarHoverDelayMs) !== null;
const revision = appearanceRevision(draft);
useSettingsSaveContributor({
id: "general-appearance",
canSave: delayValid,
invalidReason: delayValid ? undefined : t("settings:sidebarHoverDelayError"),
order: 10,
revision,
isDirty: revision !== appearanceRevision(saved),
@@ -0,0 +1,35 @@
import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { defaultState } from "@/lib/state/default-state";
import { createAppearanceSavedState } from "./appearance-settings-state";
import { SidebarHoverSettingsCard } from "./sidebar-hover-settings-card";
afterEach(cleanup);
function Editor() {
const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings);
const [draft, setDraft] = useState(saved);
return (
<SidebarHoverSettingsCard
draft={draft}
saved={saved}
updateDraft={(patch) => setDraft((current) => ({ ...current, ...patch }))}
/>
);
}
it("retains the last valid delay when disabling an invalid draft", () => {
render(<Editor />);
const delay = screen.getByRole("spinbutton", { name: "Hover delay (ms)" });
const toggle = screen.getByRole("switch", { name: "Show sidebar on hover" });
fireEvent.change(delay, { target: { value: "1250" } });
fireEvent.change(delay, { target: { value: "" } });
expect(delay.getAttribute("aria-invalid")).toBe("true");
fireEvent.click(toggle);
expect((delay as HTMLInputElement).disabled).toBe(true);
expect((delay as HTMLInputElement).value).toBe("1250");
fireEvent.click(toggle);
expect((delay as HTMLInputElement).disabled).toBe(false);
expect((delay as HTMLInputElement).value).toBe("1250");
});
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { parseSidebarHoverDelay, type AppearanceState } from "./appearance-settings-state";
import { SettingsCard } from "./settings-card";
import { settingsControlClassName } from "./settings-control";
import { useSettingsTargetRegistration } from "./settings-target-provider";
export function SidebarHoverSettingsCard({
draft,
saved,
updateDraft,
}: {
draft: AppearanceState;
saved: AppearanceState;
updateDraft: (patch: Partial<AppearanceState>) => void;
}) {
const { t } = useTranslation();
const lastValidDelay = useRef(saved.sidebarHoverDelayMs);
const delay = parseSidebarHoverDelay(draft.sidebarHoverDelayMs);
useEffect(() => {
if (delay !== null) lastValidDelay.current = String(delay);
}, [delay]);
const toggleTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHover);
const delayTarget = useSettingsTargetRegistration(GENERAL_SETTINGS_TARGETS.sidebarHoverDelay);
const enabledDirty = draft.sidebarHoverEnabled !== saved.sidebarHoverEnabled;
const delayDirty = draft.sidebarHoverDelayMs !== saved.sidebarHoverDelayMs;
return (
<SettingsCard isDirty={enabledDirty || delayDirty} data-testid="sidebar-hover-settings-card">
<CardContent className="space-y-4">
<div ref={toggleTarget} className="flex items-center justify-between gap-4">
<Label htmlFor="sidebar-hover-enabled">{t("settings:sidebarHoverEnabled")}</Label>
<Switch
id="sidebar-hover-enabled"
checked={draft.sidebarHoverEnabled}
data-settings-dirty={enabledDirty}
onCheckedChange={(sidebarHoverEnabled) =>
updateDraft({
sidebarHoverEnabled,
...(!sidebarHoverEnabled && delay === null
? { sidebarHoverDelayMs: lastValidDelay.current }
: {}),
})
}
className="data-checked:bg-transparent data-unchecked:bg-transparent dark:data-unchecked:bg-transparent data-[size=default]:h-7 max-md:data-[size=default]:h-11 [@media(pointer:coarse)]:data-[size=default]:h-11 data-[size=default]:w-11 p-2 before:absolute before:left-2 before:top-1/2 before:h-[16.6px] before:w-7 before:-translate-y-1/2 before:rounded-full before:bg-input before:content-[''] data-checked:before:bg-primary dark:data-unchecked:before:bg-input/80 [&_[data-slot=switch-thumb]]:z-10"
/>
</div>
<div ref={delayTarget} className="space-y-2">
<Label htmlFor="sidebar-hover-delay">{t("settings:sidebarHoverDelay")}</Label>
<Input
id="sidebar-hover-delay"
type="number"
inputMode="numeric"
min={0}
max={5000}
step={1}
value={draft.sidebarHoverDelayMs}
disabled={!draft.sidebarHoverEnabled}
aria-invalid={delay === null}
aria-describedby={delay === null ? "sidebar-hover-delay-error" : "sidebar-hover-help"}
data-settings-dirty={delayDirty}
className={settingsControlClassName("md:max-w-40")}
onChange={(event) => {
const value = event.target.value;
if (parseSidebarHoverDelay(value) !== null) lastValidDelay.current = value;
updateDraft({ sidebarHoverDelayMs: value });
}}
/>
{delay === null && (
<p id="sidebar-hover-delay-error" className="text-sm text-destructive" role="alert">
{t("settings:sidebarHoverDelayError")}
</p>
)}
</div>
<p id="sidebar-hover-help" className="text-xs text-muted-foreground">
{t("settings:sidebarHoverHelp")}
</p>
</CardContent>
</SettingsCard>
);
}
@@ -0,0 +1,48 @@
import { expect, test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
// @covers AC-UI-SIDEBAR-HOVER-001.5
test("phone navigation stays available by tap with no hover rail", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
const first = await apiClient.createTask(seedData.workspaceId, "Hover mobile origin", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const second = await apiClient.createTask(seedData.workspaceId, "Hover mobile destination", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${first.id}`);
await new SessionPage(testPage).waitForLoad();
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
await testPage.getByTestId("mobile-session-menu").tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
await expect(drawer).toBeVisible();
await drawer.evaluate(async (element) => {
await Promise.all(
element
.getAnimations({ subtree: true })
.filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().iterations))
.map((animation) => animation.finished.catch(() => undefined)),
);
});
const bounds = await drawer.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
expect(bounds!.y).toBeGreaterThanOrEqual(0);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(testPage.viewportSize()!.height);
await testPage.screenshot({ path: testInfo.outputPath("mobile-navigation.png") });
await drawer.getByText("Hover mobile destination", { exact: true }).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${second.id}`));
await expect
.poll(() =>
testPage.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
)
.toBe(0);
});
@@ -0,0 +1,120 @@
import { test, expect } from "../../fixtures/test-base";
// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.2
test("collapsed sidebar reveals after dwell without moving page content", async ({
testPage,
}, testInfo) => {
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
const pageBefore = await testPage
.getByTestId("app-shell")
.locator(":scope > div > main")
.boundingBox();
await sidebar.hover({ position: { x: 20, y: 200 } });
await expect(sidebar).toHaveCSS("width", "320px");
await expect(testPage.getByTestId("app-sidebar-layout")).toHaveCSS("width", "56px");
await expect(sidebar).toHaveAttribute("data-collapsed", "true");
const pageAfter = await testPage
.getByTestId("app-shell")
.locator(":scope > div > main")
.boundingBox();
expect(pageAfter).toEqual(pageBefore);
await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover.png") });
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
});
// @covers AC-UI-SIDEBAR-HOVER-001.3, AC-UI-SIDEBAR-HOVER-001.4
test("reveal supports the workspace menu, Escape, and permanent expansion", async ({
testPage,
}) => {
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
await sidebar.hover({ position: { x: 20, y: 200 } });
await expect(sidebar.getByTestId("sidebar-workspace-trigger")).toBeVisible();
await sidebar.getByTestId("sidebar-workspace-trigger").click();
const menu = testPage.getByRole("menu");
await expect(menu).toBeVisible();
await menu.hover();
await expect(sidebar).toHaveCSS("width", "320px");
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(sidebar).toHaveCSS("width", "320px");
await testPage.keyboard.press("Escape");
await expect(sidebar).toHaveCSS("width", "56px");
await expect(sidebar.getByRole("button", { name: "Expand sidebar", exact: true })).toBeFocused();
await testPage.mouse.move(600, 400);
await sidebar.hover({ position: { x: 20, y: 200 } });
await expect(sidebar.getByTestId("sidebar-workspace-trigger")).toBeVisible();
await sidebar.getByRole("button", { name: "Expand sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveAttribute("data-collapsed", "false");
await expect(testPage.getByTestId("app-sidebar-layout")).toHaveCSS("width", "320px");
await testPage.reload();
await expect(sidebar).toHaveAttribute("data-collapsed", "false");
});
// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.5
test("short hover cancels and crossing the phone breakpoint closes the reveal", async ({
testPage,
}) => {
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
await testPage.clock.install();
await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100));
await sidebar.hover({ position: { x: 20, y: 200 } });
await testPage.clock.runFor(499);
await expect(sidebar).toHaveCSS("width", "56px");
await testPage.mouse.move(600, 400);
await testPage.clock.runFor(1000);
await expect(sidebar).toHaveCSS("width", "56px");
await testPage.clock.resume();
await testPage.setViewportSize({ width: 768, height: 900 });
await sidebar.hover({ position: { x: 20, y: 200 } });
await expect(sidebar).toHaveCSS("width", "320px");
await testPage.setViewportSize({ width: 767, height: 900 });
await expect(sidebar).toBeHidden();
await testPage.mouse.move(600, 400);
await testPage.setViewportSize({ width: 768, height: 900 });
await expect(sidebar).toHaveCSS("width", "56px");
await expect(sidebar).toHaveAttribute("data-collapsed", "true");
});
// @covers AC-UI-SIDEBAR-HOVER-001.3
test("revealed tasks support context menus and navigation", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Hover navigation task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
await sidebar.hover({ position: { x: 20, y: 200 } });
const row = sidebar.getByText("Hover navigation task", { exact: true });
await expect(row).toBeVisible();
await row.click({ button: "right" });
const menu = testPage.getByRole("menu");
await expect(menu).toBeVisible();
await menu.hover();
await expect(row).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await row.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`));
await expect(sidebar).toHaveCSS("width", "56px");
});
@@ -0,0 +1,37 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
// @covers AC-UI-SIDEBAR-HOVER-002.3, AC-UI-SIDEBAR-HOVER-002.5
test("phone can save both hover preferences with contained touch controls", async ({
testPage,
}, testInfo) => {
await testPage.goto("/settings/preferences/appearance");
const card = testPage.getByTestId("sidebar-hover-settings-card");
const toggle = card.getByRole("switch", { name: "Show sidebar on hover" });
const delay = card.getByRole("spinbutton", { name: "Hover delay (ms)" });
await card.scrollIntoViewIfNeeded();
for (const control of [toggle, delay]) {
const bounds = await control.boundingBox();
expect(bounds!.height).toBeGreaterThanOrEqual(44);
expect(bounds!.width).toBeGreaterThanOrEqual(44);
}
await delay.fill("900");
await toggle.tap();
await expect(delay).toBeDisabled();
const saved = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/);
await testPage.getByRole("button", { name: "Save changes", exact: true }).tap();
expect((await saved).ok()).toBe(true);
await expect(testPage.getByTestId("settings-floating-save")).toBeHidden();
await testPage.reload();
await expect(toggle).not.toBeChecked();
await expect(delay).toHaveValue("900");
await card.scrollIntoViewIfNeeded();
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
).toBe(0);
await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover-settings-phone.png") });
await testPage.goto("/");
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
});
@@ -0,0 +1,62 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
// @covers AC-UI-SIDEBAR-HOVER-002.1, AC-UI-SIDEBAR-HOVER-002.2, AC-UI-SIDEBAR-HOVER-002.3, AC-UI-SIDEBAR-HOVER-002.4
test("saves custom dwell and disabled hover without changing explicit expansion", async ({
testPage,
}, testInfo) => {
await testPage.goto("/settings/preferences/appearance");
const toggle = testPage.getByRole("switch", { name: "Show sidebar on hover" });
const delay = testPage.getByRole("spinbutton", { name: "Hover delay (ms)" });
await expect(toggle).toBeChecked();
await expect(delay).toHaveValue("500");
await delay.fill("");
await expect(testPage.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
await delay.fill("1200");
const saved = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/);
await testPage.getByRole("button", { name: "Save changes", exact: true }).click();
expect((await saved).ok()).toBe(true);
await expect(testPage.getByTestId("settings-floating-save")).toBeHidden();
await testPage.reload();
await expect(delay).toHaveValue("1200");
await delay.scrollIntoViewIfNeeded();
expect((await delay.boundingBox())!.height).toBe(28);
await testPage.screenshot({ path: testInfo.outputPath("sidebar-hover-settings-desktop.png") });
await testPage.goto("/");
const sidebar = testPage.getByTestId("app-sidebar");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
await testPage.clock.install();
await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100));
await sidebar.hover({ position: { x: 20, y: 200 } });
await testPage.clock.runFor(1199);
await expect(sidebar).toHaveAttribute("data-hover-revealed", "false");
await testPage.clock.runFor(1);
await expect(sidebar).toHaveAttribute("data-hover-revealed", "true");
await testPage.clock.resume();
await testPage.goto("/settings/preferences/appearance");
await toggle.click();
await expect(delay).toBeDisabled();
const disabled = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/);
await testPage.getByRole("button", { name: "Save changes", exact: true }).click();
expect((await disabled).ok()).toBe(true);
await expect(testPage.getByTestId("settings-floating-save")).toBeHidden();
await testPage.reload();
await expect(toggle).not.toBeChecked();
await expect(delay).toHaveValue("1200");
await testPage.goto("/");
await sidebar.getByRole("button", { name: "Collapse sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveCSS("width", "56px");
await testPage.clock.pauseAt(await testPage.evaluate(() => Date.now() + 100));
await sidebar.hover({ position: { x: 20, y: 200 } });
await testPage.clock.runFor(5000);
await expect(sidebar).toHaveAttribute("data-hover-revealed", "false");
await testPage.clock.resume();
await sidebar.getByRole("button", { name: "Expand sidebar", exact: true }).click();
await testPage.mouse.move(600, 400);
await expect(sidebar).toHaveAttribute("data-collapsed", "false");
});
@@ -0,0 +1,345 @@
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createElement, type PointerEvent } from "react";
import { createPortal } from "react-dom";
import { useSidebarHoverReveal } from "./use-sidebar-hover-reveal";
const pointer = (pointerType = "mouse") => ({ pointerType }) as PointerEvent<HTMLElement>;
// @covers AC-UI-SIDEBAR-HOVER-001.1, AC-UI-SIDEBAR-HOVER-001.3,
// @covers AC-UI-SIDEBAR-HOVER-001.4, AC-UI-SIDEBAR-HOVER-001.5
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function setup() {
return renderHook(
({ collapsed, pathname }) =>
useSidebarHoverReveal({ enabled: true, delayMs: 500, collapsed, pathname }),
{
initialProps: { collapsed: true, pathname: "/" },
},
);
}
it("requires a continuous full dwell and cancels early exits", () => {
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(499));
expect(result.current.revealed).toBe(false);
act(() => result.current.handlers.onPointerLeave());
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(false);
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(true);
act(() => result.current.handlers.onPointerLeave());
expect(result.current.revealed).toBe(false);
});
it("retains focused content until both focus and pointer leave", async () => {
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => result.current.handlers.onFocusCapture());
act(() => result.current.handlers.onPointerLeave());
expect(result.current.revealed).toBe(true);
await act(async () => result.current.handlers.onBlurCapture());
expect(result.current.revealed).toBe(false);
});
it("ignores touch and already expanded sidebars", () => {
const { result, rerender } = setup();
act(() => result.current.handlers.onPointerEnter(pointer("touch")));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(false);
rerender({ collapsed: false, pathname: "/" });
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(false);
});
it("cancels pending and visible reveals on route and saved-state changes", () => {
const { result, rerender } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
rerender({ collapsed: true, pathname: "/tasks" });
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(false);
act(() => result.current.handlers.onPointerLeave());
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(true);
rerender({ collapsed: false, pathname: "/tasks" });
expect(result.current.revealed).toBe(false);
});
it("dismisses until a fresh entry and cleans up timers on unmount", () => {
const { result, unmount } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => result.current.dismiss());
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(false);
act(() => result.current.handlers.onPointerLeave());
act(() => result.current.handlers.onPointerEnter(pointer()));
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it("dismisses on Escape even when keyboard focus is outside the sidebar", () => {
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
fireEvent.keyDown(document.body, { key: "Escape" });
expect(result.current.revealed).toBe(false);
});
it("does not dismiss for an Escape consumed by a child", () => {
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
event.preventDefault();
act(() => document.body.dispatchEvent(event));
expect(result.current.revealed).toBe(true);
});
it("cancels a pending dwell and a visible reveal when hover capability is lost", () => {
let changed = () => {};
let eligible = true;
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
get matches() {
return eligible;
},
addEventListener: (_name: string, callback: () => void) => {
changed = callback;
},
removeEventListener: vi.fn(),
})),
);
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => {
eligible = false;
changed();
vi.advanceTimersByTime(500);
});
expect(result.current.revealed).toBe(false);
act(() => {
eligible = true;
changed();
result.current.handlers.onPointerLeave();
});
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
expect(result.current.revealed).toBe(true);
act(() => {
eligible = false;
changed();
});
expect(result.current.revealed).toBe(false);
});
it("retains an owned portal across pointer gaps and closes when it dismisses", async () => {
let current: ReturnType<typeof useSidebarHoverReveal>;
function Harness() {
current = useSidebarHoverReveal({
enabled: true,
delayMs: 500,
collapsed: true,
pathname: "/",
});
return createElement(
"aside",
{ ref: current.ref, ...current.handlers },
createElement("button", { "aria-expanded": true, "aria-controls": "owned-menu" }),
createPortal(createElement("div", { id: "owned-menu", role: "menu" }), document.body),
);
}
const { container } = render(createElement(Harness));
act(() => current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => current.handlers.onPointerLeave());
expect(current!.revealed).toBe(true);
await act(async () => {
container.querySelector("button")!.setAttribute("aria-expanded", "false");
});
expect(current!.revealed).toBe(false);
});
it("does not retain the reveal for unrelated application menus", () => {
const menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.setAttribute("data-state", "open");
document.body.append(menu);
const { result } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => result.current.handlers.onPointerLeave());
expect(result.current.revealed).toBe(false);
menu.remove();
});
it("drops stale focus when navigation replaces the revealed contents", () => {
const { result, rerender } = setup();
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => result.current.handlers.onFocusCapture());
rerender({ collapsed: true, pathname: "/tasks" });
act(() => result.current.handlers.onPointerLeave());
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => result.current.handlers.onPointerLeave());
expect(result.current.revealed).toBe(false);
});
// @covers AC-UI-SIDEBAR-HOVER-002.4
it("honors disabled hover, custom dwell and setting changes", () => {
const { result, rerender } = renderHook(
(settings) =>
useSidebarHoverReveal({
collapsed: true,
pathname: "/",
...settings,
}),
{ initialProps: { enabled: false, delayMs: 1200 } },
);
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(1500));
expect(result.current.revealed).toBe(false);
rerender({ enabled: true, delayMs: 1200 });
act(() => result.current.handlers.onPointerLeave());
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(1199));
expect(result.current.revealed).toBe(false);
act(() => vi.advanceTimersByTime(1));
expect(result.current.revealed).toBe(true);
rerender({ enabled: false, delayMs: 1200 });
expect(result.current.revealed).toBe(false);
rerender({ enabled: true, delayMs: 0 });
act(() => result.current.handlers.onPointerLeave());
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(0));
expect(result.current.revealed).toBe(true);
});
it("cancels old timers when the delay changes and requires fresh entry", () => {
const { result, rerender } = renderHook(
(settings) =>
useSidebarHoverReveal({
collapsed: true,
pathname: "/",
...settings,
}),
{ initialProps: { enabled: true, delayMs: 500 } },
);
act(() => result.current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(300));
rerender({ enabled: true, delayMs: 1000 });
act(() => vi.advanceTimersByTime(2000));
expect(result.current.revealed).toBe(false);
});
it("returns focus to the visible toggle when a saved setting dismisses a reveal", () => {
let current: ReturnType<typeof useSidebarHoverReveal>;
function Harness({ enabled }: { enabled: boolean }) {
current = useSidebarHoverReveal({ collapsed: true, pathname: "/", enabled, delayMs: 500 });
return createElement(
"aside",
{ ref: current.ref, ...current.handlers },
createElement("button", { "data-sidebar-toggle": "" }, "Expand"),
createElement("button", { "data-navigation": "" }, "Navigation"),
);
}
const { container, rerender } = render(createElement(Harness, { enabled: true }));
act(() => current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
const navigation = container.querySelector<HTMLButtonElement>("[data-navigation]")!;
act(() => navigation.focus());
rerender(createElement(Harness, { enabled: false }));
act(() => vi.advanceTimersByTime(20));
expect(current!.revealed).toBe(false);
expect(document.activeElement).toBe(container.querySelector("[data-sidebar-toggle]"));
});
it("does not restore stale focus after explicit collapse and retain a later hover", () => {
let current: ReturnType<typeof useSidebarHoverReveal>;
function Harness({ collapsed }: { collapsed: boolean }) {
current = useSidebarHoverReveal({ collapsed, pathname: "/", enabled: true, delayMs: 500 });
return createElement(
"aside",
{ ref: current.ref, ...current.handlers },
createElement("button", { "data-sidebar-toggle": "" }, "Toggle"),
createElement("button", { "data-navigation": "" }, "Navigation"),
);
}
const { container, rerender } = render(createElement(Harness, { collapsed: false }));
act(() => container.querySelector<HTMLButtonElement>("[data-navigation]")!.focus());
rerender(createElement(Harness, { collapsed: true }));
act(() => vi.advanceTimersByTime(20));
act(() => current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => current.handlers.onPointerLeave());
expect(current!.revealed).toBe(false);
});
it.each(["(hover: hover)", "(pointer: fine)"])(
"restores focus when %s capability disappears while navigation is focused",
(capabilityQuery) => {
let hover = true;
let changed = () => {};
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
get matches() {
return query === capabilityQuery ? hover : true;
},
addEventListener: (_name: string, callback: () => void) => {
if (query === capabilityQuery) changed = callback;
},
removeEventListener: vi.fn(),
})),
);
let current: ReturnType<typeof useSidebarHoverReveal>;
function Harness() {
current = useSidebarHoverReveal({
collapsed: true,
pathname: "/",
enabled: true,
delayMs: 500,
});
return createElement(
"aside",
{ ref: current.ref, ...current.handlers },
createElement("button", { "data-sidebar-toggle": "" }, "Expand"),
current.revealed && createElement("button", { "data-navigation": "" }, "Navigation"),
);
}
const { container } = render(createElement(Harness));
act(() => current.handlers.onPointerEnter(pointer()));
act(() => vi.advanceTimersByTime(500));
act(() => container.querySelector<HTMLButtonElement>("[data-navigation]")!.focus());
act(() => {
hover = false;
changed();
});
act(() => vi.advanceTimersByTime(20));
expect(current!.revealed).toBe(false);
expect(document.activeElement).toBe(container.querySelector("[data-sidebar-toggle]"));
},
);
@@ -0,0 +1,153 @@
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
// i18n-exempt: CSS media query, not displayed copy.
const HOVER_QUERY = "(hover: hover)";
function hasOpenSidebarSurface(root: HTMLElement | null): boolean {
if (!root) return false;
if (root.querySelector('[data-slot="context-menu-trigger"][data-state="open"]')) return true;
return Array.from(root.querySelectorAll('[aria-expanded="true"][aria-controls]')).some(
(trigger) => {
const surface = document.getElementById(trigger.getAttribute("aria-controls") ?? "");
return surface !== null && !root.contains(surface);
},
);
}
function observeSidebarSurfaces(root: HTMLElement | null, onChange: () => void) {
const observer = new MutationObserver(onChange);
if (root)
observer.observe(root, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-expanded", "data-state"],
});
return observer;
}
function returnFocusToToggle(root: HTMLElement | null) {
requestAnimationFrame(() => {
if (root?.isConnected) root.querySelector<HTMLButtonElement>("[data-sidebar-toggle]")?.focus();
});
}
type SidebarHoverOptions = {
enabled: boolean;
delayMs: number;
collapsed: boolean;
pathname: string | null;
};
export function useSidebarHoverReveal({
collapsed,
pathname,
enabled,
delayMs,
}: SidebarHoverOptions) {
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const eligible = enabled && !isMobile && isFinePointer;
const ref = useRef<HTMLElement>(null);
const [revealed, setRevealed] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const inside = useRef(false);
const focused = useRef(false);
const mounted = useRef(true);
const previousSettings = useRef({ enabled, delayMs, eligible });
function cancelTimer() {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
}
function dismiss() {
cancelTimer();
setRevealed(false);
}
function closeIfOutside() {
if (
mounted.current &&
!inside.current &&
!focused.current &&
!hasOpenSidebarSurface(ref.current)
) {
setRevealed(false);
}
}
useEffect(() => {
mounted.current = true;
const media = window.matchMedia(HOVER_QUERY);
const reset = (capabilityChanged = false) => {
cancelTimer();
const changed =
capabilityChanged ||
previousSettings.current.eligible !== eligible ||
previousSettings.current.enabled !== enabled ||
previousSettings.current.delayMs !== delayMs;
if (changed && collapsed && focused.current) returnFocusToToggle(ref.current);
previousSettings.current = { enabled, delayMs, eligible };
focused.current = false;
setRevealed(false);
};
reset();
const onCapabilityChange = () => reset(true);
media.addEventListener("change", onCapabilityChange);
return () => {
mounted.current = false;
cancelTimer();
media.removeEventListener("change", onCapabilityChange);
};
// The callbacks only read refs; saved-state and route changes reset the interaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collapsed, pathname, eligible, delayMs]);
useEffect(() => {
if (!revealed) return;
const observer = observeSidebarSurfaces(ref.current, closeIfOutside);
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented || hasOpenSidebarSurface(ref.current))
return;
event.preventDefault();
setRevealed(false);
if (focused.current) returnFocusToToggle(ref.current);
}
window.addEventListener("keydown", onKeyDown);
return () => {
observer.disconnect();
window.removeEventListener("keydown", onKeyDown);
};
// Only the reveal state changes this subscription; containment reads live refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revealed]);
const handlers = {
onPointerEnter(event: PointerEvent<HTMLElement>) {
if (inside.current || event.pointerType === "touch") return;
inside.current = true;
if (!collapsed || !eligible || !window.matchMedia(HOVER_QUERY).matches) return;
timer.current = setTimeout(() => {
timer.current = null;
setRevealed(true);
}, delayMs);
},
onPointerLeave() {
inside.current = false;
cancelTimer();
closeIfOutside();
},
onFocusCapture() {
focused.current = true;
},
onBlurCapture() {
focused.current = false;
// React focus events bubble through portals; wait for the destination's focus event.
void Promise.resolve().then(closeIfOutside);
},
};
return { ref, revealed: collapsed && revealed, dismiss, handlers };
}
@@ -112,6 +112,8 @@ function makeUnloadedSettings(): UserSettingsState {
changesPanelLayout: "tree",
lastSeenDisplay: "absolute",
systemMetricsDisplay: { showInTopbar: false, simplified: false },
sidebarHoverEnabled: true,
sidebarHoverDelayMs: 500,
appStatusBarEnabled: false,
resolveSessionHostnames: false,
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
@@ -20,6 +20,8 @@ export const GENERAL_SETTINGS_TARGETS = {
settingsMenuMode: "setting-settings-menu-mode",
startupPage: "setting-startup-page",
displayLanguage: "setting-display-language",
sidebarHover: "setting-sidebar-hover",
sidebarHoverDelay: "setting-sidebar-hover-delay",
appStatusBar: "setting-status-bar",
changesPanelLayout: "setting-changes-panel-layout",
resourceMetrics: "setting-resource-metrics",
@@ -45,6 +47,29 @@ export const GENERAL_SETTINGS_TARGETS = {
} as const;
export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
{
id: "appearance-sidebar-hover-enabled",
kind: "control",
labelKey: "settings:sidebarHoverEnabled",
aliasesKey: "settings:discoveryAliasesSidebarHover",
parentId: APPEARANCE_ID,
groupId: "preferences",
href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.sidebarHover,
order: 24,
},
{
id: "appearance-sidebar-hover-delay",
kind: "control",
labelKey: "settings:sidebarHoverDelay",
aliasesKey: "settings:discoveryAliasesSidebarHover",
parentId: APPEARANCE_ID,
groupId: "preferences",
href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.sidebarHoverDelay,
order: 25,
},
{
id: APPEARANCE_ID,
kind: "page",
@@ -5236,6 +5236,36 @@
"change_timing": "future_views",
"settings_href": "/settings/preferences"
},
{
"key": "user_settings.sidebar_hover_enabled",
"field_path": "sidebar_hover_enabled",
"label": "sidebar hover enabled",
"description": "Portable preference owned by the current user.",
"json_type": "boolean",
"support": "supported",
"classification": "writable",
"owner": "user-settings",
"writable": true,
"validator": "user-settings",
"authority": "user.self",
"change_timing": "future_views",
"settings_href": "/settings/preferences"
},
{
"key": "user_settings.sidebar_hover_delay_ms",
"field_path": "sidebar_hover_delay_ms",
"label": "sidebar hover delay",
"description": "Portable preference owned by the current user.",
"json_type": "integer",
"support": "supported",
"classification": "writable",
"owner": "user-settings",
"writable": true,
"validator": "user-settings",
"authority": "user.self",
"change_timing": "future_views",
"settings_href": "/settings/preferences"
},
{
"key": "user_settings.system_metrics_display",
"field_path": "system_metrics_display",
@@ -6208,6 +6238,8 @@
"show_transcript_auto_scroll_control",
"sidebar_active_view_id",
"sidebar_draft",
"sidebar_hover_delay_ms",
"sidebar_hover_enabled",
"sidebar_task_color_automation",
"sidebar_task_color_patch",
"sidebar_task_colors",
@@ -367,6 +367,8 @@
"show_transcript_auto_scroll_control",
"sidebar_active_view_id",
"sidebar_draft",
"sidebar_hover_delay_ms",
"sidebar_hover_enabled",
"sidebar_task_color_automation",
"sidebar_task_color_patch",
"sidebar_task_colors",
+16
View File
@@ -759,6 +759,22 @@ describe("prevent auto-start on open preference", () => {
});
});
it("maps hover settings and retains false/zero through omitted updates", () => {
expect(createDefaultUserSettings()).toMatchObject({
sidebarHoverEnabled: true,
sidebarHoverDelayMs: 500,
});
const current = mapUserSettingsData({ sidebar_hover_enabled: false, sidebar_hover_delay_ms: 0 });
expect(current).toMatchObject({ sidebarHoverEnabled: false, sidebarHoverDelayMs: 0 });
expect(buildCoreFields({}, current)).toMatchObject({
sidebarHoverEnabled: false,
sidebarHoverDelayMs: 0,
});
expect(
mapUserSettingsData({ sidebar_hover_enabled: true, sidebar_hover_delay_ms: 1200 }),
).toMatchObject({ sidebarHoverEnabled: true, sidebarHoverDelayMs: 1200 });
});
// @covers AC-TASKS-CREATION-AUTO-FOCUS-001.1, AC-TASKS-CREATION-AUTO-FOCUS-001.4
describe("task creation auto-focus preference", () => {
it("defaults on and preserves explicit false across partial hydration", () => {
+4
View File
@@ -99,6 +99,8 @@ export function createDefaultUserSettings(): UserSettingsState {
lastSeenDisplay: "absolute",
systemMetricsDisplay: { showInTopbar: false, simplified: false },
appStatusBarEnabled: false,
sidebarHoverEnabled: true,
sidebarHoverDelayMs: 500,
resolveSessionHostnames: false,
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
quickChatTabOrderByWorkspace: {},
@@ -377,6 +379,8 @@ export function buildCoreFields(
parseAppStatusBarOrder,
),
appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled,
sidebarHoverEnabled: s.sidebar_hover_enabled ?? current.sidebarHoverEnabled,
sidebarHoverDelayMs: s.sidebar_hover_delay_ms ?? current.sidebarHoverDelayMs,
quickChatTabOrderByWorkspace:
s.quick_chat_tab_order_by_workspace ?? current.quickChatTabOrderByWorkspace,
resolveSessionHostnames: s.resolve_session_hostnames ?? current.resolveSessionHostnames,
@@ -468,6 +468,8 @@ export type UserSettingsState = {
lastSeenDisplay: LastSeenDisplay;
systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean };
appStatusBarEnabled: boolean;
sidebarHoverEnabled: boolean;
sidebarHoverDelayMs: number;
resolveSessionHostnames: boolean;
appStatusBarOrder: AppStatusBarOrderState;
quickChatTabOrderByWorkspace: Record<string, string[]>;
+4
View File
@@ -214,6 +214,8 @@ export type UserSettings = {
last_seen_display?: LastSeenDisplay;
system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean };
app_status_bar_enabled?: boolean;
sidebar_hover_enabled?: boolean;
sidebar_hover_delay_ms?: number;
resolve_session_hostnames?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
quick_chat_tab_order_by_workspace?: Record<string, string[]>;
@@ -290,6 +292,8 @@ export type UserSettingsUpdatePayload = {
last_seen_display?: LastSeenDisplay;
system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean };
app_status_bar_enabled?: boolean;
sidebar_hover_enabled?: boolean;
sidebar_hover_delay_ms?: number;
resolve_session_hostnames?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
quick_chat_tab_order_by_workspace?: Record<string, string[]>;
+16
View File
@@ -812,3 +812,19 @@ describe("user settings websocket sidebar settings", () => {
});
});
});
it("syncs hover settings while preserving omitted fields and rejecting old revisions", () => {
const store = makeStore();
const handler = registerUsersHandlers(store)["user.settings.updated"];
handler?.(
userSettingsMessage({ sidebar_hover_enabled: false, sidebar_hover_delay_ms: 0, revision: 10 }),
);
handler?.(userSettingsMessage({ app_status_bar_enabled: true, revision: 11 }));
handler?.(
userSettingsMessage({ sidebar_hover_enabled: true, sidebar_hover_delay_ms: 500, revision: 9 }),
);
expect(store.getState().userSettings).toMatchObject({
sidebarHoverEnabled: false,
sidebarHoverDelayMs: 0,
});
});
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "Remove member",
"unitPickPerson": "Pick someone",
"unitActionFailed": "That did not work.",
"sidebarHoverTitle": "Sidebar",
"sidebarHoverEnabled": "Show sidebar on hover",
"sidebarHoverDelay": "Hover delay (ms)",
"sidebarHoverHelp": "Use a mouse or trackpad to reveal the collapsed sidebar. Set the delay from 0 to 5000 ms; 0 reveals immediately.",
"sidebarHoverDelayError": "Enter a whole number from 0 to 5000.",
"discoveryAliasesSidebarHover": "sidebar hover reveal delay wait mouse trackpad",
"autoFocusNewTasks": "Auto-focus new tasks",
"autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested."
}
@@ -790,6 +790,12 @@
"unitRemoveMember": "Ŕēḿōvē ḿēḿƀēŕ",
"unitPickPerson": "Ƥĩćķ śōḿēōńē",
"unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ.",
"sidebarHoverTitle": "Śĩďēƀàŕ",
"sidebarHoverEnabled": "Śĥōŵ śĩďēƀàŕ ōń ĥōvēŕ",
"sidebarHoverDelay": "Ĥōvēŕ ďēĺàŷ (ḿś)",
"sidebarHoverHelp": "Ũśē à ḿōũśē ōŕ ţŕàćķƥàď ţō ŕēvēàĺ ţĥē ćōĺĺàƥśēď śĩďēƀàŕ. Śēţ ţĥē ďēĺàŷ ƒŕōḿ 0 ţō 5000 ḿś; 0 ŕēvēàĺś ĩḿḿēďĩàţēĺŷ.",
"sidebarHoverDelayError": "Ēńţēŕ à ŵĥōĺē ńũḿƀēŕ ƒŕōḿ 0 ţō 5000.",
"discoveryAliasesSidebarHover": "śĩďēƀàŕ ĥōvēŕ ŕēvēàĺ ďēĺàŷ ŵàĩţ ḿōũśē ţŕàćķƥàď",
"autoFocusNewTasks": "Àũţō-ƒōćũś ńēŵ ţàśķś",
"autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď."
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "Remover membro",
"unitPickPerson": "Escolher alguém",
"unitActionFailed": "Não foi possível concluir.",
"sidebarHoverTitle": "Barra lateral",
"sidebarHoverEnabled": "Mostrar a barra lateral ao passar o rato",
"sidebarHoverDelay": "Tempo de espera (ms)",
"sidebarHoverHelp": "Use um rato ou trackpad para mostrar a barra lateral recolhida. Defina o tempo de espera entre 0 e 5000 ms; 0 mostra-a imediatamente.",
"sidebarHoverDelayError": "Introduza um número inteiro entre 0 e 5000.",
"discoveryAliasesSidebarHover": "barra lateral passar rato mostrar atraso espera trackpad",
"autoFocusNewTasks": "Focar automaticamente novas tarefas",
"autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado."
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成员",
"unitPickPerson": "选择成员",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "侧边栏",
"sidebarHoverEnabled": "悬停时显示侧边栏",
"sidebarHoverDelay": "悬停延迟(毫秒)",
"sidebarHoverHelp": "使用鼠标或触控板悬停以显示已折叠的侧边栏。延迟可设为 0 到 5000 毫秒;设为 0 时立即显示。",
"sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。",
"discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板",
"autoFocusNewTasks": "自动聚焦新任务",
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。"
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成員",
"unitPickPerson": "選擇成員",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "側邊欄",
"sidebarHoverEnabled": "懸停時顯示側邊欄",
"sidebarHoverDelay": "懸停延遲(毫秒)",
"sidebarHoverHelp": "使用鼠標或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。"
}
+6
View File
@@ -790,6 +790,12 @@
"unitRemoveMember": "移除成員",
"unitPickPerson": "選擇成員",
"unitActionFailed": "操作未成功。",
"sidebarHoverTitle": "側邊欄",
"sidebarHoverEnabled": "懸停時顯示側邊欄",
"sidebarHoverDelay": "懸停延遲(毫秒)",
"sidebarHoverHelp": "使用滑鼠或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板",
"autoFocusNewTasks": "自動聚焦新任務",
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。"
}