mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3025,6 +3025,15 @@ export type CreateTaskFeedbackRequest = {
|
||||
class ExperimentalApiMethods {
|
||||
constructor(protected readonly axios: AxiosInstance) {}
|
||||
|
||||
getChatsByWorkspace = async (
|
||||
workspaceIds: readonly string[],
|
||||
): Promise<Record<string, string>> => {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BatchAction>();
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -64,6 +64,7 @@ interface WorkspacesPageViewProps {
|
||||
canChangeVersions: boolean;
|
||||
onActionSuccess: () => Promise<void>;
|
||||
onActionError: (error: unknown) => void;
|
||||
chatsByWorkspace?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||
@@ -87,6 +88,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||
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<WorkspacesPageViewProps> = ({
|
||||
templates={templates}
|
||||
onActionSuccess={onActionSuccess}
|
||||
onActionError={onActionError}
|
||||
chatsByWorkspace={chatsByWorkspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
onActionError: (error: unknown) => void;
|
||||
chatsByWorkspace?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
@@ -107,6 +108,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
canCreateTemplate,
|
||||
onActionSuccess,
|
||||
onActionError,
|
||||
chatsByWorkspace,
|
||||
}) => {
|
||||
const dashboard = useDashboard();
|
||||
|
||||
@@ -211,6 +213,17 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
Task
|
||||
</Badge>
|
||||
)}
|
||||
{chatsByWorkspace?.[workspace.id] && (
|
||||
<Badge size="xs" variant="info" hover asChild>
|
||||
<Link
|
||||
to={`/agents/${chatsByWorkspace[workspace.id]}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`View agent chat for ${workspace.name}`}
|
||||
>
|
||||
Agent
|
||||
</Link>
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subtitle={
|
||||
|
||||
Reference in New Issue
Block a user