mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: show agent badge on workspace list (#23453)
- Adds `GET /api/experimental/chats/by-workspace` endpoint that returns workspace_id → latest chat_id mapping - Modifies FE to fetch this alongside the workspace list, gated on `agents` experiment and render an "Agent" badge similar to the existing "Task" badge in `WorkspacesTable` - Badge links to the "latest chat" linked to the given workspace. Notes: - Intentionally uses `fetchWithPostFilter` for RBAC to decouple from workspaces API — will migrate to `workspaces_expanded` view later. - If users have multiple chats linked to the same workspace, the badge will link to the most recently updated one. > 🤖 This PR was created with the help of Coder Agents, and has been reviewed by my human. 🧑💻
This commit is contained in:
@@ -1155,6 +1155,7 @@ func New(options *Options) *API {
|
||||
apiKeyMiddleware,
|
||||
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentAgents),
|
||||
)
|
||||
r.Get("/by-workspace", api.chatsByWorkspace)
|
||||
r.Get("/", api.listChats)
|
||||
r.Post("/", api.postChats)
|
||||
r.Get("/models", api.listChatModels)
|
||||
|
||||
@@ -2724,6 +2724,10 @@ func (q *querier) GetChats(ctx context.Context, arg database.GetChatsParams) ([]
|
||||
return q.db.GetAuthorizedChats(ctx, arg, prep)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
|
||||
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByWorkspaceIDs)(ctx, ids)
|
||||
}
|
||||
|
||||
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
// Just like with the audit logs query, shortcut if the user is an owner.
|
||||
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
|
||||
|
||||
@@ -449,6 +449,13 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().GetChatByIDForUpdate(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
|
||||
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(chat)
|
||||
}))
|
||||
s.Run("GetChatsByWorkspaceIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chatA := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
chatB := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := []uuid.UUID{chatA.WorkspaceID.UUID, chatB.WorkspaceID.UUID}
|
||||
dbm.EXPECT().GetChatsByWorkspaceIDs(gomock.Any(), arg).Return([]database.Chat{chatA, chatB}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chatA, policy.ActionRead, chatB, policy.ActionRead).Returns([]database.Chat{chatA, chatB})
|
||||
}))
|
||||
s.Run("GetChatCostPerChat", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetChatCostPerChatParams{
|
||||
OwnerID: uuid.New(),
|
||||
|
||||
@@ -1256,6 +1256,14 @@ func (m queryMetricsStore) GetChats(ctx context.Context, arg database.GetChatsPa
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatsByWorkspaceIDs(ctx, ids)
|
||||
m.queryLatencies.WithLabelValues("GetChatsByWorkspaceIDs").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatsByWorkspaceIDs").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg)
|
||||
|
||||
@@ -2313,6 +2313,21 @@ func (mr *MockStoreMockRecorder) GetChats(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChats", reflect.TypeOf((*MockStore)(nil).GetChats), ctx, arg)
|
||||
}
|
||||
|
||||
// GetChatsByWorkspaceIDs mocks base method.
|
||||
func (m *MockStore) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatsByWorkspaceIDs", ctx, ids)
|
||||
ret0, _ := ret[0].([]database.Chat)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatsByWorkspaceIDs indicates an expected call of GetChatsByWorkspaceIDs.
|
||||
func (mr *MockStoreMockRecorder) GetChatsByWorkspaceIDs(ctx, ids any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatsByWorkspaceIDs", reflect.TypeOf((*MockStore)(nil).GetChatsByWorkspaceIDs), ctx, ids)
|
||||
}
|
||||
|
||||
// GetConnectionLogsOffset mocks base method.
|
||||
func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -264,6 +264,7 @@ type sqlcQuerier interface {
|
||||
// Returns "0s" (disabled) when no value has been configured.
|
||||
GetChatWorkspaceTTL(ctx context.Context) (string, error)
|
||||
GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, error)
|
||||
GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]Chat, error)
|
||||
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
GetCryptoKeys(ctx context.Context) ([]CryptoKey, error)
|
||||
|
||||
@@ -5153,6 +5153,58 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id
|
||||
FROM chats
|
||||
WHERE archived = false
|
||||
AND workspace_id = ANY($1::uuid[])
|
||||
ORDER BY workspace_id, updated_at DESC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]Chat, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getChatsByWorkspaceIDs, pq.Array(ids))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Chat
|
||||
for rows.Next() {
|
||||
var i Chat
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.WorkerID,
|
||||
&i.StartedAt,
|
||||
&i.HeartbeatAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentChatID,
|
||||
&i.RootChatID,
|
||||
&i.LastModelConfigID,
|
||||
&i.Archived,
|
||||
&i.LastError,
|
||||
&i.Mode,
|
||||
pq.Array(&i.MCPServerIDs),
|
||||
&i.Labels,
|
||||
&i.BuildID,
|
||||
&i.AgentID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one
|
||||
SELECT
|
||||
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id
|
||||
|
||||
@@ -889,6 +889,13 @@ JOIN group_members_expanded gme ON gme.group_id = g.id
|
||||
WHERE gme.user_id = @user_id::uuid
|
||||
AND g.chat_spend_limit_micros IS NOT NULL;
|
||||
|
||||
-- name: GetChatsByWorkspaceIDs :many
|
||||
SELECT *
|
||||
FROM chats
|
||||
WHERE archived = false
|
||||
AND workspace_id = ANY(@ids::uuid[])
|
||||
ORDER BY workspace_id, updated_at DESC;
|
||||
|
||||
-- name: ResolveUserChatSpendLimit :one
|
||||
-- Resolves the effective spend limit for a user using the hierarchy:
|
||||
-- 1. Individual user override (highest priority)
|
||||
|
||||
@@ -196,6 +196,88 @@ func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: chatsByWorkspace returns a mapping of workspace ID to
|
||||
// the latest non-archived chat ID for each requested workspace.
|
||||
// The query returns all matching chats and RBAC post-filters them;
|
||||
// the handler then picks the latest per workspace in Go. This avoids
|
||||
// the DISTINCT ON + post-filter bug where the sole candidate is
|
||||
// silently dropped when the caller can't read it.
|
||||
//
|
||||
// TODO:
|
||||
// 1. move aggregation to a SQL view with proper in-query authz so we
|
||||
// can return a single row per workspace without this two-pass approach.
|
||||
// 2. Restore the below router annotation and un-skip docs gen
|
||||
// <at>Router /experimental/chats/by-workspace [post]
|
||||
//
|
||||
// @Summary Get latest chats by workspace IDs
|
||||
// @ID get-latest-chats-by-workspace-ids
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
idsParam := r.URL.Query().Get("workspace_ids")
|
||||
if idsParam == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, map[uuid.UUID]uuid.UUID{})
|
||||
return
|
||||
}
|
||||
|
||||
raw := strings.Split(idsParam, ",")
|
||||
|
||||
// maxWorkspaceIDs is coupled to DEFAULT_RECORDS_PER_PAGE (25) in
|
||||
// site/src/components/PaginationWidget/utils.ts.
|
||||
// If the page size changes, this limit should too.
|
||||
const maxWorkspaceIDs = 25
|
||||
if len(raw) > maxWorkspaceIDs {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Too many workspace IDs, maximum is %d.", maxWorkspaceIDs),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
workspaceIDs := make([]uuid.UUID, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Invalid workspace ID %q: %s", s, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
workspaceIDs = append(workspaceIDs, id)
|
||||
}
|
||||
|
||||
chats, err := api.Database.GetChatsByWorkspaceIDs(ctx, workspaceIDs)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
} else if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get chats by workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// The SQL orders by (workspace_id, updated_at DESC), so the first
|
||||
// chat seen per workspace after RBAC filtering is the latest
|
||||
// readable one.
|
||||
result := make(map[uuid.UUID]uuid.UUID, len(chats))
|
||||
for _, chat := range chats {
|
||||
if chat.WorkspaceID.Valid {
|
||||
if _, exists := result[chat.WorkspaceID.UUID]; !exists {
|
||||
result[chat.WorkspaceID.UUID] = chat.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -5289,6 +5289,137 @@ func TestChatTemplateAllowlist(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChatsByWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
// Helper to create a workspace owned by the test user.
|
||||
newWorkspace := func() dbfake.WorkspaceBuildBuilder {
|
||||
return dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent()
|
||||
}
|
||||
|
||||
// Helper to insert a chat linked to a workspace.
|
||||
insertChat := func(ctx context.Context, title string, workspaceID uuid.UUID) database.Chat {
|
||||
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OwnerID: user.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: title,
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
t.Run("EmptyRequestReturnsEmptyMap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceWithNoChatsOmitted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ws := newWorkspace().Do()
|
||||
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{ws.Workspace.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("ReturnsChatLinkedToWorkspace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ws := newWorkspace().Do()
|
||||
chat := insertChat(ctx, "workspace chat", ws.Workspace.ID)
|
||||
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{ws.Workspace.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, chat.ID, result[ws.Workspace.ID])
|
||||
})
|
||||
|
||||
t.Run("ArchivedChatsExcluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ws := newWorkspace().Do()
|
||||
chat := insertChat(ctx, "soon to be archived", ws.Workspace.ID)
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{ws.Workspace.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("ReturnsLatestNonArchivedChat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ws := newWorkspace().Do()
|
||||
|
||||
// Insert an older chat and archive it.
|
||||
olderChat := insertChat(ctx, "older archived", ws.Workspace.ID)
|
||||
err := client.UpdateChat(ctx, olderChat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert two active chats — the second is newer due to insert
|
||||
// ordering and should win the "latest" selection in Go after
|
||||
// the SQL returns both ordered by updated_at DESC.
|
||||
_ = insertChat(ctx, "older active", ws.Workspace.ID)
|
||||
newerChat := insertChat(ctx, "newer active", ws.Workspace.ID)
|
||||
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{ws.Workspace.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, newerChat.ID, result[ws.Workspace.ID])
|
||||
})
|
||||
|
||||
t.Run("MultipleWorkspaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
wsA := newWorkspace().Do()
|
||||
wsB := newWorkspace().Do()
|
||||
wsC := newWorkspace().Do()
|
||||
|
||||
chatA := insertChat(ctx, "chat for workspace A", wsA.Workspace.ID)
|
||||
chatB := insertChat(ctx, "chat for workspace B", wsB.Workspace.ID)
|
||||
|
||||
// Query all three workspaces; C has no chats.
|
||||
result, err := client.GetChatsByWorkspace(ctx, []uuid.UUID{
|
||||
wsA.Workspace.ID,
|
||||
wsB.Workspace.ID,
|
||||
wsC.Workspace.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, chatA.ID, result[wsA.Workspace.ID])
|
||||
require.Equal(t, chatB.ID, result[wsB.Workspace.ID])
|
||||
_, hasC := result[wsC.Workspace.ID]
|
||||
require.False(t, hasC, "workspace C should not appear in result")
|
||||
})
|
||||
|
||||
t.Run("RejectsTooManyWorkspaceIDs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ids := make([]uuid.UUID, 26)
|
||||
for i := range ids {
|
||||
ids[i] = uuid.New()
|
||||
}
|
||||
|
||||
_, err := client.GetChatsByWorkspace(ctx, ids)
|
||||
require.Error(t, err)
|
||||
requireSDKError(t, err, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
|
||||
func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user