refactor(settings): centralize portable user-setting defaults (#2107)
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<NonNullable<UpdateUserSettingsResponse>["settings"]>,
|
||||
) {
|
||||
return {
|
||||
...mapTasksListSettingsFields(s),
|
||||
...mapEditorBehaviorFields(s),
|
||||
...mapEditorLspFields(s),
|
||||
...mapEditorSidebarFields(s),
|
||||
...mapEditorSyncedLocalFields(s),
|
||||
loaded: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTasksListSettingsFields(
|
||||
s: NonNullable<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["settings"]>) {
|
||||
return {
|
||||
lspAutoStartLanguages: s.lsp_auto_start_languages ?? [],
|
||||
lspAutoInstallLanguages: s.lsp_auto_install_languages ?? [],
|
||||
lspServerConfigs: s.lsp_server_configs ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function mapEditorSidebarFields(
|
||||
s: NonNullable<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["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<UpdateUserSettingsResponse>["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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Parameters<typeof mapUserSettingsResponse>[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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+216
-112
@@ -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<UserSettingsResponse["settings"]>;
|
||||
export type UserSettingsData = Omit<Partial<UserSettings>, "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<TInput, TOutput>(
|
||||
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<string, { key: string; modifiers?: Record<string, boolean> }>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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<UserSettings>,
|
||||
"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<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AppState>): 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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 `<home>/data/master.key`; filesystem permissions, database backups, and key backup are part of the security boundary.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user