From 5c3b59151e9c4d0920310a3d16b0c43926c15792 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 12 May 2026 10:09:34 +0200 Subject: [PATCH] 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.
Coder Agents notes Generated by Coder Agents from a Slack request. Dogfooded with agent-browser against the Storybook settings and chat input stories.
--- coderd/apidoc/docs.go | 17 ++++ coderd/apidoc/swagger.json | 14 +++ coderd/database/dbauthz/dbauthz.go | 22 +++++ coderd/database/dbauthz/dbauthz_test.go | 13 +++ coderd/database/dbmetrics/querymetrics.go | 16 ++++ coderd/database/dbmock/dbmock.go | 30 +++++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 44 ++++++++++ coderd/database/queries/users.sql | 23 +++++ coderd/users.go | 50 ++++++++++- coderd/users_test.go | 85 +++++++++++++++++++ codersdk/users.go | 26 ++++-- docs/reference/api/schemas.md | 38 ++++++--- docs/reference/api/users.md | 3 + site/src/api/typesGenerated.ts | 10 +++ site/src/pages/AgentsPage/AgentChatPage.tsx | 12 ++- .../AgentsPage/AgentChatPageView.stories.tsx | 5 ++ .../pages/AgentsPage/AgentChatPageView.tsx | 12 ++- site/src/pages/AgentsPage/AgentCreatePage.tsx | 7 ++ .../AgentSettingsGeneralPageView.stories.tsx | 62 +++++++++++--- .../AgentSettingsGeneralPageView.tsx | 2 + .../components/AgentChatInput.stories.tsx | 49 +++++++++++ .../AgentsPage/components/AgentChatInput.tsx | 73 +++++++++++----- .../components/AgentCreateForm.stories.tsx | 1 + .../AgentsPage/components/AgentCreateForm.tsx | 4 + .../ConversationTimeline.stories.tsx | 6 ++ .../ChatMessageInput/ChatMessageInput.tsx | 49 ++++++++--- .../AgentsPage/components/ChatPageContent.tsx | 4 + .../components/ChatSendShortcutSettings.tsx | 56 ++++++++++++ .../utils/agentChatSendShortcut.test.ts | 26 ++++++ .../AgentsPage/utils/agentChatSendShortcut.ts | 20 +++++ .../NotificationsPage.stories.tsx | 2 + 32 files changed, 720 insertions(+), 63 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatSendShortcutSettings.tsx create mode 100644 site/src/pages/AgentsPage/utils/agentChatSendShortcut.test.ts create mode 100644 site/src/pages/AgentsPage/utils/agentChatSendShortcut.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2ac5aa5c0c..b9364631c4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 81300bbedc..f7b890c3a8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e4e3550639..a1473f48f0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 53023ad41f..913a26d6fa 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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"} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 88a5ecd766..1bca8d357d 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b8c4b73d64..eca308d5d0 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6c3a951730..cc8a499a8b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index daefd542ff..913231b402 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index e4cff00cc7..d9043575cf 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -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 diff --git a/coderd/users.go b/coderd/users.go index fd6ff00b0f..87ea63c49e 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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) } diff --git a/coderd/users_test.go b/coderd/users_test.go index 1369ab22bc..16383ead2f 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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() diff --git a/codersdk/users.go b/codersdk/users.go index c652be4766..81407739cf 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0cda9d7334..80f1e39513 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 39323c6540..4d89c70f5f 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -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" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cc2c1d4154..1b5cc35a9c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index eb694ecfb0..7ddce57d1f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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 ( { return ( = ({ 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: () => ( Loading — Agents} isInputDisabled effectiveSelectedModel={defaultModelConfigID} @@ -638,6 +640,7 @@ export const Loading: Story = { export const LoadingWithModelOptions: Story = { render: () => ( Loading — Agents} isInputDisabled={false} effectiveSelectedModel={defaultModelConfigID} @@ -655,6 +658,7 @@ export const LoadingWithModelOptions: Story = { export const LoadingWithRightPanel: Story = { render: () => ( Loading — Agents} isInputDisabled effectiveSelectedModel={defaultModelConfigID} @@ -673,6 +677,7 @@ export const LoadingWithRightPanel: Story = { export const LoadingSidebarCollapsed: Story = { render: () => ( Loading — Agents} isInputDisabled effectiveSelectedModel={defaultModelConfigID} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 60d321fc46..69dc835df5 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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 = ({ agentId, + sendShortcut, organizationId, chatTitle, parentChat, @@ -521,6 +527,7 @@ export const AgentChatPageView: FC = ({
= ({ }; interface AgentChatPageLoadingViewProps { + sendShortcut: AgentChatSendShortcut; titleElement: React.ReactNode; isInputDisabled: boolean; effectiveSelectedModel: string; @@ -616,6 +624,7 @@ interface AgentChatPageLoadingViewProps { } export const AgentChatPageLoadingView: FC = ({ + sendShortcut, titleElement, isInputDisabled, effectiveSelectedModel, @@ -668,6 +677,7 @@ export const AgentChatPageLoadingView: FC = ({
{}} + sendShortcut={sendShortcut} initialValue="" isDisabled={isInputDisabled} isLoading={false} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 2b957d8d27..46fcb59a58 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -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 = () => { ; 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); diff --git a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx index 359a6f0d91..d69373cda8 100644 --- a/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsGeneralPageView.tsx @@ -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} /> + = { 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 diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 85c4fee9c1..d48010a45e 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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 = ({ onSend, + sendShortcut = DEFAULT_AGENT_CHAT_SEND_SHORTCUT, placeholder = "Type a message...", isDisabled, isLoading, @@ -845,6 +855,14 @@ export const AgentChatInput: FC = ({ : 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 = (
= ({ onChange={handleContentChange} onKeyDown={handleEditorKeyDown} onEnter={handleSubmit} + sendShortcut={sendShortcut} disabled={isDisabled || isLoading} autoFocus /> @@ -1334,26 +1353,38 @@ export const AgentChatInput: FC = ({ )} {!(isStreaming && editingQueuedMessageID === null) && ( - + + + + + + {speech.isRecording + ? "Accept voice input" + : `${sendButtonLabel}: ${sendShortcutLabel}`} + + )}
diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 3e7a7a52a4..1b7a96e8cf 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -108,6 +108,7 @@ const meta: Meta = { decorators: [withDashboardProvider], args: { onCreateChat: fn(), + sendShortcut: "enter", isCreating: false, createError: undefined, canCreateChat: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index f787504d1d..c18a4854c5 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -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; + sendShortcut: AgentChatSendShortcut; isCreating: boolean; createError: unknown; canCreateChat: boolean; @@ -146,6 +148,7 @@ interface AgentCreateFormProps { export const AgentCreateForm: FC = ({ onCreateChat, + sendShortcut, isCreating, createError, canCreateChat, @@ -509,6 +512,7 @@ export const AgentCreateForm: FC = ({ {agentSetupNotice} 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} /> - + Promise | void; + sendShortcut: AgentChatSendShortcut; onDeleteQueuedMessage: (id: number) => Promise; onPromoteQueuedMessage: (id: number) => Promise; onInterrupt: () => void; @@ -214,6 +216,7 @@ export const ChatPageInput: FC = ({ store, compressionThreshold, onSend, + sendShortcut, onDeleteQueuedMessage, onPromoteQueuedMessage, onInterrupt, @@ -445,6 +448,7 @@ export const ChatPageInput: FC = ({ } })(); }} + sendShortcut={sendShortcut} attachments={attachments} onAttach={handleAttach} onRemoveAttachment={handleRemoveAttachment} diff --git a/site/src/pages/AgentsPage/components/ChatSendShortcutSettings.tsx b/site/src/pages/AgentsPage/components/ChatSendShortcutSettings.tsx new file mode 100644 index 0000000000..3a408f5f5f --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatSendShortcutSettings.tsx @@ -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 ( +
+

+ Keyboard Shortcuts +

+
+

+ Require Cmd/Ctrl+Enter to send agent messages. When enabled, Enter + inserts a newline instead. +

+ + 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} + /> +
+ {mutation.isError && ( +

+ Failed to save your keyboard shortcut preference. +

+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/utils/agentChatSendShortcut.test.ts b/site/src/pages/AgentsPage/utils/agentChatSendShortcut.test.ts new file mode 100644 index 0000000000..a7c1a68d26 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/agentChatSendShortcut.test.ts @@ -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, + ); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/agentChatSendShortcut.ts b/site/src/pages/AgentsPage/utils/agentChatSendShortcut.ts new file mode 100644 index 0000000000..01022f4f9d --- /dev/null +++ b/site/src/pages/AgentsPage/utils/agentChatSendShortcut.ts @@ -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; +} diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index 1c35286761..d709c06b0d 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -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 () => {