diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index b819dc7c59..310792f75a 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -566,34 +566,43 @@ func mergeVoiceModeDefaults(stored *storedVoiceMode) models.VoiceModeSettings { return out } +func defaultUserSettings(userID string) *models.UserSettings { + return &models.UserSettings{ + UserID: userID, + RepositoryIDs: []string{}, + TasksListSort: models.TasksListSortDefault, + TasksListGroup: models.TasksListGroupDefault, + ReviewAutoMarkOnScroll: true, + ConfirmTaskArchive: true, + UnreadDivider: false, + MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultCurrentTask, + ShowAnchoredPromptBar: false, + ShowScrollToLastPrompt: true, + ShowScrollToStart: false, + ShowTranscriptAutoScrollControl: false, + ShowReleaseNotification: true, + LspAutoStartLanguages: []string{}, + LspAutoInstallLanguages: []string{}, + LspServerConfigs: map[string]map[string]interface{}{}, + SavedLayouts: []models.SavedLayout{}, + ChatSubmitKey: "cmd_enter", + KeyboardShortcuts: map[string]interface{}{}, + TerminalLinkBehavior: "new_tab", + ChangesPanelLayout: "tree", + SidebarViews: []models.SidebarView{}, + SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), + AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), + VoiceMode: defaultVoiceModeSettings(), + } +} + func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) { - settings := &models.UserSettings{} + settings := defaultUserSettings(userID) var settingsRaw string if err := scanner.Scan(&settingsRaw, &settings.UpdatedAt); err != nil { return nil, err } - settings.UserID = userID if settingsRaw == "" || settingsRaw == "{}" { - settings.RepositoryIDs = []string{} - settings.TasksListSort = models.TasksListSortDefault - settings.TasksListGroup = models.TasksListGroupDefault - settings.ShowReleaseNotification = true - settings.ReviewAutoMarkOnScroll = true - settings.ConfirmTaskArchive = true - settings.UnreadDivider = true - settings.MCPTaskAgentProfileDefault = models.MCPTaskAgentProfileDefaultCurrentTask - settings.ShowAnchoredPromptBar = false - settings.ShowScrollToLastPrompt = true - settings.ShowScrollToStart = false - settings.ShowTranscriptAutoScrollControl = true - settings.ChatSubmitKey = "cmd_enter" - settings.KeyboardShortcuts = map[string]interface{}{} - settings.TerminalLinkBehavior = "new_tab" - settings.ChangesPanelLayout = "tree" - settings.SidebarViews = []models.SidebarView{} - settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}) - settings.AppStatusBarOrder = normalizeAppStatusBarOrder(models.AppStatusBarOrder{}) - settings.VoiceMode = defaultVoiceModeSettings() return settings, nil } var payload struct { @@ -651,6 +660,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.KanbanViewMode = payload.KanbanViewMode settings.WorkflowFilterID = payload.WorkflowFilterID settings.RepositoryIDs = payload.RepositoryIDs + if settings.RepositoryIDs == nil { + settings.RepositoryIDs = []string{} + } settings.TasksListSort = models.NormalizeTasksListSort(payload.TasksListSort) settings.TasksListGroup = models.NormalizeTasksListGroup(payload.TasksListGroup) settings.TasksListShowDetails = payload.TasksListShowDetails @@ -658,52 +670,33 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.PreferredShell = payload.PreferredShell settings.DefaultEditorID = payload.DefaultEditorID settings.EnablePreviewOnClick = payload.EnablePreviewOnClick - settings.ChatSubmitKey = payload.ChatSubmitKey - // Default to "cmd_enter" if empty - if settings.ChatSubmitKey == "" { - settings.ChatSubmitKey = "cmd_enter" + if payload.ChatSubmitKey != "" { + settings.ChatSubmitKey = payload.ChatSubmitKey } - // Default to true when not set if payload.ReviewAutoMarkOnScroll != nil { settings.ReviewAutoMarkOnScroll = *payload.ReviewAutoMarkOnScroll - } else { - settings.ReviewAutoMarkOnScroll = true } if payload.ConfirmTaskArchive != nil { settings.ConfirmTaskArchive = *payload.ConfirmTaskArchive - } else { - settings.ConfirmTaskArchive = true } if payload.UnreadDivider != nil { settings.UnreadDivider = *payload.UnreadDivider - } else { - settings.UnreadDivider = true } settings.MCPTaskAgentProfileDefault = models.NormalizeMCPTaskAgentProfileDefault(payload.MCPTaskAgentProfileDefault) if payload.ShowAnchoredPromptBar != nil { settings.ShowAnchoredPromptBar = *payload.ShowAnchoredPromptBar - } else { - settings.ShowAnchoredPromptBar = false } if payload.ShowScrollToLastPrompt != nil { settings.ShowScrollToLastPrompt = *payload.ShowScrollToLastPrompt - } else { - settings.ShowScrollToLastPrompt = true } if payload.ShowScrollToStart != nil { settings.ShowScrollToStart = *payload.ShowScrollToStart - } else { - settings.ShowScrollToStart = false } if payload.ShowTranscriptAutoScrollControl != nil { settings.ShowTranscriptAutoScrollControl = *payload.ShowTranscriptAutoScrollControl - } else { - settings.ShowTranscriptAutoScrollControl = true } if payload.ShowReleaseNotification != nil { settings.ShowReleaseNotification = *payload.ShowReleaseNotification - } else { - settings.ShowReleaseNotification = true } settings.ReleaseNotesLastSeenVersion = payload.ReleaseNotesLastSeenVersion settings.LspAutoStartLanguages = payload.LspAutoStartLanguages @@ -715,6 +708,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.LspAutoInstallLanguages = []string{} } settings.LspServerConfigs = payload.LspServerConfigs + if settings.LspServerConfigs == nil { + settings.LspServerConfigs = map[string]map[string]interface{}{} + } settings.SavedLayouts = payload.SavedLayouts if settings.SavedLayouts == nil { settings.SavedLayouts = []models.SavedLayout{} diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index cfaf4fbe94..217ecd3cb7 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -98,9 +98,10 @@ func TestScanUserSettingsUnreadDividerDefault(t *testing.T) { raw string want bool }{ - {name: "empty settings enable the divider", raw: `{}`, want: true}, - {name: "missing setting enables the divider", raw: `{"chat_submit_key":"enter"}`, want: true}, + {name: "empty settings disable the divider", raw: `{}`, want: false}, + {name: "missing setting disables the divider", raw: `{"chat_submit_key":"enter"}`, want: false}, {name: "explicit false disables the divider", raw: `{"unread_divider":false}`, want: false}, + {name: "explicit true enables the divider", raw: `{"unread_divider":true}`, want: true}, } for _, tt := range tests { @@ -194,29 +195,29 @@ func TestScanUserSettingsTranscriptNavigationDefaults(t *testing.T) { settings.ShowScrollToStart, ) } - if !settings.ShowTranscriptAutoScrollControl { - t.Fatal("ShowTranscriptAutoScrollControl = false, want true (default)") + if settings.ShowTranscriptAutoScrollControl { + t.Fatal("ShowTranscriptAutoScrollControl = true, want false (default)") } settings, err = scanUserSettings( - settingsScanner{raw: `{"show_transcript_auto_scroll_control":false}`}, + settingsScanner{raw: `{"show_transcript_auto_scroll_control":true}`}, DefaultUserID, ) if err != nil { t.Fatalf("scan stored auto-scroll-control preference: %v", err) } - if settings.ShowTranscriptAutoScrollControl { - t.Fatal("ShowTranscriptAutoScrollControl = true, want false (stored)") + if !settings.ShowTranscriptAutoScrollControl { + t.Fatal("ShowTranscriptAutoScrollControl = false, want true (stored)") } } -func TestScanUserSettingsDefaultsTranscriptAutoScrollControlToVisible(t *testing.T) { +func TestScanUserSettingsDefaultsTranscriptAutoScrollControlToHidden(t *testing.T) { settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID) if err != nil { t.Fatalf("scan settings: %v", err) } - if !settings.ShowTranscriptAutoScrollControl { - t.Fatal("ShowTranscriptAutoScrollControl = false, want true (default)") + if settings.ShowTranscriptAutoScrollControl { + t.Fatal("ShowTranscriptAutoScrollControl = true, want false (default)") } } diff --git a/apps/web/components/settings/anchored-prompt-bar-settings.test.tsx b/apps/web/components/settings/anchored-prompt-bar-settings.test.tsx index 2844ca771b..593cc8d2c3 100644 --- a/apps/web/components/settings/anchored-prompt-bar-settings.test.tsx +++ b/apps/web/components/settings/anchored-prompt-bar-settings.test.tsx @@ -56,7 +56,7 @@ describe("AnchoredPromptBarSettings", () => { ).toBe("unchecked"); expect( screen.getByRole("switch", { name: AUTO_SCROLL_CONTROL_LABEL }).getAttribute(DATA_STATE), - ).toBe("checked"); + ).toBe("unchecked"); screen.getByText(/desktop only/i); screen.getByText(/show scroll to last prompt/i); }); @@ -115,7 +115,7 @@ describe("AnchoredPromptBarSettings", () => { await waitFor(() => expect(updateUserSettings).toHaveBeenCalledWith({ - show_transcript_auto_scroll_control: false, + show_transcript_auto_scroll_control: true, }), ); }); diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 533ec6ddaf..9de0d6351e 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -5,19 +5,9 @@ import { useAppStore } from "@/components/state-provider"; import { useEditors } from "@/hooks/domains/settings/use-editors"; import { createEditor, deleteEditor, updateEditor, updateUserSettings } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; -import { parseTasksListGroup, parseTasksListSort } from "@/lib/tasks/tasks-list-options"; import type { EditorOption } from "@/lib/types/http"; import { type ComboboxOption } from "@/components/combobox"; -import { - parseChangesPanelLayout, - parseAppStatusBarOrder, - parseSystemMetricsDisplay, - parseTerminalLinkBehavior, - taskCreateLastUsedHasValue, - parseVoiceMode, - parseMCPTaskAgentProfileDefault, -} from "@/lib/ssr/user-settings"; -import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { type EditorFormState, buildConfig, @@ -217,125 +207,13 @@ function buildSettingsPayload( }; } -function mapEditorSettingsFields( - s: NonNullable["settings"]>, -) { - return { - ...mapTasksListSettingsFields(s), - ...mapEditorBehaviorFields(s), - ...mapEditorLspFields(s), - ...mapEditorSidebarFields(s), - ...mapEditorSyncedLocalFields(s), - loaded: true as const, - }; -} - -function mapTasksListSettingsFields( - s: NonNullable["settings"]>, -) { - return { - tasksListSort: parseTasksListSort(s.tasks_list_sort), - tasksListGroup: parseTasksListGroup(s.tasks_list_group), - tasksListShowDetails: s.tasks_list_show_details ?? false, - }; -} - -function mapEditorBehaviorFields( - s: NonNullable["settings"]>, -) { - return { - chatSubmitKey: s.chat_submit_key ?? "cmd_enter", - reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? true, - confirmTaskArchive: s.confirm_task_archive ?? true, - unreadDivider: s.unread_divider ?? true, - mcpTaskAgentProfileDefault: parseMCPTaskAgentProfileDefault(s.mcp_task_agent_profile_default), - showAnchoredPromptBar: s.show_anchored_prompt_bar ?? false, - showScrollToLastPrompt: s.show_scroll_to_last_prompt ?? true, - showScrollToStart: s.show_scroll_to_start ?? false, - showTranscriptAutoScrollControl: s.show_transcript_auto_scroll_control ?? true, - showReleaseNotification: s.show_release_notification ?? true, - releaseNotesLastSeenVersion: (s.release_notes_last_seen_version as string) || null, - savedLayouts: s.saved_layouts ?? [], - }; -} - -function mapEditorLspFields(s: NonNullable["settings"]>) { - return { - lspAutoStartLanguages: s.lsp_auto_start_languages ?? [], - lspAutoInstallLanguages: s.lsp_auto_install_languages ?? [], - lspServerConfigs: s.lsp_server_configs ?? {}, - }; -} - -function mapEditorSidebarFields( - s: NonNullable["settings"]>, -) { - return { - sidebarViews: (s.sidebar_views ?? []).map(fromApiSidebarView), - sidebarActiveViewId: s.sidebar_active_view_id || null, - sidebarDraft: s.sidebar_draft ? fromApiSidebarDraft(s.sidebar_draft) : null, - sidebarTaskPrefs: { - pinnedTaskIds: s.sidebar_task_prefs?.pinned_task_ids ?? [], - orderedTaskIds: s.sidebar_task_prefs?.ordered_task_ids ?? [], - subtaskOrderByParentId: s.sidebar_task_prefs?.subtask_order_by_parent_id ?? {}, - }, - }; -} - -function mapEditorSyncedLocalFields( - s: NonNullable["settings"]>, -) { - return { - taskCreateLastUsed: { - repositoryId: s.task_create_last_used?.repository_id || null, - branch: s.task_create_last_used?.branch || null, - agentProfileId: s.task_create_last_used?.agent_profile_id || null, - executorProfileId: s.task_create_last_used?.executor_profile_id || null, - synced: taskCreateLastUsedHasValue(s.task_create_last_used), - }, - jiraSavedViews: s.jira_saved_views, - jiraTaskPresets: s.jira_task_presets, - githubSavedPresets: s.github_saved_presets, - githubDefaultQueryPresets: s.github_default_query_presets, - gitlabSavedPresets: s.gitlab_saved_presets, - }; -} - -function buildUserSettingsFromResponse( - s: NonNullable["settings"], - shellOptions: Array<{ value: string; label: string }> | null | undefined, -) { - if (!s) return null; - return { - workspaceId: s.workspace_id || null, - workflowId: s.workflow_filter_id || null, - kanbanViewMode: s.kanban_view_mode || null, - repositoryIds: s.repository_ids ?? [], - preferredShell: s.preferred_shell || null, - shellOptions: shellOptions ?? [], - defaultEditorId: s.default_editor_id || null, - enablePreviewOnClick: s.enable_preview_on_click ?? false, - defaultUtilityAgentId: s.default_utility_agent_id || null, - keyboardShortcuts: s.keyboard_shortcuts ?? {}, - terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior), - terminalFontFamily: s.terminal_font_family || null, - terminalFontSize: s.terminal_font_size || null, - changesPanelLayout: parseChangesPanelLayout(s.changes_panel_layout), - systemMetricsDisplay: parseSystemMetricsDisplay(s.system_metrics_display), - appStatusBarOrder: parseAppStatusBarOrder(s.app_status_bar_order), - voiceMode: parseVoiceMode(s.voice_mode), - ...mapEditorSettingsFields(s), - }; -} - function applySettingsResponseToStore( response: UpdateUserSettingsResponse, - shellOptions: Array<{ value: string; label: string }> | null | undefined, + current: UserSettingsState, setUserSettings: SetUserSettingsFn, ) { if (!response?.settings) return; - const settings = buildUserSettingsFromResponse(response.settings, shellOptions); - if (settings) setUserSettings(settings); + setUserSettings(mapUserSettingsResponse(response, current)); } export function useApplyEditors(state: EditorsSettingsState) { @@ -442,7 +320,7 @@ export function useSaveRequest(state: EditorsSettingsState) { setBaselineLspAutoStart([...lspAutoStartLanguages]); setBaselineLspAutoInstall([...lspAutoInstallLanguages]); setBaselineLspConfigStrings({ ...lspConfigStrings }); - applySettingsResponseToStore(response, currentUserSettings.shellOptions, setUserSettings); + applySettingsResponseToStore(response, currentUserSettings, setUserSettings); }); } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 9b3eb1b3d1..272412cad3 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -843,6 +843,7 @@ export class ApiClient { async saveUserSettings(settings: { enable_preview_on_click?: boolean; confirm_task_archive?: boolean; + unread_divider?: boolean; mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; show_anchored_prompt_bar?: boolean; show_scroll_to_last_prompt?: boolean; diff --git a/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts b/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts index bb296f38d9..6bef76151a 100644 --- a/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts @@ -11,6 +11,10 @@ import { isScrolledIntoView, seedScrollTestConversation } from "../../helpers/un * helpers/unread-divider.ts) — same conversation shape, different viewport. */ test.describe("Mobile unread divider", () => { + test.beforeEach(async ({ apiClient }) => { + await apiClient.saveUserSettings({ unread_divider: true }); + }); + test("scrolls to the New divider on visit start under the mobile chat layout", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/chat/unread-divider.spec.ts b/apps/web/e2e/tests/chat/unread-divider.spec.ts index b2e77e47fb..67bb548c4c 100644 --- a/apps/web/e2e/tests/chat/unread-divider.spec.ts +++ b/apps/web/e2e/tests/chat/unread-divider.spec.ts @@ -3,6 +3,10 @@ import { openTaskSession } from "../../helpers/session"; import { isScrolledIntoView, seedScrollTestConversation } from "../../helpers/unread-divider"; test.describe("Unread divider", () => { + test.beforeEach(async ({ apiClient }) => { + await apiClient.saveUserSettings({ unread_divider: true }); + }); + test("shows the Slack-style New divider only for messages read before a rewound cursor, and clears after the next visit", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts index 41861033e0..73f68b2df8 100644 --- a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts +++ b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts @@ -6,6 +6,7 @@ test.describe("Unread divider preference on mobile", () => { apiClient, prCapture, }, testInfo) => { + await apiClient.saveUserSettings({ unread_divider: true }); await testPage.goto("/settings/general/task-actions"); const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" }); diff --git a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts index 8129434b9c..a55c6de0e0 100644 --- a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts +++ b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts @@ -6,6 +6,7 @@ test.describe("Unread divider preference", () => { apiClient, prCapture, }, testInfo) => { + await apiClient.saveUserSettings({ unread_divider: true }); await testPage.goto("/settings/general/task-actions"); const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" }); diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 244ed835b5..9e6c43ee59 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -7,11 +7,7 @@ import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings"; import { repositoryId, type Repository } from "@/lib/types/http"; -import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; -import { - DEFAULT_VOICE_MODE_STATE, - type UserSettingsState, -} from "@/lib/state/slices/settings/types"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; type DisplaySettings = UserSettingsState; @@ -32,127 +28,15 @@ type CommitPayload = { kanbanViewMode?: string | null; }; -function carryForwardTerminalSettings(current: DisplaySettings) { - return { - terminalLinkBehavior: (current.terminalLinkBehavior ?? "new_tab") as - | "new_tab" - | "browser_panel", - terminalFontFamily: current.terminalFontFamily ?? null, - terminalFontSize: current.terminalFontSize ?? null, - }; -} - -function carryForwardLspSettings(current: DisplaySettings) { - return { - lspAutoStartLanguages: current.lspAutoStartLanguages ?? [], - lspAutoInstallLanguages: current.lspAutoInstallLanguages ?? [], - lspServerConfigs: current.lspServerConfigs ?? {}, - }; -} - -function carryForwardUtilitySettings(current: DisplaySettings) { - return { - defaultUtilityAgentId: current.defaultUtilityAgentId ?? null, - keyboardShortcuts: current.keyboardShortcuts ?? {}, - }; -} - -function carryForwardTranscriptNavigationSettings(current: DisplaySettings) { - return { - showAnchoredPromptBar: current.showAnchoredPromptBar ?? false, - showScrollToLastPrompt: current.showScrollToLastPrompt ?? true, - showScrollToStart: current.showScrollToStart ?? false, - showTranscriptAutoScrollControl: current.showTranscriptAutoScrollControl ?? true, - }; -} - -function carryForwardCoreSettings(current: DisplaySettings) { - return { - shellOptions: current.shellOptions ?? [], - defaultEditorId: current.defaultEditorId ?? null, - chatSubmitKey: current.chatSubmitKey ?? "cmd_enter", - ...carryForwardTranscriptNavigationSettings(current), - reviewAutoMarkOnScroll: current.reviewAutoMarkOnScroll ?? true, - showReleaseNotification: current.showReleaseNotification ?? true, - releaseNotesLastSeenVersion: current.releaseNotesLastSeenVersion ?? null, - savedLayouts: current.savedLayouts ?? [], - ...carryForwardUtilitySettings(current), - tasksListSort: current.tasksListSort ?? DEFAULT_TASKS_LIST_SORT, - tasksListGroup: current.tasksListGroup ?? DEFAULT_TASKS_LIST_GROUP, - changesPanelLayout: current.changesPanelLayout ?? "tree", - ...carryForwardAppStatusSettings(current), - voiceMode: current.voiceMode ?? { ...DEFAULT_VOICE_MODE_STATE }, - }; -} - -function carryForwardAppStatusSettings(current: DisplaySettings) { - return { - systemMetricsDisplay: current.systemMetricsDisplay ?? { - showInTopbar: false, - simplified: false, - }, - appStatusBarOrder: current.appStatusBarOrder ?? { leftItemIds: [], rightItemIds: [] }, - }; -} - -function carryForwardTaskActionSettings(current: DisplaySettings) { - return { - confirmTaskArchive: current.confirmTaskArchive ?? true, - unreadDivider: current.unreadDivider ?? true, - mcpTaskAgentProfileDefault: current.mcpTaskAgentProfileDefault ?? "current_task", - }; -} - -function carryForwardSidebarSettings(current: DisplaySettings) { - return { - sidebarViews: current.sidebarViews ?? [], - sidebarActiveViewId: current.sidebarActiveViewId ?? null, - sidebarDraft: current.sidebarDraft ?? null, - sidebarTaskPrefs: current.sidebarTaskPrefs ?? { - pinnedTaskIds: [], - orderedTaskIds: [], - subtaskOrderByParentId: {}, - }, - }; -} - -function carryForwardSyncedLocalSettings(current: DisplaySettings) { - return { - taskCreateLastUsed: current.taskCreateLastUsed ?? { - repositoryId: null, - branch: null, - agentProfileId: null, - executorProfileId: null, - }, - jiraSavedViews: current.jiraSavedViews, - jiraTaskPresets: current.jiraTaskPresets, - githubSavedPresets: current.githubSavedPresets, - githubDefaultQueryPresets: current.githubDefaultQueryPresets, - gitlabSavedPresets: current.gitlabSavedPresets, - }; -} - -function carryForwardSettings(current: DisplaySettings) { - return { - ...carryForwardCoreSettings(current), - ...carryForwardTaskActionSettings(current), - ...carryForwardSidebarSettings(current), - ...carryForwardSyncedLocalSettings(current), - ...carryForwardLspSettings(current), - ...carryForwardTerminalSettings(current), - }; -} - function buildNormalizedSettings(next: CommitPayload, current: DisplaySettings): DisplaySettings { return { + ...current, workspaceId: next.workspaceId, workflowId: next.workflowId, - kanbanViewMode: current.kanbanViewMode ?? null, repositoryIds: Array.from(new Set(next.repositoryIds)).sort(), - preferredShell: next.preferredShell ?? current.preferredShell ?? null, + preferredShell: next.preferredShell ?? current.preferredShell, enablePreviewOnClick: next.enablePreviewOnClick ?? current.enablePreviewOnClick, - tasksListShowDetails: next.tasksListShowDetails ?? current.tasksListShowDetails ?? false, - ...carryForwardSettings(current), + tasksListShowDetails: next.tasksListShowDetails ?? current.tasksListShowDetails, loaded: true, }; } diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 6546833f34..073b3eec25 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -249,20 +249,20 @@ describe("mapUserSettingsResponse", () => { }); describe("mapUserSettingsResponse unread divider", () => { - it("defaults the preference to enabled and preserves an explicit disable", () => { + it("defaults the preference to disabled and preserves explicit enablement", () => { const fallback = mapUserSettingsResponse(null) as { unreadDivider?: boolean }; const disabled = mapUserSettingsResponse({ settings: { user_id: DEFAULT_USER_ID, workspace_id: toWorkspaceId(""), repository_ids: [], - unread_divider: false, + unread_divider: true, updated_at: UPDATED_AT, } as unknown as NonNullable[0]>["settings"], }) as { unreadDivider?: boolean }; - expect(fallback.unreadDivider).toBe(true); - expect(disabled.unreadDivider).toBe(false); + expect(fallback.unreadDivider).toBe(false); + expect(disabled.unreadDivider).toBe(true); }); }); @@ -274,7 +274,7 @@ describe("transcript navigation settings", () => { showAnchoredPromptBar: false, showScrollToLastPrompt: true, showScrollToStart: false, - showTranscriptAutoScrollControl: true, + showTranscriptAutoScrollControl: false, }); }); }); diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index f3ef0bb1a2..aa52719055 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -6,12 +6,75 @@ import { } from "@/lib/tasks/tasks-list-options"; import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types"; -import { DEFAULT_VOICE_MODE_STATE, type VoiceModeState } from "@/lib/state/slices/settings/types"; -import type { SavedLayout, SidebarTaskPrefsApi, UserSettingsResponse } from "@/lib/types/http"; +import { + DEFAULT_VOICE_MODE_STATE, + type UserSettingsState, + type VoiceModeState, +} from "@/lib/state/slices/settings/types"; +import type { SidebarTaskPrefsApi, UserSettings, UserSettingsResponse } from "@/lib/types/http"; import type { MCPTaskAgentProfileDefault } from "@/lib/types/http-user-settings"; import type { VoiceModeSettings } from "@/lib/types/http-voice"; -export type UserSettingsData = NonNullable; +export type UserSettingsData = Omit, "workspace_id"> & { + workspace_id?: string; +}; + +export function createDefaultUserSettings(): UserSettingsState { + return { + workspaceId: null, + workflowId: null, + kanbanViewMode: null, + repositoryIds: [], + tasksListSort: DEFAULT_TASKS_LIST_SORT, + tasksListGroup: DEFAULT_TASKS_LIST_GROUP, + tasksListShowDetails: false, + preferredShell: null, + shellOptions: [], + defaultEditorId: null, + enablePreviewOnClick: false, + chatSubmitKey: "cmd_enter", + reviewAutoMarkOnScroll: true, + confirmTaskArchive: true, + unreadDivider: false, + mcpTaskAgentProfileDefault: "current_task", + showAnchoredPromptBar: false, + showScrollToLastPrompt: true, + showScrollToStart: false, + showTranscriptAutoScrollControl: false, + showReleaseNotification: true, + releaseNotesLastSeenVersion: null, + lspAutoStartLanguages: [], + lspAutoInstallLanguages: [], + lspServerConfigs: {}, + savedLayouts: [], + sidebarViews: [], + sidebarActiveViewId: null, + sidebarDraft: null, + sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + taskCreateLastUsed: { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + synced: false, + }, + jiraSavedViews: undefined, + jiraTaskPresets: undefined, + githubSavedPresets: undefined, + githubDefaultQueryPresets: undefined, + gitlabSavedPresets: undefined, + defaultUtilityAgentId: null, + keyboardShortcuts: {}, + terminalLinkBehavior: "new_tab", + terminalFontFamily: null, + terminalFontSize: null, + changesPanelLayout: "tree", + systemMetricsDisplay: { showInTopbar: false, simplified: false }, + appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, + voiceMode: { ...DEFAULT_VOICE_MODE_STATE }, + loaded: false, + }; +} export function parseTerminalLinkBehavior(value: string | undefined): "new_tab" | "browser_panel" { return value === "browser_panel" ? "browser_panel" : "new_tab"; @@ -60,22 +123,40 @@ export function parseVoiceMode(value: VoiceModeSettings | undefined): VoiceModeS }; } -function buildTerminalFields(s: UserSettingsData) { +function buildTerminalFields(s: UserSettingsData, current: UserSettingsState) { return { - terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior), - terminalFontFamily: s.terminal_font_family || null, - terminalFontSize: s.terminal_font_size || null, - changesPanelLayout: parseChangesPanelLayout(s.changes_panel_layout), + terminalLinkBehavior: + s.terminal_link_behavior === undefined + ? current.terminalLinkBehavior + : parseTerminalLinkBehavior(s.terminal_link_behavior), + terminalFontFamily: + s.terminal_font_family === undefined + ? current.terminalFontFamily + : s.terminal_font_family || null, + terminalFontSize: + s.terminal_font_size === undefined ? current.terminalFontSize : s.terminal_font_size || null, + changesPanelLayout: + s.changes_panel_layout === undefined + ? current.changesPanelLayout + : parseChangesPanelLayout(s.changes_panel_layout), }; } -function buildVoiceModeFields(s: UserSettingsData) { - return { voiceMode: parseVoiceMode(s.voice_mode) }; +function buildVoiceModeFields(s: UserSettingsData, current: UserSettingsState) { + return { + voiceMode: s.voice_mode === undefined ? current.voiceMode : parseVoiceMode(s.voice_mode), + }; } -function buildSystemMetricsDisplayFields(s: UserSettingsData | undefined) { +function buildSystemMetricsDisplayFields( + s: UserSettingsData | undefined, + current: UserSettingsState, +) { return { - systemMetricsDisplay: parseSystemMetricsDisplay(s?.system_metrics_display), + systemMetricsDisplay: + s?.system_metrics_display === undefined + ? current.systemMetricsDisplay + : parseSystemMetricsDisplay(s.system_metrics_display), }; } @@ -105,68 +186,135 @@ function parseTaskCreateLastUsed(value: UserSettingsData["task_create_last_used" }; } -function buildIdentityFields(s: UserSettingsData) { +function mapDefined( + value: TInput | undefined, + current: TOutput, + map: (defined: TInput) => TOutput, +): TOutput { + return value === undefined ? current : map(value); +} + +function mapNullableString(value: string | undefined, current: string | null) { + return mapDefined(value, current, (defined) => defined || null); +} + +function buildIdentityFields(s: UserSettingsData, current: UserSettingsState) { return { - workspaceId: s.workspace_id || null, - workflowId: s.workflow_filter_id || null, - kanbanViewMode: s.kanban_view_mode || null, - repositoryIds: s.repository_ids ?? [], - tasksListSort: parseTasksListSort(s.tasks_list_sort), - tasksListGroup: parseTasksListGroup(s.tasks_list_group), - tasksListShowDetails: s.tasks_list_show_details ?? false, - preferredShell: s.preferred_shell || null, - defaultEditorId: s.default_editor_id || null, - defaultUtilityAgentId: s.default_utility_agent_id || null, + workspaceId: mapNullableString(s.workspace_id, current.workspaceId), + workflowId: mapNullableString(s.workflow_filter_id, current.workflowId), + kanbanViewMode: mapNullableString(s.kanban_view_mode, current.kanbanViewMode), + repositoryIds: s.repository_ids ?? current.repositoryIds, + tasksListSort: mapDefined(s.tasks_list_sort, current.tasksListSort, parseTasksListSort), + tasksListGroup: mapDefined(s.tasks_list_group, current.tasksListGroup, parseTasksListGroup), + tasksListShowDetails: s.tasks_list_show_details ?? current.tasksListShowDetails, + preferredShell: mapNullableString(s.preferred_shell, current.preferredShell), + defaultEditorId: mapNullableString(s.default_editor_id, current.defaultEditorId), + defaultUtilityAgentId: mapNullableString( + s.default_utility_agent_id, + current.defaultUtilityAgentId, + ), }; } -function buildBehaviorFields(s: UserSettingsData) { +function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) { return { - enablePreviewOnClick: s.enable_preview_on_click ?? false, - chatSubmitKey: s.chat_submit_key ?? "cmd_enter", - reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? true, - confirmTaskArchive: s.confirm_task_archive ?? true, - unreadDivider: s.unread_divider ?? true, - mcpTaskAgentProfileDefault: parseMCPTaskAgentProfileDefault(s.mcp_task_agent_profile_default), - showAnchoredPromptBar: s.show_anchored_prompt_bar ?? false, - showScrollToLastPrompt: s.show_scroll_to_last_prompt ?? true, - showScrollToStart: s.show_scroll_to_start ?? false, - showTranscriptAutoScrollControl: s.show_transcript_auto_scroll_control ?? true, - showReleaseNotification: s.show_release_notification ?? true, - releaseNotesLastSeenVersion: s.release_notes_last_seen_version || null, - keyboardShortcuts: s.keyboard_shortcuts ?? {}, + enablePreviewOnClick: s.enable_preview_on_click ?? current.enablePreviewOnClick, + chatSubmitKey: s.chat_submit_key ?? current.chatSubmitKey, + reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? current.reviewAutoMarkOnScroll, + confirmTaskArchive: s.confirm_task_archive ?? current.confirmTaskArchive, + unreadDivider: s.unread_divider ?? current.unreadDivider, + mcpTaskAgentProfileDefault: mapDefined( + s.mcp_task_agent_profile_default, + current.mcpTaskAgentProfileDefault, + parseMCPTaskAgentProfileDefault, + ), + showAnchoredPromptBar: s.show_anchored_prompt_bar ?? current.showAnchoredPromptBar, + showScrollToLastPrompt: s.show_scroll_to_last_prompt ?? current.showScrollToLastPrompt, + showScrollToStart: s.show_scroll_to_start ?? current.showScrollToStart, + showTranscriptAutoScrollControl: + s.show_transcript_auto_scroll_control ?? current.showTranscriptAutoScrollControl, + showReleaseNotification: s.show_release_notification ?? current.showReleaseNotification, + releaseNotesLastSeenVersion: mapNullableString( + s.release_notes_last_seen_version, + current.releaseNotesLastSeenVersion, + ), + keyboardShortcuts: s.keyboard_shortcuts ?? current.keyboardShortcuts, }; } -export function buildCoreFields(s: UserSettingsData) { +export function buildCoreFields( + s: UserSettingsData, + current: UserSettingsState = createDefaultUserSettings(), +) { return { - ...buildIdentityFields(s), - ...buildBehaviorFields(s), - savedLayouts: s.saved_layouts ?? [], - sidebarViews: (s.sidebar_views ?? []).map(fromApiSidebarView) as SidebarView[], - sidebarActiveViewId: s.sidebar_active_view_id || null, - sidebarDraft: s.sidebar_draft - ? (fromApiSidebarDraft(s.sidebar_draft) as SidebarViewDraft) - : null, - sidebarTaskPrefs: parseSidebarTaskPrefs(s.sidebar_task_prefs), - taskCreateLastUsed: parseTaskCreateLastUsed(s.task_create_last_used), - jiraSavedViews: s.jira_saved_views, - jiraTaskPresets: s.jira_task_presets, - githubSavedPresets: s.github_saved_presets, - githubDefaultQueryPresets: s.github_default_query_presets, - gitlabSavedPresets: s.gitlab_saved_presets, - appStatusBarOrder: parseAppStatusBarOrder(s.app_status_bar_order), - ...buildTerminalFields(s), - ...buildSystemMetricsDisplayFields(s), - ...buildVoiceModeFields(s), + ...buildIdentityFields(s, current), + ...buildBehaviorFields(s, current), + savedLayouts: s.saved_layouts ?? current.savedLayouts, + sidebarViews: mapDefined(s.sidebar_views, current.sidebarViews, (views) => + views.map(fromApiSidebarView), + ) as SidebarView[], + sidebarActiveViewId: mapNullableString(s.sidebar_active_view_id, current.sidebarActiveViewId), + sidebarDraft: mapDefined(s.sidebar_draft, current.sidebarDraft, (draft) => + draft ? (fromApiSidebarDraft(draft) as SidebarViewDraft) : null, + ), + sidebarTaskPrefs: mapDefined( + s.sidebar_task_prefs, + current.sidebarTaskPrefs, + parseSidebarTaskPrefs, + ), + taskCreateLastUsed: mapDefined( + s.task_create_last_used, + current.taskCreateLastUsed, + parseTaskCreateLastUsed, + ), + jiraSavedViews: mapDefined(s.jira_saved_views, current.jiraSavedViews, (value) => value), + jiraTaskPresets: mapDefined(s.jira_task_presets, current.jiraTaskPresets, (value) => value), + githubSavedPresets: mapDefined( + s.github_saved_presets, + current.githubSavedPresets, + (value) => value, + ), + githubDefaultQueryPresets: mapDefined( + s.github_default_query_presets, + current.githubDefaultQueryPresets, + (value) => value, + ), + gitlabSavedPresets: mapDefined( + s.gitlab_saved_presets, + current.gitlabSavedPresets, + (value) => value, + ), + appStatusBarOrder: mapDefined( + s.app_status_bar_order, + current.appStatusBarOrder, + parseAppStatusBarOrder, + ), + ...buildTerminalFields(s, current), + ...buildSystemMetricsDisplayFields(s, current), + ...buildVoiceModeFields(s, current), }; } -export function buildLspFields(s: UserSettingsData | undefined) { +export function buildLspFields( + s: UserSettingsData | undefined, + current: UserSettingsState = createDefaultUserSettings(), +) { return { - lspAutoStartLanguages: s?.lsp_auto_start_languages ?? [], - lspAutoInstallLanguages: s?.lsp_auto_install_languages ?? [], - lspServerConfigs: s?.lsp_server_configs ?? {}, + lspAutoStartLanguages: s?.lsp_auto_start_languages ?? current.lspAutoStartLanguages, + lspAutoInstallLanguages: s?.lsp_auto_install_languages ?? current.lspAutoInstallLanguages, + lspServerConfigs: s?.lsp_server_configs ?? current.lspServerConfigs, + }; +} + +export function mapUserSettingsData( + settings: UserSettingsData, + current: UserSettingsState = createDefaultUserSettings(), +): UserSettingsState { + return { + ...current, + ...buildCoreFields(settings, current), + ...buildLspFields(settings, current), + loaded: true, }; } @@ -174,61 +322,17 @@ export function buildLspFields(s: UserSettingsData | undefined) { * Maps a `fetchUserSettings()` API response into the shape expected by `AppState["userSettings"]`. * Use in SSR pages to build `initialState.userSettings`. */ -export function mapUserSettingsResponse(response: UserSettingsResponse | null) { +export function mapUserSettingsResponse( + response: UserSettingsResponse | null, + current: UserSettingsState = createDefaultUserSettings(), +) { const s = response?.settings; - const shellOptions = response?.shell_options ?? []; + const shellOptions = response?.shell_options ?? current.shellOptions; if (!s) { - return { - workspaceId: null, - workflowId: null, - kanbanViewMode: null, - repositoryIds: [] as string[], - tasksListSort: DEFAULT_TASKS_LIST_SORT, - tasksListGroup: DEFAULT_TASKS_LIST_GROUP, - tasksListShowDetails: false, - preferredShell: null, - shellOptions, - defaultEditorId: null, - enablePreviewOnClick: false, - chatSubmitKey: "cmd_enter" as const, - reviewAutoMarkOnScroll: true, - confirmTaskArchive: true, - unreadDivider: true, - mcpTaskAgentProfileDefault: "current_task" as const, - showAnchoredPromptBar: false, - showScrollToLastPrompt: true, - showScrollToStart: false, - showTranscriptAutoScrollControl: true, - showReleaseNotification: true, - releaseNotesLastSeenVersion: null, - savedLayouts: [] as SavedLayout[], - sidebarViews: [] as SidebarView[], - sidebarActiveViewId: null, - sidebarDraft: null, - sidebarTaskPrefs: parseSidebarTaskPrefs(undefined), - taskCreateLastUsed: parseTaskCreateLastUsed(undefined), - jiraSavedViews: undefined, - jiraTaskPresets: undefined, - githubSavedPresets: undefined, - githubDefaultQueryPresets: undefined, - gitlabSavedPresets: undefined, - defaultUtilityAgentId: null, - keyboardShortcuts: {} as Record }>, - terminalLinkBehavior: "new_tab" as const, - terminalFontFamily: null, - terminalFontSize: null, - changesPanelLayout: "tree" as const, - appStatusBarOrder: parseAppStatusBarOrder(undefined), - ...buildSystemMetricsDisplayFields(undefined), - voiceMode: { ...DEFAULT_VOICE_MODE_STATE }, - ...buildLspFields(undefined), - loaded: false, - }; + return { ...current, shellOptions, loaded: false }; } return { - ...buildCoreFields(s), + ...mapUserSettingsData(s, current), shellOptions, - ...buildLspFields(s), - loaded: true, }; } diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 52003984da..33cb634887 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from "zustand"; -import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; -import { DEFAULT_VOICE_MODE_STATE, type SettingsSlice, type SettingsSliceState } from "./types"; +import { createDefaultUserSettings } from "@/lib/ssr/user-settings"; +import type { SettingsSlice, SettingsSliceState } from "./types"; export const defaultSettingsState: SettingsSliceState = { executors: { items: [] }, @@ -22,60 +22,7 @@ export const defaultSettingsState: SettingsSliceState = { loading: false, }, settingsData: { executorsLoaded: false, agentsLoaded: false }, - userSettings: { - workspaceId: null, - kanbanViewMode: null, - workflowId: null, - repositoryIds: [], - tasksListSort: DEFAULT_TASKS_LIST_SORT, - tasksListGroup: DEFAULT_TASKS_LIST_GROUP, - tasksListShowDetails: false, - preferredShell: null, - shellOptions: [], - defaultEditorId: null, - enablePreviewOnClick: false, - terminalLinkBehavior: "new_tab", - chatSubmitKey: "cmd_enter", - reviewAutoMarkOnScroll: true, - confirmTaskArchive: true, - unreadDivider: true, - mcpTaskAgentProfileDefault: "current_task", - showAnchoredPromptBar: false, - showScrollToLastPrompt: true, - showScrollToStart: false, - showTranscriptAutoScrollControl: true, - showReleaseNotification: true, - releaseNotesLastSeenVersion: null, - lspAutoStartLanguages: [], - lspAutoInstallLanguages: [], - lspServerConfigs: {}, - savedLayouts: [], - sidebarViews: [], - sidebarActiveViewId: null, - sidebarDraft: null, - sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, - taskCreateLastUsed: { - repositoryId: null, - branch: null, - agentProfileId: null, - executorProfileId: null, - synced: false, - }, - jiraSavedViews: undefined, - jiraTaskPresets: undefined, - githubSavedPresets: undefined, - githubDefaultQueryPresets: undefined, - gitlabSavedPresets: undefined, - defaultUtilityAgentId: null, - keyboardShortcuts: {}, - terminalFontFamily: null, - terminalFontSize: null, - changesPanelLayout: "tree", - systemMetricsDisplay: { showInTopbar: false, simplified: false }, - appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, - voiceMode: { ...DEFAULT_VOICE_MODE_STATE }, - loaded: false, - }, + userSettings: createDefaultUserSettings(), }; type ImmerSet = Parameters< diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index e86bee55ff..f7aebde986 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -12,18 +12,12 @@ export type { RunEventAppendedPayload } from "./run-events"; import type { AvailableAgent, ForegroundActivity, - SavedLayout, - SidebarViewApi, - SidebarViewDraftApi, - SidebarTaskPrefsApi, - TaskCreateLastUsedApi, - AppStatusBarOrderApi, - MCPTaskAgentProfileDefault, TaskPendingAction, TaskSessionState, StepEvents, TaskState, ToolStatus, + UserSettings, } from "@/lib/types/http"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { GitEventPayload } from "@/lib/types/git-events"; @@ -397,49 +391,13 @@ export type AgentProfileChangedPayload = { profile: AgentProfilePayload; }; -export type UserSettingsUpdatedPayload = { +export type UserSettingsUpdatedPayload = Omit< + Partial, + "user_id" | "workspace_id" | "repository_ids" +> & { user_id: string; workspace_id: string; - kanban_view_mode?: string; - tasks_list_show_details?: boolean; - workflow_filter_id?: string; repository_ids: string[]; - initial_setup_complete?: boolean; - preferred_shell?: string; - default_editor_id?: string; - enable_preview_on_click?: boolean; - chat_submit_key?: string; - show_anchored_prompt_bar?: boolean; - show_scroll_to_last_prompt?: boolean; - show_scroll_to_start?: boolean; - show_transcript_auto_scroll_control?: boolean; - review_auto_mark_on_scroll?: boolean; - confirm_task_archive?: boolean; - unread_divider?: boolean; - mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; - show_release_notification?: boolean; - release_notes_last_seen_version?: string; - lsp_auto_start_languages?: string[]; - lsp_auto_install_languages?: string[]; - saved_layouts?: SavedLayout[]; - sidebar_views?: SidebarViewApi[]; - sidebar_active_view_id?: string; - sidebar_draft?: SidebarViewDraftApi | null; - sidebar_task_prefs?: SidebarTaskPrefsApi; - task_create_last_used?: TaskCreateLastUsedApi; - jira_saved_views?: unknown[] | null; - jira_task_presets?: unknown[] | null; - github_saved_presets?: unknown[] | null; - github_default_query_presets?: object | null; - gitlab_saved_presets?: unknown[] | null; - default_utility_agent_id?: string; - keyboard_shortcuts?: Record }>; - terminal_link_behavior?: string; - changes_panel_layout?: "flat" | "tree"; - system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; - app_status_bar_order?: AppStatusBarOrderApi; - voice_mode?: import("@/lib/types/http-voice").VoiceModeSettings; - updated_at?: string; }; export type ShellOutputPayload = { diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 1a5bbfa398..66f33acaa0 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -93,7 +93,7 @@ describe("user settings websocket handler", () => { expect(store.getState().userSettings.mcpTaskAgentProfileDefault).toBe("current_task"); }); - it("applies archive confirmation preferences and defaults missing values to enabled", () => { + it("applies archive confirmation preferences and preserves them when omitted", () => { const store = makeStore(); registerUsersHandlers(store)["user.settings.updated"]?.( @@ -102,7 +102,7 @@ describe("user settings websocket handler", () => { expect(store.getState().userSettings.confirmTaskArchive).toBe(false); registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); - expect(store.getState().userSettings.confirmTaskArchive).toBe(true); + expect(store.getState().userSettings.confirmTaskArchive).toBe(false); }); it("syncs transcript navigation preferences and uses the documented defaults", () => { @@ -124,8 +124,29 @@ describe("user settings websocket handler", () => { registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); expect(store.getState().userSettings).toMatchObject({ showAnchoredPromptBar: false, - showScrollToLastPrompt: true, + showScrollToLastPrompt: false, showScrollToStart: false, + showTranscriptAutoScrollControl: false, + }); + }); +}); + +describe("user settings websocket partial updates", () => { + it("preserves normalized preferences omitted from a partial live update", () => { + const store = makeStore(); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + unread_divider: true, + show_transcript_auto_scroll_control: true, + }), + ); + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ preferred_shell: "zsh" }), + ); + + expect(store.getState().userSettings).toMatchObject({ + unreadDivider: true, showTranscriptAutoScrollControl: true, }); }); diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index c8d9c04aab..09d6d9ce8e 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -2,14 +2,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { UserSettingsUpdatedPayload } from "@/lib/types/backend"; import type { WsHandlers } from "@/lib/ws/handlers/types"; -import { - parseChangesPanelLayout, - parseAppStatusBarOrder, - parseSystemMetricsDisplay, - taskCreateLastUsedHasValue, - parseVoiceMode, - parseMCPTaskAgentProfileDefault, -} from "@/lib/ssr/user-settings"; +import { mapUserSettingsData } from "@/lib/ssr/user-settings"; import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import { migrateView } from "@/lib/state/slices/ui/ui-slice"; @@ -27,115 +20,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers { } function buildUserSettingsState(state: AppState, payload: UserSettingsUpdatedPayload) { - return { - ...state.userSettings, - ...buildBehaviorSettings(state, payload), - ...buildSidebarSettings(state, payload), - ...buildLspSettings(payload), - ...buildSyncedLocalSettings(state, payload), - defaultUtilityAgentId: payload.default_utility_agent_id || null, - keyboardShortcuts: payload.keyboard_shortcuts ?? {}, - changesPanelLayout: parseChangesPanelLayout(payload.changes_panel_layout), - appStatusBarOrder: - payload.app_status_bar_order === undefined - ? state.userSettings.appStatusBarOrder - : parseAppStatusBarOrder(payload.app_status_bar_order), - systemMetricsDisplay: parseSystemMetricsDisplay(payload.system_metrics_display), - voiceMode: parseVoiceMode(payload.voice_mode), - loaded: true, - }; -} - -function buildLspSettings(payload: UserSettingsUpdatedPayload) { - return { - lspAutoStartLanguages: payload.lsp_auto_start_languages ?? [], - lspAutoInstallLanguages: payload.lsp_auto_install_languages ?? [], - }; -} - -function buildSyncedLocalSettings(state: AppState, payload: UserSettingsUpdatedPayload) { - return { - savedLayouts: payload.saved_layouts ?? [], - taskCreateLastUsed: - payload.task_create_last_used === undefined - ? state.userSettings.taskCreateLastUsed - : { - repositoryId: payload.task_create_last_used?.repository_id || null, - branch: payload.task_create_last_used?.branch || null, - agentProfileId: payload.task_create_last_used?.agent_profile_id || null, - executorProfileId: payload.task_create_last_used?.executor_profile_id || null, - synced: taskCreateLastUsedHasValue(payload.task_create_last_used), - }, - jiraSavedViews: payload.jira_saved_views, - jiraTaskPresets: payload.jira_task_presets, - githubSavedPresets: payload.github_saved_presets, - githubDefaultQueryPresets: payload.github_default_query_presets, - gitlabSavedPresets: payload.gitlab_saved_presets, - }; -} - -function buildTranscriptBehaviorSettings(payload: UserSettingsUpdatedPayload) { - return { - unreadDivider: payload.unread_divider ?? true, - showAnchoredPromptBar: payload.show_anchored_prompt_bar ?? false, - showScrollToLastPrompt: payload.show_scroll_to_last_prompt ?? true, - showScrollToStart: payload.show_scroll_to_start ?? false, - showTranscriptAutoScrollControl: payload.show_transcript_auto_scroll_control ?? true, - }; -} - -function buildBehaviorSettings(state: AppState, payload: UserSettingsUpdatedPayload) { - return { - preferredShell: payload.preferred_shell || null, - defaultEditorId: payload.default_editor_id || null, - enablePreviewOnClick: payload.enable_preview_on_click ?? false, - chatSubmitKey: (payload.chat_submit_key as "enter" | "cmd_enter") ?? "cmd_enter", - reviewAutoMarkOnScroll: payload.review_auto_mark_on_scroll ?? true, - confirmTaskArchive: payload.confirm_task_archive ?? true, - mcpTaskAgentProfileDefault: parseMCPTaskAgentProfileDefault( - payload.mcp_task_agent_profile_default, - ), - ...buildTranscriptBehaviorSettings(payload), - showReleaseNotification: payload.show_release_notification ?? true, - releaseNotesLastSeenVersion: (payload.release_notes_last_seen_version as string) || null, - terminalLinkBehavior: - payload.terminal_link_behavior === "browser_panel" - ? ("browser_panel" as const) - : ("new_tab" as const), - tasksListShowDetails: - payload.tasks_list_show_details === undefined - ? state.userSettings.tasksListShowDetails - : payload.tasks_list_show_details, - }; -} - -function buildSidebarSettings(state: AppState, payload: UserSettingsUpdatedPayload) { - const sidebarViews = - payload.sidebar_views === undefined - ? state.userSettings.sidebarViews - : (payload.sidebar_views ?? []).map(fromApiSidebarView).map(migrateView); - return { - sidebarViews, - sidebarActiveViewId: - payload.sidebar_active_view_id === undefined - ? state.userSettings.sidebarActiveViewId - : payload.sidebar_active_view_id || null, - sidebarDraft: parseSidebarDraftForSettings(state, payload), - sidebarTaskPrefs: - payload.sidebar_task_prefs === undefined - ? state.userSettings.sidebarTaskPrefs - : { - pinnedTaskIds: payload.sidebar_task_prefs.pinned_task_ids ?? [], - orderedTaskIds: payload.sidebar_task_prefs.ordered_task_ids ?? [], - subtaskOrderByParentId: payload.sidebar_task_prefs.subtask_order_by_parent_id ?? {}, - }, - }; -} - -function parseSidebarDraftForSettings(state: AppState, payload: UserSettingsUpdatedPayload) { - if (payload.sidebar_draft === undefined) return state.userSettings.sidebarDraft; - if (payload.sidebar_draft === null) return null; - return fromApiSidebarDraft(payload.sidebar_draft); + return mapUserSettingsData(payload, state.userSettings); } function buildSidebarTaskPrefsState(state: AppState, payload: UserSettingsUpdatedPayload) { diff --git a/docs/decisions/0041-backend-owned-portable-user-settings.md b/docs/decisions/0041-backend-owned-portable-user-settings.md index 8299a8d9b7..e0b002f01c 100644 --- a/docs/decisions/0041-backend-owned-portable-user-settings.md +++ b/docs/decisions/0041-backend-owned-portable-user-settings.md @@ -1,6 +1,6 @@ # 0041: Backend-Owned Portable User Settings -**Status:** accepted +**Status:** accepted (amended 2026-07-31) **Date:** 2026-07-15 **Area:** frontend, backend @@ -25,6 +25,13 @@ transient drafts. User-settings PATCH semantics remain unchanged: omitted fields are unchanged, explicit `null` clears nullable fields where supported, and an empty array is an explicit empty value. +The backend also owns the effective value of an omitted portable setting. It +starts from one default settings value and overlays fields present in the +stored JSON before emitting a complete HTTP or WebSocket payload. The frontend +uses one wire-to-store mapper for boot hydration, mutation responses, and live +updates; an unloaded frontend state is only a rendering placeholder, not an +independent source of product defaults. + ## Consequences - Stale browser values cannot override backend settings during hydration. @@ -32,6 +39,9 @@ and an empty array is an explicit empty value. the backend value wins on the next load. - Cross-device behavior is consistent because portable settings have one durable owner. +- Changing an omitted-value default no longer requires editing several + independent frontend delivery paths. +- Frontend compatibility handling remains centralized in the common mapper. - Device-local layout, collapse, pane-size, and transient draft storage remain unaffected. @@ -42,3 +52,6 @@ and an empty array is an explicit empty value. override explicit backend clears. 2. **Keep retry markers without fallback reads.** Rejected because replay still requires treating browser state as a second durable source. +3. **Let every frontend consumer apply wire fallbacks.** Rejected because HTTP, + WebSocket, and save-response paths can drift and produce different effective + settings for the same backend value. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index ca6efad270..dc23511f01 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -46,7 +46,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0038 | [Quick Chat Repository Isolation](0038-quick-chat-repository-isolation.md) | accepted | backend, frontend | 2026-07-14 | | 0039 | [Native desktop integration boundary](0039-native-desktop-integration-boundary.md) | accepted (amended 2026-07-24) | desktop, frontend, backend, infra | 2026-07-15 | | 0040 | [Separate updater integrity from OS publisher identity](0040-separate-updater-integrity-from-os-publisher-identity.md) | accepted | desktop, infra, workflow | 2026-07-15 | -| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted | backend, frontend | 2026-07-15 | +| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted (amended 2026-07-31) | backend, frontend | 2026-07-15 | | 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 | | 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 | | 2026-07-14-typed-utility-chat-sessions | [Typed Utility Chats Share the Quick Chat Session Model](2026-07-14-typed-utility-chat-sessions.md) | accepted | backend, frontend | 2026-07-14 | diff --git a/docs/plans/unread-divider-default/plan.md b/docs/plans/unread-divider-default/plan.md new file mode 100644 index 0000000000..535c4b0414 --- /dev/null +++ b/docs/plans/unread-divider-default/plan.md @@ -0,0 +1,68 @@ +--- +spec: docs/specs/office/unread-divider.md, docs/specs/ui/transcript-navigation-settings.md +created: 2026-07-31 +status: completed +--- + +# Implementation Plan: Centralize portable user-setting defaults + +## Overview + +Make the backend the authority for missing portable user-setting values and use +one frontend mapper for boot, HTTP-save, and WebSocket delivery. Change the +missing-value defaults for the unread divider and transcript auto-scroll control +to disabled while preserving every explicit stored value. + +Confirmed root cause: the backend has two default branches and the frontend +repeats the same defaults in its store, HTTP mapper, WebSocket handler, +save-response mapper, and carry-forward helper. E2E setup also treats production +defaults as a global test baseline, creating collateral changes when a default +moves. + +## Backend + +- Add one backend default constructor and overlay stored JSON values onto it. +- Resolve missing `unread_divider` and + `show_transcript_auto_scroll_control` fields to `false`. +- Preserve explicit `true` and `false` values without a schema migration. + +## Frontend + +- Export one frontend default-state factory and one wire-to-store mapper. +- Reuse the mapper for boot/HTTP hydration, WebSocket updates, and editor-save + responses; partial live payloads preserve the current in-memory value. +- Carry forward the required settings state directly instead of reconstructing + it with per-field fallbacks. +- Keep the settings controls and save behavior unchanged. + +## E2E Tests + +- Keep the shared E2E fixture as an explicit, stable test baseline rather than + mirroring production defaults. +- Explicitly enable the unread divider in chat behavior specs. +- Keep the existing desktop/mobile preference specs deterministic by seeding + the explicit enabled value they exercise; default-off behavior stays at the + backend/frontend contract-test level. + +## Validation + +- `cd apps/backend && go test ./internal/user/store -run 'TestScanUserSettingsUnreadDividerDefault'` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/ssr/user-settings.test.ts` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/users.test.ts` +- `cd apps && pnpm --filter @kandev/web test -- --run hooks/use-user-display-settings.test.ts` +- `cd apps/web && pnpm e2e:run tests/task/unread-divider-preference.spec.ts -- --project=chromium --project=mobile-chrome` +- `cd apps/web && pnpm e2e:run tests/chat/unread-divider.spec.ts tests/chat/mobile-unread-divider.spec.ts -- --project=chromium --project=mobile-chrome` + +## Implementation Waves + +Sequential: one task changes shared backend/frontend defaults and the related +regression coverage. No subagent delegation is authorized by this plan. + +- [completed] [task-01-disable-default](task-01-disable-default.md) + +## Mobile Parity Note + +This is a data/default change inside the existing General > Task Actions +surface. Desktop and mobile use the same setting control and no layout, +navigation, touch, or scrolling composition changes. Existing mobile preference +coverage remains the appropriate parity check. diff --git a/docs/plans/unread-divider-default/task-01-disable-default.md b/docs/plans/unread-divider-default/task-01-disable-default.md new file mode 100644 index 0000000000..bb7d823fdd --- /dev/null +++ b/docs/plans/unread-divider-default/task-01-disable-default.md @@ -0,0 +1,69 @@ +--- +id: "01-disable-default" +title: "Centralize portable user-setting defaults" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/office/unread-divider.md, ../../specs/ui/transcript-navigation-settings.md" +--- + +# Task 01: Centralize portable user-setting defaults + +## Acceptance + +- Missing `unread_divider` and `show_transcript_auto_scroll_control` values + resolve to `false`; explicit stored values remain unchanged. +- The backend has one default constructor and the frontend has one default-state + factory plus one wire-to-store mapper. +- Boot/HTTP, WebSocket, and settings-save responses use the common mapper. +- Shared E2E setup remains a deliberate test baseline, and behavior specs opt + into the preferences they exercise. +- Backend/frontend contract tests prove missing-value defaults; desktop/mobile + settings tests continue to prove explicit-value persistence. + +## Files likely touched + +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/user/store/sqlite_test.go` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/ssr/user-settings.test.ts` +- `apps/web/lib/state/slices/settings/settings-slice.ts` +- `apps/web/lib/ws/handlers/users.ts` +- `apps/web/hooks/use-user-display-settings.ts` +- `apps/web/components/settings/editors-settings-state.tsx` +- `apps/web/e2e/tests/task/unread-divider-preference.spec.ts` +- `apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts` +- `apps/web/e2e/tests/chat/unread-divider.spec.ts` +- `apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts` + +## TDD sequence + +1. Add a live-update regression proving omitted fields preserve the current + normalized value and run it red. +2. Consolidate backend defaults and frontend mapping while keeping focused + default and explicit-value tests green. +3. Remove collateral fixture/spec changes and keep feature preconditions + explicit in behavior tests. + +## Verification + +- `cd apps/backend && go test ./internal/user/store -run 'TestScanUserSettingsUnreadDividerDefault'` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/ssr/user-settings.test.ts` +- `cd apps && pnpm --filter @kandev/web test -- --run lib/ws/handlers/users.test.ts` +- `cd apps && pnpm --filter @kandev/web test -- --run hooks/use-user-display-settings.test.ts` +- `cd apps/web && pnpm e2e:run tests/task/unread-divider-preference.spec.ts -- --project=chromium --project=mobile-chrome` +- `cd apps/web && pnpm e2e:run tests/chat/unread-divider.spec.ts tests/chat/mobile-unread-divider.spec.ts -- --project=chromium --project=mobile-chrome` + +## Results + +- Backend focused defaults: 7 tests passed. +- Frontend mapper, live-update, carry-forward, and settings components: 48 tests passed. +- Frontend typecheck and targeted ESLint: passed. +- Managed production-build Playwright run: 7 desktop/mobile tests passed. +- Public docs validation: 58 validator tests passed; 41 published pages validated. + +## Parallelism + +Sequential. The backend and frontend defaults share one persisted contract and +the E2E tests depend on both layers. diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 4eb78e9a20..a243e9ea7c 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -307,7 +307,7 @@ UI changes are persisted in the database and require a restart. An explicitly se The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. ## Credentials and product settings -The **Unread Messages** preference in **Settings > General > Task Actions** controls the Slack-style **New** divider in session transcripts. It defaults on for each user, persists with user settings, and takes effect immediately. Disabling it also stops that user's active transcript view from advancing the session read cursor. +The **Unread Messages** preference in **Settings > General > Task Actions** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor. Most integrations, executor profiles, agent profiles, MCP servers, repository settings, and UI preferences are persistent database records edited under **Settings**. They are not fields in `config.yaml`. Secret values use an encrypted secret store backed by `/data/master.key`; filesystem permissions, database backups, and key backup are part of the security boundary. diff --git a/docs/public/operations.md b/docs/public/operations.md index 086038a57e..307e86b870 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -285,7 +285,7 @@ Kandev warns when its live WebSocket connection has not recovered for three seco - **Office mode** — experimental, medium risk, and off in the production profile by default. - **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. Urgent WebSocket connectivity warnings still remain visible while the feature is off. - **Claude background prompt handoff** — experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing. -- **Unread divider** — a per-user setting at **Settings > General > Task Actions**. It defaults on, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible. +- **Unread divider** — a per-user setting at **Settings > General > Task Actions**. It defaults off, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible. - **Debug mode** — high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content. Each feature toggle requires restart. A value supplied explicitly by its environment variable locks the UI control; the debug toggle is also locked by explicit legacy/debug-message environment variables. Otherwise the UI stores an override in the database. The page can request restart only when the native local supervisor is available. A normal Unix `kandev` terminal launch is supervised; Desktop, a service, a container, a directly started backend, a deploy preview, or Windows requires a manual application restart. diff --git a/docs/specs/office/unread-divider.md b/docs/specs/office/unread-divider.md index c4d9d51d32..c2cc237817 100644 --- a/docs/specs/office/unread-divider.md +++ b/docs/specs/office/unread-divider.md @@ -20,11 +20,13 @@ aren't otherwise obvious from the UI alone. ## What - Each user can enable or disable the divider from **Settings > General > Task - Actions > Unread Messages**. It defaults **on** and takes effect immediately: + Actions > Unread Messages**. It defaults **off** and takes effect immediately: when disabled, the frontend does not compute or render a divider, the message list performs its normal scroll behavior, and `useSessionReadTracking` does not dispatch `POST /task-sessions/:id/mark-read`; existing `last_read_message_id` values remain stored for a future re-enable. + Missing portable-setting values are resolved at the backend boundary and + mapped consistently across frontend delivery paths per [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md). - Each `task_sessions` row carries one `last_read_message_id` column — the ID of the newest message the session has "seen." This is **session-global, not per-user or per-device**: kandev tasks do not currently model multiple @@ -122,6 +124,12 @@ aren't otherwise obvious from the UI alone. - **GIVEN** a brand-new session with no messages yet, **WHEN** the chat panel renders, **THEN** no mark-read request is issued for the synthetic task-description placeholder. +- **GIVEN** a user with no stored `unread_divider` preference, **WHEN** the + user opens a transcript, **THEN** the New divider is disabled and the + session read cursor is not advanced by the divider tracking behavior. +- **GIVEN** a user with an explicit stored `unread_divider: true` preference, + **WHEN** the user opens a transcript, **THEN** the New divider behavior + remains enabled. ## Out of scope diff --git a/docs/specs/ui/transcript-navigation-settings.md b/docs/specs/ui/transcript-navigation-settings.md index 7a29b5aefd..9a53cae09d 100644 --- a/docs/specs/ui/transcript-navigation-settings.md +++ b/docs/specs/ui/transcript-navigation-settings.md @@ -20,12 +20,14 @@ action is visible. - **Show anchored prompt bar** off. - **Show scroll to last prompt** on. - **Show scroll to start** off. - - **Show transcript auto-scroll control** on. + - **Show transcript auto-scroll control** off. - Hiding the transcript auto-scroll control only removes the per-session header button. Transcript auto-scroll remains enabled by default for every session, and an existing per-session choice is not rewritten when the control is hidden or shown. - Each preference is a local settings draft until the shared **Save changes** action succeeds. - Saved values apply after reload and across devices through backend-owned user settings. +- Missing portable-setting values are resolved at the backend boundary and mapped consistently + across frontend delivery paths per [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md). - When the floating Save action is visible, the settings content scrolls far enough for the final transcript-navigation control to sit fully above the action on desktop and phone viewports. @@ -38,7 +40,7 @@ The existing per-user settings JSON object stores four independent booleans: | `show_anchored_prompt_bar` | `false` | Show the desktop-only anchored copy of the latest prompt. | | `show_scroll_to_last_prompt` | `true` | Show the control that jumps to the latest prompt when eligible. | | `show_scroll_to_start` | `false` | Show the control that jumps to the beginning of the transcript when eligible. | -| `show_transcript_auto_scroll_control` | `true` | Show the per-session auto-scroll toggle in transcript chrome. | +| `show_transcript_auto_scroll_control` | `false` | Show the per-session auto-scroll toggle in transcript chrome. | An omitted field uses its documented default. An explicit saved `true` or `false` is preserved. The per-session auto-scroll choice remains transient browser-session state and is not replaced by @@ -60,7 +62,7 @@ fields. PATCH omission leaves a saved value unchanged. - **GIVEN** a user has no saved transcript-navigation preferences, **WHEN** settings and a transcript load, **THEN** the anchored prompt bar and scroll-to-start control are disabled, the - scroll-to-last-prompt and auto-scroll controls are enabled, and transcript auto-scroll is on. + scroll-to-last-prompt is enabled, the auto-scroll control is hidden, and transcript auto-scroll is on. - **GIVEN** a user changes any transcript-navigation switch, **WHEN** Save has not been pressed, **THEN** no user-settings request is sent and the changed control is marked dirty. - **GIVEN** a user saves transcript-navigation changes, **WHEN** settings and a transcript reload,