mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add shell tool display mode preference (#25029)
This commit is contained in:
Generated
+6
@@ -23572,6 +23572,9 @@ const docTemplate = `{
|
||||
"code_diff_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"shell_tool_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"task_notification_alert_dismissed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -24060,6 +24063,9 @@ const docTemplate = `{
|
||||
"code_diff_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"shell_tool_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"task_notification_alert_dismissed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Generated
+6
@@ -21694,6 +21694,9 @@
|
||||
"code_diff_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"shell_tool_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"task_notification_alert_dismissed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -22153,6 +22156,9 @@
|
||||
"code_diff_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"shell_tool_display_mode": {
|
||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||
},
|
||||
"task_notification_alert_dismissed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -4546,6 +4546,17 @@ func (q *querier) GetUserSecretsTelemetrySummary(ctx context.Context) (database.
|
||||
return q.db.GetUserSecretsTelemetrySummary(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserShellToolDisplayMode(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.GetUserShellToolDisplayMode(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
|
||||
return nil, err
|
||||
@@ -7213,6 +7224,17 @@ func (q *querier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg datab
|
||||
return q.db.UpdateUserSecretByUserIDAndName(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (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.UpdateUserShellToolDisplayMode(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
return q.db.GetUserByID(ctx, arg.ID)
|
||||
|
||||
@@ -2862,6 +2862,19 @@ func (s *MethodTestSuite) TestUser() {
|
||||
dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes()
|
||||
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded")
|
||||
}))
|
||||
s.Run("GetUserShellToolDisplayMode", 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().GetUserShellToolDisplayMode(gomock.Any(), u.ID).Return("auto", nil).AnyTimes()
|
||||
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("auto")
|
||||
}))
|
||||
s.Run("UpdateUserShellToolDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
arg := database.UpdateUserShellToolDisplayModeParams{UserID: u.ID, ShellToolDisplayMode: "always_collapsed"}
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateUserShellToolDisplayMode(gomock.Any(), arg).Return("always_collapsed", nil).AnyTimes()
|
||||
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_collapsed")
|
||||
}))
|
||||
s.Run("GetUserCodeDiffDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
|
||||
|
||||
@@ -2969,6 +2969,14 @@ func (m queryMetricsStore) GetUserSecretsTelemetrySummary(ctx context.Context) (
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserShellToolDisplayMode(ctx, userID)
|
||||
m.queryLatencies.WithLabelValues("GetUserShellToolDisplayMode").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserShellToolDisplayMode").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
|
||||
@@ -5153,6 +5161,14 @@ func (m queryMetricsStore) UpdateUserSecretByUserIDAndName(ctx context.Context,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserShellToolDisplayMode(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateUserShellToolDisplayMode").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserShellToolDisplayMode").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateUserStatus(ctx, arg)
|
||||
|
||||
@@ -5553,6 +5553,21 @@ func (mr *MockStoreMockRecorder) GetUserSecretsTelemetrySummary(ctx any) *gomock
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).GetUserSecretsTelemetrySummary), ctx)
|
||||
}
|
||||
|
||||
// GetUserShellToolDisplayMode mocks base method.
|
||||
func (m *MockStore) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserShellToolDisplayMode", ctx, userID)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserShellToolDisplayMode indicates an expected call of GetUserShellToolDisplayMode.
|
||||
func (mr *MockStoreMockRecorder) GetUserShellToolDisplayMode(ctx, userID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserShellToolDisplayMode), ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserStatusCounts mocks base method.
|
||||
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -9708,6 +9723,21 @@ func (mr *MockStoreMockRecorder) UpdateUserSecretByUserIDAndName(ctx, arg any) *
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateUserSecretByUserIDAndName), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserShellToolDisplayMode mocks base method.
|
||||
func (m *MockStore) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateUserShellToolDisplayMode", ctx, arg)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateUserShellToolDisplayMode indicates an expected call of UpdateUserShellToolDisplayMode.
|
||||
func (mr *MockStoreMockRecorder) UpdateUserShellToolDisplayMode(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserShellToolDisplayMode), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateUserStatus mocks base method.
|
||||
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -769,6 +769,7 @@ type sqlcQuerier interface {
|
||||
// percentile_disc returns an actual integer count from the underlying
|
||||
// values rather than interpolating between rows.
|
||||
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
|
||||
GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
// GetUserStatusCounts returns the count of users in each status over time.
|
||||
// The time range is inclusively defined by the start_time and end_time parameters.
|
||||
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
|
||||
@@ -1223,6 +1224,7 @@ type sqlcQuerier interface {
|
||||
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
|
||||
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
|
||||
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
|
||||
UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error)
|
||||
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
|
||||
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
|
||||
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
|
||||
|
||||
@@ -25953,6 +25953,23 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context, includeSystem bool) (int6
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getUserShellToolDisplayMode = `-- name: GetUserShellToolDisplayMode :one
|
||||
SELECT
|
||||
value AS shell_tool_display_mode
|
||||
FROM
|
||||
user_configs
|
||||
WHERE
|
||||
user_id = $1
|
||||
AND key = 'preference_shell_tool_display_mode'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserShellToolDisplayMode, userID)
|
||||
var shell_tool_display_mode string
|
||||
err := row.Scan(&shell_tool_display_mode)
|
||||
return shell_tool_display_mode, err
|
||||
}
|
||||
|
||||
const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one
|
||||
SELECT
|
||||
value::boolean as task_notification_alert_dismissed
|
||||
@@ -26869,6 +26886,33 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUserShellToolDisplayMode = `-- name: UpdateUserShellToolDisplayMode :one
|
||||
INSERT INTO
|
||||
user_configs (user_id, key, value)
|
||||
VALUES
|
||||
($1, 'preference_shell_tool_display_mode', $2::text)
|
||||
ON CONFLICT
|
||||
ON CONSTRAINT user_configs_pkey
|
||||
DO UPDATE
|
||||
SET
|
||||
value = $2
|
||||
WHERE user_configs.user_id = $1
|
||||
AND user_configs.key = 'preference_shell_tool_display_mode'
|
||||
RETURNING value AS shell_tool_display_mode
|
||||
`
|
||||
|
||||
type UpdateUserShellToolDisplayModeParams struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
ShellToolDisplayMode string `db:"shell_tool_display_mode" json:"shell_tool_display_mode"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUserShellToolDisplayMode, arg.UserID, arg.ShellToolDisplayMode)
|
||||
var shell_tool_display_mode string
|
||||
err := row.Scan(&shell_tool_display_mode)
|
||||
return shell_tool_display_mode, err
|
||||
}
|
||||
|
||||
const updateUserStatus = `-- name: UpdateUserStatus :one
|
||||
UPDATE
|
||||
users
|
||||
|
||||
@@ -347,6 +347,29 @@ WHERE user_configs.user_id = @user_id
|
||||
AND user_configs.key = 'preference_thinking_display_mode'
|
||||
RETURNING value AS thinking_display_mode;
|
||||
|
||||
-- name: GetUserShellToolDisplayMode :one
|
||||
SELECT
|
||||
value AS shell_tool_display_mode
|
||||
FROM
|
||||
user_configs
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
AND key = 'preference_shell_tool_display_mode';
|
||||
|
||||
-- name: UpdateUserShellToolDisplayMode :one
|
||||
INSERT INTO
|
||||
user_configs (user_id, key, value)
|
||||
VALUES
|
||||
(@user_id, 'preference_shell_tool_display_mode', @shell_tool_display_mode::text)
|
||||
ON CONFLICT
|
||||
ON CONSTRAINT user_configs_pkey
|
||||
DO UPDATE
|
||||
SET
|
||||
value = @shell_tool_display_mode
|
||||
WHERE user_configs.user_id = @user_id
|
||||
AND user_configs.key = 'preference_shell_tool_display_mode'
|
||||
RETURNING value AS shell_tool_display_mode;
|
||||
|
||||
-- name: GetUserCodeDiffDisplayMode :one
|
||||
SELECT
|
||||
value AS code_diff_display_mode
|
||||
|
||||
@@ -1306,6 +1306,15 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
shellToolMode, err := api.Database.GetUserShellToolDisplayMode(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
|
||||
}
|
||||
|
||||
codeDiffMode, err := api.Database.GetUserCodeDiffDisplayMode(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -1327,6 +1336,7 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
|
||||
TaskNotificationAlertDismissed: taskAlertDismissed,
|
||||
ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode),
|
||||
ShellToolDisplayMode: sanitizeAgentDisplayMode(shellToolMode),
|
||||
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
|
||||
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
|
||||
})
|
||||
@@ -1363,6 +1373,16 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
return
|
||||
}
|
||||
if params.ShellToolDisplayMode != "" &&
|
||||
!slices.Contains(codersdk.ValidAgentDisplayModes, params.ShellToolDisplayMode) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid shell tool display mode.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "shell_tool_display_mode", Detail: agentDisplayModeValidationDetail},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if params.CodeDiffDisplayMode != "" &&
|
||||
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
@@ -1418,6 +1438,23 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
|
||||
settings.ThinkingDisplayMode = sanitizeThinkingDisplayMode(stored)
|
||||
}
|
||||
|
||||
if params.ShellToolDisplayMode != "" {
|
||||
updated, err := tx.UpdateUserShellToolDisplayMode(ctx, database.UpdateUserShellToolDisplayModeParams{
|
||||
UserID: user.ID,
|
||||
ShellToolDisplayMode: string(params.ShellToolDisplayMode),
|
||||
})
|
||||
if err != nil {
|
||||
return newUserPreferenceSettingsAPIError("Internal error updating shell tool display mode.", err)
|
||||
}
|
||||
settings.ShellToolDisplayMode = sanitizeAgentDisplayMode(updated)
|
||||
} else {
|
||||
stored, err := tx.GetUserShellToolDisplayMode(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return newUserPreferenceSettingsAPIError("Error reading shell tool display mode.", err)
|
||||
}
|
||||
settings.ShellToolDisplayMode = sanitizeAgentDisplayMode(stored)
|
||||
}
|
||||
|
||||
if params.CodeDiffDisplayMode != "" {
|
||||
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
|
||||
UserID: user.ID,
|
||||
|
||||
+63
-5
@@ -2433,9 +2433,34 @@ func TestAgentDisplayModePreferences(t *testing.T) {
|
||||
|
||||
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.ShellToolDisplayMode)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.CodeDiffDisplayMode)
|
||||
})
|
||||
|
||||
t.Run("round-trips shell tool display mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
for _, mode := range []codersdk.AgentDisplayMode{
|
||||
codersdk.AgentDisplayModeAlwaysExpanded,
|
||||
codersdk.AgentDisplayModeAlwaysCollapsed,
|
||||
} {
|
||||
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||
ShellToolDisplayMode: mode,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mode, updated.ShellToolDisplayMode)
|
||||
|
||||
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mode, settings.ShellToolDisplayMode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("round-trips code diff display mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2469,24 +2494,57 @@ func TestAgentDisplayModePreferences(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
|
||||
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
||||
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
|
||||
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysCollapsed,
|
||||
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||
ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysExpanded,
|
||||
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, updated.ThinkingDisplayMode)
|
||||
require.Equal(t, codersdk.ThinkingDisplayModePreview, updated.ThinkingDisplayMode)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.ShellToolDisplayMode)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.CodeDiffDisplayMode)
|
||||
|
||||
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, settings.ThinkingDisplayMode)
|
||||
require.Equal(t, codersdk.ThinkingDisplayModePreview, settings.ThinkingDisplayMode)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, settings.ShellToolDisplayMode)
|
||||
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, settings.CodeDiffDisplayMode)
|
||||
})
|
||||
|
||||
t.Run("rejects invalid shell tool display mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
mode codersdk.AgentDisplayMode
|
||||
}{
|
||||
{
|
||||
name: "bogus",
|
||||
mode: codersdk.AgentDisplayMode("bogus"),
|
||||
},
|
||||
{
|
||||
name: "thinking preview",
|
||||
mode: codersdk.AgentDisplayMode(codersdk.ThinkingDisplayModePreview),
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||
ShellToolDisplayMode: tt.mode,
|
||||
})
|
||||
requireValidationField(t, err, "shell_tool_display_mode")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid code diff display mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ type UpdateUserAppearanceSettingsRequest struct {
|
||||
type UserPreferenceSettings struct {
|
||||
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
|
||||
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
|
||||
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode"`
|
||||
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
|
||||
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
|
||||
}
|
||||
@@ -312,6 +313,7 @@ type UserPreferenceSettings struct {
|
||||
type UpdateUserPreferenceSettingsRequest struct {
|
||||
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
|
||||
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
|
||||
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode,omitempty"`
|
||||
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
|
||||
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,omitempty"`
|
||||
}
|
||||
|
||||
Generated
+4
@@ -12960,6 +12960,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|
||||
{
|
||||
"agent_chat_send_shortcut": "enter",
|
||||
"code_diff_display_mode": "auto",
|
||||
"shell_tool_display_mode": "auto",
|
||||
"task_notification_alert_dismissed": true,
|
||||
"thinking_display_mode": "auto"
|
||||
}
|
||||
@@ -12971,6 +12972,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|
||||
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
|
||||
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||
| `task_notification_alert_dismissed` | boolean | false | | |
|
||||
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
|
||||
|
||||
@@ -13556,6 +13558,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
{
|
||||
"agent_chat_send_shortcut": "enter",
|
||||
"code_diff_display_mode": "auto",
|
||||
"shell_tool_display_mode": "auto",
|
||||
"task_notification_alert_dismissed": true,
|
||||
"thinking_display_mode": "auto"
|
||||
}
|
||||
@@ -13567,6 +13570,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
|
||||
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||
| `task_notification_alert_dismissed` | boolean | false | | |
|
||||
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
|
||||
|
||||
|
||||
Generated
+3
@@ -1312,6 +1312,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \
|
||||
{
|
||||
"agent_chat_send_shortcut": "enter",
|
||||
"code_diff_display_mode": "auto",
|
||||
"shell_tool_display_mode": "auto",
|
||||
"task_notification_alert_dismissed": true,
|
||||
"thinking_display_mode": "auto"
|
||||
}
|
||||
@@ -1345,6 +1346,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
|
||||
{
|
||||
"agent_chat_send_shortcut": "enter",
|
||||
"code_diff_display_mode": "auto",
|
||||
"shell_tool_display_mode": "auto",
|
||||
"task_notification_alert_dismissed": true,
|
||||
"thinking_display_mode": "auto"
|
||||
}
|
||||
@@ -1365,6 +1367,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
|
||||
{
|
||||
"agent_chat_send_shortcut": "enter",
|
||||
"code_diff_display_mode": "auto",
|
||||
"shell_tool_display_mode": "auto",
|
||||
"task_notification_alert_dismissed": true,
|
||||
"thinking_display_mode": "auto"
|
||||
}
|
||||
|
||||
Generated
+2
@@ -8507,6 +8507,7 @@ export interface UpdateUserPasswordRequest {
|
||||
export interface UpdateUserPreferenceSettingsRequest {
|
||||
readonly task_notification_alert_dismissed?: boolean;
|
||||
readonly thinking_display_mode?: ThinkingDisplayMode;
|
||||
readonly shell_tool_display_mode?: AgentDisplayMode;
|
||||
readonly code_diff_display_mode?: AgentDisplayMode;
|
||||
readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
|
||||
}
|
||||
@@ -8903,6 +8904,7 @@ export interface UserParameter {
|
||||
export interface UserPreferenceSettings {
|
||||
readonly task_notification_alert_dismissed: boolean;
|
||||
readonly thinking_display_mode: ThinkingDisplayMode;
|
||||
readonly shell_tool_display_mode: AgentDisplayMode;
|
||||
readonly code_diff_display_mode: AgentDisplayMode;
|
||||
readonly agent_chat_send_shortcut: AgentChatSendShortcut;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
const preferencesData = {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "auto" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
};
|
||||
@@ -177,6 +178,7 @@ export const RendersAgentDisplayModeSettings: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText("Thinking Display")).toBeVisible();
|
||||
expect(await canvas.findByText("Shell Output Display")).toBeVisible();
|
||||
expect(await canvas.findByText("Code Diff Display")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
|
||||
import { ChatSendShortcutSettings } from "./components/ChatSendShortcutSettings";
|
||||
import {
|
||||
CodeDiffDisplaySettings,
|
||||
ShellToolDisplaySettings,
|
||||
ThinkingDisplaySettings,
|
||||
} from "./components/DisplayModeSettings";
|
||||
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
|
||||
@@ -60,6 +61,7 @@ export const AgentSettingsGeneralPageView: FC<
|
||||
<ChatFullWidthSettings />
|
||||
<ChatSendShortcutSettings />
|
||||
<ThinkingDisplaySettings />
|
||||
<ShellToolDisplaySettings />
|
||||
<CodeDiffDisplaySettings />
|
||||
<UserChatDebugLoggingSettings
|
||||
userSettings={userDebugLoggingData}
|
||||
|
||||
+30
-2
@@ -1841,7 +1841,7 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeDiffDisplayModeFromPreferences: Story = {
|
||||
export const ToolDisplayModesFromPreferences: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
@@ -1849,6 +1849,7 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "auto" as const,
|
||||
shell_tool_display_mode: "always_collapsed" as const,
|
||||
code_diff_display_mode: "always_collapsed" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
@@ -1871,6 +1872,14 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
tools: [
|
||||
{
|
||||
id: "execute-tool",
|
||||
name: "execute",
|
||||
args: { command: "pnpm test" },
|
||||
result: { output: "tests passed" },
|
||||
isError: false,
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "edit-tool",
|
||||
name: "edit_files",
|
||||
@@ -1892,7 +1901,10 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
blocks: [{ type: "tool", id: "edit-tool" }],
|
||||
blocks: [
|
||||
{ type: "tool", id: "execute-tool" },
|
||||
{ type: "tool", id: "edit-tool" },
|
||||
],
|
||||
sources: [],
|
||||
},
|
||||
},
|
||||
@@ -1900,9 +1912,20 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("pnpm test")).toBeVisible();
|
||||
expect(canvas.queryByText("tests passed")).not.toBeInTheDocument();
|
||||
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
|
||||
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
|
||||
|
||||
const commandOutputButton = canvas.getByRole("button", {
|
||||
name: "Expand command output",
|
||||
});
|
||||
expect(commandOutputButton).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(commandOutputButton);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("tests passed")).toBeVisible();
|
||||
});
|
||||
|
||||
const editFilesButton = canvas.getByRole("button", {
|
||||
name: /Edited config\.ts/,
|
||||
});
|
||||
@@ -1926,6 +1949,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "always_expanded" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
@@ -1975,6 +1999,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "always_collapsed" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
@@ -2025,6 +2050,7 @@ export const ThinkingBlockWithToolCall: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "always_collapsed" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
@@ -2088,6 +2114,7 @@ export const ThinkingBlockAutoMode: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "auto" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
@@ -2141,6 +2168,7 @@ export const ThinkingBlockPreviewMode: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "preview" as const,
|
||||
shell_tool_display_mode: "auto" as const,
|
||||
code_diff_display_mode: "auto" as const,
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
},
|
||||
|
||||
@@ -275,6 +275,8 @@ export const BlockList: FC<{
|
||||
const prefQuery = useQuery(preferenceSettings());
|
||||
const thinkingDisplayMode: ThinkingDisplayMode =
|
||||
prefQuery.data?.thinking_display_mode || "auto";
|
||||
const shellToolDisplayMode: TypesGen.AgentDisplayMode =
|
||||
prefQuery.data?.shell_tool_display_mode || "auto";
|
||||
const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
|
||||
prefQuery.data?.code_diff_display_mode || "auto";
|
||||
|
||||
@@ -367,6 +369,7 @@ export const BlockList: FC<{
|
||||
name="Tool"
|
||||
status="running"
|
||||
isError={false}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentVariants={subagentVariants}
|
||||
@@ -384,6 +387,7 @@ export const BlockList: FC<{
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
killedBySignal={tool.killedBySignal}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentVariants={subagentVariants}
|
||||
@@ -440,6 +444,7 @@ export const BlockList: FC<{
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
killedBySignal={tool.killedBySignal}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||
subagentTitles={subagentTitles}
|
||||
subagentVariants={subagentVariants}
|
||||
@@ -564,7 +569,8 @@ const ChatMessageItem = memo<{
|
||||
) : (
|
||||
<Message className="w-full">
|
||||
<MessageContent className="whitespace-normal">
|
||||
<div className="relative space-y-3 overflow-visible">
|
||||
{/* Keep consecutive shell tools tighter because execute/process_output pairs read as one terminal interaction. */}
|
||||
<div className="relative space-y-3 overflow-visible [&>[data-shell-tool]+[data-shell-tool]]:mt-2">
|
||||
<BlockList
|
||||
blocks={parsed.blocks}
|
||||
tools={parsed.tools}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { expect, screen, userEvent, within } from "storybook/test";
|
||||
import { ExecuteTool } from "./ExecuteTool";
|
||||
|
||||
const longCommand =
|
||||
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50";
|
||||
|
||||
const meta: Meta<typeof ExecuteTool> = {
|
||||
title: "components/ai-elements/tool/ExecuteTool",
|
||||
component: ExecuteTool,
|
||||
@@ -29,29 +32,29 @@ export const ShortCommand: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A long command expanded to show the full text, with the chevron visible on hover. */
|
||||
export const LongCommand: Story = {
|
||||
args: {
|
||||
command:
|
||||
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
|
||||
command: longCommand,
|
||||
output: "",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const chevron = canvas.getByRole("button", {
|
||||
name: /expand command/i,
|
||||
});
|
||||
await userEvent.click(chevron);
|
||||
// Hover the component so the chevron stays visible.
|
||||
await userEvent.hover(canvasElement.firstElementChild!);
|
||||
const command = canvas.getByText(longCommand);
|
||||
expect(command).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: longCommand }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.hover(command);
|
||||
expect(
|
||||
await screen.findByRole("tooltip", undefined, { timeout: 2000 }),
|
||||
).toHaveTextContent(longCommand);
|
||||
},
|
||||
};
|
||||
|
||||
/** A long truncated command with multi-line output below it. */
|
||||
export const LongCommandWithOutput: Story = {
|
||||
args: {
|
||||
command:
|
||||
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
|
||||
command: longCommand,
|
||||
output: [
|
||||
"src/api/legacyClient.ts",
|
||||
"src/components/OldTable/OldTable.tsx",
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
@@ -20,186 +21,210 @@ import {
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
BORDER_BG_STYLE,
|
||||
COLLAPSED_OUTPUT_HEIGHT,
|
||||
type AgentDisplayState,
|
||||
isAgentDisplayOpen,
|
||||
resolveAgentDisplayState,
|
||||
} from "./displayMode";
|
||||
import {
|
||||
formatShellDurationMs,
|
||||
signalTooltipLabel,
|
||||
type ToolStatus,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Specialized rendering for `execute` tool calls. Shows the command
|
||||
* in a terminal-style block with a copy button. Output is shown in a
|
||||
* collapsed preview (~3 lines) with an expand chevron at the bottom.
|
||||
*/
|
||||
export const ExecuteTool: React.FC<{
|
||||
type ExecuteToolProps = {
|
||||
command: string;
|
||||
output: string;
|
||||
status: ToolStatus;
|
||||
isError: boolean;
|
||||
durationMs?: number;
|
||||
isBackgrounded?: boolean;
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
||||
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
};
|
||||
|
||||
type ExecuteToolInnerProps = ExecuteToolProps & {
|
||||
outputInitiallyOpen: boolean;
|
||||
};
|
||||
|
||||
export const ExecuteTool: React.FC<ExecuteToolProps> = (props) => {
|
||||
const autoDisplayState: AgentDisplayState =
|
||||
props.output.length > 0 ||
|
||||
props.status === "running" ||
|
||||
props.isBackgrounded ||
|
||||
!!props.killedBySignal
|
||||
? "preview"
|
||||
: "collapsed";
|
||||
const resolvedDisplayState = resolveAgentDisplayState(
|
||||
props.shellToolDisplayMode,
|
||||
autoDisplayState,
|
||||
);
|
||||
return (
|
||||
<ExecuteToolInner
|
||||
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
|
||||
{...props}
|
||||
outputInitiallyOpen={isAgentDisplayOpen(resolvedDisplayState)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
|
||||
command,
|
||||
output,
|
||||
status,
|
||||
isError,
|
||||
durationMs,
|
||||
isBackgrounded = false,
|
||||
killedBySignal,
|
||||
outputInitiallyOpen,
|
||||
}) => {
|
||||
const hasOutput = output.length > 0;
|
||||
const isRunning = status === "running";
|
||||
|
||||
// Track whether the command text is truncated so we can offer
|
||||
// a click-to-expand interaction. The ResizeObserver may clear
|
||||
// commandOverflows while the text is wrapped, but
|
||||
// canToggleCommand stays true via commandExpanded so the
|
||||
// collapse affordance remains visible.
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [commandOverflows, setCommandOverflows] = useState(false);
|
||||
const canToggleCommand = commandOverflows || commandExpanded;
|
||||
const commandRef = (node: HTMLElement | null) => {
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
setCommandOverflows(node.scrollWidth > node.clientWidth);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
};
|
||||
|
||||
// Check whether the output overflows the collapsed height so we
|
||||
// know if we need to show the expand toggle at all.
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const measureRef = (node: HTMLPreElement | null) => {
|
||||
outputRef.current = node;
|
||||
if (node) {
|
||||
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
|
||||
}
|
||||
};
|
||||
const showFailureIndicator = isError && !isRunning;
|
||||
const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
|
||||
const outputToggleLabel = outputOpen
|
||||
? "Collapse command output"
|
||||
: "Expand command output";
|
||||
const durationLabel = formatShellDurationMs(durationMs);
|
||||
|
||||
return (
|
||||
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
|
||||
{/* Header: $ command + copy button */}
|
||||
<div className="flex w-full items-start justify-between gap-2 px-3 py-2">
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-start gap-2",
|
||||
canToggleCommand && "cursor-pointer",
|
||||
)}
|
||||
onClick={
|
||||
canToggleCommand ? () => setCommandExpanded((v) => !v) : undefined
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs leading-5 text-content-secondary">
|
||||
$
|
||||
</span>
|
||||
<code
|
||||
ref={commandRef}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 font-mono text-xs leading-5 text-content-primary",
|
||||
commandExpanded ? "whitespace-pre-wrap break-all" : "truncate",
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canToggleCommand && (
|
||||
<div className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-mono text-xs leading-5">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
{hasOutput ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandExpanded((v) => !v)}
|
||||
className={cn(
|
||||
"border-0 bg-transparent p-0 m-0 cursor-pointer flex items-center text-content-secondary hover:text-content-primary transition-colors transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
|
||||
commandExpanded
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/exec:opacity-100",
|
||||
)}
|
||||
aria-expanded={commandExpanded}
|
||||
aria-label={
|
||||
commandExpanded ? "Collapse command" : "Expand command"
|
||||
}
|
||||
aria-expanded={outputOpen}
|
||||
aria-label={outputToggleLabel}
|
||||
onClick={() => setOutputOpen((value) => !value)}
|
||||
className="col-start-1 row-start-1 m-0 flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
commandExpanded && "rotate-180",
|
||||
)}
|
||||
<ShellCommandLine
|
||||
command={command}
|
||||
durationLabel={durationLabel}
|
||||
expanded={outputOpen}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-2 text-content-secondary">
|
||||
<ShellCommandLine
|
||||
command={command}
|
||||
durationLabel={durationLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{isBackgrounded && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<LayersIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Running in background</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CopyButton
|
||||
text={command}
|
||||
label="Copy command"
|
||||
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xl whitespace-pre-wrap break-all font-mono">
|
||||
{command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{showFailureIndicator && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label="Command failed"
|
||||
role="img"
|
||||
className="flex shrink-0 text-content-destructive"
|
||||
>
|
||||
<TriangleAlertIcon
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Command failed</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isBackgrounded && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label="Running in background"
|
||||
role="img"
|
||||
className="flex shrink-0 text-content-secondary"
|
||||
>
|
||||
<LayersIcon aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Running in background</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CopyButton
|
||||
text={command}
|
||||
label="Copy command"
|
||||
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{/* Output preview / expanded */}
|
||||
{hasOutput && (
|
||||
<>
|
||||
<div className="h-px" style={BORDER_BG_STYLE} />
|
||||
<ScrollArea
|
||||
className="text-2xs"
|
||||
viewportClassName={expanded ? "max-h-96" : ""}
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<pre
|
||||
ref={measureRef}
|
||||
style={
|
||||
expanded
|
||||
? undefined
|
||||
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
|
||||
}
|
||||
className={cn(
|
||||
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
|
||||
"text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Expand / collapse toggle at the bottom */}
|
||||
{overflows && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="border-0 bg-transparent m-0 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
|
||||
aria-label={expanded ? "Collapse output" : "Expand output"}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{hasOutput && outputOpen && (
|
||||
<ShellOutputBody output={output} isError={isError} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShellCommandLine: React.FC<{
|
||||
command: string;
|
||||
durationLabel: string;
|
||||
expanded?: boolean;
|
||||
}> = ({ command, durationLabel, expanded }) => {
|
||||
return (
|
||||
<>
|
||||
<span className="shrink-0 text-[13px] text-content-success">$</span>
|
||||
<span className="block min-w-0 truncate text-[13px] text-content-primary">
|
||||
{command}
|
||||
</span>
|
||||
{durationLabel && (
|
||||
<span className="shrink-0 text-[13px] text-content-secondary">
|
||||
{durationLabel}
|
||||
</span>
|
||||
)}
|
||||
{expanded !== undefined && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-current transition-transform",
|
||||
expanded ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShellOutputBody: React.FC<{
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}> = ({ output, isError }) => {
|
||||
return (
|
||||
<ScrollArea
|
||||
className="col-start-1 col-span-2 mt-1 rounded-md border border-solid border-border-default/50 bg-surface-secondary/30 text-2xs"
|
||||
viewportClassName="max-h-64"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"m-0 whitespace-pre-wrap break-all border-0 bg-transparent px-2 py-1.5 font-mono text-xs leading-5",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExecuteAuthRequiredTool: React.FC<{
|
||||
command: string;
|
||||
output: string;
|
||||
|
||||
+5
-5
@@ -19,7 +19,7 @@ export default meta;
|
||||
type Story = StoryObj<typeof Tool>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tool — killed indicator via killedBySignal prop
|
||||
// Execute tool, killed indicator via killedBySignal prop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExecuteKilled: Story = {
|
||||
@@ -62,7 +62,7 @@ export const ExecuteTerminated: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Execute NOT signaled — no indicator. */
|
||||
/** Execute not signaled, no indicator. */
|
||||
export const ExecuteNotSignaled: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
@@ -81,7 +81,7 @@ export const ExecuteNotSignaled: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Running execute — killed indicator should NOT appear yet. */
|
||||
/** Running execute, killed indicator should not appear yet. */
|
||||
export const ExecuteRunningNotYetKilled: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
@@ -96,7 +96,7 @@ export const ExecuteRunningNotYetKilled: Story = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProcessOutput tool — killed indicator
|
||||
// ProcessOutput tool, killed indicator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProcessOutputKilled: Story = {
|
||||
@@ -133,7 +133,7 @@ export const ProcessOutputTerminated: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** ProcessOutput with no output and killed — indicator in empty state. */
|
||||
/** ProcessOutput with no output and killed, indicator in empty state. */
|
||||
export const ProcessOutputKilledNoOutput: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import {
|
||||
@@ -9,124 +10,152 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
type AgentDisplayState,
|
||||
isAgentDisplayFullyExpanded,
|
||||
resolveAgentDisplayState,
|
||||
} from "./displayMode";
|
||||
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
|
||||
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
|
||||
|
||||
/**
|
||||
* Specialized rendering for `process_output` tool calls. Shows
|
||||
* process output directly in a terminal-style block with a
|
||||
* collapsible preview and an expand chevron at the bottom.
|
||||
*/
|
||||
export const ProcessOutputTool: React.FC<{
|
||||
type ProcessOutputToolProps = {
|
||||
output: string;
|
||||
isRunning: boolean;
|
||||
exitCode: number | null;
|
||||
isError: boolean;
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
||||
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
};
|
||||
|
||||
type ProcessOutputToolInnerProps = ProcessOutputToolProps & {
|
||||
autoDisplayState: AgentDisplayState;
|
||||
outputInitiallyFullyExpanded: boolean;
|
||||
};
|
||||
|
||||
export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = (props) => {
|
||||
const autoDisplayState: AgentDisplayState =
|
||||
props.output.length > 0 ? "preview" : "collapsed";
|
||||
const resolvedDisplayState = resolveAgentDisplayState(
|
||||
props.shellToolDisplayMode,
|
||||
autoDisplayState,
|
||||
);
|
||||
return (
|
||||
<ProcessOutputToolInner
|
||||
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
|
||||
{...props}
|
||||
autoDisplayState={autoDisplayState}
|
||||
outputInitiallyFullyExpanded={isAgentDisplayFullyExpanded(
|
||||
resolvedDisplayState,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
|
||||
output,
|
||||
isRunning,
|
||||
exitCode,
|
||||
isError,
|
||||
killedBySignal,
|
||||
shellToolDisplayMode,
|
||||
autoDisplayState,
|
||||
outputInitiallyFullyExpanded,
|
||||
}) => {
|
||||
const [outputFullyExpanded, setOutputFullyExpanded] = useState(
|
||||
outputInitiallyFullyExpanded,
|
||||
);
|
||||
const hasOutput = output.length > 0;
|
||||
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const measureRef = (node: HTMLPreElement | null) => {
|
||||
outputRef.current = node;
|
||||
if (node) {
|
||||
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
|
||||
}
|
||||
};
|
||||
|
||||
const showExitCode = exitCode !== null && exitCode !== 0;
|
||||
const toggleOutputExpansion = () => {
|
||||
setOutputFullyExpanded((expanded) => !expanded);
|
||||
};
|
||||
const hasHeaderActions =
|
||||
isRunning || Boolean(killedBySignal) || showExitCode || hasOutput;
|
||||
|
||||
return (
|
||||
<div className="group/proc w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
|
||||
{hasOutput ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<ScrollArea
|
||||
className="text-2xs"
|
||||
viewportClassName={expanded ? "max-h-96" : ""}
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<pre
|
||||
ref={measureRef}
|
||||
style={
|
||||
expanded
|
||||
? undefined
|
||||
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
|
||||
}
|
||||
className={cn(
|
||||
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
|
||||
isError
|
||||
? "text-content-destructive"
|
||||
: "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
<div className="absolute right-1 top-0.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/proc:opacity-100">
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExitCode && (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||
exit {exitCode}
|
||||
</span>
|
||||
)}
|
||||
<CopyButton text={output} label="Copy output" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand / collapse toggle at the bottom */}
|
||||
{overflows && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="border-0 bg-transparent m-0 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
|
||||
aria-label={expanded ? "Collapse output" : "Expand output"}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<AgentDisplayModeToolCollapsible
|
||||
className="group/proc w-full"
|
||||
hasContent={hasOutput}
|
||||
displayMode={shellToolDisplayMode}
|
||||
autoDisplayState={autoDisplayState}
|
||||
ariaLabel={(expanded) =>
|
||||
expanded ? "Collapse process output" : "Expand process output"
|
||||
}
|
||||
header={<span className="text-[13px]">Process output</span>}
|
||||
headerActions={
|
||||
hasHeaderActions ? (
|
||||
<>
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExitCode && (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||
exit {exitCode}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <CopyButton text={output} label="Copy output" />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ScrollArea
|
||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<pre
|
||||
ref={measureRef}
|
||||
style={
|
||||
outputFullyExpanded
|
||||
? undefined
|
||||
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
|
||||
}
|
||||
className={cn(
|
||||
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 px-3 py-1.5">
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExitCode && (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||
exit {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
{overflows && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={outputFullyExpanded}
|
||||
onClick={toggleOutputExpansion}
|
||||
className="border-0 bg-transparent m-0 mt-0.5 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center rounded-md py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
|
||||
aria-label={
|
||||
outputFullyExpanded
|
||||
? "Collapse full process output"
|
||||
: "Expand full process output"
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
outputFullyExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AgentDisplayModeToolCollapsible>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import {
|
||||
expect,
|
||||
fn,
|
||||
screen,
|
||||
spyOn,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
|
||||
import { DesktopPanelContext } from "./DesktopPanelContext";
|
||||
import { Tool } from "./Tool";
|
||||
|
||||
const executeCommand = "git fetch origin";
|
||||
const longExecuteCommand =
|
||||
"docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest .";
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
||||
component: Tool,
|
||||
@@ -46,7 +56,11 @@ export const ExecuteRunning: Story = {
|
||||
|
||||
export const ExecuteSuccess: Story = {
|
||||
args: {
|
||||
shellToolDisplayMode: "auto",
|
||||
args: { command: longExecuteCommand },
|
||||
result: {
|
||||
wall_duration_ms: 47200,
|
||||
exit_code: 0,
|
||||
output:
|
||||
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui",
|
||||
},
|
||||
@@ -54,6 +68,167 @@ export const ExecuteSuccess: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
|
||||
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("img", { name: "Running in background" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.getByText("47.2s")).toBeVisible();
|
||||
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ExecuteError: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "error",
|
||||
isError: true,
|
||||
args: { command: longExecuteCommand },
|
||||
shellToolDisplayMode: "always_collapsed",
|
||||
result: {
|
||||
wall_duration_ms: 8600,
|
||||
exit_code: 1,
|
||||
output: Array.from(
|
||||
{ length: 47 },
|
||||
(_, index) => `error line ${index + 1}`,
|
||||
).join("\n"),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText(/error line 1/)).not.toBeInTheDocument();
|
||||
expect(canvas.getByRole("img", { name: "Command failed" })).toBeVisible();
|
||||
expect(canvas.queryByText("exit 1")).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Expand command output" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/error line 1/)).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExecuteBackgrounded: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
args: { command: "npm start" },
|
||||
shellToolDisplayMode: "always_collapsed",
|
||||
result: {
|
||||
background_process_id: "process-123",
|
||||
output: "",
|
||||
wall_duration_ms: 2100,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const backgroundIndicator = canvas.getByRole("img", {
|
||||
name: "Running in background",
|
||||
});
|
||||
expect(backgroundIndicator).toBeVisible();
|
||||
await userEvent.hover(backgroundIndicator);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"Running in background",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ExecuteAlwaysCollapsed: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
args: { command: executeCommand },
|
||||
shellToolDisplayMode: "always_collapsed",
|
||||
result: {
|
||||
output: "From github.com:coder/coder\nFetching origin/main",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(executeCommand)).toBeVisible();
|
||||
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByText(/From github\.com:coder\/coder/),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByText(executeCommand));
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ExecuteLongCommandCollapsed: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
args: { command: longExecuteCommand },
|
||||
shellToolDisplayMode: "always_collapsed",
|
||||
result: {
|
||||
wall_duration_ms: 47200,
|
||||
exit_code: 0,
|
||||
output: Array.from(
|
||||
{ length: 61 },
|
||||
(_, index) => `output line ${index + 1}`,
|
||||
).join("\n"),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const command = canvas.getByText(longExecuteCommand);
|
||||
expect(command).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: longExecuteCommand }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
|
||||
expect(canvas.getByText("47.2s")).toBeVisible();
|
||||
expect(canvas.queryByText("61 lines")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ProcessOutputAlwaysCollapsed: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
shellToolDisplayMode: "always_collapsed",
|
||||
result: {
|
||||
output: "build completed\n0 errors",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText(/build completed/)).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Expand process output" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/build completed/)).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ProcessOutputAlwaysExpanded: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
shellToolDisplayMode: "always_expanded",
|
||||
result: {
|
||||
output: Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `process output line ${index + 1}`,
|
||||
).join("\n"),
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/process output line 1/)).toBeVisible();
|
||||
expect(canvas.getByText(/process output line 30/)).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("button", {
|
||||
name: "Collapse full process output",
|
||||
}),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
||||
previousResponseText?: string;
|
||||
/** Human-readable intent extracted from the model's tool-call args. */
|
||||
modelIntent?: string;
|
||||
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ type ToolRendererProps = {
|
||||
mcpServerConfigId?: string;
|
||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||
modelIntent?: string;
|
||||
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
||||
};
|
||||
|
||||
@@ -218,11 +220,19 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
||||
result,
|
||||
isError,
|
||||
killedBySignal,
|
||||
shellToolDisplayMode,
|
||||
}) => {
|
||||
const parsedArgs = parseArgs(args);
|
||||
const command = parsedArgs ? asString(parsedArgs.command) : "";
|
||||
const rec = asRecord(result);
|
||||
const output = rec ? asString(rec.output).trim() : "";
|
||||
const durationMs = rec
|
||||
? (asNumber(rec.wall_duration_ms, { parseString: true }) ??
|
||||
asNumber(rec.duration_ms, { parseString: true }))
|
||||
: undefined;
|
||||
const isBackgrounded = Boolean(
|
||||
rec && asString(rec.background_process_id).trim(),
|
||||
);
|
||||
const authRequired = rec ? Boolean(rec.auth_required) : false;
|
||||
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
|
||||
const providerLabel = toProviderLabel(
|
||||
@@ -247,7 +257,10 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
||||
output={output}
|
||||
status={status}
|
||||
isError={isError}
|
||||
durationMs={durationMs}
|
||||
isBackgrounded={isBackgrounded}
|
||||
killedBySignal={killedBySignal}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -257,13 +270,12 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
||||
result,
|
||||
isError,
|
||||
killedBySignal,
|
||||
shellToolDisplayMode,
|
||||
}) => {
|
||||
const rec = asRecord(result);
|
||||
const output = rec ? asString(rec.output).trim() : "";
|
||||
const exitCode = rec
|
||||
? rec.exit_code !== undefined && rec.exit_code !== null
|
||||
? Number(rec.exit_code)
|
||||
: null
|
||||
? (asNumber(rec.exit_code, { parseString: true }) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -273,6 +285,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
||||
exitCode={exitCode}
|
||||
isError={isError}
|
||||
killedBySignal={killedBySignal}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1037,6 +1050,7 @@ export const Tool = memo(
|
||||
isLatestAskUserQuestion,
|
||||
previousResponseText,
|
||||
modelIntent,
|
||||
shellToolDisplayMode,
|
||||
codeDiffDisplayMode,
|
||||
ref,
|
||||
...props
|
||||
@@ -1044,21 +1058,18 @@ export const Tool = memo(
|
||||
const Renderer = isSubagentToolName(name)
|
||||
? SubagentRenderer
|
||||
: (toolRenderers[name] ?? GenericToolRenderer);
|
||||
const isShellTool = name === "execute" || name === "process_output";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-tool-call=""
|
||||
data-shell-tool={isShellTool ? "" : undefined}
|
||||
className={cn(
|
||||
name === "execute" ||
|
||||
name === "process_output" ||
|
||||
name === "propose_plan" ||
|
||||
name === "advisor"
|
||||
isShellTool || name === "propose_plan" || name === "advisor"
|
||||
? "w-full py-0.5"
|
||||
: "py-0.5",
|
||||
// Collapse padding between adjacent tool calls so they hug.
|
||||
// Bottom padding is removed on a tool followed by a tool, and
|
||||
// top padding is removed on a tool preceded by a tool.
|
||||
// Keep back-to-back tool cards visually grouped so stacked tool calls do not look double-spaced.
|
||||
"[&:has(+[data-tool-call])]:pb-0",
|
||||
"[[data-tool-call]+&]:pt-0",
|
||||
className,
|
||||
@@ -1084,6 +1095,7 @@ export const Tool = memo(
|
||||
isLatestAskUserQuestion={isLatestAskUserQuestion}
|
||||
previousResponseText={previousResponseText}
|
||||
modelIntent={modelIntent}
|
||||
shellToolDisplayMode={shellToolDisplayMode}
|
||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
resolveAgentDisplayState,
|
||||
} from "./displayMode";
|
||||
|
||||
type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
|
||||
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
|
||||
|
||||
interface ToolCollapsibleProps {
|
||||
children: ReactNode;
|
||||
header: ToolCollapsibleHeader;
|
||||
headerActions?: ReactNode;
|
||||
hasContent?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
ariaLabel?: ToolCollapsibleAriaLabel;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
}
|
||||
@@ -30,6 +33,7 @@ export const AgentDisplayModeToolCollapsible: FC<
|
||||
AgentDisplayModeToolCollapsibleProps
|
||||
> = ({ displayMode, autoDisplayState, ...props }) => {
|
||||
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
|
||||
|
||||
return (
|
||||
<ToolCollapsible
|
||||
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
|
||||
@@ -42,45 +46,63 @@ export const AgentDisplayModeToolCollapsible: FC<
|
||||
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
|
||||
children,
|
||||
header,
|
||||
headerActions,
|
||||
hasContent = true,
|
||||
defaultExpanded = false,
|
||||
ariaLabel,
|
||||
className,
|
||||
headerClassName,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const renderedHeader =
|
||||
typeof header === "function" ? header(expanded) : header;
|
||||
const headerButton = hasContent ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
|
||||
}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
"text-content-secondary transition-colors hover:text-content-primary",
|
||||
headerActions ? "min-w-0 flex-1" : "w-full",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{renderedHeader}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-current transition-transform",
|
||||
expanded ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-content-secondary",
|
||||
headerActions && "min-w-0 flex-1",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{renderedHeader}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{hasContent ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
|
||||
"flex w-full items-center gap-2 cursor-pointer",
|
||||
"text-content-secondary transition-colors hover:text-content-primary",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{renderedHeader}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-current transition-transform",
|
||||
expanded ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-content-secondary",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{renderedHeader}
|
||||
{headerActions ? (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{headerButton}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
headerButton
|
||||
)}
|
||||
{expanded && hasContent && children}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BORDER_BG_STYLE,
|
||||
buildEditDiff,
|
||||
buildWriteFileDiff,
|
||||
COLLAPSED_OUTPUT_HEIGHT,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
diffViewerCSS,
|
||||
fileViewerCSS,
|
||||
formatResultOutput,
|
||||
formatShellDurationMs,
|
||||
getDiffViewerOptions,
|
||||
getFileContentForViewer,
|
||||
getFileViewerOptions,
|
||||
@@ -70,20 +70,45 @@ describe("shortDurationMs", () => {
|
||||
expect(shortDurationMs(1000)).toBe("1s");
|
||||
expect(shortDurationMs(30_000)).toBe("30s");
|
||||
expect(shortDurationMs(59_000)).toBe("59s");
|
||||
expect(shortDurationMs(59_499)).toBe("59s");
|
||||
});
|
||||
|
||||
it("formats minutes", () => {
|
||||
expect(shortDurationMs(59_500)).toBe("1m");
|
||||
expect(shortDurationMs(60_000)).toBe("1m");
|
||||
expect(shortDurationMs(300_000)).toBe("5m");
|
||||
expect(shortDurationMs(3_540_000)).toBe("59m");
|
||||
expect(shortDurationMs(3_569_999)).toBe("59m");
|
||||
});
|
||||
|
||||
it("formats hours", () => {
|
||||
expect(shortDurationMs(3_570_000)).toBe("1h");
|
||||
expect(shortDurationMs(3_600_000)).toBe("1h");
|
||||
expect(shortDurationMs(7_200_000)).toBe("2h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatShellDurationMs", () => {
|
||||
it("returns empty string for invalid values", () => {
|
||||
expect(formatShellDurationMs(undefined)).toBe("");
|
||||
expect(formatShellDurationMs(-1)).toBe("");
|
||||
expect(formatShellDurationMs(Number.NaN)).toBe("");
|
||||
expect(formatShellDurationMs(Number.POSITIVE_INFINITY)).toBe("");
|
||||
});
|
||||
|
||||
it("formats milliseconds and rounded seconds", () => {
|
||||
expect(formatShellDurationMs(100)).toBe("100ms");
|
||||
expect(formatShellDurationMs(47_200)).toBe("47.2s");
|
||||
expect(formatShellDurationMs(59_949)).toBe("59.9s");
|
||||
expect(formatShellDurationMs(59_950)).toBe("1m");
|
||||
});
|
||||
|
||||
it("formats rounded minutes and hours", () => {
|
||||
expect(formatShellDurationMs(3_596_999)).toBe("59.9m");
|
||||
expect(formatShellDurationMs(3_597_000)).toBe("1h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStatus", () => {
|
||||
it("lowercases and trims", () => {
|
||||
expect(normalizeStatus(" COMPLETED ")).toBe("completed");
|
||||
@@ -840,13 +865,6 @@ describe("constants", () => {
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
|
||||
});
|
||||
|
||||
it("BORDER_BG_STYLE has expected background", () => {
|
||||
expect(BORDER_BG_STYLE).toHaveProperty(
|
||||
"background",
|
||||
"hsl(var(--border-default))",
|
||||
);
|
||||
});
|
||||
|
||||
it("fileViewerCSS is a non-empty string", () => {
|
||||
expect(typeof fileViewerCSS).toBe("string");
|
||||
expect(fileViewerCSS.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -55,11 +55,38 @@ export const shortDurationMs = (durationMs: number | undefined): string => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.round(seconds / 60);
|
||||
const minutes = Math.round(durationMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
const hours = Math.round(durationMs / 3_600_000);
|
||||
return `${hours}h`;
|
||||
};
|
||||
|
||||
const roundToTenths = (value: number): number => Number(value.toFixed(1));
|
||||
|
||||
export const formatShellDurationMs = (
|
||||
durationMs: number | undefined,
|
||||
): string => {
|
||||
if (
|
||||
durationMs === undefined ||
|
||||
durationMs < 0 ||
|
||||
!Number.isFinite(durationMs)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (durationMs < 1000) {
|
||||
return `${Math.round(durationMs)}ms`;
|
||||
}
|
||||
const seconds = roundToTenths(durationMs / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = roundToTenths(durationMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = roundToTenths(durationMs / 3_600_000);
|
||||
return `${hours}h`;
|
||||
};
|
||||
|
||||
@@ -439,10 +466,6 @@ export const DIFFS_FONT_STYLE = {
|
||||
"--diffs-line-height": "1.5",
|
||||
} as CSSProperties;
|
||||
|
||||
export const BORDER_BG_STYLE = {
|
||||
background: "hsl(var(--border-default))",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a tool result should be rendered as a syntax-highlighted
|
||||
* file viewer. Returns the file path, content, and whether the header
|
||||
|
||||
@@ -115,6 +115,23 @@ export const ThinkingDisplaySettings: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ShellToolDisplaySettings: FC = () => {
|
||||
return (
|
||||
<DisplayModeSettings
|
||||
title="Shell Output Display"
|
||||
description="How shell command output should be displayed by default. 'Auto' opens running commands and completed commands with output, then keeps empty output collapsed. 'Always Expanded' opens shell output by default. 'Always Collapsed' keeps it collapsed."
|
||||
ariaLabel="Shell output display mode"
|
||||
errorMessage="Failed to save your shell output display preference."
|
||||
defaultValue="auto"
|
||||
options={agentDisplayOptions}
|
||||
getMode={(settings) => settings.shell_tool_display_mode}
|
||||
updateSettings={(value) => ({
|
||||
shell_tool_display_mode: value,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeDiffDisplaySettings: FC = () => {
|
||||
return (
|
||||
<DisplayModeSettings
|
||||
|
||||
@@ -215,6 +215,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
|
||||
data: {
|
||||
task_notification_alert_dismissed: true,
|
||||
thinking_display_mode: "auto" as const,
|
||||
shell_tool_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 = {
|
||||
).mockResolvedValue({
|
||||
task_notification_alert_dismissed: false,
|
||||
thinking_display_mode: "auto",
|
||||
shell_tool_display_mode: "auto",
|
||||
code_diff_display_mode: "auto",
|
||||
agent_chat_send_shortcut: "enter" as const,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user