feat: add only-pin-when-not-empty option for the todo list panel (#2543)

This commit is contained in:
Clément Fiere
2026-08-12 09:15:11 +02:00
committed by GitHub
parent c4b4791de6
commit 115c8511c2
35 changed files with 2776 additions and 704 deletions
@@ -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,
@@ -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
+171 -168
View File
@@ -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),
}
}
+59 -58
View File
@@ -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 {
+116 -111
View File
@@ -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))
@@ -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,
+146 -140
View File
@@ -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
}
@@ -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 {
@@ -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<void> {
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<UserSettingsState> = {},
): StoreApi<AppState> {
let storeApi: StoreApi<AppState> | null = null;
function StoreProbe() {
storeApi = useAppStoreApi();
return null;
}
render(
<StateProvider
initialState={{
userSettings: { ...defaultState.userSettings, ...userSettingsOverrides },
}}
>
<StoreProbe />
<SettingsSaveProvider>
<TodoListPanelSettings />
</SettingsSaveProvider>
</StateProvider>,
);
return storeApi!;
}
describe("TodoListPanelSettings", () => {
it("keeps an enabled preference local until Save changes persists it", async () => {
render(
<StateProvider
initialState={{
userSettings: { ...defaultState.userSettings, showTodoListPanel: false },
}}
>
<SettingsSaveProvider>
<TodoListPanelSettings />
</SettingsSaveProvider>
</StateProvider>,
);
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");
});
});
@@ -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<TodoListPanelDraft>({
show: showTodoListPanel,
onlyWhenNotEmpty: onlyPinWhenNotEmpty,
});
const [draft, setDraft] = useState<TodoListPanelDraft>(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() {
<CardTitle className="text-base">{t("settings:todoListPanel")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-todo-list-panel">{t("settings:showAgentTodoListPanel")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</p>
<div className="space-y-4">
<div className="flex min-h-11 items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="show-todo-list-panel">{t("settings:showAgentTodoListPanel")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings:pinTheAgentsLiveTodoChecklistAs")}
</p>
</div>
<Switch
id="show-todo-list-panel"
checked={draft.show}
data-settings-dirty={draft.show !== saved.show}
onCheckedChange={(show) => setDraft((current) => ({ ...current, show }))}
className="shrink-0 cursor-pointer"
/>
</div>
<Switch
id="show-todo-list-panel"
checked={draft}
data-settings-dirty={isDirty}
onCheckedChange={setDraft}
className="shrink-0 cursor-pointer"
/>
{draft.show && (
<div className="flex min-h-11 items-center justify-between gap-4 border-t pt-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="todo-list-panel-only-when-not-empty">
{t("settings:onlyPinWhenTodoListIsNotEmpty")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:onlyPinWhenTodoListIsNotEmptyDescription")}
</p>
</div>
<Switch
id="todo-list-panel-only-when-not-empty"
checked={draft.onlyWhenNotEmpty}
data-settings-dirty={draft.onlyWhenNotEmpty !== saved.onlyWhenNotEmpty}
onCheckedChange={(onlyWhenNotEmpty) =>
setDraft((current) => ({ ...current, onlyWhenNotEmpty }))
}
className="shrink-0 cursor-pointer"
/>
</div>
)}
</div>
</CardContent>
</SettingsCard>
@@ -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(<TodoMessage comment={makeTodoMessage({ todos: { text: "x" } })} />),
).not.toThrow();
expect(screen.queryByText(/Updated Todos/)).toBeNull();
});
it("renders nothing instead of throwing when todos is a primitive", () => {
expect(() => render(<TodoMessage comment={makeTodoMessage({ todos: "x" })} />)).not.toThrow();
});
it("renders nothing instead of throwing when metadata is a primitive", () => {
expect(() => render(<TodoMessage comment={makeTodoMessage("not-an-object")} />)).not.toThrow();
});
it("drops null and primitive entries, keeping valid ones", () => {
render(
<TodoMessage
comment={makeTodoMessage({ todos: [null, 42, { text: "Valid" }, { text: "" }] })}
defaultExpanded
/>,
);
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(
<TodoMessage
comment={makeTodoMessage({ todos: [{ text: "A" }], previous_todo_snapshots: {} })}
defaultExpanded
/>,
),
).not.toThrow();
expect(screen.getByText("A")).toBeTruthy();
});
it("drops null and primitive snapshot entries instead of crashing on expand", () => {
render(
<TodoMessage
comment={makeTodoMessage({
todos: [{ text: "A" }],
previous_todo_snapshots: [null, 42, { todos: [{ text: "Old" }] }],
})}
defaultExpanded
/>,
);
// 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(
<TodoMessage
comment={makeTodoMessage({
todos: [{ text: "A" }],
previous_todo_snapshots: [{ todos: "not-an-array" }, { todos: [null] }],
})}
defaultExpanded
/>,
),
).not.toThrow();
fireEvent.click(screen.getByRole("button", { name: /earlier update/i }));
expect(screen.getAllByText("A").length).toBeGreaterThanOrEqual(1);
});
});
@@ -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,
@@ -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<AppState>;
function Harness() {
storeApi = useAppStoreApi();
useSyncTodoPanel();
return null;
}
const addPanel = vi.fn();
const close = vi.fn();
const pendingFrames = new Map<number, FrameRequestCallback>();
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<AppState> = {}): void {
render(
<StateProvider initialState={{ ...defaultState, ...overrides }}>
<Harness />
</StateProvider>,
);
}
/** 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<AppState> {
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();
});
});
@@ -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<SyncConditionalTodoPanelOptions, "showTodoListPanel" | "settingsLoaded"> &
Partial<Omit<SyncConditionalTodoPanelOptions, "showTodoListPanel" | "settingsLoaded">>;
/** 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);
}
});
});
@@ -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,
+1
View File
@@ -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;
};
@@ -111,17 +111,31 @@ async function setTodoListPanelPreference(apiClient: ApiClient, enabled: boolean
expect(response.ok).toBe(true);
}
async function setOnlyPinWhenNotEmptyPreference(
apiClient: ApiClient,
enabled: boolean,
): Promise<void> {
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"] });
});
});
@@ -70,6 +70,7 @@ function makeUnloadedSettings(): UserSettingsState {
showScrollToStart: false,
showTranscriptAutoScrollControl: true,
showTodoListPanel: false,
showTodoListPanelOnlyWhenNotEmpty: false,
showReleaseNotification: true,
releaseNotesLastSeenVersion: null,
savedLayouts: [],
@@ -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<string, unknown>),
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([]);
});
});
+36 -12
View File
@@ -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(
+18
View File
@@ -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<Parameters<typeof mapUserSettingsResponse>[0]>["settings"],
}) as { showTodoListPanelOnlyWhenNotEmpty?: boolean };
expect(fallback.showTodoListPanelOnlyWhenNotEmpty).toBe(false);
expect(enabled.showTodoListPanelOnlyWhenNotEmpty).toBe(true);
});
});
describe("parseChangesPanelLayout", () => {
+3
View File
@@ -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,
@@ -229,6 +229,7 @@ export type UserSettingsState = {
showScrollToStart: boolean;
showTranscriptAutoScrollControl: boolean;
showTodoListPanel: boolean;
showTodoListPanelOnlyWhenNotEmpty: boolean;
showReleaseNotification: boolean;
releaseNotesLastSeenVersion: string | null;
lspAutoStartLanguages: string[];
+2
View File
@@ -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;
+12
View File
@@ -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", () => {
+3 -1
View File
@@ -481,7 +481,9 @@
"overrideSettingsSentToEachLanguage": "Override settings sent to each language server via <2>{{method}}</2>. 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",
+3 -1
View File
@@ -481,7 +481,9 @@
"overrideSettingsSentToEachLanguage": "Ōvēŕŕĩďē śēţţĩńĝś śēńţ ţō ēàćĥ ĺàńĝũàĝē śēŕvēŕ vĩà <2>{{method}}</2>. ĴŚŌŃ ƒōŕḿàţ.",
"password": "Ƥàśśŵōŕď",
"pickTheDefaultShellForTask": "Ƥĩćķ ţĥē ďēƒàũĺţ śĥēĺĺ ƒōŕ ţàśķ śēśśĩōńś",
"pinTheAgentsLiveTodoChecklistAs": "Ƥĩń ţĥē àĝēńţ'ś ĺĩvē ţōďō ćĥēćķĺĩśţ àś à ţàƀ ĩń ţĥē ŕĩĝĥţ ƥàńēĺ.",
"pinTheAgentsLiveTodoChecklistAs": "Àũţōḿàţĩćàĺĺŷ ƥĩń ţĥē àĝēńţ'ś ĺĩvē ţōďō ćĥēćķĺĩśţ àś à Ţōďōś ţàƀ ĩń ţĥē ŕĩĝĥţ ƥàńēĺ. Ēvēń ŵĥēń ţĥĩś ĩś ōƒƒ, ŷōũ ćàń àĺŵàŷś àďď ţĥē Ţōďōś ţàƀ ḿàńũàĺĺŷ.",
"onlyPinWhenTodoListIsNotEmpty": "Ōńĺŷ ƥĩń ŵĥēń ţōďō ĺĩśţ ĩś ńōţ ēḿƥţŷ",
"onlyPinWhenTodoListIsNotEmptyDescription": "Ĺĩḿĩţś àũţōḿàţĩć ƥĩńńĩńĝ ţō śēśśĩōńś ţĥàţ ĥàvē ţōďō ēńţŕĩēś. Ḿàńũàĺĺŷ àďďēď Ţōďōś ţàƀś àŕē ńēvēŕ àƒƒēćţēď.",
"pluginShortcuts": "Ƥĺũĝĩń Śĥōŕţćũţś",
"preferredShell": "Ƥŕēƒēŕŕēď Śĥēĺĺ",
"preferences": "Ƥŕēƒēŕēńćēś",
+3 -1
View File
@@ -476,7 +476,9 @@
"overrideSettingsSentToEachLanguage": "Substituir as definições enviadas a cada servidor de linguagem através de <2>{{method}}</2>. 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...",
+3 -1
View File
@@ -476,7 +476,9 @@
"overrideSettingsSentToEachLanguage": "通过 <2>{{method}}</2> 覆盖发送给每个语言服务器的设置。JSON 格式。",
"password": "密码",
"pickTheDefaultShellForTask": "选择任务会话的默认命令解释器",
"pinTheAgentsLiveTodoChecklistAs": "将代理的实时待办清单固定为右侧面板中的标签页。",
"pinTheAgentsLiveTodoChecklistAs": "自动将代理的实时待办清单固定为右侧面板中的 Todos 标签页。即使关闭此选项,您也可以随时手动添加 Todos 标签页。",
"onlyPinWhenTodoListIsNotEmpty": "仅在待办清单不为空时固定",
"onlyPinWhenTodoListIsNotEmptyDescription": "仅在会话有待办条目时自动固定。手动添加的 Todos 标签页不受影响。",
"pluginShortcuts": "插件快捷键",
"preferredShell": "首选命令解释器",
"pressAKeyCombo": "按下按键组合...",
+238 -5
View File
@@ -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.
@@ -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.
@@ -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).
@@ -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<BackendMessageMap[...]>` 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.
@@ -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.
+72 -20
View File
@@ -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<sessionId, PlanEntry[]>`
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;