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:
Cian Johnston
2026-03-26 11:30:12 +00:00
committed by GitHub
parent 3fb7c6264f
commit d175e799da
19 changed files with 410 additions and 1 deletions
+1
View File
@@ -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)
+4
View File
@@ -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)
+7
View File
@@ -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)
+15
View File
@@ -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()
+1
View File
@@ -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)
+52
View File
@@ -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
+7
View File
@@ -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)
+82
View File
@@ -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()
+131
View File
@@ -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()
+20
View File
@@ -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)
+9
View File
@@ -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,
+20
View File
@@ -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,
});
},
});
+5
View File
@@ -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={