From d175e799da4b7529ffe702b426ddbd26680b8534 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 26 Mar 2026 11:30:12 +0000 Subject: [PATCH] feat: show agent badge on workspace list (#23453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. 🧑‍💻 --- coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbauthz/dbauthz_test.go | 7 + coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 52 +++++++ coderd/database/queries/chats.sql | 7 + coderd/exp_chats.go | 82 +++++++++++ coderd/exp_chats_test.go | 131 ++++++++++++++++++ codersdk/chats.go | 20 +++ site/src/api/api.ts | 9 ++ site/src/api/queries/chats.ts | 20 +++ site/src/components/Badge/Badge.tsx | 5 + site/src/components/PaginationWidget/utils.ts | 1 + .../pages/WorkspacesPage/WorkspacesPage.tsx | 13 ++ .../WorkspacesPageView.stories.tsx | 17 +++ .../WorkspacesPage/WorkspacesPageView.tsx | 3 + .../pages/WorkspacesPage/WorkspacesTable.tsx | 15 +- 19 files changed, 410 insertions(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 118493790c..e5314ef5ee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3587335549..3d1c86ebf6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e103a343a6..124917f6e1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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(), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 888d1f723e..7d42128d46 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 0d76ba0318..e83d12eb58 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ab3d698598..66fc9f2aed 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3001670bd0..6960f5172f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 0b9d3b7b24..548e05949f 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -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) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 75a8cf6d95..390b23caf2 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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 +// 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() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f987ee978e..4737972216 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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() diff --git a/codersdk/chats.go b/codersdk/chats.go index b4eb35d482..0fd5ba8107 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -2028,6 +2028,26 @@ func (c *ExperimentalClient) GetMyChatUsageLimitStatus(ctx context.Context) (Cha return resp, json.NewDecoder(res.Body).Decode(&resp) } +// GetChatsByWorkspace returns a mapping of workspace ID to the latest +// non-archived chat ID for each requested workspace. Workspaces with +// no chats are omitted from the response. +func (c *ExperimentalClient) GetChatsByWorkspace(ctx context.Context, workspaceIDs []uuid.UUID) (map[uuid.UUID]uuid.UUID, error) { + ids := make([]string, 0, len(workspaceIDs)) + for _, id := range workspaceIDs { + ids = append(ids, id.String()) + } + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/by-workspace?workspace_ids=%s", strings.Join(ids, ",")), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var result map[uuid.UUID]uuid.UUID + return result, json.NewDecoder(res.Body).Decode(&result) +} + func formatChatStreamResponseError(response Response) string { message := strings.TrimSpace(response.Message) detail := strings.TrimSpace(response.Detail) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2d40b4e709..a2683db1aa 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3025,6 +3025,15 @@ export type CreateTaskFeedbackRequest = { class ExperimentalApiMethods { constructor(protected readonly axios: AxiosInstance) {} + getChatsByWorkspace = async ( + workspaceIds: readonly string[], + ): Promise> => { + const res = await this.axios.get("/api/experimental/chats/by-workspace", { + params: { workspace_ids: workspaceIds.join(",") }, + }); + return res.data; + }; + uploadChatFile = async ( file: File, organizationId: string, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 6aaff8efc3..e501881406 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -7,6 +7,17 @@ export const chatKey = (chatId: string) => ["chats", chatId] as const; export const chatMessagesKey = (chatId: string) => ["chats", chatId, "messages"] as const; +const chatsByWorkspaceKeyPrefix = [...chatsKey, "by-workspace"] as const; + +export const chatsByWorkspace = (workspaceIds: string[]) => { + const sorted = workspaceIds.toSorted(); + return { + queryKey: [...chatsKey, "by-workspace", sorted], + queryFn: () => API.experimental.getChatsByWorkspace(sorted), + enabled: workspaceIds.length > 0, + }; +}; + /** * Updates a single chat inside every page of the infinite chats query * cache. Use this instead of setQueryData(chatsKey, ...) which writes @@ -240,6 +251,9 @@ export const archiveChat = (queryClient: QueryClient) => ({ queryKey: chatKey(chatId), exact: true, }); + await queryClient.invalidateQueries({ + queryKey: chatsByWorkspaceKeyPrefix, + }); }, }); @@ -300,6 +314,9 @@ export const unarchiveChat = (queryClient: QueryClient) => ({ queryKey: chatKey(chatId), exact: true, }); + await queryClient.invalidateQueries({ + queryKey: chatsByWorkspaceKeyPrefix, + }); }, }); @@ -308,6 +325,9 @@ export const createChat = (queryClient: QueryClient) => ({ API.experimental.createChat(req), onSuccess: () => { void invalidateChatListQueries(queryClient); + void queryClient.invalidateQueries({ + queryKey: chatsByWorkspaceKeyPrefix, + }); }, }); diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index df5c644033..c7a991d675 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -49,6 +49,11 @@ const badgeVariants = cva( variant: "default", class: "hover:bg-surface-tertiary", }, + { + hover: true, + variant: "info", + class: "hover:bg-surface-info/20", + }, ], defaultVariants: { variant: "default", diff --git a/site/src/components/PaginationWidget/utils.ts b/site/src/components/PaginationWidget/utils.ts index 2bd026d738..ea159f8a54 100644 --- a/site/src/components/PaginationWidget/utils.ts +++ b/site/src/components/PaginationWidget/utils.ts @@ -6,6 +6,7 @@ const range = (start: number, stop: number, step = 1) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step); +// NOTE: maxWorkspaceIDs in coderd/exp_chats.go is coupled to this value. export const DEFAULT_RECORDS_PER_PAGE = 25; // Number of pages to display on either side of the current page selection diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 89eb2f5b78..1285f9c8bc 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -10,6 +10,7 @@ import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { chatsByWorkspace } from "#/api/queries/chats"; import { workspacePermissionsByOrganization } from "#/api/queries/organizations"; import { templates, templateVersionRoot } from "#/api/queries/templates"; import { workspaces } from "#/api/queries/workspaces"; @@ -70,6 +71,8 @@ const WorkspacesPage: FC = () => { }, }); const { permissions, user: me } = useAuthenticated(); + const { experiments } = useDashboard(); + const agentsEnabled = experiments.includes("agents"); const templatesQuery = useQuery(templates()); const workspacePermissionsQuery = useQuery( workspacePermissionsByOrganization( @@ -127,6 +130,15 @@ const WorkspacesPage: FC = () => { refetchOnWindowFocus: "always", }); + const workspaceIds = useMemo( + () => data?.workspaces?.map((w) => w.id) ?? [], + [data?.workspaces], + ); + const chatsByWorkspaceQuery = useQuery({ + ...chatsByWorkspace(workspaceIds), + enabled: agentsEnabled && workspaceIds.length > 0, + }); + const [activeBatchAction, setActiveBatchAction] = useState(); const batchActions = useBatchActions({ onSuccess: async () => { @@ -146,6 +158,7 @@ const WorkspacesPage: FC = () => { canCreateTemplate={permissions.createTemplates} canChangeVersions={permissions.updateTemplates} checkedWorkspaces={checkedWorkspaces} + chatsByWorkspace={chatsByWorkspaceQuery.data} onCheckChange={(newWorkspaces) => { setCheckedWorkspaceIds((current) => { const newIds = newWorkspaces.map((ws) => ws.id); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 997584c9f2..acb54ed664 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -397,6 +397,23 @@ export const ShowWorkspaceTasks: Story = { }, }; +export const ShowWorkspaceChats: Story = { + args: { + workspaces: [ + { + ...MockWorkspace, + name: "regular-workspace", + }, + { + ...MockWorkspace, + id: "ws-with-agent", + name: "agent-workspace", + }, + ], + chatsByWorkspace: { "ws-with-agent": "some-chat-id" }, + }, +}; + export const WithCheckedWorkspaces: Story = { args: { workspaces: allWorkspaces.slice(0, 5), diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 4e59beba60..564e327c4c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -64,6 +64,7 @@ interface WorkspacesPageViewProps { canChangeVersions: boolean; onActionSuccess: () => Promise; onActionError: (error: unknown) => void; + chatsByWorkspace?: Record; } export const WorkspacesPageView: FC = ({ @@ -87,6 +88,7 @@ export const WorkspacesPageView: FC = ({ canChangeVersions, onActionSuccess, onActionError, + chatsByWorkspace, }) => { // Let's say the user has 5 workspaces, but tried to hit page 100, which // does not exist. In this case, the page is not valid and we want to show a @@ -230,6 +232,7 @@ export const WorkspacesPageView: FC = ({ templates={templates} onActionSuccess={onActionSuccess} onActionError={onActionError} + chatsByWorkspace={chatsByWorkspace} /> )} diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index ac26b6d48a..af4f929ba6 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -36,7 +36,7 @@ import { useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { cn } from "utils/cn"; import { getDisplayWorkspaceTemplateName } from "utils/workspace"; import { API } from "#/api/api"; @@ -96,6 +96,7 @@ interface WorkspacesTableProps { canCreateTemplate: boolean; onActionSuccess: () => Promise; onActionError: (error: unknown) => void; + chatsByWorkspace?: Record; } export const WorkspacesTable: FC = ({ @@ -107,6 +108,7 @@ export const WorkspacesTable: FC = ({ canCreateTemplate, onActionSuccess, onActionError, + chatsByWorkspace, }) => { const dashboard = useDashboard(); @@ -211,6 +213,17 @@ export const WorkspacesTable: FC = ({ Task )} + {chatsByWorkspace?.[workspace.id] && ( + + e.stopPropagation()} + aria-label={`View agent chat for ${workspace.name}`} + > + Agent + + + )} } subtitle={