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
+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