feat: add only-pin-when-not-empty option for the todo list panel (#2543)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ƥŕēƒēŕēńćēś",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -476,7 +476,9 @@
|
||||
"overrideSettingsSentToEachLanguage": "通过 <2>{{method}}</2> 覆盖发送给每个语言服务器的设置。JSON 格式。",
|
||||
"password": "密码",
|
||||
"pickTheDefaultShellForTask": "选择任务会话的默认命令解释器",
|
||||
"pinTheAgentsLiveTodoChecklistAs": "将代理的实时待办清单固定为右侧面板中的标签页。",
|
||||
"pinTheAgentsLiveTodoChecklistAs": "自动将代理的实时待办清单固定为右侧面板中的 Todos 标签页。即使关闭此选项,您也可以随时手动添加 Todos 标签页。",
|
||||
"onlyPinWhenTodoListIsNotEmpty": "仅在待办清单不为空时固定",
|
||||
"onlyPinWhenTodoListIsNotEmptyDescription": "仅在会话有待办条目时自动固定。手动添加的 Todos 标签页不受影响。",
|
||||
"pluginShortcuts": "插件快捷键",
|
||||
"preferredShell": "首选命令解释器",
|
||||
"pressAKeyCombo": "按下按键组合...",
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user