mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add collapsible thinking blocks with configurable display mode (#24635)
This commit is contained in:
Generated
+21
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Generated
+26
-8
@@ -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
|
||||
|
||||
|
||||
Generated
+6
-3
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Generated
+17
-1
@@ -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}
|
||||
|
||||
+200
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user