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

This commit is contained in:
Danielle Maywood
2026-06-06 00:12:20 +01:00
committed by GitHub
parent 4124d5be1e
commit fa56224eda
33 changed files with 664 additions and 121 deletions
+5 -1
View File
@@ -78,7 +78,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, source:\u003ccreated_by_me\\|shared_with_me\\|all\u003e, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
@@ -16522,6 +16522,10 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"shared": {
"description": "Shared is true when this chat's root chat has explicit user or group ACL entries.",
"type": "boolean"
},
"status": {
"$ref": "#/definitions/codersdk.ChatStatus"
},
+5 -1
View File
@@ -59,7 +59,7 @@
"parameters": [
{
"type": "string",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"description": "Search query. Supports title:\u003csubstring\u003e (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e as repeated or comma-separated values, source:\u003ccreated_by_me\\|shared_with_me\\|all\u003e, diff_url:\u003curl\u003e (quote values containing colons), pr:\u003cnumber\u003e (exact PR number match), repo:\u003cowner/repo\u003e (case-insensitive substring match against git remote origin or URL), pr_title:\u003ctext\u003e (case-insensitive PR title substring). Bare terms are not supported; use title:\u003cvalue\u003e for title filtering.",
"name": "q",
"in": "query"
},
@@ -14860,6 +14860,10 @@
"type": "string",
"format": "uuid"
},
"shared": {
"description": "Shared is true when this chat's root chat has explicit user or group ACL entries.",
"type": "boolean"
},
"status": {
"$ref": "#/definitions/codersdk.ChatStatus"
},
+1
View File
@@ -1763,6 +1763,7 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
Archived: c.Archived,
Shared: len(c.UserACL) > 0 || len(c.GroupACL) > 0,
PinOrder: c.PinOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
+53
View File
@@ -947,6 +947,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
CreatedAt: now,
UpdatedAt: now,
Archived: true,
UserACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
PinOrder: 1,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
MCPServerIDs: []uuid.UUID{uuid.New()},
@@ -1005,6 +1006,58 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
}
}
func TestChat_Shared(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
userACL database.ChatACL
groupACL database.ChatACL
expected bool
}{
{
name: "not shared",
},
{
name: "user ACL",
userACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
{
name: "group ACL",
groupACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
{
name: "user and group ACLs",
userACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
groupACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
LastModelConfigID: uuid.New(),
Title: tc.name,
Status: database.ChatStatusWaiting,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
UserACL: tc.userACL,
GroupACL: tc.groupACL,
}
got := db2sdk.Chat(chat, nil, nil)
require.Equal(t, tc.expected, got.Shared)
})
}
}
func TestChat_FileMetadataConversion(t *testing.T) {
t.Parallel()
+3 -2
View File
@@ -339,7 +339,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering."
// @Param q query string false "Search query. Supports title:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, source:<created_by_me\|shared_with_me\|all>, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering."
// @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)."
// @Success 200 {array} codersdk.Chat
// @Router /api/experimental/chats [get]
@@ -391,7 +391,8 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
}
params := database.GetChatsParams{
OwnedOnly: true,
OwnedOnly: searchParams.OwnedOnly,
SharedOnly: searchParams.SharedOnly,
ViewerID: apiKey.UserID,
Archived: searchParams.Archived,
AfterID: paginationParams.AfterID,
+56 -4
View File
@@ -368,7 +368,8 @@ func TestSharedReaderStreamChat(t *testing.T) {
require.False(t, persisted.LastReadMessageID.Valid)
}
func TestListChatsExcludesSharedChats(t *testing.T) {
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestListChatsSharedScope(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -389,6 +390,12 @@ func TestListChatsExcludesSharedChats(t *testing.T) {
LastModelConfigID: modelConfig.ID,
Title: "viewer owned",
})
unsharedChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "not shared with viewer",
})
err := client.UpdateChatACL(ctx, sharedChat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
@@ -397,9 +404,54 @@ func TestListChatsExcludesSharedChats(t *testing.T) {
})
require.NoError(t, err)
ownedOnly, err := viewerClientExp.ListChats(ctx, nil)
require.NoError(t, err)
require.Equal(t, map[uuid.UUID]struct{}{viewerChat.ID: {}}, chatIDSet(ownedOnly))
for _, tc := range []struct {
name string
opts *codersdk.ListChatsOptions
expected map[uuid.UUID]struct{}
shared map[uuid.UUID]bool
}{
{
name: "default owned only",
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false},
},
{
name: "created by me only",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceCreatedByMe,
},
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false},
},
{
name: "shared with me only",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceSharedWithMe,
},
expected: map[uuid.UUID]struct{}{sharedChat.ID: {}},
shared: map[uuid.UUID]bool{sharedChat.ID: true},
},
{
name: "all",
opts: &codersdk.ListChatsOptions{
Source: codersdk.ChatListSourceAll,
},
expected: map[uuid.UUID]struct{}{viewerChat.ID: {}, sharedChat.ID: {}},
shared: map[uuid.UUID]bool{viewerChat.ID: false, sharedChat.ID: true},
},
} {
t.Run(tc.name, func(t *testing.T) {
chats, err := viewerClientExp.ListChats(ctx, tc.opts)
require.NoError(t, err)
require.Equal(t, tc.expected, chatIDSet(chats))
require.NotContains(t, chatIDSet(chats), unsharedChat.ID)
for _, chat := range chats {
expectedShared, ok := tc.shared[chat.ID]
require.True(t, ok, "missing shared assertion for chat %s", chat.ID)
require.Equal(t, expectedShared, chat.Shared)
}
})
}
}
//nolint:paralleltest // This test verifies a process-wide RBAC kill switch.
+25 -2
View File
@@ -559,10 +559,15 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU
// - pr: positive integer (exact PR number match)
// - repo: string (case-insensitive substring match against git remote origin or URL)
// - pr_title: string (case-insensitive PR title substring match)
// - source: one of created_by_me, shared_with_me, or all (controls
// ownership scope; created_by_me returns only chats the caller owns,
// shared_with_me returns only chats shared with the caller, all returns
// both)
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
filter := database.GetChatsParams{
// Default to hiding archived chats.
Archived: sql.NullBool{Bool: false, Valid: true},
// Default to hiding archived chats and chats not owned by the caller.
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
}
if query == "" {
@@ -606,6 +611,24 @@ func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
filter.TitleQuery = parser.String(values, "", "title")
filter.PrTitleQuery = parser.String(values, "", "pr_title")
filter.RepoQuery = parser.String(values, "", "repo")
if source := parser.String(values, "", "source"); source != "" {
switch source {
case "created_by_me":
filter.OwnedOnly = true
filter.SharedOnly = false
case "shared_with_me":
filter.OwnedOnly = false
filter.SharedOnly = true
case "all":
filter.OwnedOnly = false
filter.SharedOnly = false
default:
parser.Errors = append(parser.Errors, codersdk.ValidationError{
Field: "source",
Detail: fmt.Sprintf("%q is not a valid value", source),
})
}
}
// pr: requires a positive integer.
if prStr := parser.String(values, "", "pr"); prStr != "" {
+69 -10
View File
@@ -1229,14 +1229,16 @@ func TestSearchChats(t *testing.T) {
Name: "Empty",
Query: "",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
Name: "ArchivedTrue",
Query: "archived:true",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
},
},
{
@@ -1247,14 +1249,16 @@ func TestSearchChats(t *testing.T) {
Name: "ArchivedTrueUpperCase",
Query: "archived:TRUE",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
},
},
{
Name: "ArchivedFalse",
Query: "archived:false",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
@@ -1262,6 +1266,7 @@ func TestSearchChats(t *testing.T) {
Query: "has_unread:true",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
HasUnread: sql.NullBool{Bool: true, Valid: true},
},
},
@@ -1270,6 +1275,7 @@ func TestSearchChats(t *testing.T) {
Query: "has_unread:false",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
HasUnread: sql.NullBool{Bool: false, Valid: true},
},
},
@@ -1283,6 +1289,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft"},
},
},
@@ -1291,6 +1298,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"open"},
},
},
@@ -1299,6 +1307,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"merged"},
},
},
@@ -1307,6 +1316,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"closed"},
},
},
@@ -1315,6 +1325,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft pr_status:merged",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft", "merged"},
},
},
@@ -1323,6 +1334,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:draft,closed",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft", "closed"},
},
},
@@ -1331,6 +1343,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr_status:DRAFT",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"draft"},
},
},
@@ -1344,9 +1357,43 @@ func TestSearchChats(t *testing.T) {
Query: "archived:true pr_status:open",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
PullRequestStatuses: []string{"open"},
},
},
{
Name: "SourceCreatedByMe",
Query: "source:created_by_me",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
},
},
{
Name: "SourceSharedWithMe",
Query: "source:shared_with_me",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
SharedOnly: true,
},
},
{
Name: "SourceAll",
Query: "source:all",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
},
},
{
Name: "SourceInvalid",
Query: "source:mine",
ExpectedErrorContains: "source",
},
{
Name: "SourceRepeated",
Query: "source:created_by_me source:shared_with_me",
ExpectedErrorContains: "source",
},
{
Name: "ExtraParam",
Query: "archived:true invalid:param",
@@ -1371,7 +1418,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURL",
Query: `diff_url:"https://github.com/coder/coder/pull/123"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/coder/coder/pull/123",
Valid: true,
@@ -1382,7 +1430,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLPreservesValueCase",
Query: `diff_url:"https://github.com/Coder/Coder/pull/123"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/Coder/Coder/pull/123",
Valid: true,
@@ -1393,7 +1442,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLKeyCaseInsensitive",
Query: `Diff_URL:"https://github.com/coder/coder/pull/1"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://github.com/coder/coder/pull/1",
Valid: true,
@@ -1404,7 +1454,8 @@ func TestSearchChats(t *testing.T) {
Name: "DiffURLWithArchived",
Query: `archived:true diff_url:"https://gitlab.com/foo/bar/-/merge_requests/9"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
DiffURL: sql.NullString{
String: "https://gitlab.com/foo/bar/-/merge_requests/9",
Valid: true,
@@ -1431,6 +1482,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:"hello world"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "hello world",
},
},
@@ -1439,6 +1491,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:"my chat" archived:true`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: true, Valid: true},
OwnedOnly: true,
TitleQuery: "my chat",
},
},
@@ -1447,6 +1500,7 @@ func TestSearchChats(t *testing.T) {
Query: "title:deploy",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "deploy",
},
},
@@ -1455,6 +1509,7 @@ func TestSearchChats(t *testing.T) {
Query: `title:deploy diff_url:"https://github.com/coder/coder/pull/456"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
TitleQuery: "deploy",
DiffURL: sql.NullString{String: "https://github.com/coder/coder/pull/456", Valid: true},
},
@@ -1463,8 +1518,9 @@ func TestSearchChats(t *testing.T) {
Name: "PrNumber",
Query: "pr:42",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
PrNumber: 42,
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrNumber: 42,
},
},
{
@@ -1487,6 +1543,7 @@ func TestSearchChats(t *testing.T) {
Query: "repo:coder/coder",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
RepoQuery: "coder/coder",
},
},
@@ -1495,6 +1552,7 @@ func TestSearchChats(t *testing.T) {
Query: `pr_title:"fix auth bug"`,
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrTitleQuery: "fix auth bug",
},
},
@@ -1503,6 +1561,7 @@ func TestSearchChats(t *testing.T) {
Query: "pr:99 repo:coder/coder pr_title:deploy",
Expected: database.GetChatsParams{
Archived: sql.NullBool{Bool: false, Valid: true},
OwnedOnly: true,
PrNumber: 99,
RepoQuery: "coder/coder",
PrTitleQuery: "deploy",
+52 -27
View File
@@ -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()
})
}
+15 -4
View File
@@ -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:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering. |
| `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:<substring> (case-insensitive, quote multi-word values), archived:bool, has_unread:bool, pr_status:<draft\|open\|merged\|closed> as repeated or comma-separated values, source:<created_by_me\|shared_with_me\|all>, diff_url:<url> (quote values containing colons), pr:<number> (exact PR number match), repo:<owner/repo> (case-insensitive substring match against git remote origin or URL), pr_title:<text> (case-insensitive PR title substring). Bare terms are not supported; use title:<value> for title filtering. |
| `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",
+4
View File
@@ -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",
+4 -2
View File
@@ -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",
});
});
+6 -5
View File
@@ -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;
};
+21
View File
@@ -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<string, string>;
@@ -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<string, string>;
}
@@ -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",
@@ -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}
@@ -66,6 +66,7 @@ const buildChat = (overrides: Partial<TypesGen.Chat> = {}): 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<StoryProps> = ({ editing, ...overrides }) => {
persistedError: undefined as ChatDetailError | undefined,
parentChat: undefined as TypesGen.Chat | undefined,
isArchived: false,
isSharedChat: false,
chatOwner: undefined as ComponentProps<
typeof AgentChatPageView
>["chatOwner"],
@@ -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<AgentChatPageViewProps> = ({
parentChat,
persistedError,
isArchived,
isSharedChat,
chatOwner,
canShareChat,
workspaceAgent,
@@ -480,6 +482,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
isSharedChat={isSharedChat}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
renderChatSharingContent={
@@ -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
+9 -1
View File
@@ -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
@@ -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> = {}): Chat => ({
created_at: oneWeekAgo,
updated_at: oneWeekAgo,
archived: false,
shared: false,
pin_order: 0,
has_unread: false,
client_type: "ui",
@@ -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",
@@ -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",
@@ -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<ChatTopBarProps> = ({
isSidebarCollapsed,
onToggleSidebarCollapsed,
diffStatusData,
isSharedChat,
renderChatSharingContent,
}) => {
const { isEmbedded } = useEmbedContext();
@@ -186,6 +189,12 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
>
{chatTitle}
</span>
{isSharedChat && (
<UsersIcon
className="size-3.5 shrink-0 text-content-secondary"
aria-label="Shared chat"
/>
)}
{isRegeneratingTitle && (
<Spinner
aria-label="Regenerating title"
@@ -61,7 +61,9 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<Chat> = {}): 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> = {}): 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: [
@@ -57,13 +57,16 @@ const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<Chat> = {}): 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<typeof ChatsSidebar> = {
@@ -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(
<Wrapper>
<ChatsSidebar
{...defaultProps}
sidebarFilters={defaultSidebarFilters}
onSidebarFiltersChange={onSidebarFiltersChange}
/>
</Wrapper>,
);
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(
<Wrapper>
<ChatsSidebar
{...defaultProps}
sidebarFilters={{
...defaultSidebarFilters,
sources: ["created_by_me", "shared_with_me"],
}}
onSidebarFiltersChange={onSidebarFiltersChange}
/>
</Wrapper>,
);
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"],
});
});
@@ -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<ChatsPanelProps> = ({
.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<ChatsPanelProps> = ({
...sidebarFilters,
prStatuses: [],
chatStatuses: AGENT_CHAT_STATUS_ORDER,
sources: DEFAULT_AGENT_SIDEBAR_FILTERS.sources,
});
};
@@ -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",
@@ -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"],
});
},
};
@@ -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<AgentArchiveStatusFilter, string> = {
archived: "Archived",
};
const SOURCE_LABELS: Record<AgentSourceFilter, string> = {
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<ComponentProps<"h2">> = ({ className, ...props }) => (
<h2
className={cn(
@@ -119,7 +134,8 @@ const hasActiveFilters = (filters: AgentSidebarFilters): boolean => {
!haveSameSelections(
filters.chatStatuses,
DEFAULT_AGENT_SIDEBAR_FILTERS.chatStatuses,
)
) ||
!haveSameSelections(filters.sources, DEFAULT_AGENT_SIDEBAR_FILTERS.sources)
);
};
@@ -154,12 +170,16 @@ export const FilterPopover: FC<FilterPopoverProps> = ({
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<FilterPopoverProps> = ({
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<FilterPopoverProps> = ({
</div>
)}
{visibleSourceOptions.length > 0 && (
<div className="space-y-1.5">
<FilterGroupHeading>Source</FilterGroupHeading>
<div className="space-y-2">
{visibleSourceOptions.map((option) => {
const optionId = `${id}-source-${option.value}`;
return (
<OptionRow key={option.value}>
<Checkbox
id={optionId}
checked={stagedFilters.sources.includes(
option.value,
)}
onCheckedChange={(nextChecked) =>
setSource(option.value, nextChecked === true)
}
className="m-0 my-[3px]"
/>
<label
htmlFor={optionId}
className="flex flex-1 cursor-pointer items-center text-sm font-normal leading-5 text-content-primary"
>
{option.label}
</label>
</OptionRow>
);
})}
</div>
</div>
)}
{visibleArchiveOptions.length > 0 && (
<div className="space-y-1.5">
<FilterGroupHeading id={`${id}-archive-heading`}>
@@ -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<ChatTreeNodeProps> = ({ 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<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
</div>
)}
</NavLink>
<div className="relative mt-1 flex h-6 w-7 shrink-0 items-center justify-end">
{isArchivingThisChat ? (
<Spinner
className="h-3.5 w-3.5 text-content-secondary"
loading
/>
) : (
<>
<div className="relative my-1 flex w-7 shrink-0 flex-col items-end self-stretch">
<div className="flex h-6 w-7 shrink-0 items-center justify-end">
{isArchivingThisChat ? (
<Spinner
className="h-3.5 w-3.5 text-content-secondary"
loading
/>
) : (
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
{chat.has_unread && !isActiveChat ? (
<span
@@ -339,29 +341,35 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
</span>
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisVerticalIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{renderMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{isSharedChat && (
<UsersIcon
className="mt-auto size-3.5 text-content-secondary"
aria-label="Shared chat"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisVerticalIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{renderMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
@@ -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");
});
});
@@ -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<AgentChatStatusFilter>(
AGENT_CHAT_STATUS_ORDER,
);
const canonicalizeChatStatuses = (
values: Iterable<string>,
): readonly AgentChatStatusFilter[] => {
const selected = new Set<AgentChatStatusFilter>();
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) => {