diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d43886d80a..5733d1566a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e6cd64b7a1..af2e95dc05 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 984fdc6b09..f368ab5b02 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 7dce695afc..8f4df7ef56 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -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() diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index e71c48de13..d44c326666 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -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: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: for title filtering." +// @Param q query string false "Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, source:, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: 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, diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index a41b592e9f..ed765afafa 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -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. diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 4b808f7df9..4c6e33bd41 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -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 != "" { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5081eb8cd2..a04d1e9d03 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -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", diff --git a/codersdk/chats.go b/codersdk/chats.go index 7c860cf424..6d5e559cc9 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -106,30 +106,32 @@ const ( // Chat represents a chat session with an AI agent. type Chat struct { - ID uuid.UUID `json:"id" format:"uuid"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - OwnerUsername string `json:"owner_username,omitempty"` - OwnerName string `json:"owner_name,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` - BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` - AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` - ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` - RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` - LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` - Title string `json:"title"` - Status ChatStatus `json:"status"` - PlanMode ChatPlanMode `json:"plan_mode,omitempty"` - LastError *ChatError `json:"last_error,omitempty"` - LastTurnSummary *string `json:"last_turn_summary"` - DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - Archived bool `json:"archived"` - PinOrder int32 `json:"pin_order"` - MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"` - Labels map[string]string `json:"labels"` - Files []ChatFileMetadata `json:"files,omitempty"` + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + OwnerUsername string `json:"owner_username,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` + AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` + ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` + RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` + LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` + Title string `json:"title"` + Status ChatStatus `json:"status"` + PlanMode ChatPlanMode `json:"plan_mode,omitempty"` + LastError *ChatError `json:"last_error,omitempty"` + LastTurnSummary *string `json:"last_turn_summary"` + DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + Archived bool `json:"archived"` + // Shared is true when this chat's root chat has explicit user or group ACL entries. + Shared bool `json:"shared"` + PinOrder int32 `json:"pin_order"` + MCPServerIDs []uuid.UUID `json:"mcp_server_ids" format:"uuid"` + Labels map[string]string `json:"labels"` + Files []ChatFileMetadata `json:"files,omitempty"` // HasUnread is true when assistant messages exist beyond // the owner's read cursor, which updates on stream // connect and disconnect. @@ -2037,9 +2039,25 @@ type UpdateChatACL struct { GroupRoles map[string]ChatRole `json:"group_roles,omitempty"` } +// ChatListSource controls which chats ListChats returns by ownership. +type ChatListSource string + +const ( + // ChatListSourceCreatedByMe returns chats owned by the caller. + ChatListSourceCreatedByMe ChatListSource = "created_by_me" + // ChatListSourceSharedWithMe returns chats shared with the caller. + ChatListSourceSharedWithMe ChatListSource = "shared_with_me" + // ChatListSourceAll returns both owned and shared chats. + ChatListSourceAll ChatListSource = "all" +) + // ListChatsOptions are optional parameters for ListChats. type ListChatsOptions struct { - Query string + // Query supports raw chat search terms. If Query includes a source: term, + // Source must be empty. + Query string + // Source adds a source: term to Query. + Source ChatListSource Labels map[string]string Pagination } @@ -2049,10 +2067,17 @@ func (c *ExperimentalClient) ListChats(ctx context.Context, opts *ListChatsOptio var reqOpts []RequestOption if opts != nil { reqOpts = append(reqOpts, opts.Pagination.asRequestOption()) - if opts.Query != "" { + query := opts.Query + if opts.Source != "" { + if query != "" { + query += " " + } + query += "source:" + string(opts.Source) + } + if query != "" { reqOpts = append(reqOpts, func(r *http.Request) { q := r.URL.Query() - q.Set("q", opts.Query) + q.Set("q", query) r.URL.RawQuery = q.Encode() }) } diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index e11363788f..e9a75bef3a 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -17,10 +17,10 @@ Experimental: this endpoint is subject to change. ### Parameters -| Name | In | Type | Required | Description | -|---------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: for title filtering. | -| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | +| Name | In | Type | Required | Description | +|---------|-------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query. Supports title: (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status: as repeated or comma-separated values, source:, diff_url: (quote values containing colons), pr: (exact PR number match), repo: (case-insensitive substring match against git remote origin or URL), pr_title: (case-insensitive PR title substring). Bare terms are not supported; use title: for title filtering. | +| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | ### Example responses @@ -159,6 +159,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -284,6 +285,7 @@ Status Code **200** | `» pin_order` | integer | false | | | | `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | | `» root_chat_id` | string(uuid) | false | | | +| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | | `» title` | string | false | | | | `» updated_at` | string(date-time) | false | | | @@ -503,6 +505,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -636,6 +639,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -920,6 +924,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -1107,6 +1112,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -1240,6 +1246,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -1508,6 +1515,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -1641,6 +1649,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -2796,6 +2805,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -2929,6 +2939,7 @@ Experimental: this endpoint is subject to change. "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 642d36fc75..deb1aab657 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2319,6 +2319,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -2452,6 +2453,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", @@ -2491,6 +2493,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `pin_order` | integer | false | | | | `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | | `root_chat_id` | string | false | | | +| `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | | `title` | string | false | | | | `updated_at` | string | false | | | @@ -4130,6 +4133,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "pin_order": 0, "plan_mode": "plan", "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, "status": "waiting", "title": "string", "updated_at": "2019-08-24T14:15:22Z", diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 8b17d17e06..92e00f2201 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -121,6 +121,7 @@ const makeChat = ( created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", @@ -1542,12 +1543,13 @@ describe("infiniteChats", () => { }); }); - it("builds q from archived, prStatuses, and chatStatus", async () => { + it("builds q from archived, prStatuses, chatStatus, and source", async () => { vi.mocked(API.experimental.getChats).mockResolvedValue([]); const { queryFn } = infiniteChats({ archived: true, prStatuses: ["draft", "open", "merged"], chatStatus: "unread", + source: "all", }); await queryFn({ pageParam: 0 }); @@ -1555,7 +1557,7 @@ describe("infiniteChats", () => { expect(API.experimental.getChats).toHaveBeenCalledWith({ limit: PAGE_LIMIT, offset: 0, - q: "archived:true pr_status:draft,open,merged has_unread:true", + q: "archived:true pr_status:draft,open,merged has_unread:true source:all", }); }); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 0da5ec2197..0fef28d451 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -34,13 +34,11 @@ type InfiniteChatsFilters = Readonly<{ archived?: boolean; prStatuses?: readonly ChatListPRStatusFilter[]; chatStatus?: ChatListStatusFilter; + source?: TypesGen.ChatListSource; }>; -export const infiniteChatsKey = (filters?: { - archived?: boolean; - prStatuses?: readonly ChatListPRStatusFilter[]; - chatStatus?: ChatListStatusFilter; -}) => [...chatsKey, filters] as const; +export const infiniteChatsKey = (filters?: InfiniteChatsFilters) => + [...chatsKey, filters] as const; export const CHAT_LIST_PR_STATUS_ORDER = [ "draft", @@ -561,6 +559,9 @@ const getInfiniteChatsQueryString = ( if (filters?.chatStatus) { qParts.push(`has_unread:${filters.chatStatus === "unread"}`); } + if (filters?.source) { + qParts.push(`source:${filters.source}`); + } return qParts.length > 0 ? qParts.join(" ") : undefined; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b19bfe4770..4af14815d6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1546,6 +1546,10 @@ export interface Chat { readonly created_at: string; readonly updated_at: string; readonly archived: boolean; + /** + * Shared is true when this chat's root chat has explicit user or group ACL entries. + */ + readonly shared: boolean; readonly pin_order: number; readonly mcp_server_ids: readonly string[]; readonly labels: Record; @@ -2146,6 +2150,15 @@ export const ChatInputPartTypes: ChatInputPartType[] = [ "text", ]; +// From codersdk/chats.go +export type ChatListSource = "all" | "created_by_me" | "shared_with_me"; + +export const ChatListSources: ChatListSource[] = [ + "all", + "created_by_me", + "shared_with_me", +]; + // From codersdk/chats.go /** * ChatMessage represents a single message in a chat. @@ -5111,7 +5124,15 @@ export interface LinkConfig { * ListChatsOptions are optional parameters for ListChats. */ export interface ListChatsOptions extends Pagination { + /** + * Query supports raw chat search terms. If Query includes a source: term, + * Source must be empty. + */ readonly Query: string; + /** + * Source adds a source: term to Query. + */ + readonly Source: ChatListSource; readonly Labels: Record; } diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 0945ca1fc9..a61728c16b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -140,6 +140,7 @@ const baseChatFields = { created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 358355adbe..1b4d125a00 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -871,6 +871,7 @@ const AgentChatPage: FC = () => { const chatRecord = chatQuery.data; const isArchived = chatRecord?.archived ?? false; + const isSharedChat = chatRecord?.shared ?? false; const isViewerNotOwner = chatRecord !== undefined && currentUser.id !== chatRecord.owner_id; const isRootChat = @@ -1611,6 +1612,7 @@ const AgentChatPage: FC = () => { parentChat={parentChat} persistedError={persistedError} isArchived={isArchived} + isSharedChat={isSharedChat} chatOwner={chatOwner} canShareChat={canShareChat} workspace={workspace} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 58bf4ec44d..0d8be1b1b2 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -66,6 +66,7 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", @@ -144,6 +145,7 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { persistedError: undefined as ChatDetailError | undefined, parentChat: undefined as TypesGen.Chat | undefined, isArchived: false, + isSharedChat: false, chatOwner: undefined as ComponentProps< typeof AgentChatPageView >["chatOwner"], diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index c6cdd3c381..f7e2727561 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -98,6 +98,7 @@ interface AgentChatPageViewProps { parentChat: TypesGen.Chat | undefined; persistedError: ChatDetailError | undefined; isArchived: boolean; + isSharedChat: boolean; chatOwner: ChatOwnerInfo | undefined; canShareChat: boolean; workspaceAgent?: TypesGen.WorkspaceAgent; @@ -203,6 +204,7 @@ export const AgentChatPageView: FC = ({ parentChat, persistedError, isArchived, + isSharedChat, chatOwner, canShareChat, workspaceAgent, @@ -480,6 +482,7 @@ export const AgentChatPageView: FC = ({ hasWorkspace={Boolean(workspace)} isArchived={isArchived} diffStatusData={diffStatusData} + isSharedChat={isSharedChat} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} renderChatSharingContent={ diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 18415a820b..4e9076a7ca 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -1,6 +1,6 @@ import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { @@ -36,6 +36,7 @@ const lastModelConfigIDStorageKey = "agents.last-model-config-id"; const AgentCreatePage: FC = () => { const queryClient = useQueryClient(); + const location = useLocation(); const navigate = useNavigate(); const { permissions } = useAuthenticated(); @@ -129,7 +130,10 @@ const AgentCreatePage: FC = () => { if (model) { localStorage.setItem(lastModelConfigIDStorageKey, model); } - navigate(buildAgentChatPath({ chatId: createdChat.id })); + navigate({ + pathname: buildAgentChatPath({ chatId: createdChat.id }), + search: location.search, + }); }; const rootPersonalModelOverride = personalModelOverridesQuery.data?.enabled diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 6faaa1507e..b68011bf8a 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -56,7 +56,10 @@ import { AgentsPageView } from "./AgentsPageView"; import { emptyInputStorageKey } from "./components/AgentCreateForm"; import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings"; import { useAgentsPWA } from "./hooks/useAgentsPWA"; -import { getAgentSidebarFilters } from "./utils/agentSidebarFilters"; +import { + AGENT_SOURCE_ORDER, + getAgentSidebarFilters, +} from "./utils/agentSidebarFilters"; import { archiveChatAndDeleteWorkspace, resolveArchiveAndDeleteAction, @@ -149,11 +152,16 @@ const AgentsPage: FC = () => { sidebarFilters.chatStatuses.length === 1 ? sidebarFilters.chatStatuses[0] : undefined; + const sourceFilter = + sidebarFilters.sources.length === AGENT_SOURCE_ORDER.length + ? "all" + : sidebarFilters.sources[0]; const chatsQuery = useInfiniteQuery( infiniteChats({ archived: archivedFilter, prStatuses: sidebarFilters.prStatuses, chatStatus: chatStatusFilter, + source: sourceFilter, }), ); // Model queries are kept here for the sidebar, which displays diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 6129188949..20126da9d4 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -57,6 +57,7 @@ const defaultSidebarFilters: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], }; const defaultModelOptions: ModelSelectorOption[] = [ @@ -162,6 +163,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index ce72f306e1..e85237fe30 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -216,6 +216,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({ created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index ad60d40278..02c3826c14 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -46,6 +46,17 @@ export const RegeneratingTitle: Story = { }, }; +export const SharedChat: Story = { + args: { + isSharedChat: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByLabelText("Shared chat")).toBeInTheDocument(); + expect(canvas.queryByText("Shared")).not.toBeInTheDocument(); + }, +}; + export const WithPanelOpen: Story = { args: { panel: { @@ -71,6 +82,7 @@ export const WithParentChat: Story = { created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 67548c52be..ca6b1155fd 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -9,6 +9,7 @@ import { PanelRightOpenIcon, Share2Icon, Trash2Icon, + UsersIcon, WandSparklesIcon, } from "lucide-react"; import { type FC, Fragment, type ReactNode, useState } from "react"; @@ -54,6 +55,7 @@ type ChatTopBarProps = { isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; diffStatusData?: ChatDiffStatus; + isSharedChat?: boolean; renderChatSharingContent?: (open: boolean) => ReactNode; }; @@ -105,6 +107,7 @@ export const ChatTopBar: FC = ({ isSidebarCollapsed, onToggleSidebarCollapsed, diffStatusData, + isSharedChat, renderChatSharingContent, }) => { const { isEmbedded } = useEmbedContext(); @@ -186,6 +189,12 @@ export const ChatTopBar: FC = ({ > {chatTitle} + {isSharedChat && ( + + )} {isRegeneratingTitle && ( = {}): Chat => ({ id: "chat-default", organization_id: "test-org-id", - owner_id: "owner-1", + owner_id: MockUserOwner.id, + owner_username: MockUserOwner.username, + owner_name: MockUserOwner.name, title: "Agent", status: "completed", last_model_config_id: defaultModelConfigs[0].id, @@ -70,6 +72,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", @@ -181,6 +184,56 @@ export const ChatWithTurnSummary: Story = { * holds the previous turn's text. The sidebar replaces it with a live * "{model} streaming…" label so the status does not look stuck. */ +export const SharedChat: Story = { + args: { + chats: [ + buildChat({ + id: "shared-chat", + title: "Shared chat", + owner_id: "sharing-user", + owner_name: "Sharing User", + owner_username: "sharing-user", + shared: true, + last_turn_summary: "Original chat summary", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText("Shared chat")).toBeInTheDocument(); + await expect(canvas.getByText("Original chat summary")).toBeInTheDocument(); + expect( + canvas.queryByText("Shared by Sharing User"), + ).not.toBeInTheDocument(); + }, +}; + +export const SharedUnreadChat: Story = { + args: { + chats: [ + buildChat({ + id: "shared-unread-chat", + title: "Shared unread chat", + owner_id: "sharing-user", + owner_name: "Sharing User", + owner_username: "sharing-user", + shared: true, + has_unread: true, + last_turn_summary: "Original unread chat summary", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByLabelText("Shared chat")).toBeInTheDocument(); + await expect( + canvas.getByTestId("unread-indicator-shared-unread-chat"), + ).toBeInTheDocument(); + }, +}; + export const ChatStreamingOverridesTurnSummary: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx index 3f6aaa0c6e..e341d82e74 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -57,13 +57,16 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const buildChat = (overrides: Partial = {}): Chat => ({ id: "chat-default", organization_id: "test-org-id", - owner_id: "owner-1", + owner_id: MockUserOwner.id, + owner_username: MockUserOwner.username, + owner_name: MockUserOwner.name, title: "Agent", status: "completed", last_model_config_id: "model-1", created_at: oneWeekAgo, updated_at: oneWeekAgo, archived: false, + shared: false, pin_order: 0, has_unread: false, client_type: "ui", @@ -110,6 +113,7 @@ const defaultSidebarFilters: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], }; const defaultProps: React.ComponentProps = { @@ -171,6 +175,7 @@ describe("ChatsSidebar filters", () => { groupBy: "chat_status", prStatuses: ["draft"], chatStatuses: ["unread"], + sources: ["shared_with_me"], }; render( @@ -197,6 +202,53 @@ describe("ChatsSidebar filters", () => { ...sidebarFilters, prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], + }); + }); + + it("applies source filters", async () => { + const user = userEvent.setup(); + const onSidebarFiltersChange = vi.fn(); + + const { rerender } = render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Filter agents" })); + await user.click(screen.getByRole("checkbox", { name: "Shared with me" })); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(onSidebarFiltersChange).toHaveBeenLastCalledWith({ + ...defaultSidebarFilters, + sources: ["created_by_me", "shared_with_me"], + }); + + rerender( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Filter agents" })); + await user.click(screen.getByRole("checkbox", { name: "Created by me" })); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(onSidebarFiltersChange).toHaveBeenLastCalledWith({ + ...defaultSidebarFilters, + sources: ["shared_with_me"], }); }); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx index 749dac7937..8bdadc5a44 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -35,6 +35,7 @@ import { getOSKey } from "#/utils/platform"; import { AGENT_CHAT_STATUS_ORDER, type AgentSidebarFilters, + DEFAULT_AGENT_SIDEBAR_FILTERS, } from "../../../utils/agentSidebarFilters"; import { getTimeGroup, TIME_GROUPS } from "../../../utils/timeGroups"; import type { ModelSelectorOption } from "../../ChatElements"; @@ -157,7 +158,12 @@ export const ChatsPanel: FC = ({ .filter((chat): chat is Chat => chat !== undefined && chat.pin_order === 0); const hasAppliedResultFilters = sidebarFilters.prStatuses.length > 0 || - sidebarFilters.chatStatuses.length !== AGENT_CHAT_STATUS_ORDER.length; + sidebarFilters.chatStatuses.length !== AGENT_CHAT_STATUS_ORDER.length || + sidebarFilters.sources.length !== + DEFAULT_AGENT_SIDEBAR_FILTERS.sources.length || + sidebarFilters.sources.some( + (source) => !DEFAULT_AGENT_SIDEBAR_FILTERS.sources.includes(source), + ); const disablePinnedReordering = hasAppliedResultFilters; // Local override for pinned order during drag. Applied @@ -333,6 +339,7 @@ export const ChatsPanel: FC = ({ ...sidebarFilters, prStatuses: [], chatStatuses: AGENT_CHAT_STATUS_ORDER, + sources: DEFAULT_AGENT_SIDEBAR_FILTERS.sources, }); }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx index 24ffe62b86..5e430d222e 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx @@ -32,6 +32,7 @@ const mockChat: Chat = { created_at: "2026-05-20T05:00:00.000Z", updated_at: "2026-05-20T07:30:00.000Z", archived: false, + shared: false, pin_order: 0, has_unread: true, client_type: "ui", diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx index 43826d05a0..29688e1150 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx @@ -62,6 +62,7 @@ export const AppliesStagedFilters: Story = { groupBy: "chat_status", prStatuses: ["draft"], chatStatuses: ["unread"], + sources: ["created_by_me"], }); }, }; @@ -73,6 +74,7 @@ export const KeepsOneChatStatusSelected: Story = { groupBy: "date", prStatuses: [], chatStatuses: ["unread"], + sources: ["created_by_me"], } satisfies AgentSidebarFilters, onFiltersChange: fn(), }, @@ -91,6 +93,44 @@ export const KeepsOneChatStatusSelected: Story = { groupBy: "date", prStatuses: [], chatStatuses: ["unread"], + sources: ["created_by_me"], + }); + }, +}; + +export const KeepsOneSourceSelected: Story = { + args: { + filters: { + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread", "read"], + sources: ["shared_with_me"], + } satisfies AgentSidebarFilters, + onFiltersChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const dialog = await openFilterDialog(canvasElement); + + await userEvent.click( + dialog.getByRole("checkbox", { name: "Shared with me" }), + ); + + expect( + dialog.getByRole("checkbox", { name: "Created by me" }), + ).not.toBeChecked(); + expect( + dialog.getByRole("checkbox", { name: "Shared with me" }), + ).toBeChecked(); + + await userEvent.click(dialog.getByRole("button", { name: "Apply" })); + + await expect(args.onFiltersChange).toHaveBeenCalledWith({ + archiveStatus: "active", + groupBy: "date", + prStatuses: [], + chatStatuses: ["unread", "read"], + sources: ["shared_with_me"], }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx index 9c017a0e74..753b25e6ec 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx @@ -21,11 +21,13 @@ import { AGENT_ARCHIVE_STATUS_ORDER, AGENT_CHAT_STATUS_ORDER, AGENT_PR_STATUS_ORDER, + AGENT_SOURCE_ORDER, type AgentArchiveStatusFilter, type AgentChatStatusFilter, type AgentPRStatusFilter, type AgentSidebarFilters, type AgentSidebarGroupBy, + type AgentSourceFilter, DEFAULT_AGENT_SIDEBAR_FILTERS, } from "../../../utils/agentSidebarFilters"; @@ -54,6 +56,11 @@ const ARCHIVE_STATUS_LABELS: Record = { archived: "Archived", }; +const SOURCE_LABELS: Record = { + created_by_me: "Created by me", + shared_with_me: "Shared with me", +}; + const CHAT_STATUS_OPTIONS: readonly Readonly<{ value: AgentChatStatusFilter; label: string; @@ -70,6 +77,14 @@ const ARCHIVE_OPTIONS: readonly Readonly<{ label: ARCHIVE_STATUS_LABELS[status], })); +const SOURCE_OPTIONS: readonly Readonly<{ + value: AgentSourceFilter; + label: string; +}>[] = AGENT_SOURCE_ORDER.map((source) => ({ + value: source, + label: SOURCE_LABELS[source], +})); + const SectionHeading: FC> = ({ className, ...props }) => (

{ !haveSameSelections( filters.chatStatuses, DEFAULT_AGENT_SIDEBAR_FILTERS.chatStatuses, - ) + ) || + !haveSameSelections(filters.sources, DEFAULT_AGENT_SIDEBAR_FILTERS.sources) ); }; @@ -154,12 +170,16 @@ export const FilterPopover: FC = ({ const visibleChatStatusOptions = CHAT_STATUS_OPTIONS.filter((option) => matchesOption("Chat status", option.label), ); + const visibleSourceOptions = SOURCE_OPTIONS.filter((option) => + matchesOption("Source", option.label), + ); const visibleArchiveOptions = ARCHIVE_OPTIONS.filter((option) => matchesOption("Archive status", option.label), ); const showFilterOptions = visiblePRStatuses.length > 0 || visibleChatStatusOptions.length > 0 || + visibleSourceOptions.length > 0 || visibleArchiveOptions.length > 0; const setGroupBy = (value: string) => { @@ -208,6 +228,20 @@ export const FilterPopover: FC = ({ setStagedFilters({ ...stagedFilters, archiveStatus: value }); }; + const setSource = (source: AgentSourceFilter, checked: boolean) => { + const nextSources = checked + ? AGENT_SOURCE_ORDER.filter( + (value) => value === source || stagedFilters.sources.includes(value), + ) + : stagedFilters.sources.filter((value) => value !== source); + + if (nextSources.length === 0) { + return; + } + + setStagedFilters({ ...stagedFilters, sources: nextSources }); + }; + const applyFilters = () => { onFiltersChange(stagedFilters); setOpen(false); @@ -352,6 +386,37 @@ export const FilterPopover: FC = ({ )} + {visibleSourceOptions.length > 0 && ( +
+ Source +
+ {visibleSourceOptions.map((option) => { + const optionId = `${id}-source-${option.value}`; + return ( + + + setSource(option.value, nextChecked === true) + } + className="m-0 my-[3px]" + /> + + + ); + })} +
+
+ )} + {visibleArchiveOptions.length > 0 && (
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index aa0a693b40..169eb8d0e5 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -8,6 +8,7 @@ import { PinOffIcon, SquarePenIcon, Trash2Icon, + UsersIcon, } from "lucide-react"; import { type FC, useEffect, useState } from "react"; import { NavLink, useLocation } from "react-router"; @@ -123,6 +124,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { return () => clearTimeout(timeoutId); }, [isStaleTurnSummary]); const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; + const isSharedChat = chat.shared; const subtitle = errorReason || streamingSubtitle || displayedTurnSummary || modelName; const { @@ -318,14 +320,14 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => {
)} -
- {isArchivingThisChat ? ( - - ) : ( - <> +
+
+ {isArchivingThisChat ? ( + + ) : ( {chat.has_unread && !isActiveChat ? ( = ({ chat, isChildNode }) => { )} - - - - - - {renderMenuItems({ - Item: DropdownMenuItem, - Separator: DropdownMenuSeparator, - })} - - - + )} +
+ {isSharedChat && ( + )} + + + + + + {renderMenuItems({ + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, + })} + +
diff --git a/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts b/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts index fb53ecad30..847c7cef7e 100644 --- a/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts +++ b/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts @@ -11,6 +11,7 @@ const defaultFilters: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], }; const archivedFilters: AgentSidebarFilters = { @@ -18,6 +19,7 @@ const archivedFilters: AgentSidebarFilters = { groupBy: "chat_status", prStatuses: ["draft", "merged"], chatStatuses: ["unread"], + sources: ["created_by_me", "shared_with_me"], }; const renderFilters = (route = "/agents") => { @@ -44,14 +46,15 @@ describe(getAgentSidebarFilters.name, () => { expected: defaultFilters, }, { - name: "parses archived, group_by, pr_status, and chat_status", + name: "parses archived, group_by, pr_status, chat_status, and source", route: - "/agents?archived=archived&group_by=chat_status&pr_status=open,draft,closed&chat_status=unread", + "/agents?archived=archived&group_by=chat_status&pr_status=open,draft,closed&chat_status=unread&source=shared_with_me", expected: { archiveStatus: "archived", groupBy: "chat_status", prStatuses: ["draft", "open", "closed"], chatStatuses: ["unread"], + sources: ["shared_with_me"], }, }, { @@ -82,6 +85,7 @@ describe(getAgentSidebarFilters.name, () => { expect(search.get("group_by")).toEqual(null); expect(search.get("pr_status")).toEqual(null); expect(search.get("chat_status")).toEqual(null); + expect(search.get("source")).toEqual(null); }); it("writes archived status filter", async () => { @@ -118,5 +122,6 @@ describe(getAgentSidebarFilters.name, () => { expect(search.get("group_by")).toBe("chat_status"); expect(search.get("pr_status")).toBe("draft,merged"); expect(search.get("chat_status")).toBe("unread"); + expect(search.get("source")).toBe("created_by_me,shared_with_me"); }); }); diff --git a/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts b/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts index d898dcac1f..86bbd5de4c 100644 --- a/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts +++ b/site/src/pages/AgentsPage/utils/agentSidebarFilters.ts @@ -12,18 +12,21 @@ export const AGENT_CHAT_STATUS_ORDER = [ "read", ] as const satisfies readonly ChatListStatusFilter[]; export const AGENT_PR_STATUS_ORDER = CHAT_LIST_PR_STATUS_ORDER; +export const AGENT_SOURCE_ORDER = ["created_by_me", "shared_with_me"] as const; export type AgentArchiveStatusFilter = (typeof AGENT_ARCHIVE_STATUS_ORDER)[number]; export type AgentChatStatusFilter = ChatListStatusFilter; export type AgentPRStatusFilter = ChatListPRStatusFilter; export type AgentSidebarGroupBy = "date" | "chat_status"; +export type AgentSourceFilter = (typeof AGENT_SOURCE_ORDER)[number]; export type AgentSidebarFilters = Readonly<{ archiveStatus: AgentArchiveStatusFilter; groupBy: AgentSidebarGroupBy; prStatuses: readonly AgentPRStatusFilter[]; chatStatuses: readonly AgentChatStatusFilter[]; + sources: readonly AgentSourceFilter[]; }>; type AgentSidebarFiltersResult = readonly [ @@ -36,22 +39,7 @@ export const DEFAULT_AGENT_SIDEBAR_FILTERS: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: AGENT_CHAT_STATUS_ORDER, -}; - -const agentChatStatusSet = new Set( - AGENT_CHAT_STATUS_ORDER, -); - -const canonicalizeChatStatuses = ( - values: Iterable, -): readonly AgentChatStatusFilter[] => { - const selected = new Set(); - for (const value of values) { - if (agentChatStatusSet.has(value as AgentChatStatusFilter)) { - selected.add(value as AgentChatStatusFilter); - } - } - return AGENT_CHAT_STATUS_ORDER.filter((status) => selected.has(status)); + sources: ["created_by_me"], }; const clearSidebarFilterParams = (searchParams: URLSearchParams) => { @@ -59,6 +47,7 @@ const clearSidebarFilterParams = (searchParams: URLSearchParams) => { searchParams.delete("group_by"); searchParams.delete("pr_status"); searchParams.delete("chat_status"); + searchParams.delete("source"); }; const writeSidebarFilters = ( @@ -75,14 +64,21 @@ const writeSidebarFilters = ( searchParams.set("group_by", "chat_status"); } - const prStatuses = canonicalizeChatListPRStatuses(filters.prStatuses); - if (prStatuses.length > 0) { - searchParams.set("pr_status", prStatuses.join(",")); + if (filters.prStatuses.length > 0) { + searchParams.set("pr_status", filters.prStatuses.join(",")); } - const chatStatuses = canonicalizeChatStatuses(filters.chatStatuses); - if (chatStatuses.length === 1) { - searchParams.set("chat_status", chatStatuses[0]); + if (filters.chatStatuses.length === 1) { + searchParams.set("chat_status", filters.chatStatuses[0]); + } + + if ( + filters.sources.length !== DEFAULT_AGENT_SIDEBAR_FILTERS.sources.length || + filters.sources.some( + (source) => !DEFAULT_AGENT_SIDEBAR_FILTERS.sources.includes(source), + ) + ) { + searchParams.set("source", filters.sources.join(",")); } }; @@ -93,8 +89,17 @@ export const getAgentSidebarFilters = ( const prStatuses = canonicalizeChatListPRStatuses( (searchParams.get("pr_status") ?? "").split(",").filter(Boolean), ); - const chatStatuses = canonicalizeChatStatuses( - (searchParams.get("chat_status") ?? "").split(",").filter(Boolean), + const rawChatStatuses = (searchParams.get("chat_status") ?? "") + .split(",") + .filter(Boolean); + const chatStatuses = AGENT_CHAT_STATUS_ORDER.filter((status) => + rawChatStatuses.includes(status), + ); + const rawSources = (searchParams.get("source") ?? "") + .split(",") + .filter(Boolean); + const sources = AGENT_SOURCE_ORDER.filter((source) => + rawSources.includes(source), ); const filters: AgentSidebarFilters = { @@ -109,6 +114,8 @@ export const getAgentSidebarFilters = ( chatStatuses.length > 0 ? chatStatuses : DEFAULT_AGENT_SIDEBAR_FILTERS.chatStatuses, + sources: + sources.length > 0 ? sources : DEFAULT_AGENT_SIDEBAR_FILTERS.sources, }; const setFilters = (next: AgentSidebarFilters) => {