mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add code diff display mode preference (#25027)
This commit is contained in:
Generated
+19
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+15
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Generated
+18
@@ -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 | | |
|
||||
|
||||
|
||||
Generated
+3
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+11
@@ -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}
|
||||
|
||||
+78
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user