diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 586d07a1db..0a43b40556 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -21231,6 +21231,21 @@ const docTemplate = `{ "TerminalFontJetBrainsMono" ] }, + "codersdk.ThinkingDisplayMode": { + "type": "string", + "enum": [ + "auto", + "preview", + "always_expanded", + "always_collapsed" + ], + "x-enum-varnames": [ + "ThinkingDisplayModeAuto", + "ThinkingDisplayModePreview", + "ThinkingDisplayModeAlwaysExpanded", + "ThinkingDisplayModeAlwaysCollapsed" + ] + }, "codersdk.TimingStage": { "type": "string", "enum": [ @@ -21549,6 +21564,9 @@ const docTemplate = `{ "properties": { "task_notification_alert_dismissed": { "type": "boolean" + }, + "thinking_display_mode": { + "$ref": "#/definitions/codersdk.ThinkingDisplayMode" } } }, @@ -22007,6 +22025,9 @@ const docTemplate = `{ "properties": { "task_notification_alert_dismissed": { "type": "boolean" + }, + "thinking_display_mode": { + "$ref": "#/definitions/codersdk.ThinkingDisplayMode" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 007ba4a302..6307845378 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -19494,6 +19494,16 @@ "TerminalFontJetBrainsMono" ] }, + "codersdk.ThinkingDisplayMode": { + "type": "string", + "enum": ["auto", "preview", "always_expanded", "always_collapsed"], + "x-enum-varnames": [ + "ThinkingDisplayModeAuto", + "ThinkingDisplayModePreview", + "ThinkingDisplayModeAlwaysExpanded", + "ThinkingDisplayModeAlwaysCollapsed" + ] + }, "codersdk.TimingStage": { "type": "string", "enum": [ @@ -19805,6 +19815,9 @@ "properties": { "task_notification_alert_dismissed": { "type": "boolean" + }, + "thinking_display_mode": { + "$ref": "#/definitions/codersdk.ThinkingDisplayMode" } } }, @@ -20234,6 +20247,9 @@ "properties": { "task_notification_alert_dismissed": { "type": "boolean" + }, + "thinking_display_mode": { + "$ref": "#/definitions/codersdk.ThinkingDisplayMode" } } }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d06328d24e..e0c93fe07f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4403,6 +4403,17 @@ func (q *querier) GetUserThemePreference(ctx context.Context, userID uuid.UUID) return q.db.GetUserThemePreference(ctx, userID) } +func (q *querier) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { + user, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return "", err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, user); err != nil { + return "", err + } + return q.db.GetUserThinkingDisplayMode(ctx, userID) +} + func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { u, err := q.db.GetUserByID(ctx, params.OwnerID) if err != nil { @@ -7018,6 +7029,17 @@ func (q *querier) UpdateUserThemePreference(ctx context.Context, arg database.Up return q.db.UpdateUserThemePreference(ctx, arg) } +func (q *querier) UpdateUserThinkingDisplayMode(ctx context.Context, arg database.UpdateUserThinkingDisplayModeParams) (string, error) { + user, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return "", err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, user); err != nil { + return "", err + } + return q.db.UpdateUserThinkingDisplayMode(ctx, arg) +} + func (q *querier) UpdateVolumeResourceMonitor(ctx context.Context, arg database.UpdateVolumeResourceMonitorParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceWorkspaceAgentResourceMonitor); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 91596cb8d3..5657714c30 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2714,6 +2714,19 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserChatCustomPrompt(gomock.Any(), arg).Return(uc, nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns(uc) })) + s.Run("GetUserThinkingDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetUserThinkingDisplayMode(gomock.Any(), u.ID).Return("auto", nil).AnyTimes() + check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("auto") + })) + s.Run("UpdateUserThinkingDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserThinkingDisplayModeParams{UserID: u.ID, ThinkingDisplayMode: "always_expanded"} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded") + })) s.Run("ListUserChatCompactionThresholds", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) uc := database.UserConfig{UserID: u.ID, Key: codersdk.ChatCompactionThresholdKeyPrefix + "00000000-0000-0000-0000-000000000001", Value: "75"} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index e070a3c712..502f5a1b45 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2872,6 +2872,14 @@ func (m queryMetricsStore) GetUserThemePreference(ctx context.Context, userID uu return r0, r1 } +func (m queryMetricsStore) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { + start := time.Now() + r0, r1 := m.s.GetUserThinkingDisplayMode(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserThinkingDisplayMode").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserThinkingDisplayMode").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, arg database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { start := time.Now() r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, arg) @@ -5016,6 +5024,14 @@ func (m queryMetricsStore) UpdateUserThemePreference(ctx context.Context, arg da return r0, r1 } +func (m queryMetricsStore) UpdateUserThinkingDisplayMode(ctx context.Context, arg database.UpdateUserThinkingDisplayModeParams) (string, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserThinkingDisplayMode(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserThinkingDisplayMode").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserThinkingDisplayMode").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateVolumeResourceMonitor(ctx context.Context, arg database.UpdateVolumeResourceMonitorParams) error { start := time.Now() r0 := m.s.UpdateVolumeResourceMonitor(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 22d8cb8e8b..fa9d2a729b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -5371,6 +5371,21 @@ func (mr *MockStoreMockRecorder) GetUserThemePreference(ctx, userID any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserThemePreference", reflect.TypeOf((*MockStore)(nil).GetUserThemePreference), ctx, userID) } +// GetUserThinkingDisplayMode mocks base method. +func (m *MockStore) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserThinkingDisplayMode", ctx, userID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserThinkingDisplayMode indicates an expected call of GetUserThinkingDisplayMode. +func (mr *MockStoreMockRecorder) GetUserThinkingDisplayMode(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserThinkingDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserThinkingDisplayMode), ctx, userID) +} + // GetUserWorkspaceBuildParameters mocks base method. func (m *MockStore) GetUserWorkspaceBuildParameters(ctx context.Context, arg database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { m.ctrl.T.Helper() @@ -9450,6 +9465,21 @@ func (mr *MockStoreMockRecorder) UpdateUserThemePreference(ctx, arg any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserThemePreference", reflect.TypeOf((*MockStore)(nil).UpdateUserThemePreference), ctx, arg) } +// UpdateUserThinkingDisplayMode mocks base method. +func (m *MockStore) UpdateUserThinkingDisplayMode(ctx context.Context, arg database.UpdateUserThinkingDisplayModeParams) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserThinkingDisplayMode", ctx, arg) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserThinkingDisplayMode indicates an expected call of UpdateUserThinkingDisplayMode. +func (mr *MockStoreMockRecorder) UpdateUserThinkingDisplayMode(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserThinkingDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserThinkingDisplayMode), ctx, arg) +} + // UpdateVolumeResourceMonitor mocks base method. func (m *MockStore) UpdateVolumeResourceMonitor(ctx context.Context, arg database.UpdateVolumeResourceMonitorParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ca14af1b7d..48ce76b94c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -703,6 +703,7 @@ type sqlcQuerier interface { GetUserTaskNotificationAlertDismissed(ctx context.Context, userID uuid.UUID) (bool, error) GetUserTerminalFont(ctx context.Context, userID uuid.UUID) (string, error) GetUserThemePreference(ctx context.Context, userID uuid.UUID) (string, error) + GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) @@ -1132,6 +1133,7 @@ type sqlcQuerier interface { UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error) UpdateUserThemePreference(ctx context.Context, arg UpdateUserThemePreferenceParams) (UserConfig, error) + UpdateUserThinkingDisplayMode(ctx context.Context, arg UpdateUserThinkingDisplayModeParams) (string, error) UpdateVolumeResourceMonitor(ctx context.Context, arg UpdateVolumeResourceMonitorParams) error UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error) UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cc0036ee5d..355bdff168 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25122,6 +25122,23 @@ func (q *sqlQuerier) GetUserThemePreference(ctx context.Context, userID uuid.UUI return theme_preference, err } +const getUserThinkingDisplayMode = `-- name: GetUserThinkingDisplayMode :one +SELECT + value AS thinking_display_mode +FROM + user_configs +WHERE + user_id = $1 + AND key = 'preference_thinking_display_mode' +` + +func (q *sqlQuerier) GetUserThinkingDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { + row := q.db.QueryRowContext(ctx, getUserThinkingDisplayMode, userID) + var thinking_display_mode string + err := row.Scan(&thinking_display_mode) + return thinking_display_mode, err +} + const getUsers = `-- name: GetUsers :many SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros, COUNT(*) OVER() AS count @@ -26048,6 +26065,33 @@ func (q *sqlQuerier) UpdateUserThemePreference(ctx context.Context, arg UpdateUs return i, err } +const updateUserThinkingDisplayMode = `-- name: UpdateUserThinkingDisplayMode :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + ($1, 'preference_thinking_display_mode', $2::text) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = $2 +WHERE user_configs.user_id = $1 + AND user_configs.key = 'preference_thinking_display_mode' +RETURNING value AS thinking_display_mode +` + +type UpdateUserThinkingDisplayModeParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + ThinkingDisplayMode string `db:"thinking_display_mode" json:"thinking_display_mode"` +} + +func (q *sqlQuerier) UpdateUserThinkingDisplayMode(ctx context.Context, arg UpdateUserThinkingDisplayModeParams) (string, error) { + row := q.db.QueryRowContext(ctx, updateUserThinkingDisplayMode, arg.UserID, arg.ThinkingDisplayMode) + var thinking_display_mode string + err := row.Scan(&thinking_display_mode) + return thinking_display_mode, err +} + const upsertUserChatDebugLoggingEnabled = `-- name: UpsertUserChatDebugLoggingEnabled :exec INSERT INTO user_configs (user_id, key, value) VALUES ( diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index d2ff9d87b8..80e4b36f6d 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -263,6 +263,29 @@ WHERE user_configs.user_id = @user_id AND user_configs.key = 'preference_task_notification_alert_dismissed' RETURNING value::boolean AS task_notification_alert_dismissed; +-- name: GetUserThinkingDisplayMode :one +SELECT + value AS thinking_display_mode +FROM + user_configs +WHERE + user_id = @user_id + AND key = 'preference_thinking_display_mode'; + +-- name: UpdateUserThinkingDisplayMode :one +INSERT INTO + user_configs (user_id, key, value) +VALUES + (@user_id, 'preference_thinking_display_mode', @thinking_display_mode::text) +ON CONFLICT + ON CONSTRAINT user_configs_pkey +DO UPDATE +SET + value = @thinking_display_mode +WHERE user_configs.user_id = @user_id + AND user_configs.key = 'preference_thinking_display_mode' +RETURNING value AS thinking_display_mode; + -- name: UpdateUserRoles :one UPDATE users diff --git a/coderd/users.go b/coderd/users.go index 5dc2ae193f..2283efaaa7 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1238,8 +1238,18 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request) } } + thinkingMode, err := api.Database.GetUserThinkingDisplayMode(ctx, user.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading user preference settings.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{ TaskNotificationAlertDismissed: taskAlertDismissed, + ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode), }) } @@ -1264,23 +1274,82 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques return } - updatedTaskAlertDismissed, err := api.Database.UpdateUserTaskNotificationAlertDismissed(ctx, database.UpdateUserTaskNotificationAlertDismissedParams{ - UserID: user.ID, - TaskNotificationAlertDismissed: params.TaskNotificationAlertDismissed, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating user task notification alert dismissed.", - Detail: err.Error(), + if params.ThinkingDisplayMode != "" && + !slices.Contains(codersdk.ValidThinkingDisplayModes, params.ThinkingDisplayMode) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid thinking display mode.", + Validations: []codersdk.ValidationError{ + {Field: "thinking_display_mode", Detail: "must be one of: auto, preview, always_expanded, always_collapsed"}, + }, }) return } + var err error + + var updatedTaskAlertDismissed bool + if params.TaskNotificationAlertDismissed != nil { + updatedTaskAlertDismissed, err = api.Database.UpdateUserTaskNotificationAlertDismissed(ctx, database.UpdateUserTaskNotificationAlertDismissedParams{ + UserID: user.ID, + TaskNotificationAlertDismissed: *params.TaskNotificationAlertDismissed, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating user task notification alert dismissed.", + Detail: err.Error(), + }) + return + } + } else { + updatedTaskAlertDismissed, err = api.Database.GetUserTaskNotificationAlertDismissed(ctx, user.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading task notification alert dismissed.", + Detail: err.Error(), + }) + return + } + } + + var resolvedThinkingMode codersdk.ThinkingDisplayMode + if params.ThinkingDisplayMode != "" { + updated, err := api.Database.UpdateUserThinkingDisplayMode(ctx, database.UpdateUserThinkingDisplayModeParams{ + UserID: user.ID, + ThinkingDisplayMode: string(params.ThinkingDisplayMode), + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating thinking display mode.", + Detail: err.Error(), + }) + return + } + resolvedThinkingMode = codersdk.ThinkingDisplayMode(updated) + } else { + stored, err := api.Database.GetUserThinkingDisplayMode(ctx, user.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error reading thinking display mode.", + Detail: err.Error(), + }) + return + } + resolvedThinkingMode = sanitizeThinkingDisplayMode(stored) + } httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{ TaskNotificationAlertDismissed: updatedTaskAlertDismissed, + ThinkingDisplayMode: resolvedThinkingMode, }) } +func sanitizeThinkingDisplayMode(raw string) codersdk.ThinkingDisplayMode { + mode := codersdk.ThinkingDisplayMode(raw) + if slices.Contains(codersdk.ValidThinkingDisplayModes, mode) { + return mode + } + return codersdk.ThinkingDisplayModeAuto +} + func isValidFontName(font codersdk.TerminalFontName) bool { return slices.Contains(codersdk.TerminalFontNames, font) } diff --git a/coderd/users_test.go b/coderd/users_test.go index 228665b48e..8df7bf8297 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1852,7 +1852,7 @@ func TestUserTaskNotificationAlertDismissed(t *testing.T) { // When: user dismisses the task notification alert updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ - TaskNotificationAlertDismissed: true, + TaskNotificationAlertDismissed: ptr.Ref(true), }) require.NoError(t, err) @@ -1870,14 +1870,14 @@ func TestUserTaskNotificationAlertDismissed(t *testing.T) { // Given: user has dismissed the task notification alert _, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ - TaskNotificationAlertDismissed: true, + TaskNotificationAlertDismissed: ptr.Ref(true), }) require.NoError(t, err) // When: the task notification alert dismissal is cleared // (e.g., when user enables a task notification in the UI settings) updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ - TaskNotificationAlertDismissed: false, + TaskNotificationAlertDismissed: ptr.Ref(false), }) require.NoError(t, err) @@ -1886,6 +1886,83 @@ func TestUserTaskNotificationAlertDismissed(t *testing.T) { }) } +func TestThinkingDisplayMode(t *testing.T) { + t.Parallel() + + adminClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + + t.Run("defaults to auto", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, codersdk.ThinkingDisplayModeAuto, settings.ThinkingDisplayMode) + }) + + t.Run("round-trips a valid mode", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysCollapsed, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ThinkingDisplayModeAlwaysCollapsed, updated.ThinkingDisplayMode) + + settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, codersdk.ThinkingDisplayModeAlwaysCollapsed, settings.ThinkingDisplayMode) + }) + + t.Run("rejects invalid mode", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + ThinkingDisplayMode: "bogus", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("empty mode preserves stored value", func(t *testing.T) { + t.Parallel() + + client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // Set a non-default mode. + _, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview, + }) + require.NoError(t, err) + + // Send an update that omits thinking_display_mode (zero value). + updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ + TaskNotificationAlertDismissed: ptr.Ref(true), + }) + require.NoError(t, err) + require.Equal(t, codersdk.ThinkingDisplayModePreview, updated.ThinkingDisplayMode) + }) +} + func TestWorkspacesByUser(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index 90b3147c15..2bf4a8ce50 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -261,11 +261,29 @@ type UpdateUserAppearanceSettingsRequest struct { } type UserPreferenceSettings struct { - TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` + TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` + ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"` } type UpdateUserPreferenceSettingsRequest struct { - TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` + TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"` + ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"` +} + +type ThinkingDisplayMode string + +const ( + ThinkingDisplayModeAuto ThinkingDisplayMode = "auto" + ThinkingDisplayModePreview ThinkingDisplayMode = "preview" + ThinkingDisplayModeAlwaysExpanded ThinkingDisplayMode = "always_expanded" + ThinkingDisplayModeAlwaysCollapsed ThinkingDisplayMode = "always_collapsed" +) + +var ValidThinkingDisplayModes = []ThinkingDisplayMode{ + ThinkingDisplayModeAuto, + ThinkingDisplayModePreview, + ThinkingDisplayModeAlwaysExpanded, + ThinkingDisplayModeAlwaysCollapsed, } type UpdateUserPasswordRequest struct { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9d23823eaa..659237cc80 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -10323,6 +10323,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |-------------------------------------------------------------------------------------| | ``, `fira-code`, `geist-mono`, `ibm-plex-mono`, `jetbrains-mono`, `source-code-pro` | +## codersdk.ThinkingDisplayMode + +```json +"auto" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|----------------------------------------------------------| +| `always_collapsed`, `always_expanded`, `auto`, `preview` | + ## codersdk.TimingStage ```json @@ -10658,15 +10672,17 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { - "task_notification_alert_dismissed": true + "task_notification_alert_dismissed": true, + "thinking_display_mode": "auto" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------------|---------|----------|--------------|-------------| -| `task_notification_alert_dismissed` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|--------------------------------------------------------------|----------|--------------|-------------| +| `task_notification_alert_dismissed` | boolean | false | | | +| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | | ## codersdk.UpdateUserProfileRequest @@ -11228,15 +11244,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { - "task_notification_alert_dismissed": true + "task_notification_alert_dismissed": true, + "thinking_display_mode": "auto" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------------|---------|----------|--------------|-------------| -| `task_notification_alert_dismissed` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|--------------------------------------------------------------|----------|--------------|-------------| +| `task_notification_alert_dismissed` | boolean | false | | | +| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | | ## codersdk.UserQuietHoursScheduleConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 8987b0001b..01187a8cfb 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -1298,7 +1298,8 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \ ```json { - "task_notification_alert_dismissed": true + "task_notification_alert_dismissed": true, + "thinking_display_mode": "auto" } ``` @@ -1328,7 +1329,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \ ```json { - "task_notification_alert_dismissed": true + "task_notification_alert_dismissed": true, + "thinking_display_mode": "auto" } ``` @@ -1345,7 +1347,8 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \ ```json { - "task_notification_alert_dismissed": true + "task_notification_alert_dismissed": true, + "thinking_display_mode": "auto" } ``` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4876c79710..ddedc3e99d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7589,6 +7589,20 @@ export const TerminalFontNames: TerminalFontName[] = [ "", ]; +// From codersdk/users.go +export type ThinkingDisplayMode = + | "always_collapsed" + | "always_expanded" + | "auto" + | "preview"; + +export const ThinkingDisplayModes: ThinkingDisplayMode[] = [ + "always_collapsed", + "always_expanded", + "auto", + "preview", +]; + // From codersdk/workspacebuilds.go export type TimingStage = | "apply" @@ -8021,7 +8035,8 @@ export interface UpdateUserPasswordRequest { // From codersdk/users.go export interface UpdateUserPreferenceSettingsRequest { - readonly task_notification_alert_dismissed: boolean; + readonly task_notification_alert_dismissed?: boolean; + readonly thinking_display_mode?: ThinkingDisplayMode; } // From codersdk/users.go @@ -8385,6 +8400,7 @@ export interface UserParameter { // From codersdk/users.go export interface UserPreferenceSettings { readonly task_notification_alert_dismissed: boolean; + readonly thinking_display_mode: ThinkingDisplayMode; } // From codersdk/deployment.go diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 4e11e1a4c1..b1264c3e18 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -989,9 +989,13 @@ export const WithReasoningInline: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Reasoning text renders inline. - expect(canvas.getByText("Reasoning body")).toBeInTheDocument(); - expect(canvas.queryByRole("button", { name: "Thinking" })).toBeNull(); + // Reasoning renders inside a collapsible disclosure. + const trigger = canvas.getByRole("button", { name: "Thinking" }); + expect(trigger).toBeInTheDocument(); + await userEvent.click(trigger); + await waitFor(() => { + expect(canvas.getByText("Reasoning body")).toBeVisible(); + }); }, }; diff --git a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx index 8cc1fd6373..deb8516735 100644 --- a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx @@ -4,6 +4,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings"; import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings"; import { SectionHeader } from "./components/SectionHeader"; +import { ThinkingDisplaySettings } from "./components/ThinkingDisplaySettings"; import { UserChatDebugLoggingSettings } from "./components/UserChatDebugLoggingSettings"; export interface AgentSettingsGeneralPageViewProps { @@ -53,6 +54,7 @@ export const AgentSettingsGeneralPageView: FC< isAnyPromptSaving={isSavingUserPrompt} /> + { + const canvas = within(canvasElement); + expect(canvas.getByText("Thinking")).toBeInTheDocument(); + await waitFor(() => { + expect( + canvas.getByText(/Let me think about this step by step/), + ).toBeVisible(); + }); + }, +}; + +/** + * A completed thinking block with always_collapsed mode should + * hide its content until the user clicks. + */ +export const ThinkingBlockAlwaysCollapsed: Story = { + parameters: { + queries: [ + { + key: ["me", "preferences"], + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "always_collapsed" as const, + }, + }, + ], + }, + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think about this step by step.", + }, + { + type: "text", + text: "Here is the answer.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Thinking")).toBeInTheDocument(); + expect( + canvas.queryByText(/Let me think about this step by step/), + ).not.toBeInTheDocument(); + await userEvent.click(canvas.getByText("Thinking")); + await waitFor(() => { + expect( + canvas.getByText(/Let me think about this step by step/), + ).toBeVisible(); + }); + }, +}; + +/** + * A completed thinking block with auto mode should be collapsed + * (non-streaming state means auto collapses). + */ +export const ThinkingBlockAutoMode: Story = { + parameters: { + queries: [ + { + key: ["me", "preferences"], + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "auto" as const, + }, + }, + ], + }, + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think about this step by step.", + }, + { + type: "text", + text: "Here is the answer.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Thinking")).toBeInTheDocument(); + expect( + canvas.queryByText(/Let me think about this step by step/), + ).not.toBeInTheDocument(); + await userEvent.click(canvas.getByText("Thinking")); + await waitFor(() => { + expect( + canvas.getByText(/Let me think about this step by step/), + ).toBeVisible(); + }); + }, +}; + +/** + * A completed thinking block with preview mode should be collapsed + * (non-streaming state means preview collapses). + */ +export const ThinkingBlockPreviewMode: Story = { + parameters: { + queries: [ + { + key: ["me", "preferences"], + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "preview" as const, + }, + }, + ], + }, + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think about this step by step.", + }, + { + type: "text", + text: "Here is the answer.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Thinking")).toBeInTheDocument(); + expect( + canvas.queryByText(/Let me think about this step by step/), + ).not.toBeInTheDocument(); + await userEvent.click(canvas.getByText("Thinking")); + await waitFor(() => { + expect( + canvas.getByText(/Let me think about this step by step/), + ).toBeVisible(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index a4d4a00d0c..d9dcd2afc8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,4 +1,4 @@ -import { PencilIcon } from "lucide-react"; +import { ChevronDownIcon, PencilIcon } from "lucide-react"; import { type FC, Fragment, @@ -7,9 +7,19 @@ import { useRef, useState, } from "react"; + +import { useQuery } from "react-query"; import type { UrlTransform } from "streamdown"; +import { preferenceSettings } from "#/api/queries/users"; import type * as TypesGen from "#/api/typesGenerated"; +import type { ThinkingDisplayMode } from "#/api/typesGenerated"; + import { Button } from "#/components/Button/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { Tooltip, @@ -17,6 +27,7 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; + import { ConversationItem, Message, @@ -67,40 +78,124 @@ const ReasoningDisclosure = memo<{ text: string; isStreaming?: boolean; urlTransform?: UrlTransform; -}>(({ id, text, isStreaming = false, urlTransform }) => { - const { visibleText } = useSmoothStreamingText({ - fullText: text, - isStreaming, - bypassSmoothing: !isStreaming, - streamKey: id, - }); - const displayText = isStreaming ? visibleText : text; - const hasText = displayText.trim().length > 0; + thinkingDisplayMode?: ThinkingDisplayMode; +}>( + ({ + id, + text, + isStreaming = false, + urlTransform, + thinkingDisplayMode: mode = "auto", + }) => { + const [manualToggle, setManualToggle] = useState(null); + + // Reset manual override on streaming transitions so + // auto/preview modes collapse when streaming stops. + const [prevStreaming, setPrevStreaming] = useState(isStreaming); + if (prevStreaming !== isStreaming) { + setPrevStreaming(isStreaming); + if (mode === "auto" || mode === "preview") { + setManualToggle(null); + } + } + + const autoExpanded = (() => { + switch (mode) { + case "always_expanded": + return true; + case "always_collapsed": + return false; + case "auto": + case "preview": + return isStreaming; + default: { + const _exhaustive: never = mode; + return _exhaustive; + } + } + })(); + + const expanded = manualToggle ?? autoExpanded; + + const isPreviewConstrained = + mode === "preview" && isStreaming && manualToggle === null; + + const previewScrollRef = useRef(null); + + const { visibleText } = useSmoothStreamingText({ + fullText: text, + isStreaming, + bypassSmoothing: !isStreaming, + streamKey: id, + }); + const displayText = isStreaming ? visibleText : text; + const hasText = displayText.trim().length > 0; + + // Auto-scroll the preview container to the bottom as new + // thinking content streams in. useLayoutEffect avoids a + // visible frame where content has grown but not scrolled. + const displayTextLength = displayText.length; + useLayoutEffect(() => { + if ( + displayTextLength && + isPreviewConstrained && + previewScrollRef.current + ) { + previewScrollRef.current.scrollTop = + previewScrollRef.current.scrollHeight; + } + }, [displayTextLength, isPreviewConstrained]); - if (hasText) { return ( -
- setManualToggle(open)} + className="w-full" + > + - {displayText} - -
+ + {isStreaming ? ( + + Thinking + + ) : ( + Thinking + )} + + {hasText && ( + +
+ + {displayText} + +
+
+ )} + ); - } - - return ( -
-
- - {isStreaming ? Thinking... : "Thinking"} - -
-
- ); -}); + }, +); // Wrapper that runs the smooth-streaming jitter buffer on a single // response block. Only used during live streaming — historical @@ -167,6 +262,10 @@ export const BlockList: FC<{ hasUserResponseAfterAskQuestion = false, urlTransform, }) => { + const prefQuery = useQuery(preferenceSettings()); + const thinkingDisplayMode: ThinkingDisplayMode = + prefQuery.data?.thinking_display_mode || "auto"; + const toolByID = new Map(tools.map((tool) => [tool.id, tool])); // Pre-compute which tool IDs have a corresponding block so @@ -182,6 +281,12 @@ export const BlockList: FC<{ const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id)); + // A thinking block is actively streaming only when it is the + // very last block in the list. Once newer content arrives + // (response, tool call, etc.) the thinking phase is over. + const lastBlockIsThinking = + blocks.length > 0 && blocks[blocks.length - 1].type === "thinking"; + return ( <> {blocks.map((block, index) => { @@ -214,8 +319,13 @@ export const BlockList: FC<{ key={`${keyPrefix}-thinking-${index}`} id={`${keyPrefix}-thinking-${index}`} text={block.text} - isStreaming={isStreaming} + isStreaming={ + isStreaming && + lastBlockIsThinking && + index === blocks.length - 1 + } urlTransform={urlTransform} + thinkingDisplayMode={thinkingDisplayMode} /> ); case "file-reference": diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx index 60593bb61f..461c4c7803 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx @@ -73,7 +73,7 @@ export const ReconnectingAfterDisconnect: Story = { expect(canvasElement.textContent).toMatch(/reconnecting in \d+s/i); }); expect(canvas.queryByText("Unexpected error")).not.toBeInTheDocument(); - const thinkingMatches = canvas.getAllByText(/thinking\.\.\./i); + const thinkingMatches = canvas.getAllByText(/thinking\.\.\.$/i); expect(thinkingMatches.length).toBeGreaterThanOrEqual(1); }, }; @@ -256,7 +256,7 @@ export const RetryStartupTimeout: Story = { /** * During streaming, if only tool-call blocks have arrived (no text - * or reasoning), the "Thinking..." indicator should still be visible + * or reasoning), the "Thinking" indicator should still be visible * alongside the tool cards. */ export const ThinkingDuringStreamingWithToolCalls: Story = { @@ -272,9 +272,9 @@ export const ThinkingDuringStreamingWithToolCalls: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // "Thinking..." should still be visible during streaming + // "Thinking" should still be visible during streaming // when only tool-call blocks have arrived. - const matches = canvas.getAllByText("Thinking..."); + const matches = canvas.getAllByText("Thinking"); expect(matches.length).toBeGreaterThanOrEqual(1); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx index fb846e9f50..ea634cf8c2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx @@ -5,7 +5,6 @@ import { ConversationItem, Message, MessageContent, - Response, Shimmer, } from "../ChatElements"; import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; @@ -21,30 +20,22 @@ const hasTransientLiveStatus = (liveStatus: LiveStatusModel): boolean => /** * True when the block list contains at least one text or reasoning - * block. Tool-call and other non-text blocks don't count because - * they don't replace the "Thinking..." placeholder visually. + * block. Tool-call blocks don't count; the placeholder should + * remain visible between tool calls so the user knows the model + * is still working. */ const hasTextOrReasoningBlock = (blocks: readonly RenderBlock[]): boolean => blocks.some((b) => b.type === "response" || b.type === "thinking"); /** - * Stateless "Thinking..." shimmer used during the streaming phase - * when no text or reasoning blocks have arrived yet. Unlike the - * `StartingPlaceholder` in `ChatStatusCallout`, this has no - * delayed-startup timer — the streaming phase is transient and - * will be replaced as soon as real content arrives. + * Placeholder shown during streaming before text or reasoning + * blocks arrive. Uses the same shimmer animation as the + * collapsible thinking disclosure label. */ const StreamingThinkingPlaceholder: FC = () => ( -
- - Thinking... - -
- - Thinking... - -
-
+ + Thinking + ); export const StreamingOutput: FC<{ diff --git a/site/src/pages/AgentsPage/components/ThinkingDisplaySettings.tsx b/site/src/pages/AgentsPage/components/ThinkingDisplaySettings.tsx new file mode 100644 index 0000000000..07422f0627 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ThinkingDisplaySettings.tsx @@ -0,0 +1,75 @@ +import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + preferenceSettings, + updatePreferenceSettings, +} from "#/api/queries/users"; +import type { ThinkingDisplayMode } from "#/api/typesGenerated"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; + +const options: { value: ThinkingDisplayMode; label: string }[] = [ + { value: "auto", label: "Auto" }, + { value: "preview", label: "Preview" }, + { value: "always_expanded", label: "Always Expanded" }, + { value: "always_collapsed", label: "Always Collapsed" }, +]; + +export const ThinkingDisplaySettings: FC = () => { + const queryClient = useQueryClient(); + const query = useQuery(preferenceSettings()); + const mutation = useMutation(updatePreferenceSettings(queryClient)); + + const mode: ThinkingDisplayMode = query.data?.thinking_display_mode || "auto"; + + return ( +
+

+ Thinking Display +

+
+

+ How thinking blocks should be displayed by default. 'Auto' fully + expands during streaming, then auto-collapses when done. 'Preview' + auto-expands with a height constraint during streaming. 'Always + Expanded' shows full content. 'Always Collapsed' keeps them collapsed. +

+ +
+ {mutation.isError && ( +

+ Failed to save your thinking display preference. +

+ )} +
+ ); +}; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 5fd56cd4f4..6f8af62318 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -541,7 +541,10 @@ export const AllTaskNotificationsDisabledAlertVisible: Story = { { // User preferences: alert NOT dismissed key: ["me", "preferences"], - data: { task_notification_alert_dismissed: false }, + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "auto" as const, + }, }, ], }, @@ -572,7 +575,10 @@ export const AllTaskNotificationsDisabledAlertDismissed: Story = { { // User preferences: alert IS dismissed key: ["me", "preferences"], - data: { task_notification_alert_dismissed: true }, + data: { + task_notification_alert_dismissed: true, + thinking_display_mode: "auto" as const, + }, }, ], }, @@ -609,7 +615,10 @@ export const OneTaskNotificationEnabledAlertHidden: Story = { { // User preferences: doesn't matter since alert shouldn't show anyway key: ["me", "preferences"], - data: { task_notification_alert_dismissed: false }, + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "auto" as const, + }, }, ], }, @@ -645,7 +654,10 @@ export const AllTaskNotificationsExplicitlyDisabledAlertVisible: Story = { { // User preferences: alert NOT dismissed key: ["me", "preferences"], - data: { task_notification_alert_dismissed: false }, + data: { + task_notification_alert_dismissed: false, + thinking_display_mode: "auto" as const, + }, }, ], }, diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 5ed840b424..7660468976 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -146,7 +146,9 @@ const TasksPage: FC = () => { severity="warning" dismissible onDismiss={() => { + if (!preferencesQuery.data) return; updatePreferencesMutation.mutate({ + ...preferencesQuery.data, task_notification_alert_dismissed: true, }); }} diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index e6de0ca7aa..ce90dc4109 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -211,7 +211,10 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = { { // User preferences: alert was previously dismissed key: ["me", "preferences"], - data: { task_notification_alert_dismissed: true }, + data: { + task_notification_alert_dismissed: true, + thinking_display_mode: "auto" as const, + }, }, ], }, @@ -233,6 +236,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = { "updateUserPreferenceSettings", ).mockResolvedValue({ task_notification_alert_dismissed: false, + thinking_display_mode: "auto", }); await step("Enable Task Idle notification", async () => { @@ -244,9 +248,11 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = { // Verify the preferences API was called to clear the alert dismissal await waitFor(() => { - expect(updatePreferencesSpy).toHaveBeenCalledWith({ - task_notification_alert_dismissed: false, - }); + expect(updatePreferencesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + task_notification_alert_dismissed: false, + }), + ); }); }); }, diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 1f2e05abc1..14e6862ebb 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -223,6 +223,7 @@ const NotificationsPage: FC = () => { preferencesQuery.data ) { updatePreferencesMutation.mutate({ + ...preferencesQuery.data, task_notification_alert_dismissed: false, }); }