feat: add collapsible thinking blocks with configurable display mode (#24635)

This commit is contained in:
Danielle Maywood
2026-04-24 11:29:08 +00:00
committed by GitHub
parent ce125831d3
commit 3a9a60dff8
26 changed files with 881 additions and 90 deletions
+21
View File
@@ -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"
}
}
},
+16
View File
@@ -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"
}
}
},
+22
View File
@@ -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
+13
View File
@@ -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"}
+16
View File
@@ -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)
+30
View File
@@ -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()
+2
View File
@@ -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
+44
View File
@@ -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 (
+23
View File
@@ -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
+77 -8
View File
@@ -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)
}
+80 -3
View File
@@ -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) {
+20 -2
View File
@@ -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 {
+26 -8
View File
@@ -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
+6 -3
View File
@@ -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"
}
```
+17 -1
View File
@@ -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
@@ -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();
});
},
};
@@ -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}
/>
<ChatFullWidthSettings />
<ThinkingDisplaySettings />
<UserChatDebugLoggingSettings
userSettings={userDebugLoggingData}
onSaveUserSetting={onSaveUserDebugLogging}
@@ -1669,3 +1669,203 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
expect(actions).toHaveLength(3);
},
};
/**
* A completed thinking block with always_expanded mode should show
* its content without user interaction.
*/
export const ThinkingBlockAlwaysExpanded: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" 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();
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();
});
},
};
@@ -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<boolean | null>(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<HTMLDivElement>(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 (
<div className="w-full">
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
<Collapsible
open={expanded}
onOpenChange={(open) => setManualToggle(open)}
className="w-full"
>
<CollapsibleTrigger
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-left",
"flex w-full items-center gap-1.5 cursor-pointer",
"text-content-secondary transition-colors hover:text-content-primary",
)}
>
{displayText}
</Response>
</div>
<ChevronDownIcon
className={cn(
"size-icon-sm shrink-0 transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
{isStreaming ? (
<Shimmer as="span" className="text-xs">
Thinking
</Shimmer>
) : (
<span className="text-xs">Thinking</span>
)}
</CollapsibleTrigger>
{hasText && (
<CollapsibleContent>
<div
ref={previewScrollRef}
className={cn(
"mt-1 pl-5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
{displayText}
</Response>
</div>
</CollapsibleContent>
)}
</Collapsible>
);
}
return (
<div className="w-full">
<div className="flex items-center gap-2 text-content-secondary transition-colors hover:text-content-primary">
<span className="text-sm">
{isStreaming ? <Shimmer as="span">Thinking...</Shimmer> : "Thinking"}
</span>
</div>
</div>
);
});
},
);
// 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":
@@ -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);
},
};
@@ -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 = () => (
<div className="relative">
<Response aria-hidden className="invisible select-none">
Thinking...
</Response>
<div className="pointer-events-none absolute inset-0 flex items-baseline gap-2">
<Shimmer as="div" className="text-[13px] leading-relaxed">
Thinking...
</Shimmer>
</div>
</div>
<Shimmer as="span" className="text-xs text-content-secondary">
Thinking
</Shimmer>
);
export const StreamingOutput: FC<{
@@ -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 (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Thinking Display
</h3>
<div className="flex items-center justify-between gap-4">
<p className="m-0 flex-1 text-xs text-content-secondary">
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.
</p>
<Select
value={mode}
disabled={query.isLoading || !query.data}
onValueChange={(value: string) => {
if (!query.data) return;
mutation.mutate({
...query.data,
thinking_display_mode: value as ThinkingDisplayMode,
});
}}
>
<SelectTrigger
className="w-44 shrink-0"
aria-label="Thinking display mode"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{mutation.isError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save your thinking display preference.
</p>
)}
</div>
);
};
+16 -4
View File
@@ -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,
},
},
],
},
+2
View File
@@ -146,7 +146,9 @@ const TasksPage: FC = () => {
severity="warning"
dismissible
onDismiss={() => {
if (!preferencesQuery.data) return;
updatePreferencesMutation.mutate({
...preferencesQuery.data,
task_notification_alert_dismissed: true,
});
}}
@@ -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,
}),
);
});
});
},
@@ -223,6 +223,7 @@ const NotificationsPage: FC = () => {
preferencesQuery.data
) {
updatePreferencesMutation.mutate({
...preferencesQuery.data,
task_notification_alert_dismissed: false,
});
}