feat: add code diff display mode preference (#25027)

This commit is contained in:
Danielle Maywood
2026-05-07 20:15:28 +01:00
committed by GitHub
parent d32842f084
commit e7958713a9
29 changed files with 968 additions and 207 deletions
+19
View File
@@ -14922,6 +14922,19 @@ const docTemplate = `{
}
}
},
"codersdk.AgentDisplayMode": {
"type": "string",
"enum": [
"auto",
"always_expanded",
"always_collapsed"
],
"x-enum-varnames": [
"AgentDisplayModeAuto",
"AgentDisplayModeAlwaysExpanded",
"AgentDisplayModeAlwaysCollapsed"
]
},
"codersdk.AgentScriptTiming": {
"type": "object",
"properties": {
@@ -23385,6 +23398,9 @@ const docTemplate = `{
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
@@ -23855,6 +23871,9 @@ const docTemplate = `{
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
+15
View File
@@ -13388,6 +13388,15 @@
}
}
},
"codersdk.AgentDisplayMode": {
"type": "string",
"enum": ["auto", "always_expanded", "always_collapsed"],
"x-enum-varnames": [
"AgentDisplayModeAuto",
"AgentDisplayModeAlwaysExpanded",
"AgentDisplayModeAlwaysCollapsed"
]
},
"codersdk.AgentScriptTiming": {
"type": "object",
"properties": {
@@ -21529,6 +21538,9 @@
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
@@ -21970,6 +21982,9 @@
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
+22
View File
@@ -4408,6 +4408,17 @@ func (q *querier) GetUserChatSpendInPeriod(ctx context.Context, arg database.Get
return q.db.GetUserChatSpendInPeriod(ctx, arg)
}
func (q *querier) GetUserCodeDiffDisplayMode(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.GetUserCodeDiffDisplayMode(ctx, userID)
}
func (q *querier) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return 0, err
@@ -7024,6 +7035,17 @@ func (q *querier) UpdateUserChatProviderKey(ctx context.Context, arg database.Up
return q.db.UpdateUserChatProviderKey(ctx, arg)
}
func (q *querier) UpdateUserCodeDiffDisplayMode(ctx context.Context, arg database.UpdateUserCodeDiffDisplayModeParams) (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.UpdateUserCodeDiffDisplayMode(ctx, arg)
}
func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id)
}
+13
View File
@@ -2829,6 +2829,19 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded")
}))
s.Run("GetUserCodeDiffDisplayMode", 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().GetUserCodeDiffDisplayMode(gomock.Any(), u.ID).Return("auto", nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("auto")
}))
s.Run("UpdateUserCodeDiffDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserCodeDiffDisplayModeParams{UserID: u.ID, CodeDiffDisplayMode: "always_collapsed"}
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().UpdateUserCodeDiffDisplayMode(gomock.Any(), arg).Return("always_collapsed", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_collapsed")
}))
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
@@ -2848,6 +2848,14 @@ func (m queryMetricsStore) GetUserChatSpendInPeriod(ctx context.Context, arg dat
return r0, r1
}
func (m queryMetricsStore) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
start := time.Now()
r0, r1 := m.s.GetUserCodeDiffDisplayMode(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserCodeDiffDisplayMode").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserCodeDiffDisplayMode").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetUserCount(ctx, includeSystem)
@@ -5008,6 +5016,14 @@ func (m queryMetricsStore) UpdateUserChatProviderKey(ctx context.Context, arg da
return r0, r1
}
func (m queryMetricsStore) UpdateUserCodeDiffDisplayMode(ctx context.Context, arg database.UpdateUserCodeDiffDisplayModeParams) (string, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserCodeDiffDisplayMode(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserCodeDiffDisplayMode").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserCodeDiffDisplayMode").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.UpdateUserDeletedByID(ctx, id)
+30
View File
@@ -5327,6 +5327,21 @@ func (mr *MockStoreMockRecorder) GetUserChatSpendInPeriod(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatSpendInPeriod", reflect.TypeOf((*MockStore)(nil).GetUserChatSpendInPeriod), ctx, arg)
}
// GetUserCodeDiffDisplayMode mocks base method.
func (m *MockStore) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserCodeDiffDisplayMode", ctx, userID)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserCodeDiffDisplayMode indicates an expected call of GetUserCodeDiffDisplayMode.
func (mr *MockStoreMockRecorder) GetUserCodeDiffDisplayMode(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCodeDiffDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserCodeDiffDisplayMode), ctx, userID)
}
// GetUserCount mocks base method.
func (m *MockStore) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
m.ctrl.T.Helper()
@@ -9441,6 +9456,21 @@ func (mr *MockStoreMockRecorder) UpdateUserChatProviderKey(ctx, arg any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserChatProviderKey", reflect.TypeOf((*MockStore)(nil).UpdateUserChatProviderKey), ctx, arg)
}
// UpdateUserCodeDiffDisplayMode mocks base method.
func (m *MockStore) UpdateUserCodeDiffDisplayMode(ctx context.Context, arg database.UpdateUserCodeDiffDisplayModeParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserCodeDiffDisplayMode", ctx, arg)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserCodeDiffDisplayMode indicates an expected call of UpdateUserCodeDiffDisplayMode.
func (mr *MockStoreMockRecorder) UpdateUserCodeDiffDisplayMode(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserCodeDiffDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserCodeDiffDisplayMode), ctx, arg)
}
// UpdateUserDeletedByID mocks base method.
func (m *MockStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
+2
View File
@@ -709,6 +709,7 @@ type sqlcQuerier interface {
// returned (global behavior). Otherwise only spend within the
// specified organization is included.
GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error)
GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
GetUserCount(ctx context.Context, includeSystem bool) (int64, error)
// Returns the minimum (most restrictive) group limit for a user.
// Returns -1 if no group limits match the specified scope.
@@ -1193,6 +1194,7 @@ type sqlcQuerier interface {
UpdateUserChatCompactionThreshold(ctx context.Context, arg UpdateUserChatCompactionThresholdParams) (UserConfig, error)
UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error)
UpdateUserChatProviderKey(ctx context.Context, arg UpdateUserChatProviderKeyParams) (UserChatProviderKey, error)
UpdateUserCodeDiffDisplayMode(ctx context.Context, arg UpdateUserCodeDiffDisplayModeParams) (string, error)
UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error
UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error
UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error
+44
View File
@@ -25703,6 +25703,23 @@ func (q *sqlQuerier) GetUserChatPersonalModelOverride(ctx context.Context, arg G
return personal_model_override, err
}
const getUserCodeDiffDisplayMode = `-- name: GetUserCodeDiffDisplayMode :one
SELECT
value AS code_diff_display_mode
FROM
user_configs
WHERE
user_id = $1
AND key = 'preference_code_diff_display_mode'
`
func (q *sqlQuerier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getUserCodeDiffDisplayMode, userID)
var code_diff_display_mode string
err := row.Scan(&code_diff_display_mode)
return code_diff_display_mode, err
}
const getUserCount = `-- name: GetUserCount :one
SELECT
COUNT(*)
@@ -26301,6 +26318,33 @@ func (q *sqlQuerier) UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateU
return i, err
}
const updateUserCodeDiffDisplayMode = `-- name: UpdateUserCodeDiffDisplayMode :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
($1, 'preference_code_diff_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_code_diff_display_mode'
RETURNING value AS code_diff_display_mode
`
type UpdateUserCodeDiffDisplayModeParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
CodeDiffDisplayMode string `db:"code_diff_display_mode" json:"code_diff_display_mode"`
}
func (q *sqlQuerier) UpdateUserCodeDiffDisplayMode(ctx context.Context, arg UpdateUserCodeDiffDisplayModeParams) (string, error) {
row := q.db.QueryRowContext(ctx, updateUserCodeDiffDisplayMode, arg.UserID, arg.CodeDiffDisplayMode)
var code_diff_display_mode string
err := row.Scan(&code_diff_display_mode)
return code_diff_display_mode, err
}
const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec
UPDATE
users
+24
View File
@@ -303,6 +303,30 @@ WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_thinking_display_mode'
RETURNING value AS thinking_display_mode;
-- name: GetUserCodeDiffDisplayMode :one
SELECT
value AS code_diff_display_mode
FROM
user_configs
WHERE
user_id = @user_id
AND key = 'preference_code_diff_display_mode';
-- name: UpdateUserCodeDiffDisplayMode :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
(@user_id, 'preference_code_diff_display_mode', @code_diff_display_mode::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = @code_diff_display_mode
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_code_diff_display_mode'
RETURNING value AS code_diff_display_mode;
-- name: UpdateUserRoles :one
UPDATE
users
+120 -51
View File
@@ -1248,9 +1248,19 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
return
}
codeDiffMode, err := api.Database.GetUserCodeDiffDisplayMode(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),
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
})
}
@@ -1280,69 +1290,120 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
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"},
{Field: "thinking_display_mode", Detail: thinkingDisplayModeValidationDetail},
},
})
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 params.CodeDiffDisplayMode != "" &&
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid code diff display mode.",
Validations: []codersdk.ValidationError{
{Field: "code_diff_display_mode", Detail: agentDisplayModeValidationDetail},
},
})
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 settings codersdk.UserPreferenceSettings
err := api.Database.InTx(func(tx database.Store) error {
var err error
if params.TaskNotificationAlertDismissed != nil {
settings.TaskNotificationAlertDismissed, err = tx.UpdateUserTaskNotificationAlertDismissed(ctx, database.UpdateUserTaskNotificationAlertDismissedParams{
UserID: user.ID,
TaskNotificationAlertDismissed: *params.TaskNotificationAlertDismissed,
})
if err != nil {
return newUserPreferenceSettingsAPIError("Internal error updating user task notification alert dismissed.", err)
}
} else {
settings.TaskNotificationAlertDismissed, err = tx.GetUserTaskNotificationAlertDismissed(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return newUserPreferenceSettingsAPIError("Error reading task notification alert dismissed.", err)
}
}
if params.ThinkingDisplayMode != "" {
updated, err := tx.UpdateUserThinkingDisplayMode(ctx, database.UpdateUserThinkingDisplayModeParams{
UserID: user.ID,
ThinkingDisplayMode: string(params.ThinkingDisplayMode),
})
if err != nil {
return newUserPreferenceSettingsAPIError("Internal error updating thinking display mode.", err)
}
settings.ThinkingDisplayMode = sanitizeThinkingDisplayMode(updated)
} else {
stored, err := tx.GetUserThinkingDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return newUserPreferenceSettingsAPIError("Error reading thinking display mode.", err)
}
settings.ThinkingDisplayMode = sanitizeThinkingDisplayMode(stored)
}
if params.CodeDiffDisplayMode != "" {
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
UserID: user.ID,
CodeDiffDisplayMode: string(params.CodeDiffDisplayMode),
})
if err != nil {
return newUserPreferenceSettingsAPIError("Internal error updating code diff display mode.", err)
}
settings.CodeDiffDisplayMode = sanitizeAgentDisplayMode(updated)
} else {
stored, err := tx.GetUserCodeDiffDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return newUserPreferenceSettingsAPIError("Error reading code diff display mode.", err)
}
settings.CodeDiffDisplayMode = sanitizeAgentDisplayMode(stored)
}
return nil
}, database.DefaultTXOptions().WithID("user_preference_settings"))
if err != nil {
var apiErr userPreferenceSettingsAPIError
if errors.As(err, &apiErr) {
httpapi.Write(ctx, rw, apiErr.statusCode, apiErr.response)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user preference settings.",
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,
})
httpapi.Write(ctx, rw, http.StatusOK, settings)
}
type userPreferenceSettingsAPIError struct {
statusCode int
response codersdk.Response
err error
}
func newUserPreferenceSettingsAPIError(message string, err error) userPreferenceSettingsAPIError {
return userPreferenceSettingsAPIError{
statusCode: http.StatusInternalServerError,
response: codersdk.Response{
Message: message,
Detail: err.Error(),
},
err: err,
}
}
func (e userPreferenceSettingsAPIError) Error() string {
return fmt.Sprintf("%s: %s", e.response.Message, e.err)
}
func (e userPreferenceSettingsAPIError) Unwrap() error {
return e.err
}
const (
thinkingDisplayModeValidationDetail = "must be one of: auto, preview, always_expanded, always_collapsed"
agentDisplayModeValidationDetail = "must be one of: auto, always_expanded, always_collapsed"
)
func sanitizeThinkingDisplayMode(raw string) codersdk.ThinkingDisplayMode {
mode := codersdk.ThinkingDisplayMode(raw)
if slices.Contains(codersdk.ValidThinkingDisplayModes, mode) {
@@ -1351,6 +1412,14 @@ func sanitizeThinkingDisplayMode(raw string) codersdk.ThinkingDisplayMode {
return codersdk.ThinkingDisplayModeAuto
}
func sanitizeAgentDisplayMode(raw string) codersdk.AgentDisplayMode {
mode := codersdk.AgentDisplayMode(raw)
if slices.Contains(codersdk.ValidAgentDisplayModes, mode) {
return mode
}
return codersdk.AgentDisplayModeAuto
}
func isValidFontName(font codersdk.TerminalFontName) bool {
return slices.Contains(codersdk.TerminalFontNames, font)
}
+111
View File
@@ -1963,6 +1963,117 @@ func TestThinkingDisplayMode(t *testing.T) {
})
}
func TestAgentDisplayModePreferences(t *testing.T) {
t.Parallel()
adminClient := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
requireValidationField := func(t *testing.T, err error, field string) {
t.Helper()
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, field, sdkErr.Validations[0].Field)
}
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.AgentDisplayModeAuto, settings.CodeDiffDisplayMode)
})
t.Run("round-trips code diff display mode", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
for _, mode := range []codersdk.AgentDisplayMode{
codersdk.AgentDisplayModeAlwaysExpanded,
codersdk.AgentDisplayModeAlwaysCollapsed,
} {
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
CodeDiffDisplayMode: mode,
})
require.NoError(t, err)
require.Equal(t, mode, updated.CodeDiffDisplayMode)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, mode, settings.CodeDiffDisplayMode)
}
})
t.Run("updates preserve stored display modes", 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: codersdk.ThinkingDisplayModePreview,
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
})
require.NoError(t, err)
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysExpanded,
})
require.NoError(t, err)
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, updated.ThinkingDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.CodeDiffDisplayMode)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, settings.ThinkingDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, settings.CodeDiffDisplayMode)
})
t.Run("rejects invalid code diff display mode", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
for _, tt := range []struct {
name string
mode codersdk.AgentDisplayMode
}{
{
name: "bogus",
mode: codersdk.AgentDisplayMode("bogus"),
},
{
name: "thinking preview",
mode: codersdk.AgentDisplayMode(codersdk.ThinkingDisplayModePreview),
},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
CodeDiffDisplayMode: tt.mode,
})
requireValidationField(t, err, "code_diff_display_mode")
})
}
})
}
func TestWorkspacesByUser(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
+16
View File
@@ -265,11 +265,13 @@ type UpdateUserAppearanceSettingsRequest struct {
type UserPreferenceSettings struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
}
type UpdateUserPreferenceSettingsRequest struct {
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
}
type ThinkingDisplayMode string
@@ -288,6 +290,20 @@ var ValidThinkingDisplayModes = []ThinkingDisplayMode{
ThinkingDisplayModeAlwaysCollapsed,
}
type AgentDisplayMode string
const (
AgentDisplayModeAuto AgentDisplayMode = "auto"
AgentDisplayModeAlwaysExpanded AgentDisplayMode = "always_expanded"
AgentDisplayModeAlwaysCollapsed AgentDisplayMode = "always_collapsed"
)
var ValidAgentDisplayModes = []AgentDisplayMode{
AgentDisplayModeAuto,
AgentDisplayModeAlwaysExpanded,
AgentDisplayModeAlwaysCollapsed,
}
type UpdateUserPasswordRequest struct {
OldPassword string `json:"old_password" validate:""`
Password string `json:"password" validate:"required"`
+18
View File
@@ -1431,6 +1431,20 @@
| `workspace_agent_id` | string | false | | |
| `workspace_agent_name` | string | false | | |
## codersdk.AgentDisplayMode
```json
"auto"
```
### Properties
#### Enumerated Values
| Value(s) |
|-----------------------------------------------|
| `always_collapsed`, `always_expanded`, `auto` |
## codersdk.AgentScriptTiming
```json
@@ -12807,6 +12821,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
```json
{
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -12816,6 +12831,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|--------------------------------------------------------------|----------|--------------|-------------|
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
@@ -13393,6 +13409,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
```json
{
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -13402,6 +13419,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|--------------------------------------------------------------|----------|--------------|-------------|
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
+3
View File
@@ -1301,6 +1301,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -1332,6 +1333,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -1350,6 +1352,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
+11
View File
@@ -839,6 +839,15 @@ export interface AgentConnectionTiming {
readonly workspace_agent_name: string;
}
// From codersdk/users.go
export type AgentDisplayMode = "always_collapsed" | "always_expanded" | "auto";
export const AgentDisplayModes: AgentDisplayMode[] = [
"always_collapsed",
"always_expanded",
"auto",
];
// From codersdk/workspacebuilds.go
export interface AgentScriptTiming {
readonly started_at: string;
@@ -8349,6 +8358,7 @@ export interface UpdateUserPasswordRequest {
export interface UpdateUserPreferenceSettingsRequest {
readonly task_notification_alert_dismissed?: boolean;
readonly thinking_display_mode?: ThinkingDisplayMode;
readonly code_diff_display_mode?: AgentDisplayMode;
}
// From codersdk/users.go
@@ -8726,6 +8736,7 @@ export interface UserParameter {
export interface UserPreferenceSettings {
readonly task_notification_alert_dismissed: boolean;
readonly thinking_display_mode: ThinkingDisplayMode;
readonly code_diff_display_mode: AgentDisplayMode;
}
// From codersdk/deployment.go
@@ -122,6 +122,27 @@ export const RendersChatLayoutSection: Story = {
},
};
export const RendersAgentDisplayModeSettings: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Thinking Display")).toBeVisible();
expect(await canvas.findByText("Code Diff Display")).toBeVisible();
},
};
export const ShowsChatDebugLoggingToggle: Story = {
args: {
userDebugLoggingData: {
@@ -2,9 +2,12 @@ import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import {
CodeDiffDisplaySettings,
ThinkingDisplaySettings,
} from "./components/DisplayModeSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
import { SectionHeader } from "./components/SectionHeader";
import { ThinkingDisplaySettings } from "./components/ThinkingDisplaySettings";
import { UserChatDebugLoggingSettings } from "./components/UserChatDebugLoggingSettings";
export interface AgentSettingsGeneralPageViewProps {
@@ -55,6 +58,7 @@ export const AgentSettingsGeneralPageView: FC<
/>
<ChatFullWidthSettings />
<ThinkingDisplaySettings />
<CodeDiffDisplaySettings />
<UserChatDebugLoggingSettings
userSettings={userDebugLoggingData}
onSaveUserSetting={onSaveUserDebugLogging}
@@ -14,6 +14,7 @@ import { getChatFileURL } from "../../utils/chatAttachments";
import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment";
import { ConversationTimeline } from "./ConversationTimeline";
import { parseMessagesWithMergedTools } from "./messageParsing";
import type { ParsedMessageEntry } from "./types";
// 1×1 solid coral (#FF6B6B) PNG encoded as base64.
const TEST_PNG_B64 =
@@ -1753,6 +1754,78 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
},
};
export const CodeDiffDisplayModeFromPreferences: Story = {
parameters: {
queries: [
{
key: ["me", "preferences"],
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "always_collapsed" as const,
},
},
],
},
args: {
...defaultArgs,
parsedMessages: [
{
message: {
...baseMessage,
id: 1,
role: "assistant",
content: [],
},
parsed: {
markdown: "",
reasoning: "",
toolCalls: [],
toolResults: [],
tools: [
{
id: "edit-tool",
name: "edit_files",
args: {
files: [
{
path: "src/config.ts",
edits: [
{
search: "const timeout = 30;",
replace: "const timeout = 60;",
},
],
},
],
},
result: { ok: true },
isError: false,
status: "completed",
},
],
blocks: [{ type: "tool", id: "edit-tool" }],
sources: [],
},
},
] satisfies ParsedMessageEntry[],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
const editFilesButton = canvas.getByRole("button", {
name: /Edited config\.ts/,
});
expect(editFilesButton).toHaveAttribute("aria-expanded", "false");
await userEvent.click(editFilesButton);
await waitFor(() => {
expect(canvas.getAllByTestId("edit-file-diff")).toHaveLength(1);
});
},
};
/**
* A completed thinking block with always_expanded mode should show
* its content without user interaction.
@@ -1765,6 +1838,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -1812,6 +1886,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -1860,6 +1935,7 @@ export const ThinkingBlockWithToolCall: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -1921,6 +1997,7 @@ export const ThinkingBlockAutoMode: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -1972,6 +2049,7 @@ export const ThinkingBlockPreviewMode: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "preview" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -275,6 +275,8 @@ export const BlockList: FC<{
const prefQuery = useQuery(preferenceSettings());
const thinkingDisplayMode: ThinkingDisplayMode =
prefQuery.data?.thinking_display_mode || "auto";
const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
prefQuery.data?.code_diff_display_mode || "auto";
const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
@@ -365,6 +367,7 @@ export const BlockList: FC<{
name="Tool"
status="running"
isError={false}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
@@ -381,6 +384,7 @@ export const BlockList: FC<{
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
@@ -436,6 +440,7 @@ export const BlockList: FC<{
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
@@ -3,13 +3,19 @@ import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import {
DIFFS_FONT_STYLE,
type EditFilesFileEntry,
@@ -18,22 +24,24 @@ import {
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `edit_files` tool calls.
* Shows "Edited <filename>" (or "Edited N files") with a chevron;
* expanding reveals a unified diff for each file.
*/
const EDIT_FILES_AUTO_DISPLAY_STATE: AgentDisplayState = "preview";
export const EditFilesTool: React.FC<{
files: EditFilesFileEntry[];
diffs: (FileDiffMetadata | null)[];
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ files, diffs, status, isError, errorMessage }) => {
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}> = ({ files, diffs, status, isError, errorMessage, codeDiffDisplayMode }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const isRunning = status === "running";
const hasDiffs = diffs.some((d) => d !== null);
const displayState = resolveAgentDisplayState(
codeDiffDisplayMode,
EDIT_FILES_AUTO_DISPLAY_STATE,
);
let label: string;
if (isRunning) {
@@ -54,10 +62,11 @@ export const EditFilesTool: React.FC<{
}
return (
<ToolCollapsible
<AgentDisplayModeToolCollapsible
className="w-full"
hasContent={hasDiffs}
defaultExpanded
displayMode={codeDiffDisplayMode}
autoDisplayState={EDIT_FILES_AUTO_DISPLAY_STATE}
header={
<>
<span className="text-[13px]">{label}</span>
@@ -84,7 +93,11 @@ export const EditFilesTool: React.FC<{
key={files[i].path}
data-testid="edit-file-diff"
className="rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
@@ -96,6 +109,6 @@ export const EditFilesTool: React.FC<{
) : null,
)}
</div>
</ToolCollapsible>
</AgentDisplayModeToolCollapsible>
);
};
@@ -51,6 +51,10 @@ export const ExecuteSuccess: Story = {
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
},
};
export const ExecuteAuthRequired: Story = {
@@ -983,6 +987,7 @@ export const WriteFileSuccess: Story = {
args: {
name: "write_file",
status: "completed",
codeDiffDisplayMode: "auto",
args: {
path: "src/utils/helpers.ts",
content:
@@ -992,6 +997,30 @@ export const WriteFileSuccess: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Wrote helpers\.ts/)).toBeInTheDocument();
expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: /Wrote helpers\.ts/ }),
);
await waitFor(() => {
expect(canvas.getByTestId("write-file-diff")).toBeVisible();
});
},
};
export const WriteFileAlwaysExpanded: Story = {
args: {
name: "write_file",
status: "completed",
codeDiffDisplayMode: "always_expanded",
args: {
path: "src/utils/helpers.ts",
content:
"export function greet(name: string): string {\n return `Hello, ${name}!`;\n}\n",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByTestId("write-file-diff")).toBeVisible();
},
};
@@ -1027,6 +1056,7 @@ export const EditFilesSingleSuccess: Story = {
args: {
name: "edit_files",
status: "completed",
codeDiffDisplayMode: "auto",
args: {
files: [
{
@@ -1044,6 +1074,39 @@ export const EditFilesSingleSuccess: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Edited config\.ts/)).toBeInTheDocument();
expect(canvas.getAllByTestId("edit-file-diff")).toHaveLength(1);
},
};
export const EditFilesAlwaysCollapsed: Story = {
args: {
name: "edit_files",
status: "completed",
codeDiffDisplayMode: "always_collapsed",
args: {
files: [
{
path: "src/config.ts",
edits: [
{
search: "const timeout = 30;",
replace: "const timeout = 60;",
},
],
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
await userEvent.click(
canvas.getByRole("button", { name: /Edited config\.ts/ }),
);
await waitFor(() => {
expect(canvas.getAllByTestId("edit-file-diff")).toHaveLength(1);
});
},
};
@@ -1,5 +1,5 @@
import { useTheme } from "@emotion/react";
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
import { File as FileViewer } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { type ComponentPropsWithRef, type FC, memo } from "react";
import type * as TypesGen from "#/api/typesGenerated";
@@ -49,7 +49,6 @@ import {
buildEditDiff,
DIFFS_FONT_STYLE,
formatResultOutput,
getDiffViewerOptions,
getFileContentForViewer,
getFileViewerOptions,
getFileViewerOptionsNoHeader,
@@ -60,7 +59,6 @@ import {
parseEditFilesArgs,
parseServerEditDiffText,
parseServerEditResults,
stripNoNewline,
type ToolStatus,
toProviderLabel,
} from "./utils";
@@ -94,6 +92,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
previousResponseText?: string;
/** Human-readable intent extracted from the model's tool-call args. */
modelIntent?: string;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}
// Props passed to each tool-specific renderer function. Each renderer
@@ -117,6 +116,7 @@ type ToolRendererProps = {
mcpServerConfigId?: string;
mcpServers?: readonly TypesGen.MCPServerConfig[];
modelIntent?: string;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
};
// ---------------------------------------------------------------------------
@@ -369,6 +369,7 @@ const WriteFileRenderer: FC<ToolRendererProps> = ({
args,
result,
isError,
codeDiffDisplayMode,
}) => {
const parsedArgs = parseArgs(args);
const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
@@ -382,6 +383,7 @@ const WriteFileRenderer: FC<ToolRendererProps> = ({
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
);
};
@@ -391,6 +393,7 @@ const EditFilesRenderer: FC<ToolRendererProps> = ({
args,
result,
isError,
codeDiffDisplayMode,
}) => {
const rec = asRecord(result);
const editFiles = parseEditFilesArgs(args);
@@ -413,6 +416,7 @@ const EditFilesRenderer: FC<ToolRendererProps> = ({
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
);
};
@@ -814,8 +818,6 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
);
};
// Generic fallback renderer — only path that needs theme, diff
// viewers, and file content helpers.
const GenericToolRenderer: FC<ToolRendererProps> = ({
name,
status,
@@ -830,7 +832,6 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
const isDark = theme.palette.mode === "dark";
const resultOutput = formatResultOutput(result);
const fileContent = getFileContentForViewer(name, args, result);
const writeFileDiff = getWriteFileDiff(name, args);
const fileViewerOpts = getFileViewerOptions(isDark);
const fileContentOptions = fileContent
? {
@@ -845,64 +846,49 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
? mcpServers?.find((s) => s.id === mcpServerConfigId)
: undefined;
const hasContent = Boolean(writeFileDiff || fileContent || resultOutput);
const hasContent = Boolean(fileContent || resultOutput);
const isRunning = status === "running";
const rec = asRecord(result);
const errorMessage = rec ? asString(rec.error || rec.message) : "";
return (
<ToolCollapsible
hasContent={hasContent}
header={
<>
<ToolIcon
name={name}
isError={status === "error" || isError}
iconUrl={mcpServer?.icon_url}
isRunning={isRunning}
serverName={mcpServer?.display_name}
/>
{modelIntent ? (
<span className="truncate text-[13px]">
{modelIntent.charAt(0).toUpperCase() + modelIntent.slice(1)}
</span>
) : (
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
)}
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Tool call failed"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
}
>
{writeFileDiff ? (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={stripNoNewline(writeFileDiff)}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : fileContent ? (
const toolHeader = (
<>
<ToolIcon
name={name}
isError={status === "error" || isError}
iconUrl={mcpServer?.icon_url}
isRunning={isRunning}
serverName={mcpServer?.display_name}
/>
{modelIntent ? (
<span className="truncate text-[13px]">
{modelIntent.charAt(0).toUpperCase() + modelIntent.slice(1)}
</span>
) : (
<ToolLabel
name={name}
args={args}
result={result}
mcpSlug={mcpServer?.slug}
/>
)}
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-current" />
</TooltipTrigger>
<TooltipContent>{errorMessage || "Tool call failed"}</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
)}
</>
);
const toolContent = (
<>
{fileContent ? (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
@@ -935,6 +921,12 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
</ScrollArea>
)
)}
</>
);
return (
<ToolCollapsible hasContent={hasContent} header={toolHeader}>
{toolContent}
</ToolCollapsible>
);
};
@@ -1030,6 +1022,7 @@ export const Tool = memo(
isLatestAskUserQuestion,
previousResponseText,
modelIntent,
codeDiffDisplayMode,
ref,
...props
}: ToolProps) => {
@@ -1076,6 +1069,7 @@ export const Tool = memo(
isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText}
modelIntent={modelIntent}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
</div>
);
@@ -1,7 +1,13 @@
import { ChevronDownIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { useState } from "react";
import type { AgentDisplayMode } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import {
type AgentDisplayState,
isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
@@ -14,6 +20,25 @@ interface ToolCollapsibleProps {
headerClassName?: string;
}
interface AgentDisplayModeToolCollapsibleProps
extends Omit<ToolCollapsibleProps, "defaultExpanded"> {
displayMode: AgentDisplayMode | undefined;
autoDisplayState: AgentDisplayState;
}
export const AgentDisplayModeToolCollapsible: FC<
AgentDisplayModeToolCollapsibleProps
> = ({ displayMode, autoDisplayState, ...props }) => {
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
return (
<ToolCollapsible
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
{...props}
defaultExpanded={isAgentDisplayOpen(displayState)}
/>
);
};
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
children,
header,
@@ -3,13 +3,19 @@ import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ToolCollapsible } from "./ToolCollapsible";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
@@ -17,29 +23,34 @@ import {
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `write_file` tool calls. Shows
* "Wrote <filename>" with a chevron; expanding reveals the unified diff.
*/
const WRITE_FILE_AUTO_DISPLAY_STATE: AgentDisplayState = "collapsed";
export const WriteFileTool: React.FC<{
path: string;
diff: FileDiffMetadata | null;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ path, diff, status, isError, errorMessage }) => {
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}> = ({ path, diff, status, isError, errorMessage, codeDiffDisplayMode }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const hasDiff = diff !== null;
const isRunning = status === "running";
const displayState = resolveAgentDisplayState(
codeDiffDisplayMode,
WRITE_FILE_AUTO_DISPLAY_STATE,
);
const filename = path.split("/").pop() || path;
const label = isRunning ? `Writing ${filename}` : `Wrote ${filename}`;
return (
<ToolCollapsible
<AgentDisplayModeToolCollapsible
className="w-full"
hasContent={hasDiff}
displayMode={codeDiffDisplayMode}
autoDisplayState={WRITE_FILE_AUTO_DISPLAY_STATE}
header={
<>
<span className="text-[13px]">{label}</span>
@@ -61,8 +72,13 @@ export const WriteFileTool: React.FC<{
>
{hasDiff && (
<ScrollArea
data-testid="write-file-diff"
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
viewportClassName={
isAgentDisplayFullyExpanded(displayState)
? "max-h-[80vh]"
: "max-h-64"
}
scrollBarClassName="w-1.5"
>
<FileDiff
@@ -72,6 +88,6 @@ export const WriteFileTool: React.FC<{
/>
</ScrollArea>
)}
</ToolCollapsible>
</AgentDisplayModeToolCollapsible>
);
};
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
isAgentDisplayFullyExpanded,
isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
describe("resolveAgentDisplayState", () => {
it("resolves auto and explicit display modes", () => {
expect(resolveAgentDisplayState(undefined, "preview")).toBe("preview");
expect(resolveAgentDisplayState("auto", "collapsed")).toBe("collapsed");
expect(resolveAgentDisplayState("auto", "expanded")).toBe("expanded");
expect(resolveAgentDisplayState("always_expanded", "collapsed")).toBe(
"expanded",
);
expect(resolveAgentDisplayState("always_collapsed", "expanded")).toBe(
"collapsed",
);
});
});
describe("isAgentDisplayOpen", () => {
it("returns whether a display state shows content", () => {
expect(isAgentDisplayOpen("collapsed")).toBe(false);
expect(isAgentDisplayOpen("preview")).toBe(true);
expect(isAgentDisplayOpen("expanded")).toBe(true);
});
});
describe("isAgentDisplayFullyExpanded", () => {
it("returns whether a display state uses a fully expanded view", () => {
expect(isAgentDisplayFullyExpanded("expanded")).toBe(true);
expect(isAgentDisplayFullyExpanded("preview")).toBe(false);
expect(isAgentDisplayFullyExpanded("collapsed")).toBe(false);
});
});
@@ -0,0 +1,32 @@
import type { AgentDisplayMode } from "#/api/typesGenerated";
export type AgentDisplayState = "collapsed" | "preview" | "expanded";
export const resolveAgentDisplayState = (
mode: AgentDisplayMode | undefined,
autoState: AgentDisplayState,
): AgentDisplayState => {
switch (mode) {
case undefined:
case "auto":
return autoState;
case "always_expanded":
return "expanded";
case "always_collapsed":
return "collapsed";
default: {
const _exhaustive: never = mode;
return _exhaustive;
}
}
};
export const isAgentDisplayOpen = (state: AgentDisplayState): boolean => {
return state !== "collapsed";
};
export const isAgentDisplayFullyExpanded = (
state: AgentDisplayState,
): boolean => {
return state === "expanded";
};
@@ -0,0 +1,133 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
preferenceSettings,
updatePreferenceSettings,
} from "#/api/queries/users";
import type {
UpdateUserPreferenceSettingsRequest,
UserPreferenceSettings,
} from "#/api/typesGenerated";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
type DisplayModeOption<T extends string> = { value: T; label: string };
type ThinkingDisplayMode = UserPreferenceSettings["thinking_display_mode"];
type AgentDisplayMode = UserPreferenceSettings["code_diff_display_mode"];
const thinkingDisplayOptions: DisplayModeOption<ThinkingDisplayMode>[] = [
{ value: "auto", label: "Auto" },
{ value: "preview", label: "Preview" },
{ value: "always_expanded", label: "Always Expanded" },
{ value: "always_collapsed", label: "Always Collapsed" },
];
const agentDisplayOptions: DisplayModeOption<AgentDisplayMode>[] = [
{ value: "auto", label: "Auto" },
{ value: "always_expanded", label: "Always Expanded" },
{ value: "always_collapsed", label: "Always Collapsed" },
];
type DisplayModeSettingsProps<T extends string> = {
title: string;
description: string;
ariaLabel: string;
errorMessage: string;
defaultValue: T;
options: DisplayModeOption<T>[];
getMode: (settings: UserPreferenceSettings) => T;
updateSettings: (value: T) => UpdateUserPreferenceSettingsRequest;
};
const DisplayModeSettings = <T extends string>({
title,
description,
ariaLabel,
errorMessage,
defaultValue,
options,
getMode,
updateSettings,
}: DisplayModeSettingsProps<T>) => {
const queryClient = useQueryClient();
const query = useQuery(preferenceSettings());
const mutation = useMutation(updatePreferenceSettings(queryClient));
const mode = query.data ? getMode(query.data) : defaultValue;
return (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
{title}
</h3>
<div className="flex items-center justify-between gap-4">
<p className="m-0 flex-1 text-xs text-content-secondary">
{description}
</p>
<Select
value={mode}
disabled={query.isLoading || !query.data}
onValueChange={(value: string) => {
const selected = options.find((opt) => opt.value === value);
if (!query.data || !selected) return;
mutation.mutate(updateSettings(selected.value));
}}
>
<SelectTrigger className="w-44 shrink-0" aria-label={ariaLabel}>
<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">{errorMessage}</p>
)}
</div>
);
};
export const ThinkingDisplaySettings: FC = () => {
return (
<DisplayModeSettings
title="Thinking Display"
description="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."
ariaLabel="Thinking display mode"
errorMessage="Failed to save your thinking display preference."
defaultValue="auto"
options={thinkingDisplayOptions}
getMode={(settings) => settings.thinking_display_mode}
updateSettings={(value) => ({
thinking_display_mode: value,
})}
/>
);
};
export const CodeDiffDisplaySettings: FC = () => {
return (
<DisplayModeSettings
title="Code Diff Display"
description="Controls how code edit diffs appear. Auto starts single-file writes collapsed and opens multi-file edits with a height-constrained preview. Always Expanded opens diffs by default; Always Collapsed keeps them collapsed."
ariaLabel="Code diff display mode"
errorMessage="Failed to save your code diff display preference."
defaultValue="auto"
options={agentDisplayOptions}
getMode={(settings) => settings.code_diff_display_mode}
updateSettings={(value) => ({
code_diff_display_mode: value,
})}
/>
);
};
@@ -1,75 +0,0 @@
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>
);
};
@@ -215,6 +215,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
data: {
task_notification_alert_dismissed: true,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
},
},
],
@@ -238,6 +239,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
).mockResolvedValue({
task_notification_alert_dismissed: false,
thinking_display_mode: "auto",
code_diff_display_mode: "auto",
});
await step("Enable Task Idle notification", async () => {