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
+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()