mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+17
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+28
-10
@@ -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
|
||||
|
||||
|
||||
Generated
+3
@@ -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"
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user