mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: show shared chats in agents sidebar (#26056)
This commit is contained in:
Generated
+5
-1
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+5
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+15
-4
@@ -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",
|
||||
|
||||
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Generated
+21
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user