feat: show shared chats in agents sidebar (#26056)

This commit is contained in:
Danielle Maywood
2026-06-06 00:12:20 +01:00
committed by GitHub
parent 4124d5be1e
commit fa56224eda
33 changed files with 664 additions and 121 deletions
+5 -1
View File
@@ -78,7 +78,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, source:\u003ccreated_by_me\\|shared_with_me\\|all\u003e, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
@@ -16522,6 +16522,10 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"shared": {
"description": "Shared is true when this chat's root chat has explicit user or group ACL entries.",
"type": "boolean"
},
"status": {
"$ref": "#/definitions/codersdk.ChatStatus"
},
+5 -1
View File
@@ -59,7 +59,7 @@
"parameters": [
{
"type": "string",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, source:\u003ccreated_by_me\\|shared_with_me\\|all\u003e, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
@@ -14860,6 +14860,10 @@
"type": "string",
"format": "uuid"
},
"shared": {
"description": "Shared is true when this chat's root chat has explicit user or group ACL entries.",
"type": "boolean"
},
"status": {
"$ref": "#/definitions/codersdk.ChatStatus"
},
+1
View File
@@ -1763,6 +1763,7 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
Archived: c.Archived,
Shared: len(c.UserACL) > 0 || len(c.GroupACL) > 0,
PinOrder: c.PinOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
+53
View File
@@ -947,6 +947,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
CreatedAt: now,
UpdatedAt: now,
Archived: true,
UserACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
PinOrder: 1,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
MCPServerIDs: []uuid.UUID{uuid.New()},
@@ -1005,6 +1006,58 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
}
}
func TestChat_Shared(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
userACL database.ChatACL
groupACL database.ChatACL
expected bool
}{
{
name: "not shared",
},
{
name: "user ACL",
userACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
{
name: "group ACL",
groupACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
{
name: "user and group ACLs",
userACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
groupACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: tc.name,
Status: database.ChatStatusWaiting,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
UserACL: tc.userACL,
GroupACL: tc.groupACL,
}
got := db2sdk.Chat(chat, nil, nil)
require.Equal(t, tc.expected, got.Shared)
})
}
}
func TestChat_FileMetadataConversion(t *testing.T) {
t.Parallel()
+3 -2
View File
@@ -339,7 +339,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering."
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, source:<created_by_me\|shared_with_me\|all>, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering."
// @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)."
// @Success 200 {array} codersdk.Chat
// @Router /api/experimental/chats [get]
@@ -391,7 +391,8 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
}
params := database.GetChatsParams{
OwnedOnly: true,
OwnedOnly: searchParams.OwnedOnly,
SharedOnly: searchParams.SharedOnly,
ViewerID: apiKey.UserID,
Archived: searchParams.Archived,
AfterID: paginationParams.AfterID,
+56 -4
View File
@@ -368,7 +368,8 @@ func TestSharedReaderStreamChat(t *testing.T) {
require.False(t, persisted.LastReadMessageID.Valid)
}
func TestListChatsExcludesSharedChats(t *testing.T) {
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestListChatsSharedScope(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -389,6 +390,12 @@ func TestListChatsExcludesSharedChats(t *testing.T) {
LastModelConfigID: modelConfig.ID,
Title: "viewer owned",
})
unsharedChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "not shared with viewer",
})
err := client.UpdateChatACL(ctx, sharedChat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
@@ -397,9 +404,54 @@ func TestListChatsExcludesSharedChats(t *testing.T) {
})
require.NoError(t, err)
ownedOnly, err := viewerClientExp.ListChats(ctx, nil)
require.NoError(t, err)
require.Equal(t, map[uuid.UUID]struct{}{viewerChat.ID: {}}, chatIDSet(ownedOnly))
for _, tc := range []struct {
name string
opts *codersdk.ListChatsOptions
expected map[uuid.UUID]struct{}
shared map[uuid.UUID]bool
}{
{
name: "default owned only",
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false},
},
{
name: "created by me only",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceCreatedByMe,
},
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false},
},
{
name: "shared with me only",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceSharedWithMe,
},
expected: map[uuid.UUID]struct{}{sharedChat.ID: {}},
shared: map[uuid.UUID]bool{sharedChat.ID: true},
},
{
name: "all",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceAll,
},
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}, sharedChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false, sharedChat.ID: true},
},
} {
t.Run(tc.name, func(t *testing.T) {
chats, err := viewerClientExp.ListChats(ctx, tc.opts)
require.NoError(t, err)
require.Equal(t, tc.expected, chatIDSet(chats))
require.NotContains(t, chatIDSet(chats), unsharedChat.ID)
for _, chat := range chats {
expectedShared, ok := tc.shared[chat.ID]
require.True(t, ok, "missing shared assertion for chat %s", chat.ID)
require.Equal(t, expectedShared, chat.Shared)
}
})
}
}
//nolint:paralleltest // This test verifies a process-wide RBAC kill switch.
+25 -2
View File
@@ -559,10 +559,15 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU
// - pr: positive integer (exact PR number match)
// - repo: string (case-insensitive substring match against git remote origin or URL)
// - pr_title: string (case-insensitive PR title substring match)
// - source: one of created_by_me, shared_with_me, or all (controls
// ownership scope; created_by_me returns only chats the caller owns,
// shared_with_me returns only chats shared with the caller, all returns
// both)
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
filter := database.GetChatsParams{
// Default to hiding archived chats.
Archived: sql.NullBool{Bool: false, Valid: true},
// Default to hiding archived chats and chats not owned by the caller.
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
}
if query == "" {
@@ -606,6 +611,24 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
filter.TitleQuery = parser.String(values, "", "title")
filter.PrTitleQuery = parser.String(values, "", "pr_title")
filter.RepoQuery = parser.String(values, "", "repo")
if source := parser.String(values, "", "source"); source != "" {
switch source {
case "created_by_me":
filter.OwnedOnly = true
filter.SharedOnly = false
case "shared_with_me":
filter.OwnedOnly = false
filter.SharedOnly = true
case "all":
filter.OwnedOnly = false
filter.SharedOnly = false
default:
parser.Errors = append(parser.Errors, codersdk.ValidationError{
Field: "source",
Detail: fmt.Sprintf("%q is not a valid value", source),
})
}
}
// pr: requires a positive integer.
if prStr := parser.String(values, "", "pr"); prStr != "" {
+69 -10
View File
@@ -1229,14 +1229,16 @@ func TestSearchChats(t *testing.T) {
Name: "Empty",
Query: "",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
Name: "ArchivedTrue",
Query: "archived:true",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
},
},
{
@@ -1247,14 +1249,16 @@ func TestSearchChats(t *testing.T) {
Name: "ArchivedTrueUpperCase",
Query: "archived:TRUE",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
},
},
{
Name: "ArchivedFalse",
Query: "archived:false",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
@@ -1262,6 +1266,7 @@ func TestSearchChats(t *testing.T) {
Query: "has_unread:true",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
HasUnread: sql.NullBool{Bool: true, Valid: true},
},
},
@@ -1270,6 +1275,7 @@ func TestSearchChats(t *testing.T) {
Query: "has_unread:false",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
HasUnread: sql.NullBool{Bool: false, Valid: true},
},
},
@@ -1283,6 +1289,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft"},
},
},
@@ -1291,6 +1298,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"open"},
},
},
@@ -1299,6 +1307,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"merged"},
},
},
@@ -1307,6 +1316,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"closed"},
},
},
@@ -1315,6 +1325,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft", "merged"},
},
},
@@ -1323,6 +1334,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft,closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft", "closed"},
},
},
@@ -1331,6 +1343,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:DRAFT",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft"},
},
},
@@ -1344,9 +1357,43 @@ func TestSearchChats(t *testing.T) {
Query: "archived:true pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"open"},
},
},
{
Name: "SourceCreatedByMe",
Query: "source:created_by_me",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
Name: "SourceSharedWithMe",
Query: "source:shared_with_me",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
SharedOnly: true,
},
},
{
Name: "SourceAll",
Query: "source:all",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
},
},
{
Name: "SourceInvalid",
Query: "source:mine",
ExpectedErrorContains: "source",
},
{
Name: "SourceRepeated",
Query: "source:created_by_me source:shared_with_me",
ExpectedErrorContains: "source",
},
{
Name: "ExtraParam",
Query: "archived:true invalid:param",
@@ -1371,7 +1418,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURL",
Query: `diff_url:"https://github.com/coder/coder/pull/123"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/coder/coder/pull/123",
Valid: true,
@@ -1382,7 +1430,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLPreservesValueCase",
Query: `diff_url:"https://github.com/Coder/Coder/pull/123"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/Coder/Coder/pull/123",
Valid: true,
@@ -1393,7 +1442,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLKeyCaseInsensitive",
Query: `Diff_URL:"https://github.com/coder/coder/pull/1"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/coder/coder/pull/1",
Valid: true,
@@ -1404,7 +1454,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLWithArchived",
Query: `archived:true diff_url:"https://gitlab.com/foo/bar/-/merge_requests/9"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://gitlab.com/foo/bar/-/merge_requests/9",
Valid: true,
@@ -1431,6 +1482,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:"hello world"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "hello world",
},
},
@@ -1439,6 +1491,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:"my chat" archived:true`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
TitleQuery: "my chat",
},
},
@@ -1447,6 +1500,7 @@ func TestSearchChats(t *testing.T) {
Query: "title:deploy",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "deploy",
},
},
@@ -1455,6 +1509,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:deploy diff_url:"https://github.com/coder/coder/pull/456"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "deploy",
DiffURL: sql.NullString{String: "https://github.com/coder/coder/pull/456", Valid: true},
},
@@ -1463,8 +1518,9 @@ func TestSearchChats(t *testing.T) {
Name: "PrNumber",
Query: "pr:42",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PrNumber: 42,
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrNumber: 42,
},
},
{
@@ -1487,6 +1543,7 @@ func TestSearchChats(t *testing.T) {
Query: "repo:coder/coder",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
RepoQuery: "coder/coder",
},
},
@@ -1495,6 +1552,7 @@ func TestSearchChats(t *testing.T) {
Query: `pr_title:"fix auth bug"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrTitleQuery: "fix auth bug",
},
},
@@ -1503,6 +1561,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr:99 repo:coder/coder pr_title:deploy",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrNumber: 99,
RepoQuery: "coder/coder",
PrTitleQuery: "deploy",