feat: add Cmd/Ctrl+Enter send setting (#25062)

Adds an Agents General setting to require Cmd/Ctrl+Enter before sending
chat messages. When enabled, plain Enter inserts a newline in agent chat
inputs while the send button remains available.

The preference is now persisted server-side through
`/api/v2/users/{user}/preferences`, alongside the existing user
preference settings, and is applied to both the create-agent input and
existing chat composer. Storybook and API coverage verify the setting,
keyboard behavior, validation, and persistence.

<details>
<summary>Coder Agents notes</summary>

Generated by Coder Agents from a Slack request. Dogfooded with
agent-browser against the Storybook settings and chat input stories.

</details>
This commit is contained in:
Thomas Kosiewski
2026-05-12 10:09:34 +02:00
committed by GitHub
parent 376fc80451
commit 5c3b59151e
32 changed files with 720 additions and 63 deletions
+17
View File
@@ -14909,6 +14909,17 @@ const docTemplate = `{
}
}
},
"codersdk.AgentChatSendShortcut": {
"type": "string",
"enum": [
"enter",
"modifier_enter"
],
"x-enum-varnames": [
"AgentChatSendShortcutEnter",
"AgentChatSendShortcutModifierEnter"
]
},
"codersdk.AgentConnectionTiming": {
"type": "object",
"properties": {
@@ -23426,6 +23437,9 @@ const docTemplate = `{
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"agent_chat_send_shortcut": {
"$ref": "#/definitions/codersdk.AgentChatSendShortcut"
},
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
@@ -23899,6 +23913,9 @@ const docTemplate = `{
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"agent_chat_send_shortcut": {
"$ref": "#/definitions/codersdk.AgentChatSendShortcut"
},
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
+14
View File
@@ -13375,6 +13375,14 @@
}
}
},
"codersdk.AgentChatSendShortcut": {
"type": "string",
"enum": ["enter", "modifier_enter"],
"x-enum-varnames": [
"AgentChatSendShortcutEnter",
"AgentChatSendShortcutModifierEnter"
]
},
"codersdk.AgentConnectionTiming": {
"type": "object",
"properties": {
@@ -21566,6 +21574,9 @@
"codersdk.UpdateUserPreferenceSettingsRequest": {
"type": "object",
"properties": {
"agent_chat_send_shortcut": {
"$ref": "#/definitions/codersdk.AgentChatSendShortcut"
},
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
@@ -22010,6 +22021,9 @@
"codersdk.UserPreferenceSettings": {
"type": "object",
"properties": {
"agent_chat_send_shortcut": {
"$ref": "#/definitions/codersdk.AgentChatSendShortcut"
},
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
+22
View File
@@ -4346,6 +4346,17 @@ func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetU
return q.db.GetUserActivityInsights(ctx, arg)
}
func (q *querier) GetUserAgentChatSendShortcut(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.GetUserAgentChatSendShortcut(ctx, userID)
}
func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg)
}
@@ -7010,6 +7021,17 @@ func (q *querier) UpdateUsageEventsPostPublish(ctx context.Context, arg database
return q.db.UpdateUsageEventsPostPublish(ctx, arg)
}
func (q *querier) UpdateUserAgentChatSendShortcut(ctx context.Context, arg database.UpdateUserAgentChatSendShortcutParams) (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.UpdateUserAgentChatSendShortcut(ctx, arg)
}
func (q *querier) UpdateUserChatCompactionThreshold(ctx context.Context, arg database.UpdateUserChatCompactionThresholdParams) (database.UserConfig, error) {
u, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
+13
View File
@@ -2842,6 +2842,19 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserCodeDiffDisplayMode(gomock.Any(), arg).Return("always_collapsed", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_collapsed")
}))
s.Run("GetUserAgentChatSendShortcut", 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().GetUserAgentChatSendShortcut(gomock.Any(), u.ID).Return("modifier_enter", nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("modifier_enter")
}))
s.Run("UpdateUserAgentChatSendShortcut", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserAgentChatSendShortcutParams{UserID: u.ID, AgentChatSendShortcut: "modifier_enter"}
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().UpdateUserAgentChatSendShortcut(gomock.Any(), arg).Return("modifier_enter", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("modifier_enter")
}))
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
@@ -2793,6 +2793,14 @@ func (m queryMetricsStore) GetUserActivityInsights(ctx context.Context, arg data
return r0, r1
}
func (m queryMetricsStore) GetUserAgentChatSendShortcut(ctx context.Context, userID uuid.UUID) (string, error) {
start := time.Now()
r0, r1 := m.s.GetUserAgentChatSendShortcut(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserAgentChatSendShortcut").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAgentChatSendShortcut").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.GetUserByEmailOrUsername(ctx, arg)
@@ -5001,6 +5009,14 @@ func (m queryMetricsStore) UpdateUsageEventsPostPublish(ctx context.Context, arg
return r0
}
func (m queryMetricsStore) UpdateUserAgentChatSendShortcut(ctx context.Context, arg database.UpdateUserAgentChatSendShortcutParams) (string, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserAgentChatSendShortcut(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserAgentChatSendShortcut").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserAgentChatSendShortcut").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserChatCompactionThreshold(ctx context.Context, arg database.UpdateUserChatCompactionThresholdParams) (database.UserConfig, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserChatCompactionThreshold(ctx, arg)
+30
View File
@@ -5223,6 +5223,21 @@ func (mr *MockStoreMockRecorder) GetUserActivityInsights(ctx, arg any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserActivityInsights", reflect.TypeOf((*MockStore)(nil).GetUserActivityInsights), ctx, arg)
}
// GetUserAgentChatSendShortcut mocks base method.
func (m *MockStore) GetUserAgentChatSendShortcut(ctx context.Context, userID uuid.UUID) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserAgentChatSendShortcut", ctx, userID)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserAgentChatSendShortcut indicates an expected call of GetUserAgentChatSendShortcut.
func (mr *MockStoreMockRecorder) GetUserAgentChatSendShortcut(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAgentChatSendShortcut", reflect.TypeOf((*MockStore)(nil).GetUserAgentChatSendShortcut), ctx, userID)
}
// GetUserByEmailOrUsername mocks base method.
func (m *MockStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
m.ctrl.T.Helper()
@@ -9427,6 +9442,21 @@ func (mr *MockStoreMockRecorder) UpdateUsageEventsPostPublish(ctx, arg any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUsageEventsPostPublish", reflect.TypeOf((*MockStore)(nil).UpdateUsageEventsPostPublish), ctx, arg)
}
// UpdateUserAgentChatSendShortcut mocks base method.
func (m *MockStore) UpdateUserAgentChatSendShortcut(ctx context.Context, arg database.UpdateUserAgentChatSendShortcutParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserAgentChatSendShortcut", ctx, arg)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserAgentChatSendShortcut indicates an expected call of UpdateUserAgentChatSendShortcut.
func (mr *MockStoreMockRecorder) UpdateUserAgentChatSendShortcut(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserAgentChatSendShortcut", reflect.TypeOf((*MockStore)(nil).UpdateUserAgentChatSendShortcut), ctx, arg)
}
// UpdateUserChatCompactionThreshold mocks base method.
func (m *MockStore) UpdateUserChatCompactionThreshold(ctx context.Context, arg database.UpdateUserChatCompactionThresholdParams) (database.UserConfig, error) {
m.ctrl.T.Helper()
+2
View File
@@ -699,6 +699,7 @@ type sqlcQuerier interface {
// produces a bloated value if a user has used multiple templates
// simultaneously.
GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error)
GetUserAgentChatSendShortcut(ctx context.Context, userID uuid.UUID) (string, error)
GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error)
@@ -1195,6 +1196,7 @@ type sqlcQuerier interface {
UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg UpdateTemplateVersionFlagsByJobIDParams) error
UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error
UpdateUsageEventsPostPublish(ctx context.Context, arg UpdateUsageEventsPostPublishParams) error
UpdateUserAgentChatSendShortcut(ctx context.Context, arg UpdateUserAgentChatSendShortcutParams) (string, error)
UpdateUserChatCompactionThreshold(ctx context.Context, arg UpdateUserChatCompactionThresholdParams) (UserConfig, error)
UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error)
UpdateUserChatProviderKey(ctx context.Context, arg UpdateUserChatProviderKeyParams) (UserChatProviderKey, error)
+44
View File
@@ -25607,6 +25607,23 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
return i, err
}
const getUserAgentChatSendShortcut = `-- name: GetUserAgentChatSendShortcut :one
SELECT
value AS agent_chat_send_shortcut
FROM
user_configs
WHERE
user_id = $1
AND key = 'preference_agent_chat_send_shortcut'
`
func (q *sqlQuerier) GetUserAgentChatSendShortcut(ctx context.Context, userID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getUserAgentChatSendShortcut, userID)
var agent_chat_send_shortcut string
err := row.Scan(&agent_chat_send_shortcut)
return agent_chat_send_shortcut, err
}
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros
@@ -26327,6 +26344,33 @@ func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg Updat
return items, nil
}
const updateUserAgentChatSendShortcut = `-- name: UpdateUserAgentChatSendShortcut :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
($1, 'preference_agent_chat_send_shortcut', $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_agent_chat_send_shortcut'
RETURNING value AS agent_chat_send_shortcut
`
type UpdateUserAgentChatSendShortcutParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
AgentChatSendShortcut string `db:"agent_chat_send_shortcut" json:"agent_chat_send_shortcut"`
}
func (q *sqlQuerier) UpdateUserAgentChatSendShortcut(ctx context.Context, arg UpdateUserAgentChatSendShortcutParams) (string, error) {
row := q.db.QueryRowContext(ctx, updateUserAgentChatSendShortcut, arg.UserID, arg.AgentChatSendShortcut)
var agent_chat_send_shortcut string
err := row.Scan(&agent_chat_send_shortcut)
return agent_chat_send_shortcut, err
}
const updateUserChatCompactionThreshold = `-- name: UpdateUserChatCompactionThreshold :one
INSERT INTO user_configs (user_id, key, value)
VALUES ($1, $2, ($3::int)::text)
+23
View File
@@ -327,6 +327,29 @@ WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_code_diff_display_mode'
RETURNING value AS code_diff_display_mode;
-- name: GetUserAgentChatSendShortcut :one
SELECT
value AS agent_chat_send_shortcut
FROM
user_configs
WHERE
user_id = @user_id
AND key = 'preference_agent_chat_send_shortcut';
-- name: UpdateUserAgentChatSendShortcut :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
(@user_id, 'preference_agent_chat_send_shortcut', @agent_chat_send_shortcut::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = @agent_chat_send_shortcut
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_agent_chat_send_shortcut'
RETURNING value AS agent_chat_send_shortcut;
-- name: UpdateUserRoles :one
UPDATE
users
+48 -2
View File
@@ -1257,10 +1257,20 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
return
}
agentChatSendShortcut, err := api.Database.GetUserAgentChatSendShortcut(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),
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
})
}
@@ -1305,6 +1315,16 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
})
return
}
if params.AgentChatSendShortcut != "" &&
!slices.Contains(codersdk.ValidAgentChatSendShortcuts, params.AgentChatSendShortcut) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid agent chat send shortcut.",
Validations: []codersdk.ValidationError{
{Field: "agent_chat_send_shortcut", Detail: agentChatSendShortcutValidationDetail},
},
})
return
}
var settings codersdk.UserPreferenceSettings
err := api.Database.InTx(func(tx database.Store) error {
var err error
@@ -1356,6 +1376,23 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
}
settings.CodeDiffDisplayMode = sanitizeAgentDisplayMode(stored)
}
if params.AgentChatSendShortcut != "" {
updated, err := tx.UpdateUserAgentChatSendShortcut(ctx, database.UpdateUserAgentChatSendShortcutParams{
UserID: user.ID,
AgentChatSendShortcut: string(params.AgentChatSendShortcut),
})
if err != nil {
return newUserPreferenceSettingsAPIError("Internal error updating agent chat send shortcut.", err)
}
settings.AgentChatSendShortcut = sanitizeAgentChatSendShortcut(updated)
} else {
stored, err := tx.GetUserAgentChatSendShortcut(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return newUserPreferenceSettingsAPIError("Error reading agent chat send shortcut.", err)
}
settings.AgentChatSendShortcut = sanitizeAgentChatSendShortcut(stored)
}
return nil
}, database.DefaultTXOptions().WithID("user_preference_settings"))
if err != nil {
@@ -1400,8 +1437,9 @@ func (e userPreferenceSettingsAPIError) Unwrap() error {
}
const (
thinkingDisplayModeValidationDetail = "must be one of: auto, preview, always_expanded, always_collapsed"
agentDisplayModeValidationDetail = "must be one of: auto, always_expanded, always_collapsed"
thinkingDisplayModeValidationDetail = "must be one of: auto, preview, always_expanded, always_collapsed"
agentDisplayModeValidationDetail = "must be one of: auto, always_expanded, always_collapsed"
agentChatSendShortcutValidationDetail = "must be one of: enter, modifier_enter"
)
func sanitizeThinkingDisplayMode(raw string) codersdk.ThinkingDisplayMode {
@@ -1420,6 +1458,14 @@ func sanitizeAgentDisplayMode(raw string) codersdk.AgentDisplayMode {
return codersdk.AgentDisplayModeAuto
}
func sanitizeAgentChatSendShortcut(raw string) codersdk.AgentChatSendShortcut {
shortcut := codersdk.AgentChatSendShortcut(raw)
if slices.Contains(codersdk.ValidAgentChatSendShortcuts, shortcut) {
return shortcut
}
return codersdk.AgentChatSendShortcutEnter
}
func isValidFontName(font codersdk.TerminalFontName) bool {
return slices.Contains(codersdk.TerminalFontNames, font)
}
+85
View File
@@ -1963,6 +1963,91 @@ func TestThinkingDisplayMode(t *testing.T) {
})
}
func TestAgentChatSendShortcutPreference(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 enter", 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.AgentChatSendShortcutEnter, settings.AgentChatSendShortcut)
})
t.Run("round-trips shortcut", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
AgentChatSendShortcut: codersdk.AgentChatSendShortcutModifierEnter,
})
require.NoError(t, err)
require.Equal(t, codersdk.AgentChatSendShortcutModifierEnter, updated.AgentChatSendShortcut)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.AgentChatSendShortcutModifierEnter, settings.AgentChatSendShortcut)
})
t.Run("rejects invalid shortcut", 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{
AgentChatSendShortcut: codersdk.AgentChatSendShortcut("bogus"),
})
requireValidationField(t, err, "agent_chat_send_shortcut")
})
t.Run("updates preserve stored shortcut", 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{
AgentChatSendShortcut: codersdk.AgentChatSendShortcutModifierEnter,
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
})
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.AgentChatSendShortcutModifierEnter, updated.AgentChatSendShortcut)
})
}
func TestAgentDisplayModePreferences(t *testing.T) {
t.Parallel()
+20 -6
View File
@@ -263,15 +263,29 @@ 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"`
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
}
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"`
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,omitempty"`
}
type AgentChatSendShortcut string
const (
AgentChatSendShortcutEnter AgentChatSendShortcut = "enter"
AgentChatSendShortcutModifierEnter AgentChatSendShortcut = "modifier_enter"
)
var ValidAgentChatSendShortcuts = []AgentChatSendShortcut{
AgentChatSendShortcutEnter,
AgentChatSendShortcutModifierEnter,
}
type ThinkingDisplayMode string
+28 -10
View File
@@ -1412,6 +1412,20 @@
|-----------|--------|----------|--------------|-------------|
| `license` | string | true | | |
## codersdk.AgentChatSendShortcut
```json
"enter"
```
### Properties
#### Enumerated Values
| Value(s) |
|---------------------------|
| `enter`, `modifier_enter` |
## codersdk.AgentConnectionTiming
```json
@@ -12867,6 +12881,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
```json
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
@@ -12875,11 +12890,12 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
### Properties
| 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 | | |
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
## codersdk.UpdateUserProfileRequest
@@ -13455,6 +13471,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
```json
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
@@ -13463,11 +13480,12 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| 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 | | |
| Name | Type | Required | Restrictions | Description |
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
## codersdk.UserQuietHoursScheduleConfig
+3
View File
@@ -1301,6 +1301,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
@@ -1333,6 +1334,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
@@ -1352,6 +1354,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
```json
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
+10
View File
@@ -837,6 +837,14 @@ export interface AdvisorConfig {
readonly model_config_id: string;
}
// From codersdk/users.go
export type AgentChatSendShortcut = "enter" | "modifier_enter";
export const AgentChatSendShortcuts: AgentChatSendShortcut[] = [
"enter",
"modifier_enter",
];
// From codersdk/workspacebuilds.go
export interface AgentConnectionTiming {
readonly started_at: string;
@@ -8392,6 +8400,7 @@ export interface UpdateUserPreferenceSettingsRequest {
readonly task_notification_alert_dismissed?: boolean;
readonly thinking_display_mode?: ThinkingDisplayMode;
readonly code_diff_display_mode?: AgentDisplayMode;
readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
}
// From codersdk/users.go
@@ -8770,6 +8779,7 @@ export interface UserPreferenceSettings {
readonly task_notification_alert_dismissed: boolean;
readonly thinking_display_mode: ThinkingDisplayMode;
readonly code_diff_display_mode: AgentDisplayMode;
readonly agent_chat_send_shortcut: AgentChatSendShortcut;
}
// From codersdk/deployment.go
+11 -1
View File
@@ -37,7 +37,7 @@ import {
userCompactionThresholds,
} from "#/api/queries/chats";
import { deploymentSSHConfig } from "#/api/queries/deployment";
import { user as userQuery } from "#/api/queries/users";
import { preferenceSettings, user as userQuery } from "#/api/queries/users";
import {
workspaceById,
workspaceByIdKey,
@@ -80,6 +80,7 @@ import {
} from "./components/MCPServerPicker";
import { getModelSelectorHelp } from "./components/ModelSelectorHelp";
import { useGitWatcher } from "./hooks/useGitWatcher";
import { getAgentChatSendShortcut } from "./utils/agentChatSendShortcut";
import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage";
import {
countConfiguredProviderConfigs,
@@ -768,6 +769,7 @@ const AgentChatPage: FC = () => {
enabled: permissions.editDeploymentConfig,
});
const userThresholdsQuery = useQuery(userCompactionThresholds());
const preferencesQuery = useQuery(preferenceSettings());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const userDebugLoggingQuery = useQuery(userChatDebugLogging());
const mcpServersQuery = useQuery(mcpServerConfigs());
@@ -1507,6 +1509,10 @@ const AgentChatPage: FC = () => {
if (chatQuery.isLoading || chatMessagesQuery.isLoading) {
return (
<AgentChatPageLoadingView
sendShortcut={getAgentChatSendShortcut(
preferencesQuery.data?.agent_chat_send_shortcut,
preferencesQuery.isLoading,
)}
titleElement={titleElement}
isInputDisabled={isInputDisabled}
effectiveSelectedModel={effectiveSelectedModel}
@@ -1537,6 +1543,10 @@ const AgentChatPage: FC = () => {
return (
<AgentChatPageView
agentId={agentId}
sendShortcut={getAgentChatSendShortcut(
preferencesQuery.data?.agent_chat_send_shortcut,
preferencesQuery.isLoading,
)}
organizationId={chatQuery.data?.organization_id}
chatTitle={chatTitle}
parentChat={parentChat}
@@ -132,6 +132,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
const props = {
agentId: AGENT_ID,
sendShortcut: "enter" as const,
organizationId: "test-org-id",
chatTitle: "Help me refactor",
persistedError: undefined as ChatDetailError | undefined,
@@ -620,6 +621,7 @@ export const WorkspaceNoAgent: Story = {
export const Loading: Story = {
render: () => (
<AgentChatPageLoadingView
sendShortcut="enter"
titleElement={<title>Loading — Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -638,6 +640,7 @@ export const Loading: Story = {
export const LoadingWithModelOptions: Story = {
render: () => (
<AgentChatPageLoadingView
sendShortcut="enter"
titleElement={<title>Loading — Agents</title>}
isInputDisabled={false}
effectiveSelectedModel={defaultModelConfigID}
@@ -655,6 +658,7 @@ export const LoadingWithModelOptions: Story = {
export const LoadingWithRightPanel: Story = {
render: () => (
<AgentChatPageLoadingView
sendShortcut="enter"
titleElement={<title>Loading — Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -673,6 +677,7 @@ export const LoadingWithRightPanel: Story = {
export const LoadingSidebarCollapsed: Story = {
render: () => (
<AgentChatPageLoadingView
sendShortcut="enter"
titleElement={<title>Loading — Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -11,7 +11,11 @@ import { useQueryClient } from "react-query";
import type { UrlTransform } from "streamdown";
import { chatDiffContentsKey } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
import type {
AgentChatSendShortcut,
ChatDiffStatus,
ChatMessagePart,
} from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import {
@@ -82,6 +86,7 @@ interface EditingState {
interface AgentChatPageViewProps {
// Chat data.
agentId: string;
sendShortcut: AgentChatSendShortcut;
organizationId: string | undefined;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
@@ -185,6 +190,7 @@ interface AgentChatPageViewProps {
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
agentId,
sendShortcut,
organizationId,
chatTitle,
parentChat,
@@ -521,6 +527,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
organizationId={organizationId}
sendShortcut={sendShortcut}
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
@@ -600,6 +607,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
};
interface AgentChatPageLoadingViewProps {
sendShortcut: AgentChatSendShortcut;
titleElement: React.ReactNode;
isInputDisabled: boolean;
effectiveSelectedModel: string;
@@ -616,6 +624,7 @@ interface AgentChatPageLoadingViewProps {
}
export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
sendShortcut,
titleElement,
isInputDisabled,
effectiveSelectedModel,
@@ -668,6 +677,7 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentChatInput
onSend={() => {}}
sendShortcut={sendShortcut}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
@@ -11,6 +11,7 @@ import {
mcpServerConfigs,
userChatPersonalModelOverrides,
} from "#/api/queries/chats";
import { preferenceSettings } from "#/api/queries/users";
import { workspaces } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
@@ -23,6 +24,7 @@ import { AgentPageHeader } from "./components/AgentPageHeader";
import { AgentSetupNotice } from "./components/AgentSetupNotice";
import { ChimeButton } from "./components/ChimeButton";
import { WebPushButton } from "./components/WebPushButton";
import { getAgentChatSendShortcut } from "./utils/agentChatSendShortcut";
import { getChimeEnabled, setChimeEnabled } from "./utils/chime";
import {
countConfiguredProviderConfigs,
@@ -46,6 +48,7 @@ const AgentCreatePage: FC = () => {
const personalModelOverridesQuery = useQuery(
userChatPersonalModelOverrides(),
);
const preferencesQuery = useQuery(preferenceSettings());
const mcpServersQuery = useQuery(mcpServerConfigs());
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const createMutation = useMutation(createChat(queryClient));
@@ -148,6 +151,10 @@ const AgentCreatePage: FC = () => {
</AgentPageHeader>
<AgentCreateForm
onCreateChat={handleCreateChat}
sendShortcut={getAgentChatSendShortcut(
preferencesQuery.data?.agent_chat_send_shortcut,
preferencesQuery.isLoading,
)}
isCreating={createMutation.isPending}
createError={createMutation.error}
canCreateChat={permissions.createChat}
@@ -1,10 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import {
AgentSettingsGeneralPageView,
type AgentSettingsGeneralPageViewProps,
} from "./AgentSettingsGeneralPageView";
const preferencesData = {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
};
const baseArgs: AgentSettingsGeneralPageViewProps = {
userPromptData: {
custom_prompt: "Prefer concise answers with clear next steps.",
@@ -26,6 +35,9 @@ const meta = {
title: "pages/AgentsPage/AgentSettingsGeneralPageView",
component: AgentSettingsGeneralPageView,
args: baseArgs,
parameters: {
queries: [{ key: ["me", "preferences"], data: preferencesData }],
},
} satisfies Meta<typeof AgentSettingsGeneralPageView>;
export default meta;
@@ -122,19 +134,45 @@ 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,
},
export const TogglesSendShortcut: Story = {
beforeEach: () => {
let agentChatSendShortcut: AgentChatSendShortcut =
preferencesData.agent_chat_send_shortcut;
spyOn(API, "getUserPreferenceSettings").mockImplementation(async () => ({
...preferencesData,
agent_chat_send_shortcut: agentChatSendShortcut,
}));
spyOn(API, "updateUserPreferenceSettings").mockImplementation(
async (req) => {
agentChatSendShortcut =
req.agent_chat_send_shortcut ?? agentChatSendShortcut;
return {
...preferencesData,
agent_chat_send_shortcut: agentChatSendShortcut,
};
},
],
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Require Cmd/Ctrl+Enter to send messages",
});
expect(await canvas.findByText("Keyboard Shortcuts")).toBeInTheDocument();
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(API.updateUserPreferenceSettings).toHaveBeenCalledWith({
agent_chat_send_shortcut: "modifier_enter",
});
expect(toggle).toBeChecked();
});
},
};
export const RendersAgentDisplayModeSettings: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -2,6 +2,7 @@ import type { FC } from "react";
import type { UseMutateFunction } from "react-query";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import { ChatSendShortcutSettings } from "./components/ChatSendShortcutSettings";
import {
CodeDiffDisplaySettings,
ThinkingDisplaySettings,
@@ -57,6 +58,7 @@ export const AgentSettingsGeneralPageView: FC<
isAnyPromptSaving={isSavingUserPrompt}
/>
<ChatFullWidthSettings />
<ChatSendShortcutSettings />
<ThinkingDisplaySettings />
<CodeDiffDisplaySettings />
<UserChatDebugLoggingSettings
@@ -29,6 +29,7 @@ const meta: Meta<typeof AgentChatInput> = {
decorators: [withProxyProvider()],
args: {
onSend: fn(),
sendShortcut: "enter",
onContentChange: fn(),
onModelChange: fn(),
initialValue: "",
@@ -210,6 +211,54 @@ export const SendsAndClearsInput: Story = {
},
};
export const EnterSendsByDefault: Story = {
args: {
onSend: fn(),
initialValue: "Run focused tests",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const editor = canvas.getByTestId("chat-message-input");
await waitFor(() => {
expect(editor.textContent).toBe("Run focused tests");
});
await userEvent.click(editor);
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(args.onSend).toHaveBeenCalledWith("Run focused tests");
});
},
};
export const ModifierEnterSendsWhenRequired: Story = {
args: {
onSend: fn(),
sendShortcut: "modifier_enter",
initialValue: "Run focused tests",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const editor = canvas.getByTestId("chat-message-input");
await waitFor(() => {
expect(editor.textContent).toBe("Run focused tests");
});
await userEvent.click(editor);
await userEvent.keyboard("{Enter}");
expect(args.onSend).not.toHaveBeenCalled();
await waitFor(() => {
expect(editor.querySelectorAll("br").length).toBeGreaterThan(0);
});
await userEvent.keyboard("{Control>}{Enter}{/Control}");
await waitFor(() => {
expect(args.onSend).toHaveBeenCalledWith("Run focused tests");
});
},
};
/**
* CODAGT-210: On mobile viewports, Enter must insert a newline rather
* than submit the message, because Shift+Enter is cumbersome on
@@ -22,7 +22,11 @@ import {
} from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated";
import type {
AgentChatSendShortcut,
ChatMessagePart,
ChatQueuedMessage,
} from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import {
@@ -54,6 +58,10 @@ import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useOverflowCount } from "../hooks/useOverflowCount";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import {
DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
} from "../utils/agentChatSendShortcut";
import {
chatAttachmentAcceptAttribute,
isChatAttachmentFile,
@@ -86,6 +94,7 @@ export type { AgentContextUsage } from "./ContextUsageIndicator";
interface AgentChatInputProps {
onSend: (message: string) => void;
sendShortcut?: AgentChatSendShortcut;
placeholder?: string;
isDisabled: boolean;
isLoading: boolean;
@@ -281,6 +290,7 @@ const ToolBadge: FC<{
export const AgentChatInput: FC<AgentChatInputProps> = ({
onSend,
sendShortcut = DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
placeholder = "Type a message...",
isDisabled,
isLoading,
@@ -845,6 +855,14 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
: isEditingHistoryMessage
? "Save Edit"
: "Send";
const sendShortcutLabel =
sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
? "Cmd/Ctrl+Enter"
: "Enter";
const sendButtonKeyShortcuts =
sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT
? "Control+Enter Meta+Enter"
: "Enter";
const content = (
<div
@@ -949,6 +967,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onChange={handleContentChange}
onKeyDown={handleEditorKeyDown}
onEnter={handleSubmit}
sendShortcut={sendShortcut}
disabled={isDisabled || isLoading}
autoFocus
/>
@@ -1334,26 +1353,38 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</Button>
)}
{!(isStreaming && editingQueuedMessageID === null) && (
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-5 [&>svg]:p-0"
onClick={
speech.isRecording ? handleAcceptRecording : handleSubmit
}
disabled={speech.isRecording ? false : !canSend}
>
{isLoading ? (
<Spinner size="sm" loading aria-hidden="true" />
) : speech.isRecording ? (
<CheckIcon />
) : (
<ArrowUpIcon />
)}
<span className="sr-only">
{speech.isRecording ? "Accept voice input" : sendButtonLabel}
</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-5 [&>svg]:p-0"
onClick={
speech.isRecording ? handleAcceptRecording : handleSubmit
}
disabled={speech.isRecording ? false : !canSend}
aria-keyshortcuts={sendButtonKeyShortcuts}
>
{isLoading ? (
<Spinner size="sm" loading aria-hidden="true" />
) : speech.isRecording ? (
<CheckIcon />
) : (
<ArrowUpIcon />
)}
<span className="sr-only">
{speech.isRecording
? "Accept voice input"
: sendButtonLabel}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{speech.isRecording
? "Accept voice input"
: `${sendButtonLabel}: ${sendShortcutLabel}`}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -108,6 +108,7 @@ const meta: Meta<typeof AgentCreateForm> = {
decorators: [withDashboardProvider],
args: {
onCreateChat: fn(),
sendShortcut: "enter",
isCreating: false,
createError: undefined,
canCreateChat: true,
@@ -12,6 +12,7 @@ import { toast } from "sonner";
import { isApiError } from "#/api/errors";
import { permittedOrganizations } from "#/api/queries/organizations";
import type * as TypesGen from "#/api/typesGenerated";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
@@ -125,6 +126,7 @@ export function useEmptyStateDraft() {
interface AgentCreateFormProps {
onCreateChat: (options: CreateChatOptions) => Promise<void>;
sendShortcut: AgentChatSendShortcut;
isCreating: boolean;
createError: unknown;
canCreateChat: boolean;
@@ -146,6 +148,7 @@ interface AgentCreateFormProps {
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onCreateChat,
sendShortcut,
isCreating,
createError,
canCreateChat,
@@ -509,6 +512,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
{agentSetupNotice}
<AgentChatInput
onSend={handleSendWithAttachments}
sendShortcut={sendShortcut}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={
isCreating || isForbidden || isPersonalModelOverridesLoading
@@ -1763,6 +1763,7 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "always_collapsed" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -1839,6 +1840,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -1887,6 +1889,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -1936,6 +1939,7 @@ export const ThinkingBlockWithToolCall: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -1998,6 +2002,7 @@ export const ThinkingBlockAutoMode: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -2050,6 +2055,7 @@ export const ThinkingBlockPreviewMode: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "preview" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -29,8 +29,13 @@ import {
useLayoutEffect,
useRef,
} from "react";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { isMobileViewport } from "#/utils/mobile";
import {
DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
} from "../../utils/agentChatSendShortcut";
import { isChatAttachmentFile } from "../../utils/chatAttachments";
import {
$createFileReferenceNode,
@@ -257,21 +262,40 @@ const PasteSanitizationPlugin: FC<{
return null;
};
// Handles Enter key behavior: plain Enter submits via the onEnter
// callback, Shift+Enter inserts a newline. On mobile viewports, Enter
// always inserts a newline; users submit via the send button because
// Handles Enter key behavior. By default, plain Enter submits via
// the onEnter callback, and Shift+Enter inserts a newline. When the
// modifier shortcut is selected, Cmd/Ctrl+Enter submits instead, and
// plain Enter inserts a newline. On mobile viewports, Enter always
// inserts a newline; users submit via the send button because
// Shift+Enter is cumbersome on touch keyboards (CODAGT-210).
const EnterKeyPlugin: FC<{ onEnter?: () => void }> = function EnterKeyPlugin({
onEnter,
}) {
const EnterKeyPlugin: FC<{
onEnter?: () => void;
sendShortcut: AgentChatSendShortcut;
}> = function EnterKeyPlugin({ onEnter, sendShortcut }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (event?.shiftKey || isMobileViewport()) {
return false;
const shouldInsertLineBreak =
event?.shiftKey ||
isMobileViewport() ||
(sendShortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT &&
!(event?.metaKey || event?.ctrlKey));
if (shouldInsertLineBreak) {
event?.preventDefault();
editor.update(() => {
let selection = $getSelection();
if (!$isRangeSelection(selection)) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
selection.insertLineBreak();
}
});
return true;
}
if (onEnter) {
event?.preventDefault();
@@ -281,7 +305,7 @@ const EnterKeyPlugin: FC<{ onEnter?: () => void }> = function EnterKeyPlugin({
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
}, [editor, onEnter, sendShortcut]);
return null;
};
@@ -456,6 +480,7 @@ interface ChatMessageInputProps
remountKey?: number;
rows?: number;
onEnter?: () => void;
sendShortcut?: AgentChatSendShortcut;
onFilePaste?: (file: File) => void;
allowTextAttachmentPaste?: boolean;
disabled?: boolean;
@@ -486,6 +511,7 @@ const ChatMessageInput = ({
remountKey,
rows,
onEnter,
sendShortcut = DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
onFilePaste,
allowTextAttachmentPaste,
disabled,
@@ -706,7 +732,10 @@ const ChatMessageInput = ({
onFilePaste={onFilePaste}
allowTextAttachmentPaste={allowTextAttachmentPaste}
/>
<EnterKeyPlugin onEnter={disabled ? undefined : onEnter} />
<EnterKeyPlugin
onEnter={disabled ? undefined : onEnter}
sendShortcut={sendShortcut}
/>
<ContentChangePlugin onChange={onChange} />
<ValueSyncPlugin
initialValue={initialValue}
@@ -2,6 +2,7 @@ import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { useChatDraftAttachments } from "../hooks/useChatDraftAttachments";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
@@ -150,6 +151,7 @@ interface ChatPageInputProps {
message: string,
attachments?: readonly PendingAttachment[],
) => Promise<void> | void;
sendShortcut: AgentChatSendShortcut;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
onInterrupt: () => void;
@@ -214,6 +216,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
store,
compressionThreshold,
onSend,
sendShortcut,
onDeleteQueuedMessage,
onPromoteQueuedMessage,
onInterrupt,
@@ -445,6 +448,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
}
})();
}}
sendShortcut={sendShortcut}
attachments={attachments}
onAttach={handleAttach}
onRemoveAttachment={handleRemoveAttachment}
@@ -0,0 +1,56 @@
import { type FC, useId } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
preferenceSettings,
updatePreferenceSettings,
} from "#/api/queries/users";
import { Switch } from "#/components/Switch/Switch";
import {
DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
} from "../utils/agentChatSendShortcut";
export const ChatSendShortcutSettings: FC = () => {
const queryClient = useQueryClient();
const query = useQuery(preferenceSettings());
const mutation = useMutation(updatePreferenceSettings(queryClient));
const descriptionId = useId();
const shortcut =
query.data?.agent_chat_send_shortcut ?? DEFAULT_AGENT_CHAT_SEND_SHORTCUT;
const requiresModifierEnter = shortcut === MODIFIER_AGENT_CHAT_SEND_SHORTCUT;
return (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Keyboard Shortcuts
</h3>
<div className="flex items-center justify-between gap-4">
<p
id={descriptionId}
className="m-0 flex-1 text-xs text-content-secondary"
>
Require Cmd/Ctrl+Enter to send agent messages. When enabled, Enter
inserts a newline instead.
</p>
<Switch
checked={requiresModifierEnter}
onCheckedChange={(checked) =>
mutation.mutate({
agent_chat_send_shortcut: checked
? MODIFIER_AGENT_CHAT_SEND_SHORTCUT
: DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
})
}
aria-label="Require Cmd/Ctrl+Enter to send messages"
aria-describedby={descriptionId}
disabled={query.isLoading || !query.data || mutation.isPending}
/>
</div>
{mutation.isError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save your keyboard shortcut preference.
</p>
)}
</div>
);
};
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
getAgentChatSendShortcut,
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
} from "./agentChatSendShortcut";
describe("getAgentChatSendShortcut", () => {
it("returns the stored shortcut when present", () => {
expect(
getAgentChatSendShortcut(MODIFIER_AGENT_CHAT_SEND_SHORTCUT, false),
).toBe(MODIFIER_AGENT_CHAT_SEND_SHORTCUT);
});
it("uses the modifier shortcut while preferences are loading", () => {
expect(getAgentChatSendShortcut(undefined, true)).toBe(
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
);
});
it("uses the default shortcut after preferences finish loading", () => {
expect(getAgentChatSendShortcut(undefined, false)).toBe(
DEFAULT_AGENT_CHAT_SEND_SHORTCUT,
);
});
});
@@ -0,0 +1,20 @@
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
export const DEFAULT_AGENT_CHAT_SEND_SHORTCUT: AgentChatSendShortcut = "enter";
export const MODIFIER_AGENT_CHAT_SEND_SHORTCUT: AgentChatSendShortcut =
"modifier_enter";
export function getAgentChatSendShortcut(
storedShortcut: AgentChatSendShortcut | undefined,
isLoading: boolean,
): AgentChatSendShortcut {
if (storedShortcut) {
return storedShortcut;
}
// Keep the loading fallback conservative. If a user saved
// modifier_enter, falling back to enter before preferences load can
// send a draft when they intended to insert a newline.
return isLoading
? MODIFIER_AGENT_CHAT_SEND_SHORTCUT
: DEFAULT_AGENT_CHAT_SEND_SHORTCUT;
}
@@ -216,6 +216,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
task_notification_alert_dismissed: true,
thinking_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
},
],
@@ -240,6 +241,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto",
code_diff_display_mode: "auto",
agent_chat_send_shortcut: "enter" as const,
});
await step("Enable Task Idle notification", async () => {