feat(ui): reveal collapsed sidebar on configurable hover (#3678)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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ũēśţēď."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成员",
|
||||
"unitPickPerson": "选择成员",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "侧边栏",
|
||||
"sidebarHoverEnabled": "悬停时显示侧边栏",
|
||||
"sidebarHoverDelay": "悬停延迟(毫秒)",
|
||||
"sidebarHoverHelp": "使用鼠标或触控板悬停以显示已折叠的侧边栏。延迟可设为 0 到 5000 毫秒;设为 0 时立即显示。",
|
||||
"sidebarHoverDelayError": "请输入 0 到 5000 之间的整数。",
|
||||
"discoveryAliasesSidebarHover": "侧边栏 悬停 显示 延迟 等待 鼠标 触控板",
|
||||
"autoFocusNewTasks": "自动聚焦新任务",
|
||||
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。"
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成員",
|
||||
"unitPickPerson": "選擇成員",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "側邊欄",
|
||||
"sidebarHoverEnabled": "懸停時顯示側邊欄",
|
||||
"sidebarHoverDelay": "懸停延遲(毫秒)",
|
||||
"sidebarHoverHelp": "使用鼠標或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
|
||||
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
|
||||
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 鼠標 觸控板",
|
||||
"autoFocusNewTasks": "自動聚焦新任務",
|
||||
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。"
|
||||
}
|
||||
|
||||
@@ -790,6 +790,12 @@
|
||||
"unitRemoveMember": "移除成員",
|
||||
"unitPickPerson": "選擇成員",
|
||||
"unitActionFailed": "操作未成功。",
|
||||
"sidebarHoverTitle": "側邊欄",
|
||||
"sidebarHoverEnabled": "懸停時顯示側邊欄",
|
||||
"sidebarHoverDelay": "懸停延遲(毫秒)",
|
||||
"sidebarHoverHelp": "使用滑鼠或觸控板懸停以顯示已摺疊的側邊欄。延遲可設為 0 到 5000 毫秒;設為 0 時立即顯示。",
|
||||
"sidebarHoverDelayError": "請輸入 0 到 5000 之間的整數。",
|
||||
"discoveryAliasesSidebarHover": "側邊欄 懸停 顯示 延遲 等待 滑鼠 觸控板",
|
||||
"autoFocusNewTasks": "自動聚焦新任務",
|
||||
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user