From 115c8511c2e5f1fa8c1ae5cfbf0f78120949097e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Wed, 12 Aug 2026 09:15:11 +0200 Subject: [PATCH] feat: add only-pin-when-not-empty option for the todo list panel (#2543) --- .../internal/backendapp/boot_state_routes.go | 41 ++- .../internal/user/controller/controller.go | 109 +++--- apps/backend/internal/user/dto/dto.go | 339 +++++++++--------- apps/backend/internal/user/models/models.go | 117 +++--- apps/backend/internal/user/service/service.go | 227 ++++++------ .../internal/user/service/service_test.go | 32 ++ apps/backend/internal/user/store/sqlite.go | 286 +++++++-------- .../internal/user/store/sqlite_test.go | 48 +++ .../todo-list-panel-settings.test.tsx | 210 +++++++++-- .../settings/todo-list-panel-settings.tsx | 115 ++++-- .../task/chat/messages/todo-message.test.tsx | 93 +++++ .../task/chat/messages/todo-message.tsx | 47 ++- .../dockview-todo-panel-sync.hook.test.tsx | 230 ++++++++++++ .../task/dockview-todo-panel-sync.test.ts | 270 +++++++++++--- .../task/dockview-todo-panel-sync.ts | 108 +++++- apps/web/e2e/helpers/api-client.ts | 1 + .../tests/settings/todo-list-panel.spec.ts | 101 ++++++ .../hooks/use-ensure-user-settings.test.ts | 1 + apps/web/hooks/use-processed-messages.test.ts | 72 ++++ apps/web/hooks/use-processed-messages.ts | 48 ++- apps/web/lib/ssr/user-settings.test.ts | 18 + apps/web/lib/ssr/user-settings.ts | 3 + apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/types/http-user-settings.ts | 2 + apps/web/lib/ws/handlers/users.test.ts | 12 + apps/web/src/locales/en/settings.json | 4 +- apps/web/src/locales/pseudo/settings.json | 4 +- apps/web/src/locales/pt-pt/settings.json | 4 +- apps/web/src/locales/zh-cn/settings.json | 4 +- docs/plans/agent-todo-list-panel/plan.md | 243 ++++++++++++- .../task-06-backend-sub-setting-field.md | 90 +++++ .../task-07-frontend-settings-plumbing.md | 132 +++++++ .../task-08-conditional-pin-sync.md | 261 ++++++++++++++ .../task-09-e2e-coverage.md | 115 ++++++ docs/specs/ui/agent-todo-list-panel.md | 92 +++-- 35 files changed, 2776 insertions(+), 704 deletions(-) create mode 100644 apps/web/components/task/chat/messages/todo-message.test.tsx create mode 100644 apps/web/components/task/dockview-todo-panel-sync.hook.test.tsx create mode 100644 docs/plans/agent-todo-list-panel/task-06-backend-sub-setting-field.md create mode 100644 docs/plans/agent-todo-list-panel/task-07-frontend-settings-plumbing.md create mode 100644 docs/plans/agent-todo-list-panel/task-08-conditional-pin-sync.md create mode 100644 docs/plans/agent-todo-list-panel/task-09-e2e-coverage.md diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index da9931cd95..664e509dd0 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -466,25 +466,28 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "showScrollToStart": settings.ShowScrollToStart, "showTranscriptAutoScrollControl": settings.ShowTranscriptAutoScrollControl, "showTodoListPanel": settings.ShowTodoListPanel, - "showReleaseNotification": settings.ShowReleaseNotification, - "releaseNotesLastSeenVersion": nullString(settings.ReleaseNotesLastSeenVersion), - "lspAutoStartLanguages": stringSlice(settings.LspAutoStartLanguages), - "lspAutoInstallLanguages": stringSlice(settings.LspAutoInstallLanguages), - "lspServerConfigs": mapStringMap(settings.LspServerConfigs), - "lspStatusLocation": usermodels.NormalizeLspStatusLocation(settings.LspStatusLocation), - "savedLayouts": settings.SavedLayouts, - "sidebarViews": mapSidebarViews(settings.SidebarViews), - "sidebarActiveViewId": nullString(settings.SidebarActiveViewID), - "sidebarDraft": mapSidebarDraft(settings.SidebarDraft), - "sidebarTaskPrefs": mapSidebarTaskPrefs(settings.SidebarTaskPrefs), - "taskCreateLastUsed": mapTaskCreateLastUsed(settings.TaskCreateLastUsed), - "defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID), - "keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts), - "terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior), - "terminalFontFamily": nullString(settings.TerminalFontFamily), - "terminalFontSize": nullInt(settings.TerminalFontSize), - "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), - "azureDevOpsBrowsePreferences": settings.AzureDevOpsBrowsePreferences, + + // Sub-option: only auto-pin when the agent's todo list is not empty. + "showTodoListPanelOnlyWhenNotEmpty": settings.ShowTodoListPanelOnlyWhenNotEmpty, + "showReleaseNotification": settings.ShowReleaseNotification, + "releaseNotesLastSeenVersion": nullString(settings.ReleaseNotesLastSeenVersion), + "lspAutoStartLanguages": stringSlice(settings.LspAutoStartLanguages), + "lspAutoInstallLanguages": stringSlice(settings.LspAutoInstallLanguages), + "lspServerConfigs": mapStringMap(settings.LspServerConfigs), + "lspStatusLocation": usermodels.NormalizeLspStatusLocation(settings.LspStatusLocation), + "savedLayouts": settings.SavedLayouts, + "sidebarViews": mapSidebarViews(settings.SidebarViews), + "sidebarActiveViewId": nullString(settings.SidebarActiveViewID), + "sidebarDraft": mapSidebarDraft(settings.SidebarDraft), + "sidebarTaskPrefs": mapSidebarTaskPrefs(settings.SidebarTaskPrefs), + "taskCreateLastUsed": mapTaskCreateLastUsed(settings.TaskCreateLastUsed), + "defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID), + "keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts), + "terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior), + "terminalFontFamily": nullString(settings.TerminalFontFamily), + "terminalFontSize": nullInt(settings.TerminalFontSize), + "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), + "azureDevOpsBrowsePreferences": settings.AzureDevOpsBrowsePreferences, "systemMetricsDisplay": map[string]any{ "showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar, "simplified": settings.SystemMetricsDisplay.Simplified, diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index c55ae9b0bd..bddaaa96ed 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -44,60 +44,61 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ - WorkspaceID: req.WorkspaceID, - KanbanViewMode: req.KanbanViewMode, - StartupPage: req.StartupPage, - WorkflowFilterID: req.WorkflowFilterID, - RepositoryIDs: req.RepositoryIDs, - TasksListSort: req.TasksListSort, - TasksListGroup: req.TasksListGroup, - TasksListShowDetails: req.TasksListShowDetails, - InitialSetupComplete: req.InitialSetupComplete, - PreferredShell: req.PreferredShell, - DefaultEditorID: req.DefaultEditorID, - EnablePreviewOnClick: req.EnablePreviewOnClick, - ChatSubmitKey: req.ChatSubmitKey, - ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll, - ConfirmTaskArchive: req.ConfirmTaskArchive, - UnreadDivider: req.UnreadDivider, - AgentGeneratedTaskTitles: req.AgentGeneratedTaskTitles, - MCPTaskAgentProfileDefault: req.MCPTaskAgentProfileDefault, - ShowAnchoredPromptBar: req.ShowAnchoredPromptBar, - ShowScrollToLastPrompt: req.ShowScrollToLastPrompt, - ShowScrollToStart: req.ShowScrollToStart, - ShowTranscriptAutoScrollControl: req.ShowTranscriptAutoScrollControl, - ShowTodoListPanel: req.ShowTodoListPanel, - ShowReleaseNotification: req.ShowReleaseNotification, - ReleaseNotesLastSeenVersion: req.ReleaseNotesLastSeenVersion, - LspAutoStartLanguages: req.LspAutoStartLanguages, - LspAutoInstallLanguages: req.LspAutoInstallLanguages, - LspServerConfigs: req.LspServerConfigs, - LspStatusLocation: req.LspStatusLocation, - SavedLayouts: req.SavedLayouts, - SidebarViews: req.SidebarViews, - SidebarActiveViewID: req.SidebarActiveViewID, - SidebarDraft: req.SidebarDraft.ServiceValue(), - SidebarTaskPrefs: req.SidebarTaskPrefs, - TaskCreateLastUsed: req.TaskCreateLastUsed, - JiraSavedViews: req.JiraSavedViews.ServiceValue(), - JiraTaskPresets: req.JiraTaskPresets.ServiceValue(), - GitHubSavedPresets: req.GitHubSavedPresets.ServiceValue(), - GitHubDefaultQueryPresets: req.GitHubDefaultQueryPresets.ServiceValue(), - GitLabSavedPresets: req.GitLabSavedPresets.ServiceValue(), - AzureDevOpsBrowsePreferences: req.AzureDevOpsBrowsePreferences.ServiceValue(), - DefaultUtilityAgentID: req.DefaultUtilityAgentID, - DefaultUtilityModel: req.DefaultUtilityModel, - DefaultUtilityAgentProfileID: req.DefaultUtilityAgentProfileID, - KeyboardShortcuts: req.KeyboardShortcuts, - TerminalLinkBehavior: req.TerminalLinkBehavior, - TerminalFontFamily: req.TerminalFontFamily, - TerminalFontSize: req.TerminalFontSize, - ChangesPanelLayout: req.ChangesPanelLayout, - SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), - AppStatusBarEnabled: req.AppStatusBarEnabled, - AppStatusBarOrder: req.AppStatusBarOrder, - VoiceMode: req.VoiceMode, - KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, + WorkspaceID: req.WorkspaceID, + KanbanViewMode: req.KanbanViewMode, + StartupPage: req.StartupPage, + WorkflowFilterID: req.WorkflowFilterID, + RepositoryIDs: req.RepositoryIDs, + TasksListSort: req.TasksListSort, + TasksListGroup: req.TasksListGroup, + TasksListShowDetails: req.TasksListShowDetails, + InitialSetupComplete: req.InitialSetupComplete, + PreferredShell: req.PreferredShell, + DefaultEditorID: req.DefaultEditorID, + EnablePreviewOnClick: req.EnablePreviewOnClick, + ChatSubmitKey: req.ChatSubmitKey, + ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll, + ConfirmTaskArchive: req.ConfirmTaskArchive, + UnreadDivider: req.UnreadDivider, + AgentGeneratedTaskTitles: req.AgentGeneratedTaskTitles, + MCPTaskAgentProfileDefault: req.MCPTaskAgentProfileDefault, + ShowAnchoredPromptBar: req.ShowAnchoredPromptBar, + ShowScrollToLastPrompt: req.ShowScrollToLastPrompt, + ShowScrollToStart: req.ShowScrollToStart, + ShowTranscriptAutoScrollControl: req.ShowTranscriptAutoScrollControl, + ShowTodoListPanel: req.ShowTodoListPanel, + ShowTodoListPanelOnlyWhenNotEmpty: req.ShowTodoListPanelOnlyWhenNotEmpty, + ShowReleaseNotification: req.ShowReleaseNotification, + ReleaseNotesLastSeenVersion: req.ReleaseNotesLastSeenVersion, + LspAutoStartLanguages: req.LspAutoStartLanguages, + LspAutoInstallLanguages: req.LspAutoInstallLanguages, + LspServerConfigs: req.LspServerConfigs, + LspStatusLocation: req.LspStatusLocation, + SavedLayouts: req.SavedLayouts, + SidebarViews: req.SidebarViews, + SidebarActiveViewID: req.SidebarActiveViewID, + SidebarDraft: req.SidebarDraft.ServiceValue(), + SidebarTaskPrefs: req.SidebarTaskPrefs, + TaskCreateLastUsed: req.TaskCreateLastUsed, + JiraSavedViews: req.JiraSavedViews.ServiceValue(), + JiraTaskPresets: req.JiraTaskPresets.ServiceValue(), + GitHubSavedPresets: req.GitHubSavedPresets.ServiceValue(), + GitHubDefaultQueryPresets: req.GitHubDefaultQueryPresets.ServiceValue(), + GitLabSavedPresets: req.GitLabSavedPresets.ServiceValue(), + AzureDevOpsBrowsePreferences: req.AzureDevOpsBrowsePreferences.ServiceValue(), + DefaultUtilityAgentID: req.DefaultUtilityAgentID, + DefaultUtilityModel: req.DefaultUtilityModel, + DefaultUtilityAgentProfileID: req.DefaultUtilityAgentProfileID, + KeyboardShortcuts: req.KeyboardShortcuts, + TerminalLinkBehavior: req.TerminalLinkBehavior, + TerminalFontFamily: req.TerminalFontFamily, + TerminalFontSize: req.TerminalFontSize, + ChangesPanelLayout: req.ChangesPanelLayout, + SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), + AppStatusBarEnabled: req.AppStatusBarEnabled, + AppStatusBarOrder: req.AppStatusBarOrder, + VoiceMode: req.VoiceMode, + KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 0dfd1937a0..50f765b2c5 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -15,63 +15,64 @@ type UserDTO struct { } type UserSettingsDTO struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - KanbanViewMode string `json:"kanban_view_mode"` - StartupPage string `json:"startup_page"` - WorkflowFilterID string `json:"workflow_filter_id"` - RepositoryIDs []string `json:"repository_ids"` - TasksListSort string `json:"tasks_list_sort"` - TasksListGroup string `json:"tasks_list_group"` - TasksListShowDetails bool `json:"tasks_list_show_details"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` - ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` - ConfirmTaskArchive bool `json:"confirm_task_archive"` - UnreadDivider bool `json:"unread_divider"` - AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` - MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` - ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` - ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` - ShowScrollToStart bool `json:"show_scroll_to_start"` - ShowTranscriptAutoScrollControl bool `json:"show_transcript_auto_scroll_control"` - ShowTodoListPanel bool `json:"show_todo_list_panel"` - ShowReleaseNotification bool `json:"show_release_notification"` - ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` - LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` - LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` - LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` - LspStatusLocation string `json:"lsp_status_location"` - SavedLayouts []models.SavedLayout `json:"saved_layouts"` - SidebarViews []models.SidebarView `json:"sidebar_views"` - SidebarActiveViewID string `json:"sidebar_active_view_id"` - SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` - SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` - TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` - JiraSavedViews json.RawMessage `json:"jira_saved_views,omitempty"` - JiraTaskPresets json.RawMessage `json:"jira_task_presets,omitempty"` - GitHubSavedPresets json.RawMessage `json:"github_saved_presets,omitempty"` - GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets,omitempty"` - GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets,omitempty"` - AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences,omitempty"` - DefaultUtilityAgentID string `json:"default_utility_agent_id"` - DefaultUtilityModel string `json:"default_utility_model"` - DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` - KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` - TerminalLinkBehavior string `json:"terminal_link_behavior"` - TerminalFontFamily string `json:"terminal_font_family"` - TerminalFontSize int `json:"terminal_font_size"` - ChangesPanelLayout string `json:"changes_panel_layout"` - SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` - AppStatusBarEnabled bool `json:"app_status_bar_enabled"` - AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` - VoiceMode models.VoiceModeSettings `json:"voice_mode"` - KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` - Revision int64 `json:"revision"` - UpdatedAt string `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` + WorkflowFilterID string `json:"workflow_filter_id"` + RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` + TasksListShowDetails bool `json:"tasks_list_show_details"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` + ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` + ConfirmTaskArchive bool `json:"confirm_task_archive"` + UnreadDivider bool `json:"unread_divider"` + AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` + MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` + ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` + ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` + ShowScrollToStart bool `json:"show_scroll_to_start"` + ShowTranscriptAutoScrollControl bool `json:"show_transcript_auto_scroll_control"` + ShowTodoListPanel bool `json:"show_todo_list_panel"` + ShowTodoListPanelOnlyWhenNotEmpty bool `json:"show_todo_list_panel_only_when_not_empty"` + ShowReleaseNotification bool `json:"show_release_notification"` + ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` + LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` + LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` + LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` + LspStatusLocation string `json:"lsp_status_location"` + SavedLayouts []models.SavedLayout `json:"saved_layouts"` + SidebarViews []models.SidebarView `json:"sidebar_views"` + SidebarActiveViewID string `json:"sidebar_active_view_id"` + SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views,omitempty"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets,omitempty"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets,omitempty"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets,omitempty"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets,omitempty"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences,omitempty"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` + DefaultUtilityModel string `json:"default_utility_model"` + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` + TerminalLinkBehavior string `json:"terminal_link_behavior"` + TerminalFontFamily string `json:"terminal_font_family"` + TerminalFontSize int `json:"terminal_font_size"` + ChangesPanelLayout string `json:"changes_panel_layout"` + SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` + VoiceMode models.VoiceModeSettings `json:"voice_mode"` + KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + Revision int64 `json:"revision"` + UpdatedAt string `json:"updated_at"` } type UserResponse struct { @@ -90,60 +91,61 @@ type ShellOption struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string `json:"workspace_id,omitempty"` - KanbanViewMode *string `json:"kanban_view_mode,omitempty"` - StartupPage *string `json:"startup_page,omitempty"` - WorkflowFilterID *string `json:"workflow_filter_id,omitempty"` - RepositoryIDs *[]string `json:"repository_ids,omitempty"` - TasksListSort *string `json:"tasks_list_sort,omitempty"` - TasksListGroup *string `json:"tasks_list_group,omitempty"` - TasksListShowDetails *bool `json:"tasks_list_show_details,omitempty"` - InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` - PreferredShell *string `json:"preferred_shell,omitempty"` - DefaultEditorID *string `json:"default_editor_id,omitempty"` - EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` - ChatSubmitKey *string `json:"chat_submit_key,omitempty"` - ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"` - ConfirmTaskArchive *bool `json:"confirm_task_archive,omitempty"` - UnreadDivider *bool `json:"unread_divider,omitempty"` - AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles,omitempty"` - MCPTaskAgentProfileDefault *string `json:"mcp_task_agent_profile_default,omitempty"` - ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar,omitempty"` - ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt,omitempty"` - ShowScrollToStart *bool `json:"show_scroll_to_start,omitempty"` - ShowTranscriptAutoScrollControl *bool `json:"show_transcript_auto_scroll_control,omitempty"` - ShowTodoListPanel *bool `json:"show_todo_list_panel,omitempty"` - ShowReleaseNotification *bool `json:"show_release_notification,omitempty"` - ReleaseNotesLastSeenVersion *string `json:"release_notes_last_seen_version,omitempty"` - LspAutoStartLanguages *[]string `json:"lsp_auto_start_languages,omitempty"` - LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"` - LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` - LspStatusLocation *string `json:"lsp_status_location,omitempty"` - SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` - SidebarViews *[]models.SidebarView `json:"sidebar_views,omitempty"` - SidebarActiveViewID *string `json:"sidebar_active_view_id,omitempty"` - SidebarDraft NullableSidebarDraft `json:"sidebar_draft,omitempty"` - SidebarTaskPrefs *models.SidebarTaskPrefs `json:"sidebar_task_prefs,omitempty"` - TaskCreateLastUsed *models.TaskCreateLastUsed `json:"task_create_last_used,omitempty"` - JiraSavedViews NullableRawMessage `json:"jira_saved_views,omitempty"` - JiraTaskPresets NullableRawMessage `json:"jira_task_presets,omitempty"` - GitHubSavedPresets NullableRawMessage `json:"github_saved_presets,omitempty"` - GitHubDefaultQueryPresets NullableRawMessage `json:"github_default_query_presets,omitempty"` - GitLabSavedPresets NullableRawMessage `json:"gitlab_saved_presets,omitempty"` - AzureDevOpsBrowsePreferences NullableRawMessage `json:"azure_devops_browse_preferences,omitempty"` - DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` - DefaultUtilityModel *string `json:"default_utility_model,omitempty"` - DefaultUtilityAgentProfileID *string `json:"default_utility_agent_profile_id,omitempty"` - KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` - TerminalLinkBehavior *string `json:"terminal_link_behavior,omitempty"` - TerminalFontFamily *string `json:"terminal_font_family,omitempty"` - TerminalFontSize *int `json:"terminal_font_size,omitempty"` - ChangesPanelLayout *string `json:"changes_panel_layout,omitempty"` - SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` - AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` - AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` - VoiceMode *models.VoiceModeSettings `json:"voice_mode,omitempty"` - KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + KanbanViewMode *string `json:"kanban_view_mode,omitempty"` + StartupPage *string `json:"startup_page,omitempty"` + WorkflowFilterID *string `json:"workflow_filter_id,omitempty"` + RepositoryIDs *[]string `json:"repository_ids,omitempty"` + TasksListSort *string `json:"tasks_list_sort,omitempty"` + TasksListGroup *string `json:"tasks_list_group,omitempty"` + TasksListShowDetails *bool `json:"tasks_list_show_details,omitempty"` + InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` + DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` + ChatSubmitKey *string `json:"chat_submit_key,omitempty"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"` + ConfirmTaskArchive *bool `json:"confirm_task_archive,omitempty"` + UnreadDivider *bool `json:"unread_divider,omitempty"` + AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles,omitempty"` + MCPTaskAgentProfileDefault *string `json:"mcp_task_agent_profile_default,omitempty"` + ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar,omitempty"` + ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt,omitempty"` + ShowScrollToStart *bool `json:"show_scroll_to_start,omitempty"` + ShowTranscriptAutoScrollControl *bool `json:"show_transcript_auto_scroll_control,omitempty"` + ShowTodoListPanel *bool `json:"show_todo_list_panel,omitempty"` + ShowTodoListPanelOnlyWhenNotEmpty *bool `json:"show_todo_list_panel_only_when_not_empty,omitempty"` + ShowReleaseNotification *bool `json:"show_release_notification,omitempty"` + ReleaseNotesLastSeenVersion *string `json:"release_notes_last_seen_version,omitempty"` + LspAutoStartLanguages *[]string `json:"lsp_auto_start_languages,omitempty"` + LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"` + LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` + LspStatusLocation *string `json:"lsp_status_location,omitempty"` + SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` + SidebarViews *[]models.SidebarView `json:"sidebar_views,omitempty"` + SidebarActiveViewID *string `json:"sidebar_active_view_id,omitempty"` + SidebarDraft NullableSidebarDraft `json:"sidebar_draft,omitempty"` + SidebarTaskPrefs *models.SidebarTaskPrefs `json:"sidebar_task_prefs,omitempty"` + TaskCreateLastUsed *models.TaskCreateLastUsed `json:"task_create_last_used,omitempty"` + JiraSavedViews NullableRawMessage `json:"jira_saved_views,omitempty"` + JiraTaskPresets NullableRawMessage `json:"jira_task_presets,omitempty"` + GitHubSavedPresets NullableRawMessage `json:"github_saved_presets,omitempty"` + GitHubDefaultQueryPresets NullableRawMessage `json:"github_default_query_presets,omitempty"` + GitLabSavedPresets NullableRawMessage `json:"gitlab_saved_presets,omitempty"` + AzureDevOpsBrowsePreferences NullableRawMessage `json:"azure_devops_browse_preferences,omitempty"` + DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` + DefaultUtilityModel *string `json:"default_utility_model,omitempty"` + DefaultUtilityAgentProfileID *string `json:"default_utility_agent_profile_id,omitempty"` + KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` + TerminalLinkBehavior *string `json:"terminal_link_behavior,omitempty"` + TerminalFontFamily *string `json:"terminal_font_family,omitempty"` + TerminalFontSize *int `json:"terminal_font_size,omitempty"` + ChangesPanelLayout *string `json:"changes_panel_layout,omitempty"` + SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` + AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` + AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` + VoiceMode *models.VoiceModeSettings `json:"voice_mode,omitempty"` + KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` } type SystemMetricsDisplaySettingsPatch struct { @@ -223,62 +225,63 @@ func FromUser(user *models.User) UserDTO { func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { return UserSettingsDTO{ - UserID: settings.UserID, - WorkspaceID: settings.WorkspaceID, - KanbanViewMode: settings.KanbanViewMode, - StartupPage: models.NormalizeStartupPage(settings.StartupPage), - WorkflowFilterID: settings.WorkflowFilterID, - RepositoryIDs: settings.RepositoryIDs, - TasksListSort: settings.TasksListSort, - TasksListGroup: settings.TasksListGroup, - TasksListShowDetails: settings.TasksListShowDetails, - InitialSetupComplete: settings.InitialSetupComplete, - PreferredShell: settings.PreferredShell, - DefaultEditorID: settings.DefaultEditorID, - EnablePreviewOnClick: settings.EnablePreviewOnClick, - ChatSubmitKey: settings.ChatSubmitKey, - ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll, - ConfirmTaskArchive: settings.ConfirmTaskArchive, - UnreadDivider: settings.UnreadDivider, - AgentGeneratedTaskTitles: settings.AgentGeneratedTaskTitles, - MCPTaskAgentProfileDefault: models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), - ShowAnchoredPromptBar: settings.ShowAnchoredPromptBar, - ShowScrollToLastPrompt: settings.ShowScrollToLastPrompt, - ShowScrollToStart: settings.ShowScrollToStart, - ShowTranscriptAutoScrollControl: settings.ShowTranscriptAutoScrollControl, - ShowTodoListPanel: settings.ShowTodoListPanel, - ShowReleaseNotification: settings.ShowReleaseNotification, - ReleaseNotesLastSeenVersion: settings.ReleaseNotesLastSeenVersion, - LspAutoStartLanguages: settings.LspAutoStartLanguages, - LspAutoInstallLanguages: settings.LspAutoInstallLanguages, - LspServerConfigs: settings.LspServerConfigs, - LspStatusLocation: models.NormalizeLspStatusLocation(settings.LspStatusLocation), - SavedLayouts: settings.SavedLayouts, - SidebarViews: settings.SidebarViews, - SidebarActiveViewID: settings.SidebarActiveViewID, - SidebarDraft: settings.SidebarDraft, - SidebarTaskPrefs: settings.SidebarTaskPrefs, - TaskCreateLastUsed: settings.TaskCreateLastUsed, - JiraSavedViews: settings.JiraSavedViews, - JiraTaskPresets: settings.JiraTaskPresets, - GitHubSavedPresets: settings.GitHubSavedPresets, - GitHubDefaultQueryPresets: settings.GitHubDefaultQueryPresets, - GitLabSavedPresets: settings.GitLabSavedPresets, - AzureDevOpsBrowsePreferences: settings.AzureDevOpsBrowsePreferences, - DefaultUtilityAgentID: settings.DefaultUtilityAgentID, - DefaultUtilityModel: settings.DefaultUtilityModel, - DefaultUtilityAgentProfileID: settings.DefaultUtilityAgentProfileID, - KeyboardShortcuts: settings.KeyboardShortcuts, - TerminalLinkBehavior: settings.TerminalLinkBehavior, - TerminalFontFamily: settings.TerminalFontFamily, - TerminalFontSize: settings.TerminalFontSize, - ChangesPanelLayout: settings.ChangesPanelLayout, - SystemMetricsDisplay: settings.SystemMetricsDisplay, - AppStatusBarEnabled: settings.AppStatusBarEnabled, - AppStatusBarOrder: settings.AppStatusBarOrder, - VoiceMode: settings.VoiceMode, - KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, - Revision: settings.Revision, - UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), + UserID: settings.UserID, + WorkspaceID: settings.WorkspaceID, + KanbanViewMode: settings.KanbanViewMode, + StartupPage: models.NormalizeStartupPage(settings.StartupPage), + WorkflowFilterID: settings.WorkflowFilterID, + RepositoryIDs: settings.RepositoryIDs, + TasksListSort: settings.TasksListSort, + TasksListGroup: settings.TasksListGroup, + TasksListShowDetails: settings.TasksListShowDetails, + InitialSetupComplete: settings.InitialSetupComplete, + PreferredShell: settings.PreferredShell, + DefaultEditorID: settings.DefaultEditorID, + EnablePreviewOnClick: settings.EnablePreviewOnClick, + ChatSubmitKey: settings.ChatSubmitKey, + ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll, + ConfirmTaskArchive: settings.ConfirmTaskArchive, + UnreadDivider: settings.UnreadDivider, + AgentGeneratedTaskTitles: settings.AgentGeneratedTaskTitles, + MCPTaskAgentProfileDefault: models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), + ShowAnchoredPromptBar: settings.ShowAnchoredPromptBar, + ShowScrollToLastPrompt: settings.ShowScrollToLastPrompt, + ShowScrollToStart: settings.ShowScrollToStart, + ShowTranscriptAutoScrollControl: settings.ShowTranscriptAutoScrollControl, + ShowTodoListPanel: settings.ShowTodoListPanel, + ShowTodoListPanelOnlyWhenNotEmpty: settings.ShowTodoListPanelOnlyWhenNotEmpty, + ShowReleaseNotification: settings.ShowReleaseNotification, + ReleaseNotesLastSeenVersion: settings.ReleaseNotesLastSeenVersion, + LspAutoStartLanguages: settings.LspAutoStartLanguages, + LspAutoInstallLanguages: settings.LspAutoInstallLanguages, + LspServerConfigs: settings.LspServerConfigs, + LspStatusLocation: models.NormalizeLspStatusLocation(settings.LspStatusLocation), + SavedLayouts: settings.SavedLayouts, + SidebarViews: settings.SidebarViews, + SidebarActiveViewID: settings.SidebarActiveViewID, + SidebarDraft: settings.SidebarDraft, + SidebarTaskPrefs: settings.SidebarTaskPrefs, + TaskCreateLastUsed: settings.TaskCreateLastUsed, + JiraSavedViews: settings.JiraSavedViews, + JiraTaskPresets: settings.JiraTaskPresets, + GitHubSavedPresets: settings.GitHubSavedPresets, + GitHubDefaultQueryPresets: settings.GitHubDefaultQueryPresets, + GitLabSavedPresets: settings.GitLabSavedPresets, + AzureDevOpsBrowsePreferences: settings.AzureDevOpsBrowsePreferences, + DefaultUtilityAgentID: settings.DefaultUtilityAgentID, + DefaultUtilityModel: settings.DefaultUtilityModel, + DefaultUtilityAgentProfileID: settings.DefaultUtilityAgentProfileID, + KeyboardShortcuts: settings.KeyboardShortcuts, + TerminalLinkBehavior: settings.TerminalLinkBehavior, + TerminalFontFamily: settings.TerminalFontFamily, + TerminalFontSize: settings.TerminalFontSize, + ChangesPanelLayout: settings.ChangesPanelLayout, + SystemMetricsDisplay: settings.SystemMetricsDisplay, + AppStatusBarEnabled: settings.AppStatusBarEnabled, + AppStatusBarOrder: settings.AppStatusBarOrder, + VoiceMode: settings.VoiceMode, + KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, + Revision: settings.Revision, + UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index be048e9875..8a2523bc0f 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -64,64 +64,65 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - KanbanViewMode string `json:"kanban_view_mode"` - StartupPage string `json:"startup_page"` - WorkflowFilterID string `json:"workflow_filter_id"` - RepositoryIDs []string `json:"repository_ids"` - TasksListSort string `json:"tasks_list_sort"` - TasksListGroup string `json:"tasks_list_group"` - TasksListShowDetails bool `json:"tasks_list_show_details"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter" - ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` - ConfirmTaskArchive bool `json:"confirm_task_archive"` - UnreadDivider bool `json:"unread_divider"` - AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` - MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` - ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` // desktop-only sticky last-prompt bar - ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` - ShowScrollToStart bool `json:"show_scroll_to_start"` - ShowTranscriptAutoScrollControl bool `json:"show_transcript_auto_scroll_control"` - ShowTodoListPanel bool `json:"show_todo_list_panel"` - ShowReleaseNotification bool `json:"show_release_notification"` - ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` - LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` - LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` - LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` - LspStatusLocation string `json:"lsp_status_location"` - SavedLayouts []SavedLayout `json:"saved_layouts"` - SidebarViews []SidebarView `json:"sidebar_views"` - SidebarActiveViewID string `json:"sidebar_active_view_id"` - SidebarDraft *SidebarViewDraft `json:"sidebar_draft"` - SidebarTaskPrefs SidebarTaskPrefs `json:"sidebar_task_prefs"` - TaskCreateLastUsed TaskCreateLastUsed `json:"task_create_last_used"` - JiraSavedViews json.RawMessage `json:"jira_saved_views"` - JiraTaskPresets json.RawMessage `json:"jira_task_presets"` - GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` - GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` - GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` - AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` - DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents - DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents - DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` - KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides - TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel" - TerminalFontFamily string `json:"terminal_font_family"` - TerminalFontSize int `json:"terminal_font_size"` - ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree" - SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` - AppStatusBarEnabled bool `json:"app_status_bar_enabled"` - AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` - VoiceMode VoiceModeSettings `json:"voice_mode"` - KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` + WorkflowFilterID string `json:"workflow_filter_id"` + RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` + TasksListShowDetails bool `json:"tasks_list_show_details"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter" + ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"` + ConfirmTaskArchive bool `json:"confirm_task_archive"` + UnreadDivider bool `json:"unread_divider"` + AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` + MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` + ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` // desktop-only sticky last-prompt bar + ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` + ShowScrollToStart bool `json:"show_scroll_to_start"` + ShowTranscriptAutoScrollControl bool `json:"show_transcript_auto_scroll_control"` + ShowTodoListPanel bool `json:"show_todo_list_panel"` + ShowTodoListPanelOnlyWhenNotEmpty bool `json:"show_todo_list_panel_only_when_not_empty"` + ShowReleaseNotification bool `json:"show_release_notification"` + ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` + LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` + LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` + LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` + LspStatusLocation string `json:"lsp_status_location"` + SavedLayouts []SavedLayout `json:"saved_layouts"` + SidebarViews []SidebarView `json:"sidebar_views"` + SidebarActiveViewID string `json:"sidebar_active_view_id"` + SidebarDraft *SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents + DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides + TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel" + TerminalFontFamily string `json:"terminal_font_family"` + TerminalFontSize int `json:"terminal_font_size"` + ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree" + SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled bool `json:"app_status_bar_enabled"` + AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` + VoiceMode VoiceModeSettings `json:"voice_mode"` + KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type SystemMetricsDisplaySettings struct { diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index e30f678fe1..cfb5736940 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -36,60 +36,61 @@ type Service struct { } type UpdateUserSettingsRequest struct { - WorkspaceID *string - KanbanViewMode *string - StartupPage *string - WorkflowFilterID *string - RepositoryIDs *[]string - TasksListSort *string - TasksListGroup *string - TasksListShowDetails *bool - InitialSetupComplete *bool - PreferredShell *string - DefaultEditorID *string - EnablePreviewOnClick *bool - ChatSubmitKey *string - ReviewAutoMarkOnScroll *bool - ConfirmTaskArchive *bool - UnreadDivider *bool - AgentGeneratedTaskTitles *bool - MCPTaskAgentProfileDefault *string - ShowAnchoredPromptBar *bool - ShowScrollToLastPrompt *bool - ShowScrollToStart *bool - ShowTranscriptAutoScrollControl *bool - ShowTodoListPanel *bool - ShowReleaseNotification *bool - ReleaseNotesLastSeenVersion *string - LspAutoStartLanguages *[]string - LspAutoInstallLanguages *[]string - LspServerConfigs *map[string]map[string]interface{} - LspStatusLocation *string - SavedLayouts *[]models.SavedLayout - SidebarViews *[]models.SidebarView - SidebarActiveViewID *string - SidebarDraft **models.SidebarViewDraft - SidebarTaskPrefs *models.SidebarTaskPrefs - TaskCreateLastUsed *models.TaskCreateLastUsed - JiraSavedViews **json.RawMessage - JiraTaskPresets **json.RawMessage - GitHubSavedPresets **json.RawMessage - GitHubDefaultQueryPresets **json.RawMessage - GitLabSavedPresets **json.RawMessage - AzureDevOpsBrowsePreferences **json.RawMessage - DefaultUtilityAgentID *string - DefaultUtilityModel *string - DefaultUtilityAgentProfileID *string - KeyboardShortcuts *map[string]interface{} - TerminalLinkBehavior *string - TerminalFontFamily *string - TerminalFontSize *int - ChangesPanelLayout *string - SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch - AppStatusBarEnabled *bool - AppStatusBarOrder *models.AppStatusBarOrder - VoiceMode *models.VoiceModeSettings - KanbanHiddenStepIDs *map[string][]string + WorkspaceID *string + KanbanViewMode *string + StartupPage *string + WorkflowFilterID *string + RepositoryIDs *[]string + TasksListSort *string + TasksListGroup *string + TasksListShowDetails *bool + InitialSetupComplete *bool + PreferredShell *string + DefaultEditorID *string + EnablePreviewOnClick *bool + ChatSubmitKey *string + ReviewAutoMarkOnScroll *bool + ConfirmTaskArchive *bool + UnreadDivider *bool + AgentGeneratedTaskTitles *bool + MCPTaskAgentProfileDefault *string + ShowAnchoredPromptBar *bool + ShowScrollToLastPrompt *bool + ShowScrollToStart *bool + ShowTranscriptAutoScrollControl *bool + ShowTodoListPanel *bool + ShowTodoListPanelOnlyWhenNotEmpty *bool + ShowReleaseNotification *bool + ReleaseNotesLastSeenVersion *string + LspAutoStartLanguages *[]string + LspAutoInstallLanguages *[]string + LspServerConfigs *map[string]map[string]interface{} + LspStatusLocation *string + SavedLayouts *[]models.SavedLayout + SidebarViews *[]models.SidebarView + SidebarActiveViewID *string + SidebarDraft **models.SidebarViewDraft + SidebarTaskPrefs *models.SidebarTaskPrefs + TaskCreateLastUsed *models.TaskCreateLastUsed + JiraSavedViews **json.RawMessage + JiraTaskPresets **json.RawMessage + GitHubSavedPresets **json.RawMessage + GitHubDefaultQueryPresets **json.RawMessage + GitLabSavedPresets **json.RawMessage + AzureDevOpsBrowsePreferences **json.RawMessage + DefaultUtilityAgentID *string + DefaultUtilityModel *string + DefaultUtilityAgentProfileID *string + KeyboardShortcuts *map[string]interface{} + TerminalLinkBehavior *string + TerminalFontFamily *string + TerminalFontSize *int + ChangesPanelLayout *string + SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch + AppStatusBarEnabled *bool + AppStatusBarOrder *models.AppStatusBarOrder + VoiceMode *models.VoiceModeSettings + KanbanHiddenStepIDs *map[string][]string } type SystemMetricsDisplaySettingsPatch struct { @@ -383,6 +384,9 @@ func applyTaskActionPreferences(settings *models.UserSettings, req *UpdateUserSe if req.ShowTodoListPanel != nil { settings.ShowTodoListPanel = *req.ShowTodoListPanel } + if req.ShowTodoListPanelOnlyWhenNotEmpty != nil { + settings.ShowTodoListPanelOnlyWhenNotEmpty = *req.ShowTodoListPanelOnlyWhenNotEmpty + } if req.ShowReleaseNotification != nil { settings.ShowReleaseNotification = *req.ShowReleaseNotification } @@ -768,63 +772,64 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models return } data := map[string]interface{}{ - "user_id": settings.UserID, - "workspace_id": settings.WorkspaceID, - "kanban_view_mode": settings.KanbanViewMode, - "startup_page": models.NormalizeStartupPage(settings.StartupPage), - "workflow_filter_id": settings.WorkflowFilterID, - "repository_ids": settings.RepositoryIDs, - "tasks_list_sort": settings.TasksListSort, - "tasks_list_group": settings.TasksListGroup, - "tasks_list_show_details": settings.TasksListShowDetails, - "initial_setup_complete": settings.InitialSetupComplete, - "preferred_shell": settings.PreferredShell, - "default_editor_id": settings.DefaultEditorID, - "enable_preview_on_click": settings.EnablePreviewOnClick, - "chat_submit_key": settings.ChatSubmitKey, - "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, - "confirm_task_archive": settings.ConfirmTaskArchive, - "unread_divider": settings.UnreadDivider, - "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, - "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), - "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, - "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, - "show_scroll_to_start": settings.ShowScrollToStart, - "show_transcript_auto_scroll_control": settings.ShowTranscriptAutoScrollControl, - "show_todo_list_panel": settings.ShowTodoListPanel, - "show_release_notification": settings.ShowReleaseNotification, - "release_notes_last_seen_version": settings.ReleaseNotesLastSeenVersion, - "lsp_auto_start_languages": settings.LspAutoStartLanguages, - "lsp_auto_install_languages": settings.LspAutoInstallLanguages, - "lsp_server_configs": settings.LspServerConfigs, - "lsp_status_location": models.NormalizeLspStatusLocation(settings.LspStatusLocation), - "saved_layouts": settings.SavedLayouts, - "sidebar_views": settings.SidebarViews, - "sidebar_active_view_id": settings.SidebarActiveViewID, - "sidebar_draft": settings.SidebarDraft, - "sidebar_task_prefs": settings.SidebarTaskPrefs, - "task_create_last_used": settings.TaskCreateLastUsed, - "jira_saved_views": settings.JiraSavedViews, - "jira_task_presets": settings.JiraTaskPresets, - "github_saved_presets": settings.GitHubSavedPresets, - "github_default_query_presets": settings.GitHubDefaultQueryPresets, - "gitlab_saved_presets": settings.GitLabSavedPresets, - "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, - "default_utility_agent_id": settings.DefaultUtilityAgentID, - "default_utility_model": settings.DefaultUtilityModel, - "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, - "keyboard_shortcuts": settings.KeyboardShortcuts, - "terminal_link_behavior": settings.TerminalLinkBehavior, - "terminal_font_family": settings.TerminalFontFamily, - "terminal_font_size": settings.TerminalFontSize, - "changes_panel_layout": settings.ChangesPanelLayout, - "system_metrics_display": settings.SystemMetricsDisplay, - "app_status_bar_enabled": settings.AppStatusBarEnabled, - "app_status_bar_order": settings.AppStatusBarOrder, - "voice_mode": settings.VoiceMode, - "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, - "revision": settings.Revision, - "updated_at": settings.UpdatedAt.Format(time.RFC3339), + "user_id": settings.UserID, + "workspace_id": settings.WorkspaceID, + "kanban_view_mode": settings.KanbanViewMode, + "startup_page": models.NormalizeStartupPage(settings.StartupPage), + "workflow_filter_id": settings.WorkflowFilterID, + "repository_ids": settings.RepositoryIDs, + "tasks_list_sort": settings.TasksListSort, + "tasks_list_group": settings.TasksListGroup, + "tasks_list_show_details": settings.TasksListShowDetails, + "initial_setup_complete": settings.InitialSetupComplete, + "preferred_shell": settings.PreferredShell, + "default_editor_id": settings.DefaultEditorID, + "enable_preview_on_click": settings.EnablePreviewOnClick, + "chat_submit_key": settings.ChatSubmitKey, + "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, + "confirm_task_archive": settings.ConfirmTaskArchive, + "unread_divider": settings.UnreadDivider, + "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, + "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), + "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, + "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, + "show_scroll_to_start": settings.ShowScrollToStart, + "show_transcript_auto_scroll_control": settings.ShowTranscriptAutoScrollControl, + "show_todo_list_panel": settings.ShowTodoListPanel, + "show_todo_list_panel_only_when_not_empty": settings.ShowTodoListPanelOnlyWhenNotEmpty, + "show_release_notification": settings.ShowReleaseNotification, + "release_notes_last_seen_version": settings.ReleaseNotesLastSeenVersion, + "lsp_auto_start_languages": settings.LspAutoStartLanguages, + "lsp_auto_install_languages": settings.LspAutoInstallLanguages, + "lsp_server_configs": settings.LspServerConfigs, + "lsp_status_location": models.NormalizeLspStatusLocation(settings.LspStatusLocation), + "saved_layouts": settings.SavedLayouts, + "sidebar_views": settings.SidebarViews, + "sidebar_active_view_id": settings.SidebarActiveViewID, + "sidebar_draft": settings.SidebarDraft, + "sidebar_task_prefs": settings.SidebarTaskPrefs, + "task_create_last_used": settings.TaskCreateLastUsed, + "jira_saved_views": settings.JiraSavedViews, + "jira_task_presets": settings.JiraTaskPresets, + "github_saved_presets": settings.GitHubSavedPresets, + "github_default_query_presets": settings.GitHubDefaultQueryPresets, + "gitlab_saved_presets": settings.GitLabSavedPresets, + "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, + "default_utility_agent_id": settings.DefaultUtilityAgentID, + "default_utility_model": settings.DefaultUtilityModel, + "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, + "keyboard_shortcuts": settings.KeyboardShortcuts, + "terminal_link_behavior": settings.TerminalLinkBehavior, + "terminal_font_family": settings.TerminalFontFamily, + "terminal_font_size": settings.TerminalFontSize, + "changes_panel_layout": settings.ChangesPanelLayout, + "system_metrics_display": settings.SystemMetricsDisplay, + "app_status_bar_enabled": settings.AppStatusBarEnabled, + "app_status_bar_order": settings.AppStatusBarOrder, + "voice_mode": settings.VoiceMode, + "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, + "revision": settings.Revision, + "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { s.logger.Error("failed to publish user settings event", zap.Error(err)) diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 86b2c0cbd8..cc2fca0031 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -414,6 +414,38 @@ func TestApplyBasicSettingsTodoListPanel(t *testing.T) { }) } +func TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty(t *testing.T) { + t.Run("omission preserves saved value", func(t *testing.T) { + settings := &models.UserSettings{ShowTodoListPanelOnlyWhenNotEmpty: true} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply settings: %v", err) + } + if !settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = false, want true (unchanged)") + } + }) + + t.Run("explicit value replaces saved value", func(t *testing.T) { + settings := &models.UserSettings{ShowTodoListPanelOnlyWhenNotEmpty: false} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{ShowTodoListPanelOnlyWhenNotEmpty: ptr(true)}); err != nil { + t.Fatalf("apply settings: %v", err) + } + if !settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = false, want true") + } + }) + + t.Run("explicit false disables it", func(t *testing.T) { + settings := &models.UserSettings{ShowTodoListPanelOnlyWhenNotEmpty: true} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{ShowTodoListPanelOnlyWhenNotEmpty: ptr(false)}); err != nil { + t.Fatalf("apply settings: %v", err) + } + if settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = true, want false") + } + }) +} + func TestApplyBasicSettingsTranscriptNavigation(t *testing.T) { settings := &models.UserSettings{ ShowScrollToLastPrompt: true, diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index f4f624a059..27affb4926 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -512,60 +512,61 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { keyboardShortcuts = map[string]interface{}{} } return json.Marshal(map[string]interface{}{ - "workspace_id": settings.WorkspaceID, - "kanban_view_mode": settings.KanbanViewMode, - "startup_page": models.NormalizeStartupPage(settings.StartupPage), - "workflow_filter_id": settings.WorkflowFilterID, - "repository_ids": settings.RepositoryIDs, - "tasks_list_sort": models.NormalizeTasksListSort(settings.TasksListSort), - "tasks_list_group": models.NormalizeTasksListGroup(settings.TasksListGroup), - "tasks_list_show_details": settings.TasksListShowDetails, - "initial_setup_complete": settings.InitialSetupComplete, - "preferred_shell": settings.PreferredShell, - "default_editor_id": settings.DefaultEditorID, - "enable_preview_on_click": settings.EnablePreviewOnClick, - "chat_submit_key": settings.ChatSubmitKey, - "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, - "confirm_task_archive": settings.ConfirmTaskArchive, - "unread_divider": settings.UnreadDivider, - "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, - "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), - "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, - "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, - "show_scroll_to_start": settings.ShowScrollToStart, - "show_transcript_auto_scroll_control": settings.ShowTranscriptAutoScrollControl, - "show_todo_list_panel": settings.ShowTodoListPanel, - "show_release_notification": settings.ShowReleaseNotification, - "release_notes_last_seen_version": settings.ReleaseNotesLastSeenVersion, - "lsp_auto_start_languages": lspAutoStart, - "lsp_auto_install_languages": lspAutoInstall, - "lsp_server_configs": lspServerConfigs, - "lsp_status_location": models.NormalizeLspStatusLocation(settings.LspStatusLocation), - "saved_layouts": savedLayouts, - "sidebar_views": sidebarViews, - "sidebar_active_view_id": settings.SidebarActiveViewID, - "sidebar_draft": settings.SidebarDraft, - "sidebar_task_prefs": sidebarTaskPrefs, - "task_create_last_used": settings.TaskCreateLastUsed, - "jira_saved_views": settings.JiraSavedViews, - "jira_task_presets": settings.JiraTaskPresets, - "github_saved_presets": settings.GitHubSavedPresets, - "github_default_query_presets": settings.GitHubDefaultQueryPresets, - "gitlab_saved_presets": settings.GitLabSavedPresets, - "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, - "default_utility_agent_id": settings.DefaultUtilityAgentID, - "default_utility_model": settings.DefaultUtilityModel, - "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, - "keyboard_shortcuts": keyboardShortcuts, - "terminal_link_behavior": settings.TerminalLinkBehavior, - "terminal_font_family": settings.TerminalFontFamily, - "terminal_font_size": settings.TerminalFontSize, - "changes_panel_layout": settings.ChangesPanelLayout, - "system_metrics_display": settings.SystemMetricsDisplay, - "app_status_bar_enabled": settings.AppStatusBarEnabled, - "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), - "voice_mode": settings.VoiceMode, - "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, + "workspace_id": settings.WorkspaceID, + "kanban_view_mode": settings.KanbanViewMode, + "startup_page": models.NormalizeStartupPage(settings.StartupPage), + "workflow_filter_id": settings.WorkflowFilterID, + "repository_ids": settings.RepositoryIDs, + "tasks_list_sort": models.NormalizeTasksListSort(settings.TasksListSort), + "tasks_list_group": models.NormalizeTasksListGroup(settings.TasksListGroup), + "tasks_list_show_details": settings.TasksListShowDetails, + "initial_setup_complete": settings.InitialSetupComplete, + "preferred_shell": settings.PreferredShell, + "default_editor_id": settings.DefaultEditorID, + "enable_preview_on_click": settings.EnablePreviewOnClick, + "chat_submit_key": settings.ChatSubmitKey, + "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll, + "confirm_task_archive": settings.ConfirmTaskArchive, + "unread_divider": settings.UnreadDivider, + "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, + "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), + "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, + "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, + "show_scroll_to_start": settings.ShowScrollToStart, + "show_transcript_auto_scroll_control": settings.ShowTranscriptAutoScrollControl, + "show_todo_list_panel": settings.ShowTodoListPanel, + "show_todo_list_panel_only_when_not_empty": settings.ShowTodoListPanelOnlyWhenNotEmpty, + "show_release_notification": settings.ShowReleaseNotification, + "release_notes_last_seen_version": settings.ReleaseNotesLastSeenVersion, + "lsp_auto_start_languages": lspAutoStart, + "lsp_auto_install_languages": lspAutoInstall, + "lsp_server_configs": lspServerConfigs, + "lsp_status_location": models.NormalizeLspStatusLocation(settings.LspStatusLocation), + "saved_layouts": savedLayouts, + "sidebar_views": sidebarViews, + "sidebar_active_view_id": settings.SidebarActiveViewID, + "sidebar_draft": settings.SidebarDraft, + "sidebar_task_prefs": sidebarTaskPrefs, + "task_create_last_used": settings.TaskCreateLastUsed, + "jira_saved_views": settings.JiraSavedViews, + "jira_task_presets": settings.JiraTaskPresets, + "github_saved_presets": settings.GitHubSavedPresets, + "github_default_query_presets": settings.GitHubDefaultQueryPresets, + "gitlab_saved_presets": settings.GitLabSavedPresets, + "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, + "default_utility_agent_id": settings.DefaultUtilityAgentID, + "default_utility_model": settings.DefaultUtilityModel, + "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, + "keyboard_shortcuts": keyboardShortcuts, + "terminal_link_behavior": settings.TerminalLinkBehavior, + "terminal_font_family": settings.TerminalFontFamily, + "terminal_font_size": settings.TerminalFontSize, + "changes_panel_layout": settings.ChangesPanelLayout, + "system_metrics_display": settings.SystemMetricsDisplay, + "app_status_bar_enabled": settings.AppStatusBarEnabled, + "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), + "voice_mode": settings.VoiceMode, + "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, }) } @@ -639,38 +640,39 @@ func mergeVoiceModeDefaults(stored *storedVoiceMode) models.VoiceModeSettings { func defaultUserSettings(userID string) *models.UserSettings { return &models.UserSettings{ - UserID: userID, - StartupPage: models.StartupPageTaskOverview, - RepositoryIDs: []string{}, - TasksListSort: models.TasksListSortDefault, - TasksListGroup: models.TasksListGroupDefault, - ReviewAutoMarkOnScroll: true, - ConfirmTaskArchive: true, - UnreadDivider: false, - AgentGeneratedTaskTitles: true, - MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultCurrentTask, - ShowAnchoredPromptBar: false, - ShowScrollToLastPrompt: true, - ShowScrollToStart: false, - ShowTranscriptAutoScrollControl: false, - ShowTodoListPanel: false, - ShowReleaseNotification: true, - LspAutoStartLanguages: []string{}, - LspAutoInstallLanguages: []string{}, - LspServerConfigs: map[string]map[string]interface{}{}, - LspStatusLocation: models.LspStatusLocationToolbar, - SavedLayouts: []models.SavedLayout{}, - ChatSubmitKey: "cmd_enter", - KeyboardShortcuts: map[string]interface{}{}, - TerminalLinkBehavior: "new_tab", - ChangesPanelLayout: defaultChangesPanelLayout, - SidebarViews: DefaultSidebarViews(), - SidebarActiveViewID: DefaultSidebarViewID, - SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), - AppStatusBarEnabled: false, - AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), - VoiceMode: defaultVoiceModeSettings(), - KanbanHiddenStepIDs: map[string][]string{}, + UserID: userID, + StartupPage: models.StartupPageTaskOverview, + RepositoryIDs: []string{}, + TasksListSort: models.TasksListSortDefault, + TasksListGroup: models.TasksListGroupDefault, + ReviewAutoMarkOnScroll: true, + ConfirmTaskArchive: true, + UnreadDivider: false, + AgentGeneratedTaskTitles: true, + MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultCurrentTask, + ShowAnchoredPromptBar: false, + ShowScrollToLastPrompt: true, + ShowScrollToStart: false, + ShowTranscriptAutoScrollControl: false, + ShowTodoListPanel: false, + ShowTodoListPanelOnlyWhenNotEmpty: false, + ShowReleaseNotification: true, + LspAutoStartLanguages: []string{}, + LspAutoInstallLanguages: []string{}, + LspServerConfigs: map[string]map[string]interface{}{}, + LspStatusLocation: models.LspStatusLocationToolbar, + SavedLayouts: []models.SavedLayout{}, + ChatSubmitKey: "cmd_enter", + KeyboardShortcuts: map[string]interface{}{}, + TerminalLinkBehavior: "new_tab", + ChangesPanelLayout: defaultChangesPanelLayout, + SidebarViews: DefaultSidebarViews(), + SidebarActiveViewID: DefaultSidebarViewID, + SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), + AppStatusBarEnabled: false, + AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), + VoiceMode: defaultVoiceModeSettings(), + KanbanHiddenStepIDs: map[string][]string{}, } } @@ -695,60 +697,61 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin return settings, nil } var payload struct { - WorkspaceID string `json:"workspace_id"` - KanbanViewMode string `json:"kanban_view_mode"` - StartupPage string `json:"startup_page"` - WorkflowFilterID string `json:"workflow_filter_id"` - RepositoryIDs []string `json:"repository_ids"` - TasksListSort string `json:"tasks_list_sort"` - TasksListGroup string `json:"tasks_list_group"` - TasksListShowDetails bool `json:"tasks_list_show_details"` - InitialSetupComplete bool `json:"initial_setup_complete"` - PreferredShell string `json:"preferred_shell"` - DefaultEditorID string `json:"default_editor_id"` - EnablePreviewOnClick bool `json:"enable_preview_on_click"` - ChatSubmitKey string `json:"chat_submit_key"` - ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"` - ConfirmTaskArchive *bool `json:"confirm_task_archive"` - UnreadDivider *bool `json:"unread_divider"` - AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles"` - MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` - ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar"` - ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt"` - ShowScrollToStart *bool `json:"show_scroll_to_start"` - ShowTranscriptAutoScrollControl *bool `json:"show_transcript_auto_scroll_control"` - ShowTodoListPanel *bool `json:"show_todo_list_panel"` - ShowReleaseNotification *bool `json:"show_release_notification"` - ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` - LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` - LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` - LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` - LspStatusLocation string `json:"lsp_status_location"` - SavedLayouts []models.SavedLayout `json:"saved_layouts"` - SidebarViews json.RawMessage `json:"sidebar_views"` - SidebarActiveViewID json.RawMessage `json:"sidebar_active_view_id"` - SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` - SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` - TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` - JiraSavedViews json.RawMessage `json:"jira_saved_views"` - JiraTaskPresets json.RawMessage `json:"jira_task_presets"` - GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` - GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` - GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` - AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` - DefaultUtilityAgentID string `json:"default_utility_agent_id"` - DefaultUtilityModel string `json:"default_utility_model"` - DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` - KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` - TerminalLinkBehavior string `json:"terminal_link_behavior"` - TerminalFontFamily string `json:"terminal_font_family"` - TerminalFontSize int `json:"terminal_font_size"` - ChangesPanelLayout string `json:"changes_panel_layout"` - SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` - AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` - AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` - VoiceMode *storedVoiceMode `json:"voice_mode"` - KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` + WorkspaceID string `json:"workspace_id"` + KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` + WorkflowFilterID string `json:"workflow_filter_id"` + RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` + TasksListShowDetails bool `json:"tasks_list_show_details"` + InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` + ChatSubmitKey string `json:"chat_submit_key"` + ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"` + ConfirmTaskArchive *bool `json:"confirm_task_archive"` + UnreadDivider *bool `json:"unread_divider"` + AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles"` + MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` + ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar"` + ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt"` + ShowScrollToStart *bool `json:"show_scroll_to_start"` + ShowTranscriptAutoScrollControl *bool `json:"show_transcript_auto_scroll_control"` + ShowTodoListPanel *bool `json:"show_todo_list_panel"` + ShowTodoListPanelOnlyWhenNotEmpty *bool `json:"show_todo_list_panel_only_when_not_empty"` + ShowReleaseNotification *bool `json:"show_release_notification"` + ReleaseNotesLastSeenVersion string `json:"release_notes_last_seen_version"` + LspAutoStartLanguages []string `json:"lsp_auto_start_languages"` + LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` + LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` + LspStatusLocation string `json:"lsp_status_location"` + SavedLayouts []models.SavedLayout `json:"saved_layouts"` + SidebarViews json.RawMessage `json:"sidebar_views"` + SidebarActiveViewID json.RawMessage `json:"sidebar_active_view_id"` + SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` + AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` + DefaultUtilityModel string `json:"default_utility_model"` + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` + TerminalLinkBehavior string `json:"terminal_link_behavior"` + TerminalFontFamily string `json:"terminal_font_family"` + TerminalFontSize int `json:"terminal_font_size"` + ChangesPanelLayout string `json:"changes_panel_layout"` + SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` + AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` + VoiceMode *storedVoiceMode `json:"voice_mode"` + KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -799,6 +802,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if payload.ShowTodoListPanel != nil { settings.ShowTodoListPanel = *payload.ShowTodoListPanel } + if payload.ShowTodoListPanelOnlyWhenNotEmpty != nil { + settings.ShowTodoListPanelOnlyWhenNotEmpty = *payload.ShowTodoListPanelOnlyWhenNotEmpty + } if payload.ShowReleaseNotification != nil { settings.ShowReleaseNotification = *payload.ShowReleaseNotification } diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 81b8138cde..25c2a6ec46 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -545,6 +545,38 @@ func TestScanUserSettingsTodoListPanelDefault(t *testing.T) { } } +func TestScanUserSettingsTodoListPanelOnlyWhenNotEmptyDefault(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID) + if err != nil { + t.Fatalf("scan defaults: %v", err) + } + if settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = true, want false (default)") + } + + settings, err = scanUserSettings( + settingsScanner{raw: `{"show_todo_list_panel_only_when_not_empty":true}`}, + DefaultUserID, + ) + if err != nil { + t.Fatalf("scan stored preference: %v", err) + } + if !settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = false, want true (stored)") + } + + settings, err = scanUserSettings( + settingsScanner{raw: `{"show_todo_list_panel_only_when_not_empty":false}`}, + DefaultUserID, + ) + if err != nil { + t.Fatalf("scan explicit false: %v", err) + } + if settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = true, want false (explicit)") + } +} + func TestTodoListPanelSettingRoundTripThroughMarshalAndScan(t *testing.T) { raw, err := marshalUserSettingsPayload(&models.UserSettings{ShowTodoListPanel: true}) if err != nil { @@ -559,6 +591,22 @@ func TestTodoListPanelSettingRoundTripThroughMarshalAndScan(t *testing.T) { } } +func TestTodoListPanelOnlyWhenNotEmptyRoundTripThroughMarshalAndScan(t *testing.T) { + raw, err := marshalUserSettingsPayload(&models.UserSettings{ + ShowTodoListPanelOnlyWhenNotEmpty: true, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + settings, err := scanUserSettings(settingsScanner{raw: string(raw)}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + if !settings.ShowTodoListPanelOnlyWhenNotEmpty { + t.Fatal("ShowTodoListPanelOnlyWhenNotEmpty = false, want true (round-tripped)") + } +} + func TestMarshalUserSettingsPersistsDisabledArchiveConfirmation(t *testing.T) { raw, err := marshalUserSettingsPayload(&models.UserSettings{ConfirmTaskArchive: false}) if err != nil { diff --git a/apps/web/components/settings/todo-list-panel-settings.test.tsx b/apps/web/components/settings/todo-list-panel-settings.test.tsx index 6f6f6f0241..83993f1e18 100644 --- a/apps/web/components/settings/todo-list-panel-settings.test.tsx +++ b/apps/web/components/settings/todo-list-panel-settings.test.tsx @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { StateProvider } from "@/components/state-provider"; +import type { StoreApi } from "zustand"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; import { defaultState } from "@/lib/state/default-state"; +import type { AppState } from "@/lib/state/store"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; import { SettingsSaveProvider } from "./settings-save-provider"; const updateUserSettings = vi.fn(); const TODO_LIST_PANEL_LABEL = "Show agent todo list panel"; +const ONLY_PIN_WHEN_NOT_EMPTY_LABEL = "Only pin when todo list is not empty"; +const DATA_STATE_ATTR = "data-state"; +const DATA_SETTINGS_DIRTY_ATTR = "data-settings-dirty"; +const SAVE_CHANGES_BUTTON = "Save changes"; vi.mock("@/lib/api", () => ({ updateUserSettings: (...args: unknown[]) => updateUserSettings(...args), @@ -19,35 +26,198 @@ beforeEach(() => { afterEach(cleanup); +/** Asserts the last save submitted both fields with the given values. */ +async function expectSavedAs(show: boolean, onlyWhenNotEmpty: boolean): Promise { + await waitFor(() => + expect(updateUserSettings).toHaveBeenCalledWith({ + show_todo_list_panel: show, + show_todo_list_panel_only_when_not_empty: onlyWhenNotEmpty, + }), + ); +} + +/** Makes the next save hang until the returned resolver is called. */ +function deferNextSave(): () => void { + let resolveSave: (value: { settings: object }) => void = () => undefined; + updateUserSettings.mockImplementationOnce( + () => new Promise<{ settings: object }>((resolve) => (resolveSave = resolve)), + ); + return () => resolveSave({ settings: {} }); +} + +function renderTodoListPanelSettings( + userSettingsOverrides: Partial = {}, +): StoreApi { + let storeApi: StoreApi | null = null; + function StoreProbe() { + storeApi = useAppStoreApi(); + return null; + } + render( + + + + + + , + ); + return storeApi!; +} + describe("TodoListPanelSettings", () => { it("keeps an enabled preference local until Save changes persists it", async () => { - render( - - - - - , - ); + renderTodoListPanelSettings({ showTodoListPanel: false }); const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); - expect(toggle.getAttribute("data-state")).toBe("unchecked"); + expect(toggle.getAttribute(DATA_STATE_ATTR)).toBe("unchecked"); fireEvent.click(toggle); expect(updateUserSettings).not.toHaveBeenCalled(); - expect(toggle.getAttribute("data-state")).toBe("checked"); + expect(toggle.getAttribute(DATA_STATE_ATTR)).toBe("checked"); expect( - screen.getByTestId("todo-list-panel-settings-card").getAttribute("data-settings-dirty"), + screen.getByTestId("todo-list-panel-settings-card").getAttribute(DATA_SETTINGS_DIRTY_ATTR), ).toBe("true"); - fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_BUTTON })); - await waitFor(() => - expect(updateUserSettings).toHaveBeenCalledWith({ show_todo_list_panel: true }), - ); - await waitFor(() => expect(toggle.getAttribute("data-settings-dirty")).toBe("false")); + await expectSavedAs(true, false); + await waitFor(() => expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false")); + }); + + it("inhibits the sub-option while the main toggle is off and saves both fields", async () => { + renderTodoListPanelSettings({ + showTodoListPanel: false, + showTodoListPanelOnlyWhenNotEmpty: true, + }); + + // Inhibited (hidden entirely), not disabled, while the main preference is off. + expect(screen.queryByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL })).toBeNull(); + + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + fireEvent.click(toggle); + + // Appears once the main preference is on, in its preserved (on) state, + // with visible help explaining that only automatic pinning is affected. + const subToggle = screen.getByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL }); + expect(subToggle.getAttribute(DATA_STATE_ATTR)).toBe("checked"); + expect(screen.getByText(/Manually added Todos tabs are never affected/)).toBeTruthy(); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_BUTTON })); + + await expectSavedAs(true, true); + await waitFor(() => expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false")); + }); + + it("preserves the sub-option state across a main-toggle off/on cycle without saving", () => { + renderTodoListPanelSettings({ + showTodoListPanel: true, + showTodoListPanelOnlyWhenNotEmpty: true, + }); + + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + expect(screen.getByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL })).toBeTruthy(); + + fireEvent.click(toggle); + expect(screen.queryByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL })).toBeNull(); + + fireEvent.click(toggle); + const subToggle = screen.getByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL }); + expect(subToggle.getAttribute(DATA_STATE_ATTR)).toBe("checked"); + expect(updateUserSettings).not.toHaveBeenCalled(); + }); + + it("marks each toggle dirty only for its own unsaved change", () => { + renderTodoListPanelSettings({ + showTodoListPanel: true, + showTodoListPanelOnlyWhenNotEmpty: false, + }); + + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + const subToggle = screen.getByRole("switch", { name: ONLY_PIN_WHEN_NOT_EMPTY_LABEL }); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false"); + expect(subToggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false"); + + fireEvent.click(subToggle); // only the sub-option becomes dirty + expect(subToggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("true"); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false"); + + fireEvent.click(subToggle); // reverting clears it + expect(subToggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false"); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("false"); + + // Changing only the main toggle marks only the main toggle dirty (the + // sub-option unmounts with the main toggle off). + fireEvent.click(toggle); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("true"); + expect(updateUserSettings).not.toHaveBeenCalled(); + }); +}); + +describe("TodoListPanelSettings save lifecycle", () => { + it("does not clobber a newer store value with a stale delayed save", async () => { + const resolveSave = deferNextSave(); + const storeApi = renderTodoListPanelSettings({ showTodoListPanel: false }); + + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + fireEvent.click(toggle); // draft: main on + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_BUTTON })); + + // While the PATCH is in flight, a newer value arrives (e.g. via the + // user.settings.updated WebSocket push from another tab). + const { setUserSettings } = storeApi.getState(); + setUserSettings({ + ...storeApi.getState().userSettings, + showTodoListPanel: false, + showTodoListPanelOnlyWhenNotEmpty: true, + }); + + resolveSave(); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalledTimes(1)); + + expect(storeApi.getState().userSettings.showTodoListPanel).toBe(false); + expect(storeApi.getState().userSettings.showTodoListPanelOnlyWhenNotEmpty).toBe(true); + // The draft still differs from the newer WS baseline: not falsely clean. + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("true"); + }); + + it("keeps the draft dirty and the store unchanged when saving fails", async () => { + updateUserSettings.mockRejectedValueOnce(new Error("save failed")); + const storeApi = renderTodoListPanelSettings({ showTodoListPanel: false }); + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + fireEvent.click(toggle); // draft: main on + expect(toggle.getAttribute(DATA_STATE_ATTR)).toBe("checked"); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_BUTTON })); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByText("Couldn't save")).toBeTruthy()); + + // The failed PATCH must not touch the store, the draft, or the dirty flag. + expect(storeApi.getState().userSettings.showTodoListPanel).toBe(false); + expect(storeApi.getState().userSettings.showTodoListPanelOnlyWhenNotEmpty).toBe(false); + expect(toggle.getAttribute(DATA_STATE_ATTR)).toBe("checked"); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("true"); + }); + + it("keeps a draft edited during an in-flight save dirty against the completed save", async () => { + const resolveSave = deferNextSave(); + const storeApi = renderTodoListPanelSettings({ showTodoListPanel: false }); + const toggle = screen.getByRole("switch", { name: TODO_LIST_PANEL_LABEL }); + fireEvent.click(toggle); // draft: main on + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_BUTTON })); + + fireEvent.click(toggle); // draft: main back off while the PATCH is pending + + resolveSave(); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalledTimes(1)); + + // The completed save owns the store and the baseline; the newer draft + // stays unsaved and dirty. + expect(storeApi.getState().userSettings.showTodoListPanel).toBe(true); + expect(toggle.getAttribute(DATA_STATE_ATTR)).toBe("unchecked"); + expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTR)).toBe("true"); }); }); diff --git a/apps/web/components/settings/todo-list-panel-settings.tsx b/apps/web/components/settings/todo-list-panel-settings.tsx index d35bd3c555..114a35c835 100644 --- a/apps/web/components/settings/todo-list-panel-settings.tsx +++ b/apps/web/components/settings/todo-list-panel-settings.tsx @@ -10,39 +10,77 @@ import { SettingsCard } from "./settings-card"; import { useSettingsSaveContributor } from "./settings-save-provider"; import { useTranslation } from "react-i18next"; +type TodoListPanelDraft = { + show: boolean; + onlyWhenNotEmpty: boolean; +}; + /** - * Edits the per-user preference that controls whether the agent's live todo - * checklist is available as a Todos tab in the desktop task workbench's - * right panel. It participates in the shared Settings save/discard lifecycle - * and commits the persisted preference to the app store on save. + * Edits the per-user preferences that control whether the agent's live todo + * checklist is auto-pinned as a Todos tab in the desktop task workbench's + * right panel. The main preference is the master gate; the "only when not + * empty" sub-option is inhibited (hidden, never disabled) while the master + * gate is off, so its saved value survives and reappears once the master gate + * is re-enabled. Manual adds from the workbench's "+" menu are never gated by + * either preference. */ export function TodoListPanelSettings() { const { t } = useTranslation(); const showTodoListPanel = useAppStore((state) => state.userSettings.showTodoListPanel); + const onlyPinWhenNotEmpty = useAppStore( + (state) => state.userSettings.showTodoListPanelOnlyWhenNotEmpty, + ); const setUserSettings = useAppStore((state) => state.setUserSettings); const storeApi = useAppStoreApi(); - const [saved, setSaved] = useState(showTodoListPanel); - const [draft, setDraft] = useState(showTodoListPanel); + const [saved, setSaved] = useState({ + show: showTodoListPanel, + onlyWhenNotEmpty: onlyPinWhenNotEmpty, + }); + const [draft, setDraft] = useState(saved); const draftRef = useRef(draft); draftRef.current = draft; - const isDirty = draft !== saved; + const isDirty = draft.show !== saved.show || draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty; useEffect(() => { setSaved((previous) => { - if (draftRef.current === previous) setDraft(showTodoListPanel); - return showTodoListPanel; + if ( + draftRef.current.show === previous.show && + draftRef.current.onlyWhenNotEmpty === previous.onlyWhenNotEmpty + ) { + setDraft({ show: showTodoListPanel, onlyWhenNotEmpty: onlyPinWhenNotEmpty }); + } + return { show: showTodoListPanel, onlyWhenNotEmpty: onlyPinWhenNotEmpty }; }); - }, [showTodoListPanel]); + }, [showTodoListPanel, onlyPinWhenNotEmpty]); useSettingsSaveContributor({ id: "general-todo-list-panel", - revision: Number(draft), + revision: JSON.stringify(draft), isDirty, save: async (revision) => { - const submitted = Boolean(revision); - await updateUserSettings({ show_todo_list_panel: submitted }); + const submitted = JSON.parse(String(revision)) as TodoListPanelDraft; + const before = storeApi.getState().userSettings; + await updateUserSettings({ + show_todo_list_panel: submitted.show, + show_todo_list_panel_only_when_not_empty: submitted.onlyWhenNotEmpty, + }); + // Only adopt our submission as the saved baseline when nothing newer + // landed mid-flight (e.g. a WS settings push from another tab): the + // hydration effect has already moved `saved` to the newer values, so + // overwriting them here would leave the UI falsely clean against a + // stale submission. On drift, keep the newer baseline so the draft + // stays dirty and the user can reconcile. + const current = storeApi.getState().userSettings; + const drifted = + current.showTodoListPanel !== before.showTodoListPanel || + current.showTodoListPanelOnlyWhenNotEmpty !== before.showTodoListPanelOnlyWhenNotEmpty; + if (drifted) return; setSaved(submitted); - setUserSettings({ ...storeApi.getState().userSettings, showTodoListPanel: submitted }); + setUserSettings({ + ...current, + showTodoListPanel: submitted.show, + showTodoListPanelOnlyWhenNotEmpty: submitted.onlyWhenNotEmpty, + }); }, discard: () => setDraft(saved), }); @@ -53,20 +91,43 @@ export function TodoListPanelSettings() { {t("settings:todoListPanel")} -
-
- -

- {t("settings:pinTheAgentsLiveTodoChecklistAs")} -

+
+
+
+ +

+ {t("settings:pinTheAgentsLiveTodoChecklistAs")} +

+
+ setDraft((current) => ({ ...current, show }))} + className="shrink-0 cursor-pointer" + />
- + {draft.show && ( +
+
+ +

+ {t("settings:onlyPinWhenTodoListIsNotEmptyDescription")} +

+
+ + setDraft((current) => ({ ...current, onlyWhenNotEmpty })) + } + className="shrink-0 cursor-pointer" + /> +
+ )}
diff --git a/apps/web/components/task/chat/messages/todo-message.test.tsx b/apps/web/components/task/chat/messages/todo-message.test.tsx new file mode 100644 index 0000000000..f44ca2d21e --- /dev/null +++ b/apps/web/components/task/chat/messages/todo-message.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { Message } from "@/lib/types/http"; +import { TodoMessage } from "./todo-message"; + +afterEach(cleanup); + +function makeTodoMessage(metadata: unknown): Message { + return { + id: "m1", + session_id: "s1" as Message["session_id"], + task_id: "t1" as Message["task_id"], + author_type: "agent", + content: "", + type: "todo", + metadata, + created_at: "", + } as unknown as Message; +} + +describe("TodoMessage malformed metadata", () => { + it("renders nothing instead of throwing when todos is a non-array object", () => { + expect(() => + render(), + ).not.toThrow(); + expect(screen.queryByText(/Updated Todos/)).toBeNull(); + }); + + it("renders nothing instead of throwing when todos is a primitive", () => { + expect(() => render()).not.toThrow(); + }); + + it("renders nothing instead of throwing when metadata is a primitive", () => { + expect(() => render()).not.toThrow(); + }); + + it("drops null and primitive entries, keeping valid ones", () => { + render( + , + ); + expect(screen.getByText(/Updated Todos/)).toBeTruthy(); + expect(screen.getByText("Valid")).toBeTruthy(); + // The empty-text entry must not count toward the total. + expect(screen.getByText(/\(0\/1\)/)).toBeTruthy(); + expect(screen.queryByText(/\(0\/2\)/)).toBeNull(); + }); + + it("renders nothing instead of throwing when previous_todo_snapshots is not an array", () => { + expect(() => + render( + , + ), + ).not.toThrow(); + expect(screen.getByText("A")).toBeTruthy(); + }); + + it("drops null and primitive snapshot entries instead of crashing on expand", () => { + render( + , + ); + // The crash surface is the snapshot-history expansion. + fireEvent.click(screen.getByRole("button", { name: /earlier update/i })); + expect(screen.getByText(/- Old/)).toBeTruthy(); + }); + + it("renders nothing instead of throwing when a snapshot's todos are malformed", () => { + expect(() => + render( + , + ), + ).not.toThrow(); + fireEvent.click(screen.getByRole("button", { name: /earlier update/i })); + expect(screen.getAllByText("A").length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/web/components/task/chat/messages/todo-message.tsx b/apps/web/components/task/chat/messages/todo-message.tsx index 4d2a9f2032..9fa95b5d04 100644 --- a/apps/web/components/task/chat/messages/todo-message.tsx +++ b/apps/web/components/task/chat/messages/todo-message.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { IconChevronDown, IconChevronRight, IconListCheck } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import type { Message } from "@/lib/types/http"; -import type { RichMetadata, TodoMetadata, TodoSnapshot } from "@/components/task/chat/types"; +import type { TodoSnapshot } from "@/components/task/chat/types"; import { ExpandableRow } from "./expandable-row"; import { StatusIcon, resolveStatus } from "../todo-indicator"; import { useTranslation } from "react-i18next"; @@ -15,26 +15,57 @@ type TodoItem = { status?: "pending" | "in_progress" | "completed" | "failed"; }; -function normalizeTodos(raw: TodoMetadata[]): TodoItem[] { +/** Normalizes persisted todo entries, dropping malformed or empty-text + * entries. Total by construction: never throws. */ +function normalizeTodos(raw: unknown): TodoItem[] { + // Total by construction: malformed persisted metadata (non-array `todos`, + // null/primitive entries, empty text) must yield an empty list, never + // throw, matching buildTodoItems' hardening in use-processed-messages.ts. + if (!Array.isArray(raw)) return []; return raw .map((item) => (typeof item === "string" ? { text: item, done: false } : item)) - .filter((item) => item.text); + .filter( + (item): item is TodoItem => + typeof item === "object" && + item !== null && + typeof item.text === "string" && + item.text.length > 0, + ); } +/** Extracts the message's current todo items via normalizeTodos. */ function parseTodos(comment: Message): TodoItem[] { - const metadata = comment.metadata as RichMetadata | undefined; - return normalizeTodos(metadata?.todos ?? []); + const metadata = comment.metadata; + const todos = + metadata !== null && typeof metadata === "object" && "todos" in metadata + ? metadata.todos + : undefined; + return normalizeTodos(todos ?? []); } +/** Extracts the message's earlier-updates snapshot history, dropping + * malformed entries. */ function parseSnapshots(comment: Message): TodoSnapshot[] { - const metadata = comment.metadata as RichMetadata | undefined; - return metadata?.previous_todo_snapshots ?? []; + const metadata = comment.metadata; + const snapshots = + metadata !== null && typeof metadata === "object" && "previous_todo_snapshots" in metadata + ? metadata.previous_todo_snapshots + : undefined; + if (!Array.isArray(snapshots)) return []; + // Total by construction: drop null/primitive snapshot entries (SnapshotHistory + // would dereference their `todos`); malformed `snapshot.todos` is already + // neutralized by the unknown-safe normalizeTodos. + return snapshots.filter( + (snapshot): snapshot is TodoSnapshot => typeof snapshot === "object" && snapshot !== null, + ); } +/** Number of todo items in the completed state. */ function countCompleted(items: TodoItem[]): number { return items.filter((t) => resolveStatus(t) === "completed").length; } +/** Collapsible history of earlier todo snapshots for one message. */ function SnapshotHistory({ snapshots }: { snapshots: TodoSnapshot[] }) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -79,6 +110,8 @@ function SnapshotHistory({ snapshots }: { snapshots: TodoSnapshot[] }) { ); } +/** Renders a `todo`-type chat message: its current checklist plus an + * expandable earlier-updates history. */ export function TodoMessage({ comment, defaultExpanded = false, diff --git a/apps/web/components/task/dockview-todo-panel-sync.hook.test.tsx b/apps/web/components/task/dockview-todo-panel-sync.hook.test.tsx new file mode 100644 index 0000000000..1ea2aaf69c --- /dev/null +++ b/apps/web/components/task/dockview-todo-panel-sync.hook.test.tsx @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import type { StoreApi } from "zustand"; +import type { DockviewApi } from "dockview-react"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { defaultState } from "@/lib/state/default-state"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import type { AppState } from "@/lib/state/store"; +import type { SessionId, TaskId } from "@/lib/types/http"; +import { useSyncTodoPanel } from "./dockview-todo-panel-sync"; + +const TASK_ID = "task-1" as TaskId; +const SESSION_ID = "session-1" as SessionId; +const WORKSPACE_ID = "workspace-1"; + +let storeApi: StoreApi; +function Harness() { + storeApi = useAppStoreApi(); + useSyncTodoPanel(); + return null; +} + +const addPanel = vi.fn(); +const close = vi.fn(); +const pendingFrames = new Map(); +let nextFrameId = 0; + +function setDockviewState(panel: { id: string; api: { close: typeof close } } | undefined): void { + useDockviewStore.setState({ + api: { + getPanel: (id: string) => (panel && id === "todos" ? panel : undefined), + groups: [{ id: "group-center" }], + addPanel, + } as unknown as DockviewApi, + centerGroupId: "group-center", + isRestoringLayout: false, + userDefaultLayout: null, + preMaximizeLayout: null, + }); +} + +function renderHook(overrides: Partial = {}): void { + render( + + + , + ); +} + +/** Runs exactly one animation frame: the outer rAF fires and schedules the + * inner rAF, which stays pending until the next call, mirroring the + * browser's two-frame scheduling. */ +function flushOneFrame(): void { + act(() => { + const due = [...pendingFrames.entries()]; + for (const [id, callback] of due) { + pendingFrames.delete(id); + callback(0); + } + }); +} + +function flushAllFrames(): void { + while (pendingFrames.size > 0) { + flushOneFrame(); + } +} + +function sessionedStateOverrides(showTodoListPanel: boolean, loaded: boolean): Partial { + return { + userSettings: { + ...defaultState.userSettings, + showTodoListPanel, + showTodoListPanelOnlyWhenNotEmpty: true, + loaded, + }, + tasks: { ...defaultState.tasks, activeTaskId: TASK_ID, activeSessionId: SESSION_ID }, + workspaces: { ...defaultState.workspaces, activeId: WORKSPACE_ID }, + }; +} + +beforeEach(() => { + pendingFrames.clear(); + nextFrameId = 0; + addPanel.mockReset(); + close.mockReset(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + const id = ++nextFrameId; + pendingFrames.set(id, callback); + return id; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => { + pendingFrames.delete(id); + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + useDockviewStore.setState({ api: null }); +}); + +describe("useSyncTodoPanel", () => { + it("adds the panel once live todos arrive when the sub-option is on", () => { + setDockviewState(undefined); + renderHook(sessionedStateOverrides(true, true)); + + // Empty todo list suppresses the add. + flushAllFrames(); + expect(addPanel).not.toHaveBeenCalled(); + + // A live sessionTodos update re-runs the sync and pins the panel. + act(() => { + storeApi.setState({ + sessionTodos: { + bySessionId: { [SESSION_ID]: [{ description: "Do it", status: "in_progress" }] }, + }, + }); + }); + flushAllFrames(); + expect(addPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "todos" })); + }); + + it("removes a materialized panel for a sessionless task when the preference is off", () => { + setDockviewState({ id: "todos", api: { close } }); + renderHook({ + ...sessionedStateOverrides(false, true), + tasks: { ...defaultState.tasks, activeTaskId: TASK_ID, activeSessionId: null }, + }); + + flushAllFrames(); + expect(close).toHaveBeenCalledOnce(); + }); + + it("never auto-adds for a sessionless task even when the preference is on", () => { + setDockviewState(undefined); + renderHook({ + ...sessionedStateOverrides(true, true), + tasks: { ...defaultState.tasks, activeTaskId: TASK_ID, activeSessionId: null }, + }); + + flushAllFrames(); + expect(addPanel).not.toHaveBeenCalled(); + }); + + it("cancels a pending sync when the preference changes between the frames", () => { + setDockviewState(undefined); + renderHook(sessionedStateOverrides(true, true)); + flushOneFrame(); // outer fired, inner pending + + // The preference flips before the inner frame: the effect cleanup must + // cancel the pending inner, and the new effect's sync must see the + // updated preference (off -> nothing to add or remove). + act(() => { + storeApi.setState({ + userSettings: { ...storeApi.getState().userSettings, showTodoListPanel: false }, + }); + }); + flushAllFrames(); + + expect(addPanel).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + }); + + it("does not apply a stale task's sync after a task switch mid-flight", () => { + setDockviewState(undefined); + renderHook(sessionedStateOverrides(true, true)); + + // Todos arrive for task-1 and re-schedule the sync, but the user + // switches to a different (sessionless) task before any frame fires. + act(() => { + storeApi.setState({ + sessionTodos: { + bySessionId: { [SESSION_ID]: [{ description: "Do it", status: "in_progress" }] }, + }, + }); + }); + act(() => { + storeApi.setState({ + tasks: { ...defaultState.tasks, activeTaskId: "task-2" as TaskId, activeSessionId: null }, + }); + }); + + flushAllFrames(); + // Neither the cancelled task-1 frames (identity guard) nor the new + // sessionless task may add a panel. + expect(addPanel).not.toHaveBeenCalled(); + }); + + it("does nothing before settings hydrate, then syncs once loaded", () => { + setDockviewState(undefined); + renderHook({ + ...sessionedStateOverrides(true, false), + // Sub-option off so the add is not gated on the (empty) todo list. + userSettings: { + ...defaultState.userSettings, + showTodoListPanel: true, + showTodoListPanelOnlyWhenNotEmpty: false, + loaded: false, + }, + }); + + flushAllFrames(); + expect(addPanel).not.toHaveBeenCalled(); + + act(() => { + storeApi.setState({ + userSettings: { ...storeApi.getState().userSettings, loaded: true }, + }); + }); + flushAllFrames(); + expect(addPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "todos" })); + }); + + it("removes the panel when the preference turns off", () => { + setDockviewState({ id: "todos", api: { close } }); + renderHook(sessionedStateOverrides(true, true)); + + flushAllFrames(); + expect(close).not.toHaveBeenCalled(); + + act(() => { + storeApi.setState({ + userSettings: { ...storeApi.getState().userSettings, showTodoListPanel: false }, + }); + }); + flushAllFrames(); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/task/dockview-todo-panel-sync.test.ts b/apps/web/components/task/dockview-todo-panel-sync.test.ts index ad58bdd80c..59f08da945 100644 --- a/apps/web/components/task/dockview-todo-panel-sync.test.ts +++ b/apps/web/components/task/dockview-todo-panel-sync.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it, vi } from "vitest"; import type { DockviewApi } from "dockview-react"; +import type { Message } from "@/lib/types/http"; import { resolveConditionalTodoPanelAction, resolveConfiguredTodoPanelPlacement, syncConditionalTodoPanel, - type ConditionalTodoPanelOptions, + todoListNotEmptyForSession, + type SyncConditionalTodoPanelOptions, } from "./dockview-todo-panel-sync"; const CENTER_GROUP_ID = "group-center"; const RIGHT_TOP_GROUP_ID = "group-right-top"; -const DEFAULT_OPTIONS: ConditionalTodoPanelOptions = { +const DEFAULT_OPTIONS = { centerGroupId: CENTER_GROUP_ID, isRestoringLayout: false, isMaximized: false, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: true, }; function makeApi( @@ -37,8 +42,36 @@ function makeApi( }; } -describe("resolveConditionalTodoPanelAction", () => { - it.each([ +type SyncOverrides = Pick & + Partial>; + +/** Runs the sync with DEFAULT_OPTIONS merged over `overrides` — every + * syncConditionalTodoPanel test shares this exact options base. */ +function runSync(api: DockviewApi, overrides: SyncOverrides): boolean { + return syncConditionalTodoPanel(api, { ...DEFAULT_OPTIONS, ...overrides }); +} + +/** Asserts the sync resolves to "none": neither addPanel nor close called. */ +function expectNoAction(overrides: SyncOverrides, panel?: { groupId?: string }): void { + const { api, addPanel, close } = makeApi(panel); + expect(runSync(api, overrides)).toBe(false); + expect(addPanel).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); +} + +type DecisionCaseInput = { + showTodoListPanel: boolean; + panelExists: boolean; + onlyPinWhenNotEmpty?: boolean; + todoListNotEmpty?: boolean; + hasActiveSession?: boolean; + settingsLoaded?: boolean; + isRestoringLayout?: boolean; + isMaximized?: boolean; +}; + +const CONDITIONAL_TODO_PANEL_CASES: Array<[string, DecisionCaseInput, "add" | "remove" | "none"]> = + [ ["adds when enabled and absent", { showTodoListPanel: true, panelExists: false }, "add"], ["does nothing when already present", { showTodoListPanel: true, panelExists: true }, "none"], [ @@ -71,12 +104,101 @@ describe("resolveConditionalTodoPanelAction", () => { { showTodoListPanel: false, panelExists: true, settingsLoaded: false }, "none", ], - ])("%s", (_name, input, expected) => { + [ + "does not pin when the sub-option is on and the todo list is empty", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: false, + panelExists: false, + }, + "none", + ], + [ + "pins when the sub-option is on and the todo list is not empty", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: true, + panelExists: false, + }, + "add", + ], + [ + "leaves an existing panel alone when the sub-option is on and the list is empty", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: false, + panelExists: true, + }, + "none", + ], + [ + "pins regardless of the list when the sub-option is off", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + panelExists: false, + }, + "add", + ], + [ + "still removes when the master preference is off, even with the sub-option on", + { + showTodoListPanel: false, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: false, + panelExists: true, + }, + "remove", + ], + [ + "never adds without an active session, even with the master preference on", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: false, + panelExists: false, + }, + "none", + ], + [ + "still removes when the master preference is off without an active session", + { + showTodoListPanel: false, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: false, + panelExists: true, + }, + "remove", + ], + [ + "leaves an existing panel alone for a sessionless task with the preference on", + { + showTodoListPanel: true, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: false, + panelExists: true, + }, + "none", + ], + ]; + +describe("resolveConditionalTodoPanelAction", () => { + it.each(CONDITIONAL_TODO_PANEL_CASES)("%s", (_name, input, expected) => { expect( resolveConditionalTodoPanelAction({ settingsLoaded: true, isRestoringLayout: false, isMaximized: false, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: true, ...input, }), ).toBe(expected); @@ -114,13 +236,7 @@ describe("syncConditionalTodoPanel", () => { it("adds an inactive panel beside Files/Changes when enabled and absent", () => { const { api, addPanel } = makeApi(); - expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, - showTodoListPanel: true, - settingsLoaded: true, - }), - ).toBe(true); + expect(runSync(api, { showTodoListPanel: true, settingsLoaded: true })).toBe(true); expect(addPanel).toHaveBeenCalledWith( expect.objectContaining({ id: "todos", @@ -135,8 +251,7 @@ describe("syncConditionalTodoPanel", () => { const { api, addPanel } = makeApi(undefined, ["custom-group"]); expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, + runSync(api, { showTodoListPanel: true, settingsLoaded: true, configuredPlacement: { groupId: "custom-group", index: 2 }, @@ -150,13 +265,7 @@ describe("syncConditionalTodoPanel", () => { it("falls back to the center group when no right column exists (e.g. compact layout)", () => { const { api, addPanel } = makeApi(undefined, []); - expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, - showTodoListPanel: true, - settingsLoaded: true, - }), - ).toBe(true); + expect(runSync(api, { showTodoListPanel: true, settingsLoaded: true })).toBe(true); expect(addPanel).toHaveBeenCalledWith( expect.objectContaining({ position: { referenceGroup: CENTER_GROUP_ID } }), ); @@ -166,44 +275,105 @@ describe("syncConditionalTodoPanel", () => { const { api, close } = makeApi({}); expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, - showTodoListPanel: false, - settingsLoaded: true, - isRestoringLayout: true, - }), + runSync(api, { showTodoListPanel: false, settingsLoaded: true, isRestoringLayout: true }), ).toBe(true); expect(close).toHaveBeenCalledOnce(); }); it("does not add while restoring or maximized", () => { - for (const options of [ - { isRestoringLayout: true, isMaximized: false }, - { isRestoringLayout: false, isMaximized: true }, - ]) { - const { api, addPanel } = makeApi(); - expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, - showTodoListPanel: true, - settingsLoaded: true, - ...options, - }), - ).toBe(false); - expect(addPanel).not.toHaveBeenCalled(); - } + expectNoAction({ showTodoListPanel: true, settingsLoaded: true, isRestoringLayout: true }); + expectNoAction({ showTodoListPanel: true, settingsLoaded: true, isMaximized: true }); }); it("does nothing before settings have hydrated", () => { - const { api, addPanel, close } = makeApi({}); + expectNoAction({ showTodoListPanel: false, settingsLoaded: false }, {}); + }); + + it("does not add an absent panel when the sub-option is on and the list is empty", () => { + expectNoAction({ + showTodoListPanel: true, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: false, + settingsLoaded: true, + }); + }); + + it("adds an absent panel when the sub-option is on and the list is not empty", () => { + const { api, addPanel } = makeApi(); + expect( - syncConditionalTodoPanel(api, { - ...DEFAULT_OPTIONS, - showTodoListPanel: false, - settingsLoaded: false, + runSync(api, { + showTodoListPanel: true, + onlyPinWhenNotEmpty: true, + todoListNotEmpty: true, + settingsLoaded: true, }), - ).toBe(false); - expect(addPanel).not.toHaveBeenCalled(); - expect(close).not.toHaveBeenCalled(); + ).toBe(true); + expect(addPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "todos", inactive: true })); + }); + + it("does not add for a sessionless task even when the preference is on", () => { + expectNoAction({ + showTodoListPanel: true, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: false, + settingsLoaded: true, + }); + }); + + it("still removes a materialized panel for a sessionless task when the preference is off", () => { + const { api, close } = makeApi({}); + + expect( + runSync(api, { + showTodoListPanel: false, + onlyPinWhenNotEmpty: false, + todoListNotEmpty: false, + hasActiveSession: false, + settingsLoaded: true, + }), + ).toBe(true); + expect(close).toHaveBeenCalledOnce(); + }); +}); + +describe("todoListNotEmptyForSession", () => { + const persistedTodoMessage = { + id: "m1", + type: "todo", + turn_id: "turn-1", + metadata: { todos: [{ text: "Write tests", done: true }] }, + } as unknown as Message; + + it("is false when the session has neither live entries nor a persisted todo message", () => { + expect(todoListNotEmptyForSession(undefined, [])).toBe(false); + }); + + it("is true when the live slice has entries, even with no persisted messages", () => { + expect( + todoListNotEmptyForSession([{ description: "Implement", status: "in_progress" }], []), + ).toBe(true); + }); + + it("falls back to the latest persisted todo message when the live slice is absent", () => { + expect(todoListNotEmptyForSession(undefined, [persistedTodoMessage])).toBe(true); + }); + + it("treats an empty live array like an absent live slice (persisted fallback wins)", () => { + expect(todoListNotEmptyForSession([], [persistedTodoMessage])).toBe(true); + expect(todoListNotEmptyForSession([], [])).toBe(false); + }); + + it("treats malformed persisted todo metadata as empty instead of throwing", () => { + const malformed = [ + { id: "m1", type: "todo", turn_id: "t", metadata: { todos: { text: "x" } } }, + { id: "m2", type: "todo", turn_id: "t", metadata: { todos: [null, 42] } }, + ] as unknown as Message[]; + + for (const message of malformed) { + expect(() => todoListNotEmptyForSession(undefined, [message])).not.toThrow(); + expect(todoListNotEmptyForSession(undefined, [message])).toBe(false); + } }); }); diff --git a/apps/web/components/task/dockview-todo-panel-sync.ts b/apps/web/components/task/dockview-todo-panel-sync.ts index 622f3549b4..7cca559fdb 100644 --- a/apps/web/components/task/dockview-todo-panel-sync.ts +++ b/apps/web/components/task/dockview-todo-panel-sync.ts @@ -3,7 +3,10 @@ import { useEffect } from "react"; import type { AddPanelOptions, DockviewApi } from "dockview-react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { buildTodoItems } from "@/hooks/use-processed-messages"; import { t } from "@/lib/i18n"; +import type { Message } from "@/lib/types/http"; +import type { TodoEntry } from "@/lib/state/slices/session-runtime/types"; import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { @@ -21,13 +24,21 @@ export type ConditionalTodoPanelAction = "add" | "remove" | "none"; * and no closed-for-session suppression: the `showTodoListPanel` preference * is the single authoritative on/off switch (see * docs/specs/ui/agent-todo-list-panel.md — "true, unconditional visibility - * gate"). `settingsLoaded` mirrors PR Details' `reviewsLoaded` guard: don't - * touch the panel until the real preference value has hydrated, so a - * cold-loading task with the preference persisted `true` doesn't flash-remove - * a panel materialized from its saved layout before settings arrive. + * gate"). The "only pin when not empty" sub-option (`onlyPinWhenNotEmpty`) + * gates only the automatic add: while it is on, an absent panel is not added + * until the active session's todo list is non-empty (`todoListNotEmpty`). + * It never removes an existing panel, and removal stays gated solely on the + * master preference. `settingsLoaded` mirrors PR Details' `reviewsLoaded` + * guard: don't touch the panel until the real preference value has hydrated, + * so a cold-loading task with the preference persisted `true` doesn't + * flash-remove a panel materialized from its saved layout before settings + * arrive. */ export function resolveConditionalTodoPanelAction(params: { showTodoListPanel: boolean; + onlyPinWhenNotEmpty: boolean; + todoListNotEmpty: boolean; + hasActiveSession: boolean; panelExists: boolean; settingsLoaded: boolean; isRestoringLayout: boolean; @@ -38,7 +49,9 @@ export function resolveConditionalTodoPanelAction(params: { return params.panelExists ? "remove" : "none"; } if (params.panelExists) return "none"; + if (!params.hasActiveSession) return "none"; if (params.isRestoringLayout || params.isMaximized) return "none"; + if (params.onlyPinWhenNotEmpty && !params.todoListNotEmpty) return "none"; return "add"; } @@ -68,6 +81,16 @@ export type ConditionalTodoPanelOptions = { isMaximized: boolean; }; +/** Full runtime inputs to {@link syncConditionalTodoPanel}: the placement + * options plus the visibility preferences and the hydrated settings flag. */ +export type SyncConditionalTodoPanelOptions = ConditionalTodoPanelOptions & { + showTodoListPanel: boolean; + onlyPinWhenNotEmpty: boolean; + todoListNotEmpty: boolean; + hasActiveSession: boolean; + settingsLoaded: boolean; +}; + /** Beside Files/Changes in the pinned right column's top group by default — * the user asked for "the right panel" specifically. Falls back to the * center group for layouts with no right column (e.g. compact). */ @@ -91,17 +114,21 @@ function resolveTodoPanelTargetPosition( /** * Synchronize the Todos panel's runtime presence with the `showTodoListPanel` - * preference. The preference is the sole visibility gate; a saved layout's - * `todos` entry only supplies placement (see - * `resolveConfiguredTodoPanelPlacement`) and is never rewritten here. + * preference (and the "only pin when not empty" sub-option). The preference + * is the sole visibility gate; a saved layout's `todos` entry only supplies + * placement (see `resolveConfiguredTodoPanelPlacement`) and is never + * rewritten here. */ export function syncConditionalTodoPanel( api: DockviewApi, - options: ConditionalTodoPanelOptions & { showTodoListPanel: boolean; settingsLoaded: boolean }, + options: SyncConditionalTodoPanelOptions, ): boolean { const panel = api.getPanel("todos"); const action = resolveConditionalTodoPanelAction({ showTodoListPanel: options.showTodoListPanel, + onlyPinWhenNotEmpty: options.onlyPinWhenNotEmpty, + todoListNotEmpty: options.todoListNotEmpty, + hasActiveSession: options.hasActiveSession, panelExists: !!panel, settingsLoaded: options.settingsLoaded, isRestoringLayout: options.isRestoringLayout, @@ -130,10 +157,29 @@ export function syncConditionalTodoPanel( return false; } +const EMPTY_MESSAGES: Message[] = []; + +/** True when the session's todo list is non-empty, using the exact two-source + * fallback the Todos panel content uses: live `sessionTodos.bySessionId` + * entries first (an empty array falls through), then the latest persisted + * `todo`-type message via `buildTodoItems`. `useSyncTodoPanel` uses the + * streaming-optimized `createTodoMessagePresenceTracker` for the messages + * half on its hot path; this canonical form is kept as the testable + * contract so the fallback can't drift from the panel content. */ +export function todoListNotEmptyForSession( + liveTodos: readonly TodoEntry[] | undefined, + messages: Message[], +): boolean { + return (liveTodos?.length ?? 0) > 0 || buildTodoItems(messages).length > 0; +} + /** Keep the Todos panel in sync with the active task's live Dockview tree. */ export function useSyncTodoPanel() { const appStore = useAppStoreApi(); const showTodoListPanel = useAppStore((state) => state.userSettings.showTodoListPanel); + const onlyPinWhenNotEmpty = useAppStore( + (state) => state.userSettings.showTodoListPanelOnlyWhenNotEmpty, + ); const settingsLoaded = useAppStore((state) => state.userSettings.loaded); const taskId = useAppStore((state) => state.tasks.activeTaskId); const sessionId = useAppStore((state) => state.tasks.activeSessionId); @@ -143,9 +189,30 @@ export function useSyncTodoPanel() { const isMaximized = useDockviewStore((state) => state.preMaximizeLayout !== null); const centerGroupId = useDockviewStore((state) => state.centerGroupId); const userDefaultLayout = useDockviewStore((state) => state.userDefaultLayout); + // The "not empty" predicate mirrors the Todos panel's two-source fallback: + // live `sessionTodos.bySessionId` entries first, then the latest persisted + // `todo`-type message. Messages are fetched by the always-mounted chat + // panel, so subscribing to the store slices here adds no fetch side effects. + // Selectors must return stable references (the stored array or `undefined`), + // never a fresh `[]`, or zustand re-renders on every store read and React + // throws "maximum update depth exceeded". The slices double as the effect's + // change signal, so the predicate is computed exactly once per sync — in + // the dispatch callback, from the dispatch-time snapshot. + const liveTodos = useAppStore((state) => + sessionId ? state.sessionTodos.bySessionId[sessionId] : undefined, + ); + const messages = useAppStore((state) => + sessionId ? (state.messages.bySession[sessionId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES, + ); useEffect(() => { - if (!taskId || !sessionId || !workspaceId || !hasApi) return; + // Run even for sessionless tasks (no `!sessionId` guard): a saved layout + // can materialize a `todos` tab for a task without a session, and the + // master preference must still be able to remove it (spec: "the + // preference is a true, unconditional visibility gate"). A null session + // never enables an add (`hasActiveSession` gates the add path in the + // resolver). + if (!taskId || !workspaceId || !hasApi) return; let innerFrame: number | null = null; const outerFrame = requestAnimationFrame(() => { @@ -161,8 +228,28 @@ export function useSyncTodoPanel() { const api = useDockviewStore.getState().api; if (!api) return; const dockview = useDockviewStore.getState(); + // Recompute the predicate from the dispatch-time snapshot rather than + // a render-time value: a WS todo event landing between the render and + // this rAF tick would otherwise make the decision one frame stale + // (the tab would be missed until the next effect run). The predicate + // only matters when the sub-option is on, so skip the O(n) transcript + // scan entirely otherwise; a null session is treated as an empty list. syncConditionalTodoPanel(api, { showTodoListPanel: live.userSettings.showTodoListPanel, + onlyPinWhenNotEmpty: live.userSettings.showTodoListPanelOnlyWhenNotEmpty, + // A sessionless task never auto-adds a Todos tab (there is nothing + // to show); it only ever gets removal handling. The predicate is + // computed only when the sub-option is on, skipping the transcript + // scan otherwise, and the live slice short-circuits the message + // scan while the session has live todo entries. + hasActiveSession: sessionId !== null, + todoListNotEmpty: + live.userSettings.showTodoListPanelOnlyWhenNotEmpty && sessionId + ? todoListNotEmptyForSession( + live.sessionTodos.bySessionId[sessionId], + live.messages.bySession[sessionId] ?? EMPTY_MESSAGES, + ) + : false, settingsLoaded: live.userSettings.loaded, centerGroupId: dockview.centerGroupId, configuredPlacement: resolveConfiguredTodoPanelPlacement(dockview.userDefaultLayout), @@ -182,6 +269,9 @@ export function useSyncTodoPanel() { hasApi, isMaximized, isRestoringLayout, + liveTodos, + messages, + onlyPinWhenNotEmpty, settingsLoaded, sessionId, showTodoListPanel, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index fde9c4e33d..15a7ad2e4b 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -916,6 +916,7 @@ export class ApiClient { tasks_list_show_details?: boolean; show_transcript_auto_scroll_control?: boolean; show_todo_list_panel?: boolean; + show_todo_list_panel_only_when_not_empty?: boolean; agent_generated_task_titles?: boolean; [key: string]: unknown; }; diff --git a/apps/web/e2e/tests/settings/todo-list-panel.spec.ts b/apps/web/e2e/tests/settings/todo-list-panel.spec.ts index 9b1f1f3ef5..1edbcf9392 100644 --- a/apps/web/e2e/tests/settings/todo-list-panel.spec.ts +++ b/apps/web/e2e/tests/settings/todo-list-panel.spec.ts @@ -111,17 +111,31 @@ async function setTodoListPanelPreference(apiClient: ApiClient, enabled: boolean expect(response.ok).toBe(true); } +async function setOnlyPinWhenNotEmptyPreference( + apiClient: ApiClient, + enabled: boolean, +): Promise { + const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + show_todo_list_panel_only_when_not_empty: enabled, + }); + expect(response.ok).toBe(true); +} + test.describe("Todo list panel preference", () => { let baseline: boolean; + let baselineOnlyWhenNotEmpty: boolean; test.beforeEach(async ({ apiClient }) => { const settings = await apiClient.getUserSettings(); baseline = Boolean(settings.settings.show_todo_list_panel); + baselineOnlyWhenNotEmpty = Boolean(settings.settings.show_todo_list_panel_only_when_not_empty); if (baseline) await setTodoListPanelPreference(apiClient, false); + if (baselineOnlyWhenNotEmpty) await setOnlyPinWhenNotEmptyPreference(apiClient, false); }); test.afterEach(async ({ apiClient }) => { await setTodoListPanelPreference(apiClient, baseline); + await setOnlyPinWhenNotEmptyPreference(apiClient, baselineOnlyWhenNotEmpty); }); test("hides the Todos tab by default", async ({ testPage, apiClient, seedData }) => { @@ -272,4 +286,91 @@ test.describe("Todo list panel preference", () => { expect(layout?.todosExists).toBe(true); expect(layout?.todosGroupId).not.toBe(layout?.filesGroupId); }); + + test("inhibits the only-pin-when-not-empty sub-option while the main preference is off, preserving its value", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(120_000); + const subSwitch = () => + testPage.getByRole("switch", { name: "Only pin when todo list is not empty" }); + + await testPage.goto("/settings/general/task-actions"); + const toggle = testPage.getByRole("switch", { name: "Show agent todo list panel" }); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await expect(subSwitch()).toHaveCount(0); + + // Enabling the main preference reveals the sub-option. + await toggle.click(); + await expect(subSwitch()).toBeVisible(); + await subSwitch().click(); + await expect(subSwitch()).toHaveAttribute("aria-checked", "true"); + let floatingSave = testPage.getByTestId("settings-floating-save"); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(floatingSave).not.toBeVisible(); + + let settings = await apiClient.getUserSettings(); + expect(settings.settings.show_todo_list_panel_only_when_not_empty).toBe(true); + + // Disabling the main preference inhibits (hides, never disables) the + // sub-option, but its saved value survives the save. + await toggle.click(); + await expect(subSwitch()).toHaveCount(0); + floatingSave = testPage.getByTestId("settings-floating-save"); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(floatingSave).not.toBeVisible(); + + settings = await apiClient.getUserSettings(); + expect(settings.settings.show_todo_list_panel).toBe(false); + expect(settings.settings.show_todo_list_panel_only_when_not_empty).toBe(true); + + // Reload: main preference off, sub-option still hidden but preserved. + await testPage.reload(); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await expect(subSwitch()).toHaveCount(0); + + // Re-enabling the main preference restores the sub-option checked. + await toggle.click(); + await expect(subSwitch()).toHaveAttribute("aria-checked", "true"); + }); + + test("does not auto-pin the Todos tab for an empty todo list when only-pin-when-not-empty is on", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await setOnlyPinWhenNotEmptyPreference(apiClient, true); + await setTodoListPanelPreference(apiClient, true); + + // This session never emits todo entries, so the automatic pin must stay + // suppressed even though the master preference is on. + const task = await createTaskWithSession(apiClient, seedData, "Todos empty not pinned"); + await openTask(testPage, task.id); + + await expect(todosTabWrapper(testPage)).toHaveCount(0); + await expect + .poll(() => readTodosLayout(testPage), { timeout: 15_000 }) + .toMatchObject({ todosExists: false, rightTopOrder: ["files", "changes"] }); + }); + + test("auto-pins the Todos tab when the todo list is not empty and only-pin-when-not-empty is on", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + await setOnlyPinWhenNotEmptyPreference(apiClient, true); + await setTodoListPanelPreference(apiClient, true); + + // Todos are persisted in message history before the task opens, so the + // sync's two-source fallback must see them and pin the tab. + const task = await createTaskWithTodos(apiClient, seedData, "Todos pinned when present"); + await openTask(testPage, task.id); + + await expect(todosTabWrapper(testPage)).toBeVisible({ timeout: 15_000 }); + await expect + .poll(() => readTodosLayout(testPage), { timeout: 15_000 }) + .toMatchObject({ todosExists: true, rightTopOrder: ["files", "changes", "todos"] }); + }); }); diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 92ba7714f7..ed7b857221 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -70,6 +70,7 @@ function makeUnloadedSettings(): UserSettingsState { showScrollToStart: false, showTranscriptAutoScrollControl: true, showTodoListPanel: false, + showTodoListPanelOnlyWhenNotEmpty: false, showReleaseNotification: true, releaseNotesLastSeenVersion: null, savedLayouts: [], diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts index bb0631c492..3c0d603a49 100644 --- a/apps/web/hooks/use-processed-messages.test.ts +++ b/apps/web/hooks/use-processed-messages.test.ts @@ -8,6 +8,7 @@ import { import type { RichMetadata } from "@/components/task/chat/types"; import { buildGroupedRenderItems, + buildTodoItems, collapseTodoSnapshotsPerTurn, deduplicateAgentBootResumes, insertLastAgentErrorItem, @@ -478,3 +479,74 @@ describe("buildGroupedRenderItems subagent hoisting", () => { expect(items.map((i) => i.type)).toEqual(["turn_group", "message", "turn_group"]); }); }); + +describe("buildTodoItems", () => { + it("returns the latest persisted todo entries, accepting string and object items", () => { + const messages = [ + makeMessage("m1", "message", undefined, "hello"), + makeTodo("m2", "turn-1", [ + { text: "Old task", done: true }, + { text: "Old task 2", done: false }, + ]), + { + ...makeMessage("m3", "todo", { + todos: ["Write tests", { text: "Implement", done: true }], + }), + turn_id: "turn-2", + }, + ]; + + expect(buildTodoItems(messages)).toEqual([ + { text: "Write tests", done: false }, + { text: "Implement", done: true }, + ]); + }); + + it("returns an empty list when no message carries todos", () => { + expect(buildTodoItems([makeMessage("m1", "message", undefined, "hello")])).toEqual([]); + expect(buildTodoItems([])).toEqual([]); + }); + + it("drops empty-text entries so they do not count as a non-empty list", () => { + const messages = [ + { + ...makeMessage("m1", "todo", { todos: [{ text: "" }, "Valid", { text: "Whitespace " }] }), + turn_id: "t", + }, + ]; + + expect(buildTodoItems(messages)).toEqual([ + { text: "Valid", done: false }, + { text: "Whitespace ", done: undefined }, + ]); + }); + + it("is total for malformed todo metadata instead of throwing", () => { + const malformed = [ + // todos is a non-array object + { ...makeMessage("m1", "todo", { todos: { text: "x" } }), turn_id: "turn-1" }, + // todos is a primitive + { ...makeMessage("m2", "todo", { todos: "x" }), turn_id: "turn-2" }, + // todos contains null / non-object entries + { ...makeMessage("m3", "todo", { todos: [null, 42, { text: "Valid" }] }), turn_id: "turn-3" }, + // metadata is a primitive (not an object) + { + ...makeMessage("m4", "todo", "not-an-object" as unknown as Record), + turn_id: "turn-4", + }, + // todo-type message without metadata + { ...makeMessage("m5", "todo", undefined), turn_id: "turn-5" }, + ]; + + // Each shape independently must yield [] (or only the valid entries), + // never throw. + for (const message of malformed) { + expect(() => buildTodoItems([message])).not.toThrow(); + } + expect(buildTodoItems([malformed[0]])).toEqual([]); + expect(buildTodoItems([malformed[1]])).toEqual([]); + expect(buildTodoItems([malformed[2]])).toEqual([{ text: "Valid", done: undefined }]); + expect(buildTodoItems([malformed[3]])).toEqual([]); + expect(buildTodoItems([malformed[4]])).toEqual([]); + }); +}); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index 11dbefc016..9038efb8d7 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -560,19 +560,43 @@ export function insertLastAgentErrorItem( return [...items.slice(0, insertAt), notice, ...items.slice(insertAt)]; } +/** Builds the todo checklist from the latest persisted `todo`-type message, + * accepting string and `{ text, done }` entries. Total by construction: + * malformed metadata yields an empty list, never throws. */ export function buildTodoItems(visibleMessages: Message[]) { - const latestTodos = [...visibleMessages] - .reverse() - .find((message) => message.type === "todo" || (message.metadata as { todos?: unknown })?.todos); - return ( - ( - latestTodos?.metadata as - | { todos?: Array<{ text: string; done?: boolean } | string> } - | undefined - )?.todos - ?.map((item) => (typeof item === "string" ? { text: item, done: false } : item)) - .filter((item) => item.text) ?? [] - ); + // Scan backward from the latest message without allocating a reversed + // copy: this runs on the todo-panel sync hot path for every message-array + // update while the "only pin when not empty" sub-option is on. + for (let i = visibleMessages.length - 1; i >= 0; i--) { + const message = visibleMessages[i]; + const hasTodoMetadata = + message.metadata !== null && + typeof message.metadata === "object" && + "todos" in message.metadata; + if (message.type !== "todo" && !hasTodoMetadata) continue; + const metadata = message.metadata; + const todos = + metadata !== null && typeof metadata === "object" && "todos" in metadata + ? metadata.todos + : undefined; + // Total by construction: malformed persisted metadata (non-array `todos`, + // null/primitive entries) must yield an empty list, never throw, so the + // todos panel, chat processing, and the todo-panel sync hook can all + // trust this call. Matches the spec's "malformed todo message falls back + // to an empty state" behavior. The latest todo-carrying message wins even + // when its payload is malformed. + if (!Array.isArray(todos)) return []; + return todos + .map((item) => (typeof item === "string" ? { text: item, done: false } : item)) + .filter( + (item): item is { text: string; done?: boolean } => + typeof item === "object" && + item !== null && + typeof item.text === "string" && + item.text.length > 0, + ); + } + return []; } export function useProcessedMessages( diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 4ae4820e6f..d3defb7d7b 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -493,6 +493,24 @@ describe("todo list panel setting", () => { expect(fallback.showTodoListPanel).toBe(false); expect(enabled.showTodoListPanel).toBe(true); }); + + it("defaults the not-empty sub-option to false and preserves an explicit true", () => { + const fallback = mapUserSettingsResponse(null) as { + showTodoListPanelOnlyWhenNotEmpty?: boolean; + }; + const enabled = mapUserSettingsResponse({ + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + show_todo_list_panel_only_when_not_empty: true, + updated_at: UPDATED_AT, + } as unknown as NonNullable[0]>["settings"], + }) as { showTodoListPanelOnlyWhenNotEmpty?: boolean }; + + expect(fallback.showTodoListPanelOnlyWhenNotEmpty).toBe(false); + expect(enabled.showTodoListPanelOnlyWhenNotEmpty).toBe(true); + }); }); describe("parseChangesPanelLayout", () => { diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index f2d01ff28a..bf9a7a6bd5 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -49,6 +49,7 @@ export function createDefaultUserSettings(): UserSettingsState { showScrollToStart: false, showTranscriptAutoScrollControl: false, showTodoListPanel: false, + showTodoListPanelOnlyWhenNotEmpty: false, showReleaseNotification: true, releaseNotesLastSeenVersion: null, lspAutoStartLanguages: [], @@ -262,6 +263,8 @@ function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) { showTranscriptAutoScrollControl: s.show_transcript_auto_scroll_control ?? current.showTranscriptAutoScrollControl, showTodoListPanel: s.show_todo_list_panel ?? current.showTodoListPanel, + showTodoListPanelOnlyWhenNotEmpty: + s.show_todo_list_panel_only_when_not_empty ?? current.showTodoListPanelOnlyWhenNotEmpty, showReleaseNotification: s.show_release_notification ?? current.showReleaseNotification, releaseNotesLastSeenVersion: mapNullableString( s.release_notes_last_seen_version, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 9e02b9390f..344ca2cedd 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -229,6 +229,7 @@ export type UserSettingsState = { showScrollToStart: boolean; showTranscriptAutoScrollControl: boolean; showTodoListPanel: boolean; + showTodoListPanelOnlyWhenNotEmpty: boolean; showReleaseNotification: boolean; releaseNotesLastSeenVersion: string | null; lspAutoStartLanguages: string[]; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index bf7550877d..987866fb92 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -68,6 +68,7 @@ export type UserSettings = { show_scroll_to_start?: boolean; show_transcript_auto_scroll_control?: boolean; show_todo_list_panel?: boolean; + show_todo_list_panel_only_when_not_empty?: boolean; review_auto_mark_on_scroll?: boolean; confirm_task_archive?: boolean; unread_divider?: boolean; @@ -131,6 +132,7 @@ export type UserSettingsUpdatePayload = { show_scroll_to_start?: boolean; show_transcript_auto_scroll_control?: boolean; show_todo_list_panel?: boolean; + show_todo_list_panel_only_when_not_empty?: boolean; review_auto_mark_on_scroll?: boolean; confirm_task_archive?: boolean; unread_divider?: boolean; diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 28433d471e..05611caa01 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -255,6 +255,18 @@ describe("todo list panel websocket sync", () => { registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); expect(store.getState().userSettings.showTodoListPanel).toBe(true); }); + + it("syncs the not-empty sub-option and preserves it when omitted", () => { + const store = makeStore(); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ show_todo_list_panel_only_when_not_empty: true }), + ); + expect(store.getState().userSettings.showTodoListPanelOnlyWhenNotEmpty).toBe(true); + + registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); + expect(store.getState().userSettings.showTodoListPanelOnlyWhenNotEmpty).toBe(true); + }); }); describe("user settings websocket partial updates", () => { diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 758bcf68f8..de65ad107b 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -481,7 +481,9 @@ "overrideSettingsSentToEachLanguage": "Override settings sent to each language server via <2>{{method}}. JSON format.", "password": "Password", "pickTheDefaultShellForTask": "Pick the default shell for task sessions", - "pinTheAgentsLiveTodoChecklistAs": "Pin the agent's live todo checklist as a tab in the right panel.", + "pinTheAgentsLiveTodoChecklistAs": "Automatically pin the agent's live todo checklist as a Todos tab in the right panel. Even when this is off, you can always add the Todos tab manually.", + "onlyPinWhenTodoListIsNotEmpty": "Only pin when todo list is not empty", + "onlyPinWhenTodoListIsNotEmptyDescription": "Limits automatic pinning to sessions that have todo entries. Manually added Todos tabs are never affected.", "pluginShortcuts": "Plugin Shortcuts", "preferredShell": "Preferred Shell", "preferences": "Preferences", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index db6b6e8f36..6681f19888 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -481,7 +481,9 @@ "overrideSettingsSentToEachLanguage": "Ōvēŕŕĩďē śēţţĩńĝś śēńţ ţō ēàćĥ ĺàńĝũàĝē śēŕvēŕ vĩà <2>{{method}}. ĴŚŌŃ ƒōŕḿàţ.", "password": "Ƥàśśŵōŕď", "pickTheDefaultShellForTask": "Ƥĩćķ ţĥē ďēƒàũĺţ śĥēĺĺ ƒōŕ ţàśķ śēśśĩōńś", - "pinTheAgentsLiveTodoChecklistAs": "Ƥĩń ţĥē àĝēńţ'ś ĺĩvē ţōďō ćĥēćķĺĩśţ àś à ţàƀ ĩń ţĥē ŕĩĝĥţ ƥàńēĺ.", + "pinTheAgentsLiveTodoChecklistAs": "Àũţōḿàţĩćàĺĺŷ ƥĩń ţĥē àĝēńţ'ś ĺĩvē ţōďō ćĥēćķĺĩśţ àś à Ţōďōś ţàƀ ĩń ţĥē ŕĩĝĥţ ƥàńēĺ. Ēvēń ŵĥēń ţĥĩś ĩś ōƒƒ, ŷōũ ćàń àĺŵàŷś àďď ţĥē Ţōďōś ţàƀ ḿàńũàĺĺŷ.", + "onlyPinWhenTodoListIsNotEmpty": "Ōńĺŷ ƥĩń ŵĥēń ţōďō ĺĩśţ ĩś ńōţ ēḿƥţŷ", + "onlyPinWhenTodoListIsNotEmptyDescription": "Ĺĩḿĩţś àũţōḿàţĩć ƥĩńńĩńĝ ţō śēśśĩōńś ţĥàţ ĥàvē ţōďō ēńţŕĩēś. Ḿàńũàĺĺŷ àďďēď Ţōďōś ţàƀś àŕē ńēvēŕ àƒƒēćţēď.", "pluginShortcuts": "Ƥĺũĝĩń Śĥōŕţćũţś", "preferredShell": "Ƥŕēƒēŕŕēď Śĥēĺĺ", "preferences": "Ƥŕēƒēŕēńćēś", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 33df1c7e82..365f05415d 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -476,7 +476,9 @@ "overrideSettingsSentToEachLanguage": "Substituir as definições enviadas a cada servidor de linguagem através de <2>{{method}}. Formato JSON.", "password": "Palavra-passe", "pickTheDefaultShellForTask": "Escolha a shell pré-definida das sessões de tarefa", - "pinTheAgentsLiveTodoChecklistAs": "Fixar a lista de tarefas por fazer em direto do agente como um separador no painel direito.", + "pinTheAgentsLiveTodoChecklistAs": "Fixar automaticamente a lista de tarefas por fazer em direto do agente como um separador Todos no painel direito. Mesmo com esta opção desativada, pode sempre adicionar o separador Todos manualmente.", + "onlyPinWhenTodoListIsNotEmpty": "Fixar apenas quando a lista de tarefas por fazer não está vazia", + "onlyPinWhenTodoListIsNotEmptyDescription": "Limita a fixação automática a sessões que têm entradas de tarefas por fazer. Os separadores Todos adicionados manualmente nunca são afetados.", "pluginShortcuts": "Atalhos de plugins", "preferredShell": "Shell preferida", "pressAKeyCombo": "Prima uma combinação de teclas...", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 13ff33c3d7..dad5e251c9 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -476,7 +476,9 @@ "overrideSettingsSentToEachLanguage": "通过 <2>{{method}} 覆盖发送给每个语言服务器的设置。JSON 格式。", "password": "密码", "pickTheDefaultShellForTask": "选择任务会话的默认命令解释器", - "pinTheAgentsLiveTodoChecklistAs": "将代理的实时待办清单固定为右侧面板中的标签页。", + "pinTheAgentsLiveTodoChecklistAs": "自动将代理的实时待办清单固定为右侧面板中的 Todos 标签页。即使关闭此选项,您也可以随时手动添加 Todos 标签页。", + "onlyPinWhenTodoListIsNotEmpty": "仅在待办清单不为空时固定", + "onlyPinWhenTodoListIsNotEmptyDescription": "仅在会话有待办条目时自动固定。手动添加的 Todos 标签页不受影响。", "pluginShortcuts": "插件快捷键", "preferredShell": "首选命令解释器", "pressAKeyCombo": "按下按键组合...", diff --git a/docs/plans/agent-todo-list-panel/plan.md b/docs/plans/agent-todo-list-panel/plan.md index 9654b5df8a..7df2d447bd 100644 --- a/docs/plans/agent-todo-list-panel/plan.md +++ b/docs/plans/agent-todo-list-panel/plan.md @@ -20,10 +20,189 @@ hook that wires the setting to the panel, then E2E coverage of the full flow. --- +## Iteration 2: "Only pin when todo list is not empty" sub-option + +Iteration 1 (Tasks 01-05) is shipped. Iteration 2 adds a second boolean +setting (`showTodoListPanelOnlyWhenNotEmpty` / wire +`show_todo_list_panel_only_when_not_empty`) that gates the automatic pin on +the active session having todo entries, plus an explicit copy pass on the +main toggle ("automatically pins ... can always be added manually"). The +sub-option is hidden (inhibited, not disabled) while the master preference is +off, and its value is preserved independently so it reappears in its saved +state. Order: backend field first, then frontend settings plumbing + UI, +then the conditional-pin sync behavior, then E2E. Spec amendment: +`docs/specs/ui/agent-todo-list-panel.md` (What / Data model / API surface / +Scenarios / Out of scope). + +--- + ## Backend ### Settings model, DTO, service, store +#### Iteration 2 additions + +- `apps/backend/internal/user/models/models.go`: add `ShowTodoListPanelOnlyWhenNotEmpty bool + \`json:"show_todo_list_panel_only_when_not_empty"\`` beside `ShowTodoListPanel`. +- `apps/backend/internal/user/dto/dto.go`: add the field to `UserSettingsDTO` + (bool, `json:"show_todo_list_panel_only_when_not_empty"`), to + `UpdateUserSettingsRequest` (`*bool`, `omitempty`), and to the DTO-from- + settings constructor beside `ShowTodoListPanel` (~line 245). +- `apps/backend/internal/user/service/service.go`: add the field to the + update-request struct (beside `ShowTodoListPanel`, ~line 60), the + `if req.ShowTodoListPanelOnlyWhenNotEmpty != nil { settings.ShowTodoListPanelOnlyWhenNotEmpty = *req.ShowTodoListPanelOnlyWhenNotEmpty }` + merge-patch block in `applyTaskActionPreferences` (after the + `ShowTodoListPanel` block, ~line 370), and the response map entry beside + `"show_todo_list_panel"` (~line 781). +- `apps/backend/internal/user/controller/controller.go`: pass the new + `*bool` through the DTO→service conversion beside `ShowTodoListPanel` + (~line 69). +- `apps/backend/internal/user/store/sqlite.go`: add the field to the JSON + column read map (beside `"show_todo_list_panel"`, ~line 542), the + fresh-user defaults (`false`, ~line 667), the partial-update payload struct + (`*bool`, ~line 730), and the apply-if-present block (~line 809). +- `apps/backend/internal/backendapp/boot_state_routes.go`: add + `"showTodoListPanelOnlyWhenNotEmpty": settings.ShowTodoListPanelOnlyWhenNotEmpty` + to the boot payload beside `showTodoListPanel` (~line 467). +- `apps/backend/internal/user/store/sqlite_test.go`: extend the existing + `TestScanUserSettingsTodoListPanelDefault` / + `TestTodoListPanelSettingRoundTripThroughMarshalAndScan` cases (or add + sibling cases) for the new field. +- `apps/backend/internal/user/service/service_test.go`: add + `TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty` mirroring + `TestApplyBasicSettingsTodoListPanel` (omission preserves, explicit value + replaces, explicit false disables). + +--- + +## Frontend (Iteration 2) + +### Settings state and persistence plumbing + +- `apps/web/lib/state/slices/settings/types.ts`: add + `showTodoListPanelOnlyWhenNotEmpty: boolean` to `UserSettingsState` beside + `showTodoListPanel` (~line 230). +- `apps/web/lib/ssr/user-settings.ts`: add the default (`false`) to + `createDefaultUserSettings()` (~line 50) and the mapping line + `showTodoListPanelOnlyWhenNotEmpty: s.show_todo_list_panel_only_when_not_empty ?? current.showTodoListPanelOnlyWhenNotEmpty` + in `buildBehaviorFields` (~line 262). The WS handler + (`apps/web/lib/ws/handlers/users.ts`) reuses `mapUserSettingsData`, so no + handler edit is needed beyond the mapping. +- `apps/web/lib/types/http-user-settings.ts`: add + `show_todo_list_panel_only_when_not_empty?: boolean` to both `UserSettings` + and `UserSettingsUpdatePayload` beside `show_todo_list_panel` (~lines 70, + 131). +- `apps/web/e2e/helpers/api-client.ts`: add + `show_todo_list_panel_only_when_not_empty?: boolean` to the + `getUserSettings` response type (~line 918). + +### Settings UI + +- `apps/web/components/settings/todo-list-panel-settings.tsx`: convert the + component from one boolean draft to a `{ show, onlyWhenNotEmpty }` draft + pair: + - The main toggle keeps its current `Label` (accessible name "Show agent + todo list panel", unchanged so existing E2E locators still match) and + `Switch` (`id="show-todo-list-panel"`). + - The description paragraph under the main toggle switches to the new + explicit copy (key `settings:pinTheAgentsLiveTodoChecklistAs`, updated + value: "Automatically pin the agent's live todo checklist as a Todos tab + in the right panel. Even when this is off, you can always add the Todos + tab manually." — note: no em dash; the same key also feeds the section + description in `general-settings.tsx`, which is fine). + - New sub-option row rendered only when `draft.show` is true: `Label` + `htmlFor="todo-list-panel-only-when-not-empty"` with + `t("settings:onlyPinWhenTodoListIsNotEmpty")` ("Only pin when todo list + is not empty") + `Switch`. Hidden (unmounted) when the main toggle is + off — inhibited, not disabled — so its draft value is preserved in the + save payload. + - `useSettingsSaveContributor` revision becomes `JSON.stringify(draft)`; + save sends `updateUserSettings({ show_todo_list_panel: d.show, + show_todo_list_panel_only_when_not_empty: d.onlyWhenNotEmpty })` and + updates both fields in the store; `discard` restores both. +- i18n: update `pinTheAgentsLiveTodoChecklistAs` and add + `onlyPinWhenTodoListIsNotEmpty` in `apps/web/src/locales/en/settings.json` + (beside the todo panel keys, ~line 476); regenerate pseudo + (`pnpm run i18n:pseudo`); mirror both into `pt-pt` and `zh-cn`. + +### Conditional-pin sync + +- `apps/web/components/task/dockview-todo-panel-sync.ts`: + - `resolveConditionalTodoPanelAction` gains two params — + `onlyPinWhenNotEmpty: boolean` and `todoListNotEmpty: boolean` — and + returns `"none"` (instead of `"add"`) when + `onlyPinWhenNotEmpty && !todoListNotEmpty`, after the existing + restoring/maximized guards and only when the panel is absent (the + sub-option never removes an existing panel; removal stays gated solely on + `showTodoListPanel`). + - `syncConditionalTodoPanel` options gain the two params and pass them + through to the decision function. + - `useSyncTodoPanel` subscribes to the active session's todo state: + `state.sessionTodos.bySessionId[sessionId]` (live) and + `state.messages.bySession[sessionId]` (persisted history, fetched by the + always-mounted chat panel) via `buildTodoItems`; computes + `todoListNotEmpty = (live.length ?? 0) > 0 || buildTodoItems(messages).length > 0`, + reads `live.userSettings.showTodoListPanelOnlyWhenNotEmpty`, passes both + into `syncConditionalTodoPanel`, and adds `todoListNotEmpty` + + `onlyPinWhenNotEmpty` to the effect dependency array so the panel appears + as soon as todo entries arrive. + +--- + +## Tests (Iteration 2) + +- **What:** merge-patch persists and returns + `show_todo_list_panel_only_when_not_empty` independently of + `show_todo_list_panel` (omission preserves, explicit replaces, explicit + false disables). **File:** `apps/backend/internal/user/service/service_test.go`. + **How:** table-driven tests mirroring `TestApplyBasicSettingsTodoListPanel`. +- **What:** SQLite store round-trips the new field for a fresh user and after + a partial update. **File:** `apps/backend/internal/user/store/sqlite_test.go`. + **How:** extend the existing todo-panel scan/round-trip tests with the new + field. +- **What:** SSR mapping default and snake_case→camelCase mapping for the new + field. **File:** `apps/web/lib/ssr/user-settings.test.ts`. **How:** extend + the existing "defaults to hidden and preserves an explicit true" case (or + add a sibling) for `showTodoListPanelOnlyWhenNotEmpty`. +- **What:** WS settings-sync partial update merges the new field. **File:** + `apps/web/lib/ws/handlers/users.test.ts`. **How:** add the field to an + existing `user.settings.updated` partial-payload case. +- **What:** `TodoListPanelSettings` shows the sub-option only when the main + toggle is on, preserves its state across main-toggle off/on, and saves both + fields. **File:** `apps/web/components/settings/todo-list-panel-settings.test.tsx`. + **How:** extend the existing component test: assert the sub-option switch + is absent with main off; toggle main on → sub-option appears; toggle + sub-option on; toggle main off → sub-option hidden; main on again → + sub-option still checked; save → `updateUserSettings` called with both + snake_case fields. +- **What:** `resolveConditionalTodoPanelAction` returns `"none"` for + `onlyPinWhenNotEmpty && !todoListNotEmpty` (absent panel), `"add"` when the + list is non-empty, and never `"remove"` from the sub-option; `syncConditionalTodoPanel` + passes the params through. **File:** + `apps/web/components/task/dockview-todo-panel-sync.test.ts`. **How:** extend + the existing `it.each` table with the new param combinations and add a + `syncConditionalTodoPanel` case proving no `addPanel` call when the list is + empty and the sub-option is on. + +--- + +## E2E Tests (Iteration 2) + +- **Scenario:** GIVEN the preference is off, WHEN the settings page opens, + THEN no "Only pin when todo list is not empty" switch is rendered; toggling + the main preference on reveals it, toggling off hides it again while a + previously saved `true` value survives a page reload. **File:** + `apps/web/e2e/tests/settings/todo-list-panel.spec.ts`. **What to verify:** + switch presence/absence via `getByRole("switch", { name: "Only pin when + todo list is not empty" })`, and the persisted value via + `apiClient.getUserSettings()`. +- **Scenario:** GIVEN main on + sub-option on, WHEN a task with no todo + entries opens, THEN no Todos tab is auto-pinned; WHEN a task whose session + already persisted todo entries opens, THEN the Todos tab is auto-pinned. + **File:** same spec file. **What to verify:** reuse `createTaskWithSession` + (no todos) and `createTaskWithTodos` (persisted `e2e:plan` entries), assert + `todosTabWrapper` count 0 vs visible, via `readTodosLayout` polling. + - `apps/backend/internal/user/models/models.go`: add `ShowTodoListPanel bool \`json:"show_todo_list_panel"\`` to the `UserSettings` struct beside `ShowTranscriptAutoScrollControl`. @@ -47,7 +226,7 @@ hook that wires the setting to the panel, then E2E coverage of the full flow. --- -## Frontend +## Frontend (Iteration 1) ### Settings state and persistence plumbing @@ -152,7 +331,7 @@ hook that wires the setting to the panel, then E2E coverage of the full flow. --- -## Tests +## Tests (Iteration 1) - **What:** merge-patch persists and returns `show_todo_list_panel`. **File:** `apps/backend/internal/user/service/service_test.go`. @@ -196,7 +375,7 @@ hook that wires the setting to the panel, then E2E coverage of the full flow. --- -## E2E Tests +## E2E Tests (Iteration 1) - **Scenario:** GIVEN the preference is off, WHEN a task opens, THEN no Todos tab exists in the right panel. **File:** @@ -223,6 +402,8 @@ hook that wires the setting to the panel, then E2E coverage of the full flow. ## Verification Results +### Iteration 1 (shipped) + All 5 tasks done. Full E2E suite for this feature (`cd apps/web && pnpm exec playwright test e2e/tests/settings/todo-list-panel.spec.ts`) passes (3/3), plus a joint run with `layout-profiles.spec.ts` to confirm no @@ -242,6 +423,32 @@ in the desktop workbench's and Layout Editor's — as opposed to the Office workbench's — separate Dockview component registries), are in Task 05's Results section. +### Iteration 2 (done) + +Adversarial review loop (DeepSeek V4 Pro, sub-tasks): round 1 APPROVE +(qualified) → N1/N3 fixed (`b2ae04fd`), N2 deferred; round 2 APPROVE → F1 +(per-field dirty) / F2 (single predicate computation) fixed (`11cfe336`); +round 3 APPROVE with **no new findings** (round-2 fixes re-verified). Loop +converged; details in task-07/task-08 Results. + +All 4 tasks done. Backend +(`cd apps/backend && go test ./internal/user/... ./internal/backendapp/...`): +all `internal/user/*` packages `ok`; `internal/backendapp` `ok` (the two +pre-existing GPG-environment failures recorded in Task 01 did not reproduce +here). Frontend unit (`cd apps/web && pnpm exec vitest run +components/settings/todo-list-panel-settings.test.tsx lib/ssr/user-settings.test.ts +lib/ws/handlers/users.test.ts hooks/use-ensure-user-settings.test.ts +components/task/dockview-todo-panel-sync.test.ts` — 101 tests total), `pnpm +run typecheck` (with `NODE_OPTIONS=--max-old-space-size=4096`; bare run +crashes node on this VM), `pnpm run i18n:check` (all 5 gates), and +`pnpm run i18n:pseudo` (regenerated) all clean. E2E +(`cd apps/web && pnpm e2e:raw --project=chromium +tests/settings/todo-list-panel.spec.ts`): **8 passed** (5 v1 + 3 new). One +mid-flight regression found and fixed by the E2E pass: an unstable zustand +selector in `useSyncTodoPanel` (`?? []` fallback) caused React error #185 +(maximum update depth) on every task page; fixed to return the stable stored +array or `undefined`. Full findings in Task 09's Results. + --- ## Implementation Waves And Parallel Candidates @@ -263,12 +470,35 @@ Wave 4 (depends on Wave 3): - [x] [Task 05 - E2E coverage](task-05-e2e-coverage.md) +Wave 5 (Iteration 2, depends on Wave 4): + +- [x] [Task 06 - Backend sub-setting field](task-06-backend-sub-setting-field.md) +- [x] [Task 07 - Frontend settings plumbing and UI](task-07-frontend-settings-plumbing.md) +- [x] [Task 08 - Conditional pin sync](task-08-conditional-pin-sync.md) +- [x] [Task 09 - E2E coverage](task-09-e2e-coverage.md) + Execution is sequential in the primary conversation by default. Tasks 01 and 02 touch disjoint backend/frontend files and are the only parallel-safe -candidates; a user may explicitly authorize running them together. +candidates of Iteration 1; a user may explicitly authorize running them +together. Iteration 2 tasks are sequential: 07 depends on 06's wire field +and 08 depends on 07's frontend state plumbing. ## Risks +- Iteration 2: the "not empty" predicate must use the panel content's exact + two-source fallback (live `sessionTodos.bySessionId` first, then persisted + `todo` messages via `buildTodoItems`); using only the live slice would skip + pinning for reopened completed sessions whose todos live in history (the + exact regression `task-05` E2E "shows the checklist for a session whose + todos were already persisted" covers). Subscribing to + `state.messages.bySession` is safe because the desktop workbench's chat + panel (`useChatPanelState` → `useSessionMessages`) is always mounted and + fetches the session's messages. +- Iteration 2: the sub-option must be hidden (inhibited), not rendered + disabled, when the main preference is off, and both fields must always be + included in the save payload so the sub-option's value survives + main-off saves. A disabled-but-visible switch would fail the "inhibited" + requirement. - `useSyncReviewPanel`'s deferred double-`requestAnimationFrame` pattern exists to avoid racing Dockview's own layout-restoration timing; the new `useSyncTodoPanel` must reuse that exact pattern rather than approximating @@ -286,6 +516,9 @@ candidates; a user may explicitly authorize running them together. - Everything listed in `docs/specs/ui/agent-todo-list-panel.md`'s Out of scope section (no backend todo-data changes, no removal of `TodoIndicator`/ - `TodoMessage`, no content-driven auto-show, no closed-for-session + `TodoMessage`, no content-driven auto-show by default, no closed-for-session suppression, no unseen-update badge, no mobile/tablet layout changes, no per-task override of the preference). +- Iteration 2 adds no auto-removal behavior: the "Only pin when todo list is + not empty" sub-option never removes an already-open Todos tab when the list + empties, and never changes the master preference's removal semantics. diff --git a/docs/plans/agent-todo-list-panel/task-06-backend-sub-setting-field.md b/docs/plans/agent-todo-list-panel/task-06-backend-sub-setting-field.md new file mode 100644 index 0000000000..9756333e33 --- /dev/null +++ b/docs/plans/agent-todo-list-panel/task-06-backend-sub-setting-field.md @@ -0,0 +1,90 @@ +--- +id: "06-backend-sub-setting-field" +title: "Backend sub-setting field" +status: done +wave: 5 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/agent-todo-list-panel.md" +--- + +# Task 06: Backend sub-setting field + +Add `ShowTodoListPanelOnlyWhenNotEmpty` (wire +`show_todo_list_panel_only_when_not_empty`) to the per-user settings round +trip on the backend, mirroring `ShowTodoListPanel` at every call site. The +field is merged independently of `ShowTodoListPanel`, so a PATCH can persist +it while the master preference is off. + +- **Acceptance:** + 1. `GET /api/v1/user/settings` returns + `show_todo_list_panel_only_when_not_empty: false` for a fresh user; + `PATCH /api/v1/user/settings` with + `{"show_todo_list_panel_only_when_not_empty": true}` persists and returns + `true` on the next `GET`, with `show_todo_list_panel` untouched. + 2. The boot/hydration payload includes + `showTodoListPanelOnlyWhenNotEmpty` beside `showTodoListPanel`. + 3. `go vet`/`go build` pass for the touched packages; new tests go red + before implementation and green after. +- **Verification:** `cd apps/backend && go test ./internal/user/... ./internal/backendapp/...` + (note: `internal/backendapp` has 2 pre-existing failures documented in + Task 01's Results — GPG commit-signing environment issue unrelated to this + change; re-confirm they are the same two before attributing anything here). +- **Files likely touched:** + - `apps/backend/internal/user/models/models.go` + - `apps/backend/internal/user/dto/dto.go` + - `apps/backend/internal/user/service/service.go` + - `apps/backend/internal/user/controller/controller.go` + - `apps/backend/internal/user/store/sqlite.go` + - `apps/backend/internal/user/store/sqlite_test.go` + - `apps/backend/internal/user/service/service_test.go` + - `apps/backend/internal/backendapp/boot_state_routes.go` +- **Dependencies:** None (backend-only; the frontend consumes the wire field + in Task 07). +- **Parallelism:** `sequential` (Iteration 2 runs sequentially in the primary + conversation). +- **Inputs:** Spec Data model / API surface sections + (`docs/specs/ui/agent-todo-list-panel.md`); plan's "Iteration 2 additions" + Backend section (`plan.md`); Task 01's diff as the exact pattern (add the + new field beside `ShowTodoListPanel` at every one of its call sites). + +## Results + +Red first: added `TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty` +(service_test.go, 3 subtests: omission preserves / explicit replaces / +explicit false disables) and `TestScanUserSettingsTodoListPanelOnlyWhenNotEmptyDefault` ++ `TestTodoListPanelOnlyWhenNotEmptyRoundTripThroughMarshalAndScan` +(sqlite_test.go). `go test ./internal/user/service/ ./internal/user/store/` +failed to compile with `unknown field ShowTodoListPanelOnlyWhenNotEmpty` at +every reference — confirmed red. + +Implemented `ShowTodoListPanelOnlyWhenNotEmpty` / +`show_todo_list_panel_only_when_not_empty` across `models.go`, `dto.go` +(DTO, update-request, `FromUserSettings`), `service.go` +(`UpdateUserSettingsRequest`, `applyTaskActionPreferences`, response map), +`controller.go` (DTO→service conversion), `store/sqlite.go` +(`marshalUserSettingsPayload`, `defaultUserSettings`, scan payload+apply), +and `boot_state_routes.go` (boot payload +`showTodoListPanelOnlyWhenNotEmpty`). Ran `gofmt -w` on all touched files. + +Commands: +- `cd apps/backend && go test ./internal/user/...` → all packages `ok` + (controller, dto, handlers, service, store). +- `cd apps/backend && go test ./internal/backendapp/...` → `ok`; the two + pre-existing failures recorded in Task 01 (`TestDetectBranchRemote_*`, + GPG signing env issue) did **not** reproduce in this environment, and no + new failures appeared. +- `cd apps/backend && golangci-lint run ./internal/user/... ./internal/backendapp/` + → clean. + +Lint note: adding the 36-char key to the boot-state map literal makes gofmt +re-align the whole key/value block, which trips a column-position-sensitive +goconst quirk that then reports pre-existing repeated map keys (`workflowId`, +`steps`, `tasks` in `boot_state.go`) — verified reproducible only with the +re-aligned columns and absent with HEAD's columns. Resolved by keeping the +original block's alignment and placing the new key in its own +comment-led group (gofmt-stable, lint-clean); extracted constants were +rejected because the quirk merely moves to the next repeated key +(whack-a-mole). Full `make lint` passes. + +Blockers/risks: none. diff --git a/docs/plans/agent-todo-list-panel/task-07-frontend-settings-plumbing.md b/docs/plans/agent-todo-list-panel/task-07-frontend-settings-plumbing.md new file mode 100644 index 0000000000..fa2511905f --- /dev/null +++ b/docs/plans/agent-todo-list-panel/task-07-frontend-settings-plumbing.md @@ -0,0 +1,132 @@ +--- +id: "07-frontend-settings-plumbing" +title: "Frontend settings plumbing and UI" +status: done +wave: 5 +depends_on: ["06-backend-sub-setting-field"] +plan: "plan.md" +spec: "../../specs/ui/agent-todo-list-panel.md" +--- + +# Task 07: Frontend settings plumbing and UI + +Plumb `showTodoListPanelOnlyWhenNotEmpty` through the frontend settings +state, extend `TodoListPanelSettings` with the inhibited sub-option, and +update the main toggle's copy to state explicitly that it auto-pins the +agent's live todo checklist and that the Todos tab can always be added +manually. + +- **Acceptance:** + 1. The sub-option switch labeled "Only pin when todo list is not empty" is + rendered only while the main "Show agent todo list panel" toggle is on; + with the main toggle off it is absent (inhibited), and its saved value + is preserved: toggling the main toggle off and on again restores the + sub-option in its previous state. + 2. Saving persists both fields: `updateUserSettings` is called with + `{ show_todo_list_panel, show_todo_list_panel_only_when_not_empty }`. + 3. The main toggle's description copy says the preference automatically + pins the checklist and that the Todos tab can always be added manually + (no em dash; i18n keys updated in `en`, regenerated `pseudo`, and + mirrored in `pt-pt`/`zh-cn`). + 4. `pnpm run typecheck`, `pnpm run i18n:check`, and the touched unit tests + pass; the component test goes red before implementation and green after. +- **Verification:** `cd apps/web && pnpm exec vitest run components/settings/todo-list-panel-settings.test.tsx lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts && pnpm run typecheck && pnpm run i18n:pseudo && pnpm run i18n:check` + (fresh worktree bootstrap first if needed: `cd apps && pnpm install --frozen-lockfile`). +- **Files likely touched:** + - `apps/web/lib/state/slices/settings/types.ts` + - `apps/web/lib/ssr/user-settings.ts` + - `apps/web/lib/ssr/user-settings.test.ts` + - `apps/web/lib/types/http-user-settings.ts` + - `apps/web/lib/ws/handlers/users.test.ts` + - `apps/web/components/settings/todo-list-panel-settings.tsx` + - `apps/web/components/settings/todo-list-panel-settings.test.tsx` + - `apps/web/e2e/helpers/api-client.ts` + - `apps/web/src/locales/en/settings.json` + - `apps/web/src/locales/pseudo/settings.json` (regenerated) + - `apps/web/src/locales/pt-pt/settings.json` + - `apps/web/src/locales/zh-cn/settings.json` +- **Dependencies:** Task 06 (wire field exists on the backend). +- **Parallelism:** `sequential`. +- **Inputs:** Spec What / Data model / API surface / Scenarios sections; + plan's "Iteration 2 additions" Frontend sections; the existing + `TodoListPanelSettings` component and its test as the base to extend; + `docs/i18n.md` for the pseudo-locale regeneration and em-dash rule. + +Implementation notes: + +- Keep the main toggle's `Label` text ("Show agent todo list panel") and + `id="show-todo-list-panel"` unchanged so existing E2E locators and the + existing component test keep working. +- Convert the component's single draft boolean to a + `{ show: boolean; onlyWhenNotEmpty: boolean }` pair; `isDirty` compares + both; `useSettingsSaveContributor` revision = `JSON.stringify(draft)`; + `save` sends both snake_case fields and updates both store fields; + `discard` restores both. The `useEffect` hydration sync must compare the + full pair. +- Sub-option row: `Label htmlFor="todo-list-panel-only-when-not-empty"` + + `Switch id="todo-list-panel-only-when-not-empty"`, rendered only when + `draft.show` is true (unmounted when off — inhibited, not disabled). +- New en copy for `pinTheAgentsLiveTodoChecklistAs`: "Automatically pin the + agent's live todo checklist as a Todos tab in the right panel. Even when + this is off, you can always add the Todos tab manually." New key + `onlyPinWhenTodoListIsNotEmpty`: "Only pin when todo list is not empty." + Run `pnpm run i18n:pseudo` to regenerate pseudo, then mirror both strings + into `pt-pt` and `zh-cn` (keep "Todos" untranslated as a UI noun). +- Component test additions: sub-option absent with main off; appears when + main toggled on; toggling main off hides it; main on again restores its + checked state; save calls `updateUserSettings` with both fields. +- SSR mapping test: `showTodoListPanelOnlyWhenNotEmpty` defaults to `false` + and maps from `show_todo_list_panel_only_when_not_empty: true`. + +## Results + +Red first: added the sub-option cases to +`components/settings/todo-list-panel-settings.test.tsx` (inhibited while +main off; appears checked when main on with preserved value; both fields in +the save payload; state survives a main off/on cycle without saving), the +SSR mapping case to `lib/ssr/user-settings.test.ts`, and the WS sync case to +`lib/ws/handlers/users.test.ts`. `pnpm exec vitest run <3 files>` → 5 failed +(expected assertions: `expected undefined to be false/true`, missing +sub-option switch, save payload without the new field). + +Implemented: +- `lib/state/slices/settings/types.ts`, `lib/ssr/user-settings.ts` (default + `false` + `buildBehaviorFields` mapping), `lib/types/http-user-settings.ts` + (both types), `e2e/helpers/api-client.ts` (response type). +- `components/settings/todo-list-panel-settings.tsx`: two-field + `{ show, onlyWhenNotEmpty }` draft, `JSON.stringify` revision, save sends + both snake_case fields and updates both store fields; sub-option row + rendered only when `draft.show` is true (inhibited, not disabled); main + toggle `Label`/`id` unchanged. +- i18n: `en/settings.json` — updated `pinTheAgentsLiveTodoChecklistAs` + ("Automatically pin the agent's live todo checklist as a Todos tab in the + right panel. Even when this is off, you can always add the Todos tab + manually.") and added `onlyPinWhenTodoListIsNotEmpty`; regenerated pseudo + (`pnpm run i18n:pseudo`, 2 additions/1 update); mirrored both into `pt-pt` + and `zh-cn`. +- `hooks/use-ensure-user-settings.test.ts`: added the new field to the + `makeUnloadedSettings()` fixture (required by `UserSettingsState`). + +Commands: +- `cd apps/web && pnpm exec vitest run components/settings/todo-list-panel-settings.test.tsx lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts` + → 62 passed (3 files), red→green. +- `cd apps/web && pnpm run typecheck` → clean (first attempt crashed node + with exit 134 on this VM; re-ran with `NODE_OPTIONS=--max-old-space-size=4096` + → clean; one TS error `Property 'showTodoListPanelOnlyWhenNotEmpty' is + missing` in the fixture fixed). +- `cd apps/web && pnpm exec vitest run hooks/use-ensure-user-settings.test.ts` + → 8 passed. +- `cd apps/web && pnpm run i18n:check` → all 5 gates pass ("i18n keys OK — + 6631 key(s) referenced, 7890 en entries, 10 orphan(s), pseudo in sync. 35 + advisory pt-pt, zh-cn issue(s)" — advisory real-locale gaps only). + +Blockers/risks: none. + +Reviewer-feedback follow-up (round 2, finding F1 = round-1 N2): both +switches now carry per-field `data-settings-dirty` +(`draft.show !== saved.show` / `draft.onlyWhenNotEmpty !== +saved.onlyWhenNotEmpty`) instead of the shared card-level flag, matching the +established per-control pattern (e.g. `lsp-language-cards.tsx`). Added a +component test "marks each toggle dirty only for its own unsaved change" +(four render blocks deduplicated into `renderTodoListPanelSettings` to stay +under the 100-line lint cap). diff --git a/docs/plans/agent-todo-list-panel/task-08-conditional-pin-sync.md b/docs/plans/agent-todo-list-panel/task-08-conditional-pin-sync.md new file mode 100644 index 0000000000..7186181a7d --- /dev/null +++ b/docs/plans/agent-todo-list-panel/task-08-conditional-pin-sync.md @@ -0,0 +1,261 @@ +--- +id: "08-conditional-pin-sync" +title: "Conditional pin sync" +status: done +wave: 5 +depends_on: ["07-frontend-settings-plumbing"] +plan: "plan.md" +spec: "../../specs/ui/agent-todo-list-panel.md" +--- + +# Task 08: Conditional pin sync + +Gate the automatic Todos pin on the "Only pin when todo list is not empty" +preference: when it is on, the sync hook must not add the `todos` panel while +the active session's todo list is empty, using the same two-source todo +fallback the panel content uses (live `sessionTodos.bySessionId` first, then +persisted `todo` messages via `buildTodoItems`). The sub-option never removes +an existing panel and never affects manual adds. + +- **Acceptance:** + 1. `resolveConditionalTodoPanelAction` returns `"none"` (not `"add"`) when + `onlyPinWhenNotEmpty` is true and `todoListNotEmpty` is false and the + panel is absent; `"add"` when the list is non-empty; and never + `"remove"` as a result of the sub-option (removal stays gated solely on + `showTodoListPanel`). + 2. `useSyncTodoPanel` subscribes to the active session's todo state (live + slice + persisted messages) and re-runs the sync when it changes, so the + panel appears as soon as todo entries arrive (live WS or hydrated + history). + 3. Unit tests go red before implementation and green after; + `pnpm run typecheck` passes. +- **Verification:** `cd apps/web && pnpm exec vitest run components/task/dockview-todo-panel-sync.test.ts && pnpm run typecheck` + (fresh worktree bootstrap first if needed: `cd apps && pnpm install --frozen-lockfile`). +- **Files likely touched:** + - `apps/web/components/task/dockview-todo-panel-sync.ts` + - `apps/web/components/task/dockview-todo-panel-sync.test.ts` +- **Dependencies:** Task 07 (store field `showTodoListPanelOnlyWhenNotEmpty` + exists in `UserSettingsState`). +- **Parallelism:** `sequential`. +- **Inputs:** Spec What / Scenarios sections + (`docs/specs/ui/agent-todo-list-panel.md`); plan's "Conditional-pin sync" + section; `apps/web/components/task/todos-panel-content.tsx` as the exact + two-source fallback reference; `buildTodoItems` + (`apps/web/hooks/use-processed-messages.ts`). + +Implementation notes: + +- `resolveConditionalTodoPanelAction` gains `onlyPinWhenNotEmpty` and + `todoListNotEmpty` params; insert the new guard after the + restoring/maximized guards and only in the add path: + `if (params.onlyPinWhenNotEmpty && !params.todoListNotEmpty) return "none";`. +- `syncConditionalTodoPanel` options gain the same two params and pass them + through. +- In `useSyncTodoPanel`, derive the active session's todo state: + - live: `useAppStore((s) => (sessionId ? s.sessionTodos.bySessionId[sessionId] : undefined))` + - persisted: `useAppStore((s) => (sessionId ? s.messages.bySession[sessionId] : EMPTY))` + then `useMemo(() => buildTodoItems(messages), [messages])` + - `todoListNotEmpty = (live?.length ?? 0) > 0 || messageTodos.length > 0` + Read `live.userSettings.showTodoListPanelOnlyWhenNotEmpty` inside the + effect (alongside the existing `showTodoListPanel` read), pass both new + values into `syncConditionalTodoPanel`, and add `todoListNotEmpty` + + `onlyPinWhenNotEmpty` to the effect dependency array. +- Do not mount `useSessionMessages` here — the desktop workbench's chat + panel always fetches the session's messages; subscribing to the store + slice is enough and avoids duplicating fetch/subscription side effects. +- Test additions: extend the `it.each` table with + `{ showTodoListPanel: true, onlyPinWhenNotEmpty: true, todoListNotEmpty: false, panelExists: false }` + → `"none"` and the non-empty variant → `"add"`; add a + `syncConditionalTodoPanel` case asserting `addPanel` is not called with the + sub-option on and an empty list; assert an existing panel is untouched + (no `close`) in that case. + +## Results + +Red first: extended `resolveConditionalTodoPanelAction`'s `it.each` table +with 5 sub-option rows (empty list + sub on → `"none"`; non-empty → `"add"`; +existing panel untouched; sub off → unchanged `"add"`; master off still +`"remove"`) and added two `syncConditionalTodoPanel` cases (no addPanel with +empty list + sub on; addPanel with non-empty list). `pnpm exec vitest run +components/task/dockview-todo-panel-sync.test.ts` → 2 failed (the empty-list +suppression rows returned `"add"`/called `addPanel`). + +Implemented in `components/task/dockview-todo-panel-sync.ts`: +- `resolveConditionalTodoPanelAction` gains `onlyPinWhenNotEmpty` + + `todoListNotEmpty` params; new guard `if (params.onlyPinWhenNotEmpty && + !params.todoListNotEmpty) return "none";` after the restoring/maximized + guards, in the add path only — never affects `"remove"` or an existing + panel. +- `syncConditionalTodoPanel` options gain both params and pass them through. +- `useSyncTodoPanel` subscribes to the active session's todo state + (`sessionTodos.bySessionId[sessionId]` live + `messages.bySession[sessionId]` + persisted via `buildTodoItems`), computes + `todoListNotEmpty = liveTodos.length > 0 || buildTodoItems(messages).length > 0`, + reads `live.userSettings.showTodoListPanelOnlyWhenNotEmpty`, passes both + into `syncConditionalTodoPanel`, and adds `onlyPinWhenNotEmpty` + + `todoListNotEmpty` to the effect deps so the panel appears as soon as todo + entries arrive. No `useSessionMessages` mount (chat panel fetches messages). +- Test `DEFAULT_OPTIONS` widened with `onlyPinWhenNotEmpty: false, + todoListNotEmpty: false` so existing `syncConditionalTodoPanel` calls stay + assignable. + +Commands: +- `cd apps/web && pnpm exec vitest run components/task/dockview-todo-panel-sync.test.ts` + → 23 passed (2 red → green). +- `cd apps/web && pnpm run typecheck` (with + `NODE_OPTIONS=--max-old-space-size=4096`) → clean. + +### Reviewer-feedback refinement (adversarial review, APPROVE qualified) + +The DeepSeek V4 Pro adversarial review (sub-task +`4c855132-fc54-4e8c-924e-3a45bcda460b`) found 3 items; two were acted on: + +- **N1 (minor, suspected)** — the inner RAF callback captured the + render-time `todoListNotEmpty` memo; a WS todo event landing between render + and rAF dispatch (~16ms) could make the pin decision one frame stale. + Fixed by extracting `todoListNotEmptyForSession(liveTodos, messages)` (the + panel's exact two-source fallback, exported so the contract is testable) + and recomputing it inside the callback from the dispatch-time + `appStore.getState()` snapshot. The render-time memo stays as the effect's + change signal in the dep array. Added 4 unit tests pinning the helper + (empty, live-wins, persisted-fallback, empty-live-array-falls-through). +- **N3 (nit, confirmed)** — dropped the now-unnecessary + `as unknown as Partial` cast in + `lib/ws/handlers/users.test.ts` (the field is statically typed). +- **N2 (nit, confirmed)** — card-level `data-settings-dirty` on both toggles; + fixed in the round-2 pass as F1 (per-field dirty flags, see Task 07 + results). +- **F2 (minor, suspected, round 2)** — the render-time `todoListNotEmpty` + memo and the RAF-dispatch recompute each called `buildTodoItems` (two O(n) + scans per effect cycle). Fixed by removing the memo entirely: the effect's + change signal is now the raw `liveTodos`/`messages` slice identities in the + dependency array (they only change when the underlying todo/message data + actually changes), so the predicate is computed exactly once per sync — in + the dispatch callback, from the dispatch-time snapshot. `useMemo` import + dropped. + +Post-fix verification: sync+WS unit tests 49 passed; `pnpm run typecheck` +clean; `make fmt` + `make lint` clean; E2E +`todo-list-panel.spec.ts` 8/8 passed (web rebuilt). Committed as follow-up +`fix:` on the feature branch. + +### Round-4 reviewer feedback (OMP GPT Luna) and disposition + +- **Finding 1 (major, confirmed by reviewer): "branch contains unrelated + changes"** — review-base artifact, not a defect: the reviewer diffed + against the stale local `main` ref (c0f5750b); against `origin/main` the + branch is exactly the four feature commits / 31 files + (`git diff origin/main...HEAD --stat`). No action; round-5 brief instructs + the reviewer to use `origin/main` as the base. +- **Finding 2 (major, confirmed): malformed persisted todo metadata crashes + `buildTodoItems`** — `metadata.todos` as a non-array (object/primitive) + throws `TypeError: ...?.todos?.map is not a function`, and `[null]` + elements throw on `item.text`. The round-1 fix made this a sync hot-path + call, so a malformed message would throw inside the RAF callback and the + sync (including panel removal with the master pref off) would silently + fail; the same throw also hits the panel content and chat processing, and + violates the spec's "malformed todo message falls back to an empty state". + Fixed by making `buildTodoItems` total: runtime `typeof`/`in` narrowing + instead of inline casts, `Array.isArray(todos)` guard, and a type-guard + filter dropping null/primitive entries. Added malformed-shape unit tests + (non-array object, primitive, `[null, 42, valid]`, primitive metadata, + missing metadata) at both the `buildTodoItems` and + `todoListNotEmptyForSession` levels (2 red → green). Committed as follow-up + `fix:`. + +### Round-5 reviewer feedback (OMP GPT Luna) and disposition — all five fixed + +1. **F1 (major): sessionless tasks bypass visibility sync** — `useSyncTodoPanel` + early-returned on `!sessionId`, so a `todos` tab materialized from a saved + layout on a task without a session could never be removed by the master + preference. Fixed: the guard now requires only `taskId`/`workspaceId`/`hasApi`; + a null session is treated as an empty todo list (never enables an add) while + still allowing the removal path. +2. **F2 (major): malformed todo metadata still crashed chat rendering** — + `TodoMessage`'s own `normalizeTodos` (non-array `todos`, `[null]` entries, + `previous_todo_snapshots` non-array) threw, bypassing the round-4 + `buildTodoItems` hardening. Fixed: `normalizeTodos` is now total (unknown + input, `Array.isArray`, type-guard filter dropping null/primitive/empty-text + entries), `parseTodos`/`parseSnapshots` narrow via `typeof`/`in` instead of + inline casts, and a new `todo-message.test.tsx` pins all malformed shapes + (5 tests, red → green). +3. **F3 (major): delayed PATCH could clobber a newer WS settings update** — the + save callback unconditionally echoed the submission into the store; a WS + push landing mid-flight would be reverted. Fixed: snapshot the store before + the PATCH and only echo when it has not drifted; otherwise the server's own + settings broadcast owns convergence. New component test with a deferred + save + mid-flight store update (red → green). +4. **F4 (minor): empty-text entries counted as non-empty** — the round-4 + type-guard filter (`typeof item.text === "string"`) kept `{ text: "" }`, + which the pre-fix code dropped; a blank entry could trigger pinning. Fixed: + require `item.text.length > 0` in `buildTodoItems` (and the mirrored + `TodoMessage` filter); regression tests at both levels. +5. **F5 (minor): unbounded transcript scan when the sub-option is off** — the + sync computed `buildTodoItems` (O(n) copy+scan) on every message-array + update even when `onlyPinWhenNotEmpty` is false. Fixed: the predicate is + only computed when the sub-option is on (and a session exists); otherwise + `false` is passed (the decision ignores it). + +Verification: 846 tests across the touched areas (chat, settings, sync, +processed-messages, SSR/WS) pass; `pnpm run typecheck`, `make fmt`, `make +lint` clean; E2E `todo-list-panel.spec.ts` 8/8 (web rebuilt). Committed as +follow-up `fix:`. + +### Round-6 reviewer feedback (OMP GPT Luna) and disposition — all three fixed + +1. **G1 (major): malformed snapshot entries still crashed TodoMessage** — + `parseSnapshots` validated array-ness but not entries, so + `previous_todo_snapshots: [null]` threw in `SnapshotHistory` on expand. + Fixed: entries are filtered to non-null objects; malformed `snapshot.todos` + was already neutralized by the unknown-safe `normalizeTodos`. New tests + click the "Earlier updates" toggle with `[null, 42, valid]` and malformed + snapshot `todos` (red → green). +2. **G2 (major): sessionless tasks could still receive an automatic Todos tab** + — the round-5 fix enabled removal for null sessions but the add path still + fired when the sub-option was off (the resolver ignored the empty-list + predicate). Fixed: `resolveConditionalTodoPanelAction` gained a required + `hasActiveSession` param gating the add path; the hook passes + `sessionId !== null`. Removal for sessionless tasks is preserved. New + decision-table rows (never-add, sessionless-remove, sessionless-keeps) and + two `syncConditionalTodoPanel` tests (red → green). +3. **G3 (major): delayed save could leave the UI falsely clean and stale** — + the round-5 drift guard skipped the store echo but still ran + `setSaved(submitted)`, overwriting the newer WS hydration baseline so + `isDirty` flipped false against a stale submission. Fixed: on drift the + save returns without touching `saved`, keeping the draft dirty against the + newer baseline so the user can reconcile; the F3 test now asserts the + dirty flag stays on (red → green). + +Verification: 853 tests across the touched areas pass; `pnpm run typecheck`, +`make fmt`, `make lint` clean; E2E `todo-list-panel.spec.ts` 8/8 (web +rebuilt). Committed as follow-up `fix:`. + +### Round-9 reviewer feedback (OMP GPT Luna) and disposition + +1. **J1 (major): full transcript scan on every message update while the + sub-option is on** — analyzed, NOT fixed by the suggested incremental + tracker: a prototype tracker that only inspected the newest message after + a todo-free scan was demonstrated UNSOUND — when the first sync runs + before the messages fetch completes (`[]`), then persisted history with a + mid-array `todo` message and a plain final message lands, the tracker + returns false forever and the auto-pin for reopened completed tasks (a + passing E2E scenario) breaks. A sound incremental version requires the + messages store to expose todo-relevant derived state, which it does not + (out of scope). The current behavior is already bounded: the predicate is + computed only when the opt-in sub-option is on, and the live + `sessionTodos` slice short-circuits the message scan while the session + has live entries. Documented in the hook comment; the E2E + persisted-history pin continues to pass. +2. **J2 (minor): hook tests did not model rAF cancellation or frame + boundaries** — FIXED: the hook-test harness now uses stable frame IDs + backed by a pending-callback map, `cancelAnimationFrame` deletes the + pending callback, and frames flush one at a time (outer schedules inner + for the next flush). Added a between-frames test: a preference change + after the outer frame cancels the pending inner, and exactly zero sync + side effects fire for the stale state. + +Verification: 862 tests across the touched areas pass; `pnpm run typecheck`, +`make fmt`, `make lint` clean; E2E `todo-list-panel.spec.ts` 8/8 (web +rebuilt). Committed as follow-up `fix:`/`test:`. + +Blockers/risks: none. diff --git a/docs/plans/agent-todo-list-panel/task-09-e2e-coverage.md b/docs/plans/agent-todo-list-panel/task-09-e2e-coverage.md new file mode 100644 index 0000000000..c3b4a94b3a --- /dev/null +++ b/docs/plans/agent-todo-list-panel/task-09-e2e-coverage.md @@ -0,0 +1,115 @@ +--- +id: "09-e2e-coverage" +title: "E2E coverage" +status: done +wave: 5 +depends_on: ["08-conditional-pin-sync"] +plan: "plan.md" +spec: "../../specs/ui/agent-todo-list-panel.md" +--- + +# Task 09: E2E coverage + +Extend `apps/web/e2e/tests/settings/todo-list-panel.spec.ts` with the +Iteration 2 user-facing scenarios: sub-option visibility/inhibition with +state preservation, and the only-pin-when-not-empty runtime behavior against +both an empty-todo session and a session whose todos are already persisted in +message history. + +- **Acceptance:** + 1. New E2E tests cover: sub-option switch absent with the main preference + off; visible after turning the main preference on; hidden again after + turning it off; previously saved `true` value survives a page reload and + shows checked when the main preference is re-enabled. + 2. Runtime tests cover: main on + sub-option on + task with no todos → + no Todos tab; main on + sub-option on + task with persisted todos + (`e2e:plan` script) → Todos tab auto-pinned. + 3. The full spec file passes: `pnpm exec playwright test + e2e/tests/settings/todo-list-panel.spec.ts` (expect 6/6 with the 4 + existing + 2 new tests, or the exact count reported). +- **Verification:** `cd apps/web && pnpm exec playwright test e2e/tests/settings/todo-list-panel.spec.ts` + (requires the e2e backend harness; run via the repo's documented e2e flow + from `apps/web/e2e/README.md`). +- **Files likely touched:** + - `apps/web/e2e/tests/settings/todo-list-panel.spec.ts` + - `apps/web/e2e/helpers/api-client.ts` (only if Task 07 did not already add + the `show_todo_list_panel_only_when_not_empty` response type) +- **Dependencies:** Task 08 (runtime behavior exists), Task 07 (settings UI + exists). +- **Parallelism:** `sequential` (E2E follows the frontend changes it covers). +- **Inputs:** Spec Scenarios section + (`docs/specs/ui/agent-todo-list-panel.md`); the existing + `todo-list-panel.spec.ts` helpers (`createTaskWithSession`, + `createTaskWithTodos`, `setTodoListPanelPreference`, `readTodosLayout`, + `todosTabWrapper`) as the base; the settings-page switch locator pattern + `getByRole("switch", { name: ... })`. + +Implementation notes: + +- Settings-page test flow (reuse the existing second-tab pattern from the + "adds then removes the tab live" test, or a fresh page to + `/settings/general/task-actions`): + 1. Assert the sub-option switch is absent while the main toggle is off. + 2. Click the main toggle ("Show agent todo list panel") → sub-option + appears; toggle it on; save; assert + `apiClient.getUserSettings()` reports + `show_todo_list_panel_only_when_not_empty: true`. + 3. Toggle the main preference off; save; assert the sub-option switch is + absent on the live page AND the persisted settings still report the + sub-option `true`. + 4. Reload the settings page; toggle the main preference on; assert the + sub-option switch appears already checked (state preserved). +- Runtime tests: + - "does not pin for an empty todo list": set both preferences true via + `rawRequest("PATCH", "/api/v1/user/settings", { show_todo_list_panel: + true, show_todo_list_panel_only_when_not_empty: true })`, open a task + from `createTaskWithSession` (no todos), assert `todosTabWrapper` count 0 + and `readTodosLayout` reports `todosExists: false` (poll 15s). + - "pins when the todo list is not empty": same preferences, open a task + from `createTaskWithTodos` (persisted `e2e:plan` entries), assert + `todosTabWrapper` visible and `todosExists: true` beside Files/Changes. +- Keep `test.beforeEach`/`afterEach` baseline restore behavior correct for + the new field: capture and restore + `show_todo_list_panel_only_when_not_empty` alongside + `show_todo_list_panel`. + +## Results + +Added three tests to `apps/web/e2e/tests/settings/todo-list-panel.spec.ts`: + +1. "inhibits the only-pin-when-not-empty sub-option while the main preference + is off, preserving its value" — settings page: sub-option absent with main + off; appears when main on; persists `show_todo_list_panel_only_when_not_empty: + true` via API; hidden (inhibited) when main turned off while the saved + value survives; survives a page reload; reappears checked when main is + re-enabled. +2. "does not auto-pin the Todos tab for an empty todo list when + only-pin-when-not-empty is on" — both prefs true + task with no todos → + `todosExists: false` beside Files/Changes. +3. "auto-pins the Todos tab when the todo list is not empty and + only-pin-when-not-empty is on" — both prefs true + task with persisted + `e2e:plan` todos → tab pinned beside Files/Changes. + +Also extended `setTodoListPanelPreference` helpers (new +`setOnlyPinWhenNotEmptyPreference`) and `beforeEach`/`afterEach` baseline +capture/restore for the new field. + +Command: +`cd apps/web && pnpm e2e:raw --project=chromium tests/settings/todo-list-panel.spec.ts` + +First run: **1 passed, 7 failed** — every task-workbench test (including the +pre-existing v1 ones) hit "This page couldn't load." Trace inspection +(`0-trace.trace`) showed `Minified React error #185` (maximum update depth +exceeded) in the desktop layout render. Root cause: my +`useSyncTodoPanel` selector `state.sessionTodos.bySessionId[sessionId] ?? []` +returned a fresh `[]` on every read, so zustand re-rendered infinitely. +Fixed by returning the stable stored array or `undefined` +(`(liveTodos?.length ?? 0) > 0`), reran sync unit tests (23 passed) + +typecheck, rebuilt web (`make build-web`), and re-ran the spec. + +Second run: **8 passed (1.3m)** — all 5 v1 tests + 3 new tests green. + +Prereqs built for the run: `make -C apps/backend build`, `make -C apps/backend +e2e-plugin-package`, `make build-web`. + +Blockers/risks: none. diff --git a/docs/specs/ui/agent-todo-list-panel.md b/docs/specs/ui/agent-todo-list-panel.md index 0b0933708c..535c4201e4 100644 --- a/docs/specs/ui/agent-todo-list-panel.md +++ b/docs/specs/ui/agent-todo-list-panel.md @@ -1,6 +1,7 @@ --- status: shipped created: 2026-08-03 +updated: 2026-08-11 owner: kandev --- @@ -20,12 +21,29 @@ to do so. Users need a Settings option to enable or disable a persistent ## What -- `Settings > General` exposes a single boolean preference, "Show agent todo - list panel" (default **off**, preserving today's behavior for every existing - user). The preference is a true, unconditional visibility gate: while it is - off, no task's right panel ever shows a Todos tab, regardless of what any - saved layout or profile records for that panel. While it is on, every open - and subsequently opened desktop task's right panel shows a Todos tab. +- `Settings > General > Task Actions` exposes a boolean preference, "Show agent + todo list panel" (default **off**, preserving today's behavior for every + existing user). Its description states explicitly that it automatically + pins the agent's live todo checklist as a Todos tab in the right panel, and + that the Todos tab can always be added manually even while the preference is + off. The preference remains the master visibility gate: while it is off, no + task's right panel ever shows a Todos tab automatically, regardless of what + any saved layout or profile records for that panel. While it is on, every + open and subsequently opened desktop task's right panel shows a Todos tab, + subject to the sub-option below. +- A sub-option, "Only pin when todo list is not empty" (default **off**), is + rendered beneath the main toggle and appears only while the main preference + is on. It is never rendered disabled: turning the main preference off + inhibits it (hides it entirely) while preserving its saved value, so + re-enabling the main preference restores the sub-option in its previous + state. When the sub-option is on, the automatic pin adds the Todos tab only + when the active session's todo list is not empty — "not empty" meaning the + same two-source fallback the panel content uses: live + `sessionTodos.bySessionId` entries, or the latest persisted `todo`-type + message (`buildTodoItems`) when the session completed before the page + loaded. The sub-option gates only the automatic pin: it never removes an + already-open Todos tab, and it never affects the workbench "+" menu's + always-available manual Todos row. - The **Todos** panel is registered as a new reusable, single-instance panel id (`todos`), alongside Agent, Files, Changes, PR Details, Terminal, Plan, Browser, and VS Code (`REUSABLE_PANEL_IDS`/`KNOWN_PANEL_IDS`, @@ -50,7 +68,10 @@ to do so. Users need a Settings option to enable or disable a persistent `todos`, add it as an inactive tab (so it never steals focus) in the group and tab index configured by the user's custom Default layout when that layout configures a `todos` placement, otherwise beside Files and Changes - in the pinned right column's top group. + in the pinned right column's top group. When the "Only pin when todo list + is not empty" sub-option is on, the add is suppressed while the active + session's todo list is empty; the sync re-runs (and then adds) as soon as + todo entries arrive, either live via WS or via persisted message history. - **Off:** if the active task's live layout contains `todos`, remove it. - Once present, the Todos tab has a normal close control and normal drag/reorder/split behavior — no special restriction is introduced. Closing @@ -97,11 +118,12 @@ to do so. Users need a Settings option to enable or disable a persistent No new entity. Reuses the existing per-user settings JSON blob (`apps/backend/internal/user/models/models.go`'s `UserSettings` struct, -persisted in SQLite) by adding one field: +persisted in SQLite) by adding two boolean fields: | Field | Type | Constraint | |---|---|---| | `ShowTodoListPanel` (Go) / `showTodoListPanel` (frontend) / `show_todo_list_panel` (wire) | boolean | Defaults to `false` for new and existing users; no migration needed since the JSON blob is read with per-field defaults. | +| `ShowTodoListPanelOnlyWhenNotEmpty` (Go) / `showTodoListPanelOnlyWhenNotEmpty` (frontend) / `show_todo_list_panel_only_when_not_empty` (wire) | boolean | Defaults to `false`. Independent of `ShowTodoListPanel`: turning the master preference off preserves this value, which is what lets the sub-option reappear in its saved state. | Reuses the existing `sessionTodos.bySessionId: Record` frontend store slice (`apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts`) @@ -119,21 +141,25 @@ alongside `pr-detail`; it carries no task-specific keyed variant (unlike No new endpoint. Extends the existing per-user settings contract: -- `GET /api/v1/user/settings` response gains `show_todo_list_panel: boolean`. -- `PATCH /api/v1/user/settings` accepts `show_todo_list_panel?: boolean` as a - partial update, following the same merge-patch semantics as every other - boolean field on that endpoint (e.g. `show_transcript_auto_scroll_control`). +- `GET /api/v1/user/settings` response gains `show_todo_list_panel: boolean` + and `show_todo_list_panel_only_when_not_empty: boolean`. +- `PATCH /api/v1/user/settings` accepts `show_todo_list_panel?: boolean` and + `show_todo_list_panel_only_when_not_empty?: boolean` as partial updates, + following the same merge-patch semantics as every other boolean field on + that endpoint (e.g. `show_transcript_auto_scroll_control`). Each field is + merged independently, so the sub-option can be persisted while the master + preference is off. - The boot/hydration payload (`apps/backend/internal/backendapp/boot_state_routes.go`) - gains `showTodoListPanel` alongside the other camelCase display-preference - fields it already returns. + gains `showTodoListPanel` and `showTodoListPanelOnlyWhenNotEmpty` alongside + the other camelCase display-preference fields it already returns. - No new WebSocket event type. The existing user-settings WS sync path (`apps/web/lib/ws/handlers/users.ts`) carries the new field like every other settings field. ## Failure modes -- If the settings PATCH fails, the Settings page keeps the unsaved toggle - state and reports the error; the previously persisted value and the +- If the settings PATCH fails, the Settings page keeps the unsaved state of + both toggles and reports the error; the previously persisted values and the workbench's current tab state are unchanged (matching the existing Settings save-contributor failure behavior used by every other toggle on the page). - If the active task has no open Dockview API yet (task still loading) when @@ -174,7 +200,31 @@ No new endpoint. Extends the existing per-user settings contract: `TodoIndicator` popover shows for that session. - **GIVEN** the preference is on and the active session has not emitted any todo entries yet, **WHEN** the user selects the Todos tab, **THEN** it shows - an empty state rather than being absent. + an empty state rather than being absent (with the "Only pin when todo list + is not empty" sub-option off). +- **GIVEN** the preference is off, **WHEN** the user opens + `Settings > General > Task Actions`, **THEN** only the main "Show agent todo + list panel" toggle is rendered; the "Only pin when todo list is not empty" + option is not shown. +- **GIVEN** the preference is off and "Only pin when todo list is not empty" + was previously saved on, **WHEN** the user turns the main preference on and + saves, **THEN** the sub-option reappears in its previously saved (on) state + and remains independently toggleable. +- **GIVEN** the preference is on and "Only pin when todo list is not empty" + is on, **WHEN** a task whose active session has no todo entries (no live + `sessionTodos` and no persisted `todo` message) opens, **THEN** no Todos tab + is auto-pinned. +- **GIVEN** the preference is on and "Only pin when todo list is not empty" + is on, **WHEN** a task whose active session has todo entries (live or in + persisted message history) opens, **THEN** the Todos tab is auto-pinned + beside Files and Changes. +- **GIVEN** the preference is on and "Only pin when todo list is not empty" + is off, **WHEN** a task with an empty todo list opens, **THEN** the Todos + tab is auto-pinned (unchanged v1 behavior). +- **GIVEN** the preference is off and "Only pin when todo list is not empty" + is on (saved), **WHEN** the user adds the Todos tab from the task + workbench's "+" menu, **THEN** the tab opens normally, because manual adds + are never gated by either option. - **GIVEN** the preference is on, **WHEN** the user turns it off and saves, **THEN** the Todos tab disappears from every currently open task's right panel immediately, without requiring a reload, even for a task whose saved @@ -206,9 +256,11 @@ No new endpoint. Extends the existing per-user settings contract: parsing, `SessionTodosEventPayload`). - Removing, hiding, or changing the existing inline `TodoIndicator` chip or `TodoMessage` transcript cards. -- Auto-showing the tab only when the session actually has todo entries (the - preference is a manual on/off switch, not a content-driven auto-show like - PR Details' review-linkage trigger). +- Content-driven auto-show by default: the master preference stays a manual + on/off switch, not a content-driven auto-show like PR Details' review-linkage + trigger. Only the opt-in "Only pin when todo list is not empty" sub-option + gates the automatic pin on content, and it gates only adds — it never + auto-removes an already-open Todos tab when the list empties. - A closed-for-session suppression memory analogous to PR Details' — closing the tab while the preference is on is temporary, not sticky. - An "unseen update" badge/dot on the Todos tab (Plan's `PlanTab` has one;