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": {
|
"code_diff_display_mode": {
|
||||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
},
|
},
|
||||||
|
"shell_tool_display_mode": {
|
||||||
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
|
},
|
||||||
"task_notification_alert_dismissed": {
|
"task_notification_alert_dismissed": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -24060,6 +24063,9 @@ const docTemplate = `{
|
|||||||
"code_diff_display_mode": {
|
"code_diff_display_mode": {
|
||||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
},
|
},
|
||||||
|
"shell_tool_display_mode": {
|
||||||
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
|
},
|
||||||
"task_notification_alert_dismissed": {
|
"task_notification_alert_dismissed": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+6
@@ -21694,6 +21694,9 @@
|
|||||||
"code_diff_display_mode": {
|
"code_diff_display_mode": {
|
||||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
},
|
},
|
||||||
|
"shell_tool_display_mode": {
|
||||||
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
|
},
|
||||||
"task_notification_alert_dismissed": {
|
"task_notification_alert_dismissed": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -22153,6 +22156,9 @@
|
|||||||
"code_diff_display_mode": {
|
"code_diff_display_mode": {
|
||||||
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
},
|
},
|
||||||
|
"shell_tool_display_mode": {
|
||||||
|
"$ref": "#/definitions/codersdk.AgentDisplayMode"
|
||||||
|
},
|
||||||
"task_notification_alert_dismissed": {
|
"task_notification_alert_dismissed": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4546,6 +4546,17 @@ func (q *querier) GetUserSecretsTelemetrySummary(ctx context.Context) (database.
|
|||||||
return q.db.GetUserSecretsTelemetrySummary(ctx)
|
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) {
|
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
|
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -7213,6 +7224,17 @@ func (q *querier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg datab
|
|||||||
return q.db.UpdateUserSecretByUserIDAndName(ctx, arg)
|
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) {
|
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||||
fetch := func(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)
|
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()
|
dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes()
|
||||||
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded")
|
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) {
|
s.Run("GetUserCodeDiffDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||||
u := testutil.Fake(s.T(), faker, database.User{})
|
u := testutil.Fake(s.T(), faker, database.User{})
|
||||||
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
|
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
|
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) {
|
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
|
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
|
||||||
@@ -5153,6 +5161,14 @@ func (m queryMetricsStore) UpdateUserSecretByUserIDAndName(ctx context.Context,
|
|||||||
return r0, r1
|
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) {
|
func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
r0, r1 := m.s.UpdateUserStatus(ctx, arg)
|
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)
|
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.
|
// GetUserStatusCounts mocks base method.
|
||||||
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
|
||||||
m.ctrl.T.Helper()
|
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)
|
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.
|
// UpdateUserStatus mocks base method.
|
||||||
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -769,6 +769,7 @@ type sqlcQuerier interface {
|
|||||||
// percentile_disc returns an actual integer count from the underlying
|
// percentile_disc returns an actual integer count from the underlying
|
||||||
// values rather than interpolating between rows.
|
// values rather than interpolating between rows.
|
||||||
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
|
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.
|
// 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.
|
// The time range is inclusively defined by the start_time and end_time parameters.
|
||||||
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
|
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
|
||||||
@@ -1223,6 +1224,7 @@ type sqlcQuerier interface {
|
|||||||
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
|
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
|
||||||
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
|
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
|
||||||
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, 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)
|
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
|
||||||
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
|
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
|
||||||
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, 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
|
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
|
const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one
|
||||||
SELECT
|
SELECT
|
||||||
value::boolean as task_notification_alert_dismissed
|
value::boolean as task_notification_alert_dismissed
|
||||||
@@ -26869,6 +26886,33 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
|
|||||||
return i, err
|
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
|
const updateUserStatus = `-- name: UpdateUserStatus :one
|
||||||
UPDATE
|
UPDATE
|
||||||
users
|
users
|
||||||
|
|||||||
@@ -347,6 +347,29 @@ WHERE user_configs.user_id = @user_id
|
|||||||
AND user_configs.key = 'preference_thinking_display_mode'
|
AND user_configs.key = 'preference_thinking_display_mode'
|
||||||
RETURNING value AS 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
|
-- name: GetUserCodeDiffDisplayMode :one
|
||||||
SELECT
|
SELECT
|
||||||
value AS code_diff_display_mode
|
value AS code_diff_display_mode
|
||||||
|
|||||||
@@ -1306,6 +1306,15 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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)
|
codeDiffMode, err := api.Database.GetUserCodeDiffDisplayMode(ctx, user.ID)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
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{
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
|
||||||
TaskNotificationAlertDismissed: taskAlertDismissed,
|
TaskNotificationAlertDismissed: taskAlertDismissed,
|
||||||
ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode),
|
ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode),
|
||||||
|
ShellToolDisplayMode: sanitizeAgentDisplayMode(shellToolMode),
|
||||||
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
|
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
|
||||||
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
|
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
|
||||||
})
|
})
|
||||||
@@ -1363,6 +1373,16 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
|
|||||||
})
|
})
|
||||||
return
|
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 != "" &&
|
if params.CodeDiffDisplayMode != "" &&
|
||||||
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
|
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
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)
|
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 != "" {
|
if params.CodeDiffDisplayMode != "" {
|
||||||
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
|
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
|||||||
+63
-5
@@ -2433,9 +2433,34 @@ func TestAgentDisplayModePreferences(t *testing.T) {
|
|||||||
|
|
||||||
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.ShellToolDisplayMode)
|
||||||
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.CodeDiffDisplayMode)
|
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.Run("round-trips code diff display mode", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2469,24 +2494,57 @@ func TestAgentDisplayModePreferences(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||||
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
|
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
|
||||||
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysCollapsed,
|
||||||
|
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
|
||||||
ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysExpanded,
|
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.CodeDiffDisplayMode)
|
||||||
|
|
||||||
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
|
||||||
require.NoError(t, err)
|
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)
|
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.Run("rejects invalid code diff display mode", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ type UpdateUserAppearanceSettingsRequest struct {
|
|||||||
type UserPreferenceSettings struct {
|
type UserPreferenceSettings struct {
|
||||||
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
|
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
|
||||||
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
|
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
|
||||||
|
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode"`
|
||||||
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
|
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
|
||||||
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
|
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
|
||||||
}
|
}
|
||||||
@@ -312,6 +313,7 @@ type UserPreferenceSettings struct {
|
|||||||
type UpdateUserPreferenceSettingsRequest struct {
|
type UpdateUserPreferenceSettingsRequest struct {
|
||||||
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
|
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
|
||||||
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
|
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
|
||||||
|
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode,omitempty"`
|
||||||
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
|
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
|
||||||
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,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",
|
"agent_chat_send_shortcut": "enter",
|
||||||
"code_diff_display_mode": "auto",
|
"code_diff_display_mode": "auto",
|
||||||
|
"shell_tool_display_mode": "auto",
|
||||||
"task_notification_alert_dismissed": true,
|
"task_notification_alert_dismissed": true,
|
||||||
"thinking_display_mode": "auto"
|
"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 | | |
|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
|
||||||
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||||
|
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||||
| `task_notification_alert_dismissed` | boolean | false | | |
|
| `task_notification_alert_dismissed` | boolean | false | | |
|
||||||
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | 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",
|
"agent_chat_send_shortcut": "enter",
|
||||||
"code_diff_display_mode": "auto",
|
"code_diff_display_mode": "auto",
|
||||||
|
"shell_tool_display_mode": "auto",
|
||||||
"task_notification_alert_dismissed": true,
|
"task_notification_alert_dismissed": true,
|
||||||
"thinking_display_mode": "auto"
|
"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 | | |
|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
|
||||||
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||||
|
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
|
||||||
| `task_notification_alert_dismissed` | boolean | false | | |
|
| `task_notification_alert_dismissed` | boolean | false | | |
|
||||||
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | 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",
|
"agent_chat_send_shortcut": "enter",
|
||||||
"code_diff_display_mode": "auto",
|
"code_diff_display_mode": "auto",
|
||||||
|
"shell_tool_display_mode": "auto",
|
||||||
"task_notification_alert_dismissed": true,
|
"task_notification_alert_dismissed": true,
|
||||||
"thinking_display_mode": "auto"
|
"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",
|
"agent_chat_send_shortcut": "enter",
|
||||||
"code_diff_display_mode": "auto",
|
"code_diff_display_mode": "auto",
|
||||||
|
"shell_tool_display_mode": "auto",
|
||||||
"task_notification_alert_dismissed": true,
|
"task_notification_alert_dismissed": true,
|
||||||
"thinking_display_mode": "auto"
|
"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",
|
"agent_chat_send_shortcut": "enter",
|
||||||
"code_diff_display_mode": "auto",
|
"code_diff_display_mode": "auto",
|
||||||
|
"shell_tool_display_mode": "auto",
|
||||||
"task_notification_alert_dismissed": true,
|
"task_notification_alert_dismissed": true,
|
||||||
"thinking_display_mode": "auto"
|
"thinking_display_mode": "auto"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
@@ -8507,6 +8507,7 @@ export interface UpdateUserPasswordRequest {
|
|||||||
export interface UpdateUserPreferenceSettingsRequest {
|
export interface UpdateUserPreferenceSettingsRequest {
|
||||||
readonly task_notification_alert_dismissed?: boolean;
|
readonly task_notification_alert_dismissed?: boolean;
|
||||||
readonly thinking_display_mode?: ThinkingDisplayMode;
|
readonly thinking_display_mode?: ThinkingDisplayMode;
|
||||||
|
readonly shell_tool_display_mode?: AgentDisplayMode;
|
||||||
readonly code_diff_display_mode?: AgentDisplayMode;
|
readonly code_diff_display_mode?: AgentDisplayMode;
|
||||||
readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
|
readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
|
||||||
}
|
}
|
||||||
@@ -8903,6 +8904,7 @@ export interface UserParameter {
|
|||||||
export interface UserPreferenceSettings {
|
export interface UserPreferenceSettings {
|
||||||
readonly task_notification_alert_dismissed: boolean;
|
readonly task_notification_alert_dismissed: boolean;
|
||||||
readonly thinking_display_mode: ThinkingDisplayMode;
|
readonly thinking_display_mode: ThinkingDisplayMode;
|
||||||
|
readonly shell_tool_display_mode: AgentDisplayMode;
|
||||||
readonly code_diff_display_mode: AgentDisplayMode;
|
readonly code_diff_display_mode: AgentDisplayMode;
|
||||||
readonly agent_chat_send_shortcut: AgentChatSendShortcut;
|
readonly agent_chat_send_shortcut: AgentChatSendShortcut;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const preferencesData = {
|
const preferencesData = {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "auto" as const,
|
thinking_display_mode: "auto" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
};
|
};
|
||||||
@@ -177,6 +178,7 @@ export const RendersAgentDisplayModeSettings: Story = {
|
|||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
expect(await canvas.findByText("Thinking Display")).toBeVisible();
|
expect(await canvas.findByText("Thinking Display")).toBeVisible();
|
||||||
|
expect(await canvas.findByText("Shell Output Display")).toBeVisible();
|
||||||
expect(await canvas.findByText("Code Diff 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 { ChatSendShortcutSettings } from "./components/ChatSendShortcutSettings";
|
||||||
import {
|
import {
|
||||||
CodeDiffDisplaySettings,
|
CodeDiffDisplaySettings,
|
||||||
|
ShellToolDisplaySettings,
|
||||||
ThinkingDisplaySettings,
|
ThinkingDisplaySettings,
|
||||||
} from "./components/DisplayModeSettings";
|
} from "./components/DisplayModeSettings";
|
||||||
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
|
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
|
||||||
@@ -60,6 +61,7 @@ export const AgentSettingsGeneralPageView: FC<
|
|||||||
<ChatFullWidthSettings />
|
<ChatFullWidthSettings />
|
||||||
<ChatSendShortcutSettings />
|
<ChatSendShortcutSettings />
|
||||||
<ThinkingDisplaySettings />
|
<ThinkingDisplaySettings />
|
||||||
|
<ShellToolDisplaySettings />
|
||||||
<CodeDiffDisplaySettings />
|
<CodeDiffDisplaySettings />
|
||||||
<UserChatDebugLoggingSettings
|
<UserChatDebugLoggingSettings
|
||||||
userSettings={userDebugLoggingData}
|
userSettings={userDebugLoggingData}
|
||||||
|
|||||||
+30
-2
@@ -1841,7 +1841,7 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CodeDiffDisplayModeFromPreferences: Story = {
|
export const ToolDisplayModesFromPreferences: Story = {
|
||||||
parameters: {
|
parameters: {
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
@@ -1849,6 +1849,7 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "auto" as const,
|
thinking_display_mode: "auto" as const,
|
||||||
|
shell_tool_display_mode: "always_collapsed" as const,
|
||||||
code_diff_display_mode: "always_collapsed" as const,
|
code_diff_display_mode: "always_collapsed" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -1871,6 +1872,14 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
|||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
toolResults: [],
|
toolResults: [],
|
||||||
tools: [
|
tools: [
|
||||||
|
{
|
||||||
|
id: "execute-tool",
|
||||||
|
name: "execute",
|
||||||
|
args: { command: "pnpm test" },
|
||||||
|
result: { output: "tests passed" },
|
||||||
|
isError: false,
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "edit-tool",
|
id: "edit-tool",
|
||||||
name: "edit_files",
|
name: "edit_files",
|
||||||
@@ -1892,7 +1901,10 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
blocks: [{ type: "tool", id: "edit-tool" }],
|
blocks: [
|
||||||
|
{ type: "tool", id: "execute-tool" },
|
||||||
|
{ type: "tool", id: "edit-tool" },
|
||||||
|
],
|
||||||
sources: [],
|
sources: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1900,9 +1912,20 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
|
|||||||
},
|
},
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(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.getByText(/Edited config\.ts/)).toBeVisible();
|
||||||
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
|
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", {
|
const editFilesButton = canvas.getByRole("button", {
|
||||||
name: /Edited config\.ts/,
|
name: /Edited config\.ts/,
|
||||||
});
|
});
|
||||||
@@ -1926,6 +1949,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "always_expanded" as const,
|
thinking_display_mode: "always_expanded" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -1975,6 +1999,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "always_collapsed" as const,
|
thinking_display_mode: "always_collapsed" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -2025,6 +2050,7 @@ export const ThinkingBlockWithToolCall: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "always_collapsed" as const,
|
thinking_display_mode: "always_collapsed" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -2088,6 +2114,7 @@ export const ThinkingBlockAutoMode: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "auto" as const,
|
thinking_display_mode: "auto" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -2141,6 +2168,7 @@ export const ThinkingBlockPreviewMode: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "preview" as const,
|
thinking_display_mode: "preview" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ export const BlockList: FC<{
|
|||||||
const prefQuery = useQuery(preferenceSettings());
|
const prefQuery = useQuery(preferenceSettings());
|
||||||
const thinkingDisplayMode: ThinkingDisplayMode =
|
const thinkingDisplayMode: ThinkingDisplayMode =
|
||||||
prefQuery.data?.thinking_display_mode || "auto";
|
prefQuery.data?.thinking_display_mode || "auto";
|
||||||
|
const shellToolDisplayMode: TypesGen.AgentDisplayMode =
|
||||||
|
prefQuery.data?.shell_tool_display_mode || "auto";
|
||||||
const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
|
const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
|
||||||
prefQuery.data?.code_diff_display_mode || "auto";
|
prefQuery.data?.code_diff_display_mode || "auto";
|
||||||
|
|
||||||
@@ -367,6 +369,7 @@ export const BlockList: FC<{
|
|||||||
name="Tool"
|
name="Tool"
|
||||||
status="running"
|
status="running"
|
||||||
isError={false}
|
isError={false}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||||
subagentTitles={subagentTitles}
|
subagentTitles={subagentTitles}
|
||||||
subagentVariants={subagentVariants}
|
subagentVariants={subagentVariants}
|
||||||
@@ -384,6 +387,7 @@ export const BlockList: FC<{
|
|||||||
status={tool.status}
|
status={tool.status}
|
||||||
isError={tool.isError}
|
isError={tool.isError}
|
||||||
killedBySignal={tool.killedBySignal}
|
killedBySignal={tool.killedBySignal}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||||
subagentTitles={subagentTitles}
|
subagentTitles={subagentTitles}
|
||||||
subagentVariants={subagentVariants}
|
subagentVariants={subagentVariants}
|
||||||
@@ -440,6 +444,7 @@ export const BlockList: FC<{
|
|||||||
status={tool.status}
|
status={tool.status}
|
||||||
isError={tool.isError}
|
isError={tool.isError}
|
||||||
killedBySignal={tool.killedBySignal}
|
killedBySignal={tool.killedBySignal}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||||
subagentTitles={subagentTitles}
|
subagentTitles={subagentTitles}
|
||||||
subagentVariants={subagentVariants}
|
subagentVariants={subagentVariants}
|
||||||
@@ -564,7 +569,8 @@ const ChatMessageItem = memo<{
|
|||||||
) : (
|
) : (
|
||||||
<Message className="w-full">
|
<Message className="w-full">
|
||||||
<MessageContent className="whitespace-normal">
|
<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
|
<BlockList
|
||||||
blocks={parsed.blocks}
|
blocks={parsed.blocks}
|
||||||
tools={parsed.tools}
|
tools={parsed.tools}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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";
|
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> = {
|
const meta: Meta<typeof ExecuteTool> = {
|
||||||
title: "components/ai-elements/tool/ExecuteTool",
|
title: "components/ai-elements/tool/ExecuteTool",
|
||||||
component: 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 = {
|
export const LongCommand: Story = {
|
||||||
args: {
|
args: {
|
||||||
command:
|
command: longCommand,
|
||||||
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
|
|
||||||
output: "",
|
output: "",
|
||||||
},
|
},
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
const chevron = canvas.getByRole("button", {
|
const command = canvas.getByText(longCommand);
|
||||||
name: /expand command/i,
|
expect(command).toBeVisible();
|
||||||
});
|
expect(
|
||||||
await userEvent.click(chevron);
|
canvas.queryByRole("button", { name: longCommand }),
|
||||||
// Hover the component so the chevron stays visible.
|
).not.toBeInTheDocument();
|
||||||
await userEvent.hover(canvasElement.firstElementChild!);
|
await userEvent.hover(command);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("tooltip", undefined, { timeout: 2000 }),
|
||||||
|
).toHaveTextContent(longCommand);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A long truncated command with multi-line output below it. */
|
/** A long truncated command with multi-line output below it. */
|
||||||
export const LongCommandWithOutput: Story = {
|
export const LongCommandWithOutput: Story = {
|
||||||
args: {
|
args: {
|
||||||
command:
|
command: longCommand,
|
||||||
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
|
|
||||||
output: [
|
output: [
|
||||||
"src/api/legacyClient.ts",
|
"src/api/legacyClient.ts",
|
||||||
"src/components/OldTable/OldTable.tsx",
|
"src/components/OldTable/OldTable.tsx",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "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 { Button } from "#/components/Button/Button";
|
||||||
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||||
@@ -20,186 +21,210 @@ import {
|
|||||||
} from "#/components/Tooltip/Tooltip";
|
} from "#/components/Tooltip/Tooltip";
|
||||||
import { cn } from "#/utils/cn";
|
import { cn } from "#/utils/cn";
|
||||||
import {
|
import {
|
||||||
BORDER_BG_STYLE,
|
type AgentDisplayState,
|
||||||
COLLAPSED_OUTPUT_HEIGHT,
|
isAgentDisplayOpen,
|
||||||
|
resolveAgentDisplayState,
|
||||||
|
} from "./displayMode";
|
||||||
|
import {
|
||||||
|
formatShellDurationMs,
|
||||||
signalTooltipLabel,
|
signalTooltipLabel,
|
||||||
type ToolStatus,
|
type ToolStatus,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
/**
|
type ExecuteToolProps = {
|
||||||
* 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<{
|
|
||||||
command: string;
|
command: string;
|
||||||
output: string;
|
output: string;
|
||||||
status: ToolStatus;
|
status: ToolStatus;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
|
durationMs?: number;
|
||||||
isBackgrounded?: boolean;
|
isBackgrounded?: boolean;
|
||||||
killedBySignal?: "kill" | "terminate";
|
killedBySignal?: "kill" | "terminate";
|
||||||
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => {
|
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
const [expanded, setExpanded] = useState(false);
|
};
|
||||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
|
||||||
|
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 hasOutput = output.length > 0;
|
||||||
const isRunning = status === "running";
|
const isRunning = status === "running";
|
||||||
|
const showFailureIndicator = isError && !isRunning;
|
||||||
// Track whether the command text is truncated so we can offer
|
const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
|
||||||
// a click-to-expand interaction. The ResizeObserver may clear
|
const outputToggleLabel = outputOpen
|
||||||
// commandOverflows while the text is wrapped, but
|
? "Collapse command output"
|
||||||
// canToggleCommand stays true via commandExpanded so the
|
: "Expand command output";
|
||||||
// collapse affordance remains visible.
|
const durationLabel = formatShellDurationMs(durationMs);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
|
<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">
|
||||||
{/* Header: $ command + copy button */}
|
<Tooltip delayDuration={300}>
|
||||||
<div className="flex w-full items-start justify-between gap-2 px-3 py-2">
|
<TooltipTrigger asChild>
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */}
|
{hasOutput ? (
|
||||||
<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 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCommandExpanded((v) => !v)}
|
aria-expanded={outputOpen}
|
||||||
className={cn(
|
aria-label={outputToggleLabel}
|
||||||
"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",
|
onClick={() => setOutputOpen((value) => !value)}
|
||||||
commandExpanded
|
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"
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0 group-hover/exec:opacity-100",
|
|
||||||
)}
|
|
||||||
aria-expanded={commandExpanded}
|
|
||||||
aria-label={
|
|
||||||
commandExpanded ? "Collapse command" : "Expand command"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ShellCommandLine
|
||||||
className={cn(
|
command={command}
|
||||||
"h-3.5 w-3.5 transition-transform",
|
durationLabel={durationLabel}
|
||||||
commandExpanded && "rotate-180",
|
expanded={outputOpen}
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</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 && (
|
</TooltipTrigger>
|
||||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
<TooltipContent className="max-w-xl whitespace-pre-wrap break-all font-mono">
|
||||||
)}
|
{command}
|
||||||
{isBackgrounded && !isRunning && (
|
</TooltipContent>
|
||||||
<Tooltip>
|
</Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
|
||||||
<LayersIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
{isRunning && (
|
||||||
</TooltipTrigger>
|
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||||
<TooltipContent>Running in background</TooltipContent>
|
)}
|
||||||
</Tooltip>
|
{showFailureIndicator && (
|
||||||
)}
|
<Tooltip>
|
||||||
{killedBySignal && !isRunning && (
|
<TooltipTrigger asChild>
|
||||||
<Tooltip>
|
<span
|
||||||
<TooltipTrigger asChild>
|
aria-label="Command failed"
|
||||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
role="img"
|
||||||
</TooltipTrigger>
|
className="flex shrink-0 text-content-destructive"
|
||||||
<TooltipContent>
|
>
|
||||||
{signalTooltipLabel(killedBySignal)}
|
<TriangleAlertIcon
|
||||||
</TooltipContent>
|
aria-hidden
|
||||||
</Tooltip>
|
className="h-3.5 w-3.5 shrink-0"
|
||||||
)}
|
/>
|
||||||
<CopyButton
|
</span>
|
||||||
text={command}
|
</TooltipTrigger>
|
||||||
label="Copy command"
|
<TooltipContent>Command failed</TooltipContent>
|
||||||
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
|
</Tooltip>
|
||||||
/>
|
)}
|
||||||
</div>
|
{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>
|
</div>
|
||||||
{/* Output preview / expanded */}
|
{hasOutput && outputOpen && (
|
||||||
{hasOutput && (
|
<ShellOutputBody output={output} isError={isError} />
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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<{
|
export const ExecuteAuthRequiredTool: React.FC<{
|
||||||
command: string;
|
command: string;
|
||||||
output: string;
|
output: string;
|
||||||
|
|||||||
+5
-5
@@ -19,7 +19,7 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof Tool>;
|
type Story = StoryObj<typeof Tool>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Execute tool — killed indicator via killedBySignal prop
|
// Execute tool, killed indicator via killedBySignal prop.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const ExecuteKilled: Story = {
|
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 = {
|
export const ExecuteNotSignaled: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: "execute",
|
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 = {
|
export const ExecuteRunningNotYetKilled: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: "execute",
|
name: "execute",
|
||||||
@@ -96,7 +96,7 @@ export const ExecuteRunningNotYetKilled: Story = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ProcessOutput tool — killed indicator
|
// ProcessOutput tool, killed indicator.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const ProcessOutputKilled: Story = {
|
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 = {
|
export const ProcessOutputKilledNoOutput: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: "process_output",
|
name: "process_output",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
|
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
|
||||||
import type React from "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 { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||||
import {
|
import {
|
||||||
@@ -9,124 +10,152 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "#/components/Tooltip/Tooltip";
|
} from "#/components/Tooltip/Tooltip";
|
||||||
import { cn } from "#/utils/cn";
|
import { cn } from "#/utils/cn";
|
||||||
|
import {
|
||||||
|
type AgentDisplayState,
|
||||||
|
isAgentDisplayFullyExpanded,
|
||||||
|
resolveAgentDisplayState,
|
||||||
|
} from "./displayMode";
|
||||||
|
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
|
||||||
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
|
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
|
||||||
|
|
||||||
/**
|
type ProcessOutputToolProps = {
|
||||||
* 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<{
|
|
||||||
output: string;
|
output: string;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
killedBySignal?: "kill" | "terminate";
|
killedBySignal?: "kill" | "terminate";
|
||||||
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => {
|
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
const [expanded, setExpanded] = useState(false);
|
};
|
||||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
|
||||||
|
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 hasOutput = output.length > 0;
|
||||||
|
|
||||||
const [overflows, setOverflows] = useState(false);
|
const [overflows, setOverflows] = useState(false);
|
||||||
const measureRef = (node: HTMLPreElement | null) => {
|
const measureRef = (node: HTMLPreElement | null) => {
|
||||||
outputRef.current = node;
|
|
||||||
if (node) {
|
if (node) {
|
||||||
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
|
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showExitCode = exitCode !== null && exitCode !== 0;
|
const showExitCode = exitCode !== null && exitCode !== 0;
|
||||||
|
const toggleOutputExpansion = () => {
|
||||||
|
setOutputFullyExpanded((expanded) => !expanded);
|
||||||
|
};
|
||||||
|
const hasHeaderActions =
|
||||||
|
isRunning || Boolean(killedBySignal) || showExitCode || hasOutput;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/proc w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
|
<AgentDisplayModeToolCollapsible
|
||||||
{hasOutput ? (
|
className="group/proc w-full"
|
||||||
<>
|
hasContent={hasOutput}
|
||||||
<div className="relative">
|
displayMode={shellToolDisplayMode}
|
||||||
<ScrollArea
|
autoDisplayState={autoDisplayState}
|
||||||
className="text-2xs"
|
ariaLabel={(expanded) =>
|
||||||
viewportClassName={expanded ? "max-h-96" : ""}
|
expanded ? "Collapse process output" : "Expand process output"
|
||||||
scrollBarClassName="w-1.5"
|
}
|
||||||
>
|
header={<span className="text-[13px]">Process output</span>}
|
||||||
<pre
|
headerActions={
|
||||||
ref={measureRef}
|
hasHeaderActions ? (
|
||||||
style={
|
<>
|
||||||
expanded
|
{isRunning && (
|
||||||
? undefined
|
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||||
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
|
)}
|
||||||
}
|
{killedBySignal && !isRunning && (
|
||||||
className={cn(
|
<Tooltip>
|
||||||
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
|
<TooltipTrigger asChild>
|
||||||
isError
|
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||||
? "text-content-destructive"
|
</TooltipTrigger>
|
||||||
: "text-content-secondary",
|
<TooltipContent>
|
||||||
)}
|
{signalTooltipLabel(killedBySignal)}
|
||||||
>
|
</TooltipContent>
|
||||||
{output}
|
</Tooltip>
|
||||||
</pre>
|
)}
|
||||||
</ScrollArea>
|
{showExitCode && (
|
||||||
<div className="absolute right-1 top-0.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/proc:opacity-100">
|
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||||
{isRunning && (
|
exit {exitCode}
|
||||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
</span>
|
||||||
)}
|
)}
|
||||||
{killedBySignal && !isRunning && (
|
{hasOutput && <CopyButton text={output} label="Copy output" />}
|
||||||
<Tooltip>
|
</>
|
||||||
<TooltipTrigger asChild>
|
) : undefined
|
||||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
}
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>
|
<ScrollArea
|
||||||
{signalTooltipLabel(killedBySignal)}
|
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||||
</TooltipContent>
|
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
|
||||||
</Tooltip>
|
scrollBarClassName="w-1.5"
|
||||||
)}
|
>
|
||||||
{showExitCode && (
|
<pre
|
||||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
ref={measureRef}
|
||||||
exit {exitCode}
|
style={
|
||||||
</span>
|
outputFullyExpanded
|
||||||
)}
|
? undefined
|
||||||
<CopyButton text={output} label="Copy output" />
|
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
|
||||||
</div>
|
}
|
||||||
</div>
|
className={cn(
|
||||||
|
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
|
||||||
{/* Expand / collapse toggle at the bottom */}
|
isError ? "text-content-destructive" : "text-content-secondary",
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
>
|
||||||
) : (
|
{output}
|
||||||
<div className="flex items-center gap-1 px-3 py-1.5">
|
</pre>
|
||||||
{isRunning && (
|
</ScrollArea>
|
||||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
{overflows && (
|
||||||
)}
|
<button
|
||||||
{killedBySignal && !isRunning && (
|
type="button"
|
||||||
<Tooltip>
|
aria-expanded={outputFullyExpanded}
|
||||||
<TooltipTrigger asChild>
|
onClick={toggleOutputExpansion}
|
||||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
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"
|
||||||
</TooltipTrigger>
|
aria-label={
|
||||||
<TooltipContent>
|
outputFullyExpanded
|
||||||
{signalTooltipLabel(killedBySignal)}
|
? "Collapse full process output"
|
||||||
</TooltipContent>
|
: "Expand full process output"
|
||||||
</Tooltip>
|
}
|
||||||
)}
|
>
|
||||||
{showExitCode && (
|
<ChevronDownIcon
|
||||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
className={cn(
|
||||||
exit {exitCode}
|
"h-3 w-3 transition-transform",
|
||||||
</span>
|
outputFullyExpanded && "rotate-180",
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AgentDisplayModeToolCollapsible>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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 { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||||
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
|
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
|
||||||
import { DesktopPanelContext } from "./DesktopPanelContext";
|
import { DesktopPanelContext } from "./DesktopPanelContext";
|
||||||
import { Tool } from "./Tool";
|
import { Tool } from "./Tool";
|
||||||
|
|
||||||
const executeCommand = "git fetch origin";
|
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> = {
|
const meta: Meta<typeof Tool> = {
|
||||||
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
||||||
component: Tool,
|
component: Tool,
|
||||||
@@ -46,7 +56,11 @@ export const ExecuteRunning: Story = {
|
|||||||
|
|
||||||
export const ExecuteSuccess: Story = {
|
export const ExecuteSuccess: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
shellToolDisplayMode: "auto",
|
||||||
|
args: { command: longExecuteCommand },
|
||||||
result: {
|
result: {
|
||||||
|
wall_duration_ms: 47200,
|
||||||
|
exit_code: 0,
|
||||||
output:
|
output:
|
||||||
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui",
|
"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 }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
|
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;
|
previousResponseText?: string;
|
||||||
/** Human-readable intent extracted from the model's tool-call args. */
|
/** Human-readable intent extracted from the model's tool-call args. */
|
||||||
modelIntent?: string;
|
modelIntent?: string;
|
||||||
|
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ type ToolRendererProps = {
|
|||||||
mcpServerConfigId?: string;
|
mcpServerConfigId?: string;
|
||||||
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
mcpServers?: readonly TypesGen.MCPServerConfig[];
|
||||||
modelIntent?: string;
|
modelIntent?: string;
|
||||||
|
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,11 +220,19 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
|||||||
result,
|
result,
|
||||||
isError,
|
isError,
|
||||||
killedBySignal,
|
killedBySignal,
|
||||||
|
shellToolDisplayMode,
|
||||||
}) => {
|
}) => {
|
||||||
const parsedArgs = parseArgs(args);
|
const parsedArgs = parseArgs(args);
|
||||||
const command = parsedArgs ? asString(parsedArgs.command) : "";
|
const command = parsedArgs ? asString(parsedArgs.command) : "";
|
||||||
const rec = asRecord(result);
|
const rec = asRecord(result);
|
||||||
const output = rec ? asString(rec.output).trim() : "";
|
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 authRequired = rec ? Boolean(rec.auth_required) : false;
|
||||||
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
|
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
|
||||||
const providerLabel = toProviderLabel(
|
const providerLabel = toProviderLabel(
|
||||||
@@ -247,7 +257,10 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
|||||||
output={output}
|
output={output}
|
||||||
status={status}
|
status={status}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
|
durationMs={durationMs}
|
||||||
|
isBackgrounded={isBackgrounded}
|
||||||
killedBySignal={killedBySignal}
|
killedBySignal={killedBySignal}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -257,13 +270,12 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
|||||||
result,
|
result,
|
||||||
isError,
|
isError,
|
||||||
killedBySignal,
|
killedBySignal,
|
||||||
|
shellToolDisplayMode,
|
||||||
}) => {
|
}) => {
|
||||||
const rec = asRecord(result);
|
const rec = asRecord(result);
|
||||||
const output = rec ? asString(rec.output).trim() : "";
|
const output = rec ? asString(rec.output).trim() : "";
|
||||||
const exitCode = rec
|
const exitCode = rec
|
||||||
? rec.exit_code !== undefined && rec.exit_code !== null
|
? (asNumber(rec.exit_code, { parseString: true }) ?? null)
|
||||||
? Number(rec.exit_code)
|
|
||||||
: null
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +285,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
|||||||
exitCode={exitCode}
|
exitCode={exitCode}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
killedBySignal={killedBySignal}
|
killedBySignal={killedBySignal}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1037,6 +1050,7 @@ export const Tool = memo(
|
|||||||
isLatestAskUserQuestion,
|
isLatestAskUserQuestion,
|
||||||
previousResponseText,
|
previousResponseText,
|
||||||
modelIntent,
|
modelIntent,
|
||||||
|
shellToolDisplayMode,
|
||||||
codeDiffDisplayMode,
|
codeDiffDisplayMode,
|
||||||
ref,
|
ref,
|
||||||
...props
|
...props
|
||||||
@@ -1044,21 +1058,18 @@ export const Tool = memo(
|
|||||||
const Renderer = isSubagentToolName(name)
|
const Renderer = isSubagentToolName(name)
|
||||||
? SubagentRenderer
|
? SubagentRenderer
|
||||||
: (toolRenderers[name] ?? GenericToolRenderer);
|
: (toolRenderers[name] ?? GenericToolRenderer);
|
||||||
|
const isShellTool = name === "execute" || name === "process_output";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-tool-call=""
|
data-tool-call=""
|
||||||
|
data-shell-tool={isShellTool ? "" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
name === "execute" ||
|
isShellTool || name === "propose_plan" || name === "advisor"
|
||||||
name === "process_output" ||
|
|
||||||
name === "propose_plan" ||
|
|
||||||
name === "advisor"
|
|
||||||
? "w-full py-0.5"
|
? "w-full py-0.5"
|
||||||
: "py-0.5",
|
: "py-0.5",
|
||||||
// Collapse padding between adjacent tool calls so they hug.
|
// Keep back-to-back tool cards visually grouped so stacked tool calls do not look double-spaced.
|
||||||
// Bottom padding is removed on a tool followed by a tool, and
|
|
||||||
// top padding is removed on a tool preceded by a tool.
|
|
||||||
"[&:has(+[data-tool-call])]:pb-0",
|
"[&:has(+[data-tool-call])]:pb-0",
|
||||||
"[[data-tool-call]+&]:pt-0",
|
"[[data-tool-call]+&]:pt-0",
|
||||||
className,
|
className,
|
||||||
@@ -1084,6 +1095,7 @@ export const Tool = memo(
|
|||||||
isLatestAskUserQuestion={isLatestAskUserQuestion}
|
isLatestAskUserQuestion={isLatestAskUserQuestion}
|
||||||
previousResponseText={previousResponseText}
|
previousResponseText={previousResponseText}
|
||||||
modelIntent={modelIntent}
|
modelIntent={modelIntent}
|
||||||
|
shellToolDisplayMode={shellToolDisplayMode}
|
||||||
codeDiffDisplayMode={codeDiffDisplayMode}
|
codeDiffDisplayMode={codeDiffDisplayMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
resolveAgentDisplayState,
|
resolveAgentDisplayState,
|
||||||
} from "./displayMode";
|
} from "./displayMode";
|
||||||
|
|
||||||
|
type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
|
||||||
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
|
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
|
||||||
|
|
||||||
interface ToolCollapsibleProps {
|
interface ToolCollapsibleProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
header: ToolCollapsibleHeader;
|
header: ToolCollapsibleHeader;
|
||||||
|
headerActions?: ReactNode;
|
||||||
hasContent?: boolean;
|
hasContent?: boolean;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
ariaLabel?: ToolCollapsibleAriaLabel;
|
||||||
className?: string;
|
className?: string;
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,7 @@ export const AgentDisplayModeToolCollapsible: FC<
|
|||||||
AgentDisplayModeToolCollapsibleProps
|
AgentDisplayModeToolCollapsibleProps
|
||||||
> = ({ displayMode, autoDisplayState, ...props }) => {
|
> = ({ displayMode, autoDisplayState, ...props }) => {
|
||||||
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
|
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCollapsible
|
<ToolCollapsible
|
||||||
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
|
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
|
||||||
@@ -42,45 +46,63 @@ export const AgentDisplayModeToolCollapsible: FC<
|
|||||||
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
|
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
|
||||||
children,
|
children,
|
||||||
header,
|
header,
|
||||||
|
headerActions,
|
||||||
hasContent = true,
|
hasContent = true,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
|
ariaLabel,
|
||||||
className,
|
className,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
const renderedHeader =
|
const renderedHeader =
|
||||||
typeof header === "function" ? header(expanded) : header;
|
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 (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{hasContent ? (
|
{headerActions ? (
|
||||||
<button
|
<div className="flex w-full items-center gap-2">
|
||||||
type="button"
|
{headerButton}
|
||||||
aria-expanded={expanded}
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
onClick={() => setExpanded(!expanded)}
|
{headerActions}
|
||||||
className={cn(
|
</div>
|
||||||
"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}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
headerButton
|
||||||
)}
|
)}
|
||||||
{expanded && hasContent && children}
|
{expanded && hasContent && children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
BORDER_BG_STYLE,
|
|
||||||
buildEditDiff,
|
buildEditDiff,
|
||||||
buildWriteFileDiff,
|
buildWriteFileDiff,
|
||||||
COLLAPSED_OUTPUT_HEIGHT,
|
COLLAPSED_OUTPUT_HEIGHT,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
diffViewerCSS,
|
diffViewerCSS,
|
||||||
fileViewerCSS,
|
fileViewerCSS,
|
||||||
formatResultOutput,
|
formatResultOutput,
|
||||||
|
formatShellDurationMs,
|
||||||
getDiffViewerOptions,
|
getDiffViewerOptions,
|
||||||
getFileContentForViewer,
|
getFileContentForViewer,
|
||||||
getFileViewerOptions,
|
getFileViewerOptions,
|
||||||
@@ -70,20 +70,45 @@ describe("shortDurationMs", () => {
|
|||||||
expect(shortDurationMs(1000)).toBe("1s");
|
expect(shortDurationMs(1000)).toBe("1s");
|
||||||
expect(shortDurationMs(30_000)).toBe("30s");
|
expect(shortDurationMs(30_000)).toBe("30s");
|
||||||
expect(shortDurationMs(59_000)).toBe("59s");
|
expect(shortDurationMs(59_000)).toBe("59s");
|
||||||
|
expect(shortDurationMs(59_499)).toBe("59s");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats minutes", () => {
|
it("formats minutes", () => {
|
||||||
|
expect(shortDurationMs(59_500)).toBe("1m");
|
||||||
expect(shortDurationMs(60_000)).toBe("1m");
|
expect(shortDurationMs(60_000)).toBe("1m");
|
||||||
expect(shortDurationMs(300_000)).toBe("5m");
|
expect(shortDurationMs(300_000)).toBe("5m");
|
||||||
expect(shortDurationMs(3_540_000)).toBe("59m");
|
expect(shortDurationMs(3_540_000)).toBe("59m");
|
||||||
|
expect(shortDurationMs(3_569_999)).toBe("59m");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats hours", () => {
|
it("formats hours", () => {
|
||||||
|
expect(shortDurationMs(3_570_000)).toBe("1h");
|
||||||
expect(shortDurationMs(3_600_000)).toBe("1h");
|
expect(shortDurationMs(3_600_000)).toBe("1h");
|
||||||
expect(shortDurationMs(7_200_000)).toBe("2h");
|
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", () => {
|
describe("normalizeStatus", () => {
|
||||||
it("lowercases and trims", () => {
|
it("lowercases and trims", () => {
|
||||||
expect(normalizeStatus(" COMPLETED ")).toBe("completed");
|
expect(normalizeStatus(" COMPLETED ")).toBe("completed");
|
||||||
@@ -840,13 +865,6 @@ describe("constants", () => {
|
|||||||
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
|
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", () => {
|
it("fileViewerCSS is a non-empty string", () => {
|
||||||
expect(typeof fileViewerCSS).toBe("string");
|
expect(typeof fileViewerCSS).toBe("string");
|
||||||
expect(fileViewerCSS.length).toBeGreaterThan(0);
|
expect(fileViewerCSS.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -55,11 +55,38 @@ export const shortDurationMs = (durationMs: number | undefined): string => {
|
|||||||
if (seconds < 60) {
|
if (seconds < 60) {
|
||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.round(durationMs / 60_000);
|
||||||
if (minutes < 60) {
|
if (minutes < 60) {
|
||||||
return `${minutes}m`;
|
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`;
|
return `${hours}h`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,10 +466,6 @@ export const DIFFS_FONT_STYLE = {
|
|||||||
"--diffs-line-height": "1.5",
|
"--diffs-line-height": "1.5",
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
export const BORDER_BG_STYLE = {
|
|
||||||
background: "hsl(var(--border-default))",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a tool result should be rendered as a syntax-highlighted
|
* Checks whether a tool result should be rendered as a syntax-highlighted
|
||||||
* file viewer. Returns the file path, content, and whether the header
|
* 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 = () => {
|
export const CodeDiffDisplaySettings: FC = () => {
|
||||||
return (
|
return (
|
||||||
<DisplayModeSettings
|
<DisplayModeSettings
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
|
|||||||
data: {
|
data: {
|
||||||
task_notification_alert_dismissed: true,
|
task_notification_alert_dismissed: true,
|
||||||
thinking_display_mode: "auto" as const,
|
thinking_display_mode: "auto" as const,
|
||||||
|
shell_tool_display_mode: "auto" as const,
|
||||||
code_diff_display_mode: "auto" as const,
|
code_diff_display_mode: "auto" as const,
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
},
|
},
|
||||||
@@ -240,6 +241,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
|
|||||||
).mockResolvedValue({
|
).mockResolvedValue({
|
||||||
task_notification_alert_dismissed: false,
|
task_notification_alert_dismissed: false,
|
||||||
thinking_display_mode: "auto",
|
thinking_display_mode: "auto",
|
||||||
|
shell_tool_display_mode: "auto",
|
||||||
code_diff_display_mode: "auto",
|
code_diff_display_mode: "auto",
|
||||||
agent_chat_send_shortcut: "enter" as const,
|
agent_chat_send_shortcut: "enter" as const,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user