diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 56aaa2a95db40..f88c081c4e466 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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 606f7ae4ea4c6..abe98d56e0b60 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" }, diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c8ed16c740fe7..f25f0b22003ee 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] @@ -354,7 +354,7 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { } queryStr := r.URL.Query().Get("q") - searchParams, errs := searchquery.Chats(queryStr) + searchParams, sourceFilter, errs := searchquery.Chats(queryStr) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid chat search query.", @@ -390,8 +390,13 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { } } + if sourceFilter == searchquery.ChatSourceFilterDefault { + searchParams.OwnedOnly = true + } + 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 a41b592e9f4b9..13eef8cf7c969 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,44 @@ 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{} + }{ + { + name: "default owned only", + expected: map[uuid.UUID]struct{}{viewerChat.ID: {}}, + }, + { + name: "created by me only", + opts: &codersdk.ListChatsOptions{ + Scope: codersdk.ChatListScopeCreatedByMe, + }, + expected: map[uuid.UUID]struct{}{viewerChat.ID: {}}, + }, + { + name: "shared with me only", + opts: &codersdk.ListChatsOptions{ + Scope: codersdk.ChatListScopeSharedWithMe, + }, + expected: map[uuid.UUID]struct{}{sharedChat.ID: {}}, + }, + { + name: "all", + opts: &codersdk.ListChatsOptions{ + Scope: codersdk.ChatListScopeAll, + }, + expected: map[uuid.UUID]struct{}{viewerChat.ID: {}, sharedChat.ID: {}}, + }, + } { + 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) + }) + } } //nolint:paralleltest // This test verifies a process-wide RBAC kill switch. diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 4b808f7df99b5..283d24be96f3d 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -559,14 +559,25 @@ 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) -func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) { +// - source: created_by_me, shared_with_me, or all +type ChatSourceFilter string + +const ( + ChatSourceFilterDefault ChatSourceFilter = "" + ChatSourceFilterCreatedByMe ChatSourceFilter = "created_by_me" + ChatSourceFilterSharedWithMe ChatSourceFilter = "shared_with_me" + ChatSourceFilterAll ChatSourceFilter = "all" +) + +func Chats(query string) (database.GetChatsParams, ChatSourceFilter, []codersdk.ValidationError) { filter := database.GetChatsParams{ // Default to hiding archived chats. Archived: sql.NullBool{Bool: false, Valid: true}, } + var sourceFilter ChatSourceFilter if query == "" { - return filter, nil + return filter, sourceFilter, nil } // Lowercase the keys so they match regardless of how the caller @@ -577,7 +588,7 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) { return xerrors.Errorf("unsupported search term: %q", term) }) if len(errors) > 0 { - return filter, errors + return filter, sourceFilter, errors } parser := httpapi.NewQueryParamParser() @@ -606,6 +617,21 @@ 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 != "" { + sourceFilter = ChatSourceFilter(source) + switch sourceFilter { + case ChatSourceFilterCreatedByMe: + filter.OwnedOnly = true + case ChatSourceFilterSharedWithMe: + filter.SharedOnly = true + case ChatSourceFilterAll: + 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 != "" { @@ -621,7 +647,7 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) { } parser.ErrorExcessParams(values) - return filter, parser.Errors + return filter, sourceFilter, parser.Errors } // validateDiffURL checks that the value is a syntactically valid HTTP(S) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5081eb8cd2d57..5aee8d58ec83b 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1519,7 +1519,7 @@ func TestSearchChats(t *testing.T) { t.Run(c.Name, func(t *testing.T) { t.Parallel() - values, errs := searchquery.Chats(c.Query) + values, _, errs := searchquery.Chats(c.Query) if c.ExpectedErrorContains != "" { require.True(t, len(errs) > 0, "expect some errors") var s strings.Builder diff --git a/codersdk/chats.go b/codersdk/chats.go index 8770368a3db89..68a1f4bd9a650 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -2036,9 +2036,18 @@ type UpdateChatACL struct { GroupRoles map[string]ChatRole `json:"group_roles,omitempty"` } +type ChatListScope string + +const ( + ChatListScopeCreatedByMe ChatListScope = "created_by_me" + ChatListScopeSharedWithMe ChatListScope = "shared_with_me" + ChatListScopeAll ChatListScope = "all" +) + // ListChatsOptions are optional parameters for ListChats. type ListChatsOptions struct { Query string + Scope ChatListScope Labels map[string]string Pagination } @@ -2048,10 +2057,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.Scope != "" { + if query != "" { + query += " " + } + query += "source:" + string(opts.Scope) + } + 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 e11363788fc1d..2d46d97b919a6 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 diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 8b17d17e06393..bcf85a668386c 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1542,12 +1542,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 +1556,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 0da5ec219761f..8d4cdba22a3c6 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -29,18 +29,17 @@ export const chatACLKey = (chatId: string) => ["chats", chatId, "acl"] as const; export type ChatListPRStatusFilter = "draft" | "open" | "merged" | "closed"; export type ChatListStatusFilter = "read" | "unread"; +type ChatListSourceFilter = "created_by_me" | "shared_with_me" | "all"; type InfiniteChatsFilters = Readonly<{ archived?: boolean; prStatuses?: readonly ChatListPRStatusFilter[]; chatStatus?: ChatListStatusFilter; + source?: ChatListSourceFilter; }>; -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 +560,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 2f3f46e6794d1..331c7686ca3b2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2146,6 +2146,15 @@ export const ChatInputPartTypes: ChatInputPartType[] = [ "text", ]; +// From codersdk/chats.go +export type ChatListScope = "all" | "created_by_me" | "shared_with_me"; + +export const ChatListScopes: ChatListScope[] = [ + "all", + "created_by_me", + "shared_with_me", +]; + // From codersdk/chats.go /** * ChatMessage represents a single message in a chat. @@ -5109,6 +5118,7 @@ export interface LinkConfig { */ export interface ListChatsOptions extends Pagination { readonly Query: string; + readonly Scope: ChatListScope; readonly Labels: Record; } diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 18415a820b4bf..4e9076a7ca7ca 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 6faaa1507e940..e18396b920621 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -149,11 +149,14 @@ const AgentsPage: FC = () => { sidebarFilters.chatStatuses.length === 1 ? sidebarFilters.chatStatuses[0] : undefined; + const sourceFilter = + sidebarFilters.sources.length === 2 ? "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 fcb3de9286881..79313dccb6e40 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -56,6 +56,7 @@ const defaultSidebarFilters: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], }; const defaultModelOptions: ModelSelectorOption[] = [ diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 240cf1bf5bf36..941a61a46c88b 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -61,7 +61,9 @@ 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: defaultModelConfigs[0].id, @@ -181,6 +183,32 @@ 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", + last_turn_summary: "This summary is hidden for shared chats", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText("Shared")).toBeInTheDocument(); + await expect( + canvas.getByText("Shared by Sharing User"), + ).toBeInTheDocument(); + expect( + canvas.queryByText("This summary is hidden for shared chats"), + ).not.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 3f6aaa0c6ed66..cd506efa83ac1 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -57,7 +57,9 @@ 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", @@ -110,6 +112,7 @@ const defaultSidebarFilters: AgentSidebarFilters = { groupBy: "date", prStatuses: [], chatStatuses: ["unread", "read"], + sources: ["created_by_me"], }; const defaultProps: React.ComponentProps = { 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 43826d05a000b..406ddddbc99a7 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,7 @@ export const KeepsOneChatStatusSelected: Story = { groupBy: "date", prStatuses: [], chatStatuses: ["unread"], + sources: ["created_by_me"], }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx index 9c017a0e74a37..753b25e6ec70f 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 aa0a693b400c1..5aa1a5edfdc53 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"; @@ -28,6 +29,7 @@ import { DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; import { Spinner } from "#/components/Spinner/Spinner"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { cn } from "#/utils/cn"; import { shortRelativeTime } from "#/utils/time"; import { asNonEmptyString } from "../../ChatConversation/blockUtils"; @@ -45,6 +47,7 @@ interface ChatTreeNodeProps { export const ChatTreeNode: FC = ({ chat, isChildNode }) => { const location = useLocation(); const locationSearch = normalizeLocationSearch(location.search); + const { user } = useAuthenticated(); const { chatTree, chatById, @@ -123,8 +126,15 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { return () => clearTimeout(timeoutId); }, [isStaleTurnSummary]); const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; + const isSharedChat = chat.owner_id !== user.id; + const ownerName = chat.owner_name || chat.owner_username || "another user"; + const sharedSubtitle = isSharedChat ? `Shared by ${ownerName}` : undefined; const subtitle = - errorReason || streamingSubtitle || displayedTurnSummary || modelName; + errorReason || + streamingSubtitle || + sharedSubtitle || + displayedTurnSummary || + modelName; const { icon: StatusIcon, className: statusClassName, @@ -280,6 +290,12 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { > {chat.title} + {isSharedChat && ( + + + Shared + + )} {chat.has_unread && !isActiveChat && ( (unread) )} diff --git a/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts b/site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts index fb53ecad301b6..847c7cef7ed0c 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 d898dcac1f187..86bbd5de4c2eb 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) => {