diff --git a/models/db/search.go b/models/db/search.go
index 37565f45e1..e0a1b6bde9 100644
--- a/models/db/search.go
+++ b/models/db/search.go
@@ -26,8 +26,10 @@ const (
 	SearchOrderByForksReverse          SearchOrderBy = "num_forks DESC"
 )
 
-const (
-	// Which means a condition to filter the records which don't match any id.
-	// It's different from zero which means the condition could be ignored.
-	NoConditionID = -1
-)
+// NoConditionID means a condition to filter the records which don't match any id.
+// eg: "milestone_id=-1" means "find the items without any milestone.
+const NoConditionID int64 = -1
+
+// NonExistingID means a condition to match no result (eg: a non-existing user)
+// It doesn't use -1 or -2 because they are used as builtin users.
+const NonExistingID int64 = -1000000
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index 5948a67d4e..f1cd125d49 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -27,8 +27,8 @@ type IssuesOptions struct { //nolint
 	RepoIDs            []int64 // overwrites RepoCond if the length is not 0
 	AllPublic          bool    // include also all public repositories
 	RepoCond           builder.Cond
-	AssigneeID         int64
-	PosterID           int64
+	AssigneeID         optional.Option[int64]
+	PosterID           optional.Option[int64]
 	MentionedID        int64
 	ReviewRequestedID  int64
 	ReviewedID         int64
@@ -231,15 +231,8 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
 		sess.And("issue.is_closed=?", opts.IsClosed.Value())
 	}
 
-	if opts.AssigneeID > 0 {
-		applyAssigneeCondition(sess, opts.AssigneeID)
-	} else if opts.AssigneeID == db.NoConditionID {
-		sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
-	}
-
-	if opts.PosterID > 0 {
-		applyPosterCondition(sess, opts.PosterID)
-	}
+	applyAssigneeCondition(sess, opts.AssigneeID)
+	applyPosterCondition(sess, opts.PosterID)
 
 	if opts.MentionedID > 0 {
 		applyMentionedCondition(sess, opts.MentionedID)
@@ -359,13 +352,27 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organizati
 	return cond
 }
 
-func applyAssigneeCondition(sess *xorm.Session, assigneeID int64) {
-	sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
-		And("issue_assignees.assignee_id = ?", assigneeID)
+func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) {
+	// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
+	if !assigneeID.Has() || assigneeID.Value() == 0 {
+		return
+	}
+	if assigneeID.Value() == db.NoConditionID {
+		sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
+	} else {
+		sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
+			And("issue_assignees.assignee_id = ?", assigneeID.Value())
+	}
 }
 
-func applyPosterCondition(sess *xorm.Session, posterID int64) {
-	sess.And("issue.poster_id=?", posterID)
+func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) {
+	if !posterID.Has() {
+		return
+	}
+	// poster doesn't need to support db.NoConditionID(-1), so just use the value as-is
+	if posterID.Has() {
+		sess.And("issue.poster_id=?", posterID.Value())
+	}
 }
 
 func applyMentionedCondition(sess *xorm.Session, mentionedID int64) {
diff --git a/models/issues/issue_stats.go b/models/issues/issue_stats.go
index 39326616f8..9ef9347a16 100644
--- a/models/issues/issue_stats.go
+++ b/models/issues/issue_stats.go
@@ -151,15 +151,9 @@ func applyIssuesOptions(sess *xorm.Session, opts *IssuesOptions, issueIDs []int6
 
 	applyProjectCondition(sess, opts)
 
-	if opts.AssigneeID > 0 {
-		applyAssigneeCondition(sess, opts.AssigneeID)
-	} else if opts.AssigneeID == db.NoConditionID {
-		sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
-	}
+	applyAssigneeCondition(sess, opts.AssigneeID)
 
-	if opts.PosterID > 0 {
-		applyPosterCondition(sess, opts.PosterID)
-	}
+	applyPosterCondition(sess, opts.PosterID)
 
 	if opts.MentionedID > 0 {
 		applyMentionedCondition(sess, opts.MentionedID)
diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go
index 1bbc0eee56..548f137f39 100644
--- a/models/issues/issue_test.go
+++ b/models/issues/issue_test.go
@@ -16,6 +16,7 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unittest"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/stretchr/testify/assert"
@@ -155,7 +156,7 @@ func TestIssues(t *testing.T) {
 	}{
 		{
 			issues_model.IssuesOptions{
-				AssigneeID: 1,
+				AssigneeID: optional.Some(int64(1)),
 				SortType:   "oldest",
 			},
 			[]int64{1, 6},
diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go
index 875a4ca279..98b097f871 100644
--- a/modules/indexer/issues/db/options.go
+++ b/modules/indexer/issues/db/options.go
@@ -54,8 +54,8 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
 		RepoIDs:            options.RepoIDs,
 		AllPublic:          options.AllPublic,
 		RepoCond:           nil,
-		AssigneeID:         convertID(options.AssigneeID),
-		PosterID:           convertID(options.PosterID),
+		AssigneeID:         optional.Some(convertID(options.AssigneeID)),
+		PosterID:           options.PosterID,
 		MentionedID:        convertID(options.MentionID),
 		ReviewRequestedID:  convertID(options.ReviewRequestedID),
 		ReviewedID:         convertID(options.ReviewedID),
diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go
index c1f454eeee..1a0f241e61 100644
--- a/modules/indexer/issues/dboptions.go
+++ b/modules/indexer/issues/dboptions.go
@@ -40,14 +40,14 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
 
 	if opts.ProjectID > 0 {
 		searchOpt.ProjectID = optional.Some(opts.ProjectID)
-	} else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places
+	} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
 		searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
 	}
 
-	if opts.AssigneeID > 0 {
-		searchOpt.AssigneeID = optional.Some(opts.AssigneeID)
-	} else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places
-		searchOpt.AssigneeID = optional.Some[int64](0)
+	if opts.AssigneeID.Value() == db.NoConditionID {
+		searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee"
+	} else if opts.AssigneeID.Value() != 0 {
+		searchOpt.AssigneeID = opts.AssigneeID
 	}
 
 	// See the comment of issues_model.SearchOptions for the reason why we need to convert
@@ -62,7 +62,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
 	}
 
 	searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
-	searchOpt.PosterID = convertID(opts.PosterID)
+	searchOpt.PosterID = opts.PosterID
 	searchOpt.MentionID = convertID(opts.MentionedID)
 	searchOpt.ReviewedID = convertID(opts.ReviewedID)
 	searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID)
diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go
index 0dce654181..7c3ba75bb0 100644
--- a/modules/indexer/issues/indexer_test.go
+++ b/modules/indexer/issues/indexer_test.go
@@ -191,7 +191,7 @@ func searchIssueByID(t *testing.T) {
 		},
 		{
 			// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
-			opts:        *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: -1}),
+			opts:        *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}),
 			expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
 		},
 		{
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index b94344f2ec..3b9ec2a7b8 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -21,6 +21,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/routers/web/shared/issue"
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
@@ -334,23 +335,15 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	var labelIDs []int64
-	// 1,-2 means including label 1 and excluding label 2
-	// 0 means issues with no label
-	// blank means labels will not be filtered for issues
-	selectLabels := ctx.FormString("labels")
-	if selectLabels != "" {
-		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
-		if err != nil {
-			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
-		}
+	labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
+	if ctx.Written() {
+		return
 	}
-
-	assigneeID := ctx.FormInt64("assignee")
+	assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 
 	issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
 		LabelIDs:   labelIDs,
-		AssigneeID: assigneeID,
+		AssigneeID: optional.Some(assigneeID),
 	})
 	if err != nil {
 		ctx.ServerError("LoadIssuesOfColumns", err)
@@ -426,8 +419,6 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 	ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
-
-	ctx.Data["SelectLabels"] = selectLabels
 	ctx.Data["AssigneeID"] = assigneeID
 
 	project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)
diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go
index 6451f7ac76..ff98bf8ec8 100644
--- a/routers/web/repo/issue_list.go
+++ b/routers/web/repo/issue_list.go
@@ -17,12 +17,12 @@ import (
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
-	"code.gitea.io/gitea/modules/base"
 	issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/optional"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/routers/web/shared/issue"
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
@@ -263,8 +263,10 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
 	return user.ID
 }
 
-// ListIssues list the issues of a repository
-func ListIssues(ctx *context.Context) {
+// SearchRepoIssuesJSON lists the issues of a repository
+// This function was copied from API (decouple the web and API routes),
+// it is only used by frontend to search some dependency or related issues
+func SearchRepoIssuesJSON(ctx *context.Context) {
 	before, since, err := context.GetQueryBeforeSince(ctx.Base)
 	if err != nil {
 		ctx.Error(http.StatusUnprocessableEntity, err.Error())
@@ -286,20 +288,11 @@ func ListIssues(ctx *context.Context) {
 		keyword = ""
 	}
 
-	var labelIDs []int64
-	if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 {
-		labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted)
-		if err != nil {
-			ctx.Error(http.StatusInternalServerError, err.Error())
-			return
-		}
-	}
-
 	var mileIDs []int64
 	if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 {
 		for i := range part {
 			// uses names and fall back to ids
-			// non existent milestones are discarded
+			// non-existent milestones are discarded
 			mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i])
 			if err == nil {
 				mileIDs = append(mileIDs, mile.ID)
@@ -370,17 +363,8 @@ func ListIssues(ctx *context.Context) {
 	if before != 0 {
 		searchOpt.UpdatedBeforeUnix = optional.Some(before)
 	}
-	if len(labelIDs) == 1 && labelIDs[0] == 0 {
-		searchOpt.NoLabelOnly = true
-	} else {
-		for _, labelID := range labelIDs {
-			if labelID > 0 {
-				searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
-			} else {
-				searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
-			}
-		}
-	}
+
+	// TODO: the "labels" query parameter is never used, so no need to handle it
 
 	if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
 		searchOpt.MilestoneIDs = []int64{0}
@@ -503,8 +487,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	if !util.SliceContainsString(types, viewType, true) {
 		viewType = "all"
 	}
-	// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
-	assigneeID := ctx.FormInt64("assignee")
+
+	assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 	posterUsername := ctx.FormString("poster")
 	posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername)
 	var mentionedID, reviewRequestedID, reviewedID int64
@@ -512,7 +496,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	if ctx.IsSigned {
 		switch viewType {
 		case "created_by":
-			posterUserID = ctx.Doer.ID
+			posterUserID = optional.Some(ctx.Doer.ID)
 		case "mentioned":
 			mentionedID = ctx.Doer.ID
 		case "assigned":
@@ -525,18 +509,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	}
 
 	repo := ctx.Repo.Repository
-	var labelIDs []int64
-	// 1,-2 means including label 1 and excluding label 2
-	// 0 means issues with no label
-	// blank means labels will not be filtered for issues
-	selectLabels := ctx.FormString("labels")
-	if selectLabels != "" {
-		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
-		if err != nil {
-			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
-		}
-	}
-
 	keyword := strings.Trim(ctx.FormString("q"), " ")
 	if bytes.Contains([]byte(keyword), []byte{0x00}) {
 		keyword = ""
@@ -547,13 +519,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 		mileIDs = []int64{milestoneID}
 	}
 
+	labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
+	if ctx.Written() {
+		return
+	}
+
 	var issueStats *issues_model.IssueStats
 	statsOpts := &issues_model.IssuesOptions{
 		RepoIDs:           []int64{repo.ID},
 		LabelIDs:          labelIDs,
 		MilestoneIDs:      mileIDs,
 		ProjectID:         projectID,
-		AssigneeID:        assigneeID,
+		AssigneeID:        optional.Some(assigneeID),
 		MentionedID:       mentionedID,
 		PosterID:          posterUserID,
 		ReviewRequestedID: reviewRequestedID,
@@ -634,7 +611,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 				PageSize: setting.UI.IssuePagingNum,
 			},
 			RepoIDs:           []int64{repo.ID},
-			AssigneeID:        assigneeID,
+			AssigneeID:        optional.Some(assigneeID),
 			PosterID:          posterUserID,
 			MentionedID:       mentionedID,
 			ReviewRequestedID: reviewRequestedID,
@@ -709,49 +686,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 		return
 	}
 
-	labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
-	if err != nil {
-		ctx.ServerError("GetLabelsByRepoID", err)
-		return
-	}
-
-	if repo.Owner.IsOrganization() {
-		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
-		if err != nil {
-			ctx.ServerError("GetLabelsByOrgID", err)
-			return
-		}
-
-		ctx.Data["OrgLabels"] = orgLabels
-		labels = append(labels, orgLabels...)
-	}
-
-	// Get the exclusive scope for every label ID
-	labelExclusiveScopes := make([]string, 0, len(labelIDs))
-	for _, labelID := range labelIDs {
-		foundExclusiveScope := false
-		for _, label := range labels {
-			if label.ID == labelID || label.ID == -labelID {
-				labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
-				foundExclusiveScope = true
-				break
-			}
-		}
-		if !foundExclusiveScope {
-			labelExclusiveScopes = append(labelExclusiveScopes, "")
-		}
-	}
-
-	for _, l := range labels {
-		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
-	}
-	ctx.Data["Labels"] = labels
-	ctx.Data["NumLabels"] = len(labels)
-
-	if ctx.FormInt64("assignee") == 0 {
-		assigneeID = 0 // Reset ID to prevent unexpected selection of assignee.
-	}
-
 	ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)
 
 	ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
@@ -792,13 +726,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	ctx.Data["OpenCount"] = issueStats.OpenCount
 	ctx.Data["ClosedCount"] = issueStats.ClosedCount
 	ctx.Data["SelLabelIDs"] = labelIDs
-	ctx.Data["SelectLabels"] = selectLabels
 	ctx.Data["ViewType"] = viewType
 	ctx.Data["SortType"] = sortType
 	ctx.Data["MilestoneID"] = milestoneID
 	ctx.Data["ProjectID"] = projectID
 	ctx.Data["AssigneeID"] = assigneeID
-	ctx.Data["PosterUserID"] = posterUserID
 	ctx.Data["PosterUsername"] = posterUsername
 	ctx.Data["Keyword"] = keyword
 	ctx.Data["IsShowClosed"] = isShowClosed
@@ -810,19 +742,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
 	default:
 		ctx.Data["State"] = "open"
 	}
-
-	pager.AddParamString("q", keyword)
-	pager.AddParamString("type", viewType)
-	pager.AddParamString("sort", sortType)
-	pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
-	pager.AddParamString("labels", fmt.Sprint(selectLabels))
-	pager.AddParamString("milestone", fmt.Sprint(milestoneID))
-	pager.AddParamString("project", fmt.Sprint(projectID))
-	pager.AddParamString("assignee", fmt.Sprint(assigneeID))
-	pager.AddParamString("poster", posterUsername)
-	if showArchivedLabels {
-		pager.AddParamString("archived_labels", "true")
-	}
+	pager.AddParamFromRequest(ctx.Req)
 	ctx.Data["Page"] = pager
 }
 
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 168da2ca1f..3be9578670 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -23,6 +23,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
+	"code.gitea.io/gitea/routers/web/shared/issue"
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
@@ -307,23 +308,13 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	var labelIDs []int64
-	// 1,-2 means including label 1 and excluding label 2
-	// 0 means issues with no label
-	// blank means labels will not be filtered for issues
-	selectLabels := ctx.FormString("labels")
-	if selectLabels != "" {
-		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
-		if err != nil {
-			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
-		}
-	}
+	labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
 
-	assigneeID := ctx.FormInt64("assignee")
+	assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 
 	issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
 		LabelIDs:   labelIDs,
-		AssigneeID: assigneeID,
+		AssigneeID: optional.Some(assigneeID),
 	})
 	if err != nil {
 		ctx.ServerError("LoadIssuesOfColumns", err)
@@ -409,8 +400,6 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 	ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
-
-	ctx.Data["SelectLabels"] = selectLabels
 	ctx.Data["AssigneeID"] = assigneeID
 
 	rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
diff --git a/routers/web/shared/issue/issue_label.go b/routers/web/shared/issue/issue_label.go
new file mode 100644
index 0000000000..eacea36b02
--- /dev/null
+++ b/routers/web/shared/issue/issue_label.go
@@ -0,0 +1,71 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package issue
+
+import (
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	issues_model "code.gitea.io/gitea/models/issues"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/services/context"
+)
+
+// PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
+func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) {
+	// 1,-2 means including label 1 and excluding label 2
+	// 0 means issues with no label
+	// blank means labels will not be filtered for issues
+	selectLabels := ctx.FormString("labels")
+	if selectLabels != "" {
+		var err error
+		labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
+		if err != nil {
+			ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
+		}
+	}
+
+	var allLabels []*issues_model.Label
+	if repoID != 0 {
+		repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
+		if err != nil {
+			ctx.ServerError("GetLabelsByRepoID", err)
+			return nil
+		}
+		allLabels = append(allLabels, repoLabels...)
+	}
+
+	if owner != nil && owner.IsOrganization() {
+		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
+		if err != nil {
+			ctx.ServerError("GetLabelsByOrgID", err)
+			return nil
+		}
+		allLabels = append(allLabels, orgLabels...)
+	}
+
+	// Get the exclusive scope for every label ID
+	labelExclusiveScopes := make([]string, 0, len(labelIDs))
+	for _, labelID := range labelIDs {
+		foundExclusiveScope := false
+		for _, label := range allLabels {
+			if label.ID == labelID || label.ID == -labelID {
+				labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
+				foundExclusiveScope = true
+				break
+			}
+		}
+		if !foundExclusiveScope {
+			labelExclusiveScopes = append(labelExclusiveScopes, "")
+		}
+	}
+
+	for _, l := range allLabels {
+		l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
+	}
+	ctx.Data["Labels"] = allLabels
+	ctx.Data["SelectLabels"] = selectLabels
+	return labelIDs
+}
diff --git a/routers/web/shared/user/helper.go b/routers/web/shared/user/helper.go
index 7268767e0a..b82181a1df 100644
--- a/routers/web/shared/user/helper.go
+++ b/routers/web/shared/user/helper.go
@@ -8,7 +8,9 @@ import (
 	"slices"
 	"strconv"
 
+	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/optional"
 )
 
 func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
@@ -31,17 +33,20 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
 // Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
 // So it's better to make it work like GitHub: users could input username directly.
 // Since it only converts the username to ID directly and is only used internally (to search issues), so no permission check is needed.
-// Old usage: poster=123, new usage: poster=the-username (at the moment, non-existing username is treated as poster=0, not ideal but acceptable)
-func GetFilterUserIDByName(ctx context.Context, name string) int64 {
+// Return values:
+// * nil: no filter
+// * some(id): match the id, the id could be -1 to match the issues without assignee
+// * some(NonExistingID): match no issue (due to the user doesn't exist)
+func GetFilterUserIDByName(ctx context.Context, name string) optional.Option[int64] {
 	if name == "" {
-		return 0
+		return optional.None[int64]()
 	}
 	u, err := user.GetUserByName(ctx, name)
 	if err != nil {
 		if id, err := strconv.ParseInt(name, 10, 64); err == nil {
-			return id
+			return optional.Some(id)
 		}
-		return 0
+		return optional.Some(db.NonExistingID)
 	}
-	return u.ID
+	return optional.Some(u.ID)
 }
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index 5a0d46869f..befa33b0c0 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -33,6 +33,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/routers/web/feed"
+	"code.gitea.io/gitea/routers/web/shared/issue"
 	"code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	feed_service "code.gitea.io/gitea/services/feed"
@@ -413,6 +414,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 		viewType = "your_repositories"
 	}
 
+	isPullList := unitType == unit.TypePullRequests
+	opts := &issues_model.IssuesOptions{
+		IsPull:     optional.Some(isPullList),
+		SortType:   sortType,
+		IsArchived: optional.Some(false),
+		User:       ctx.Doer,
+	}
 	// --------------------------------------------------------------------------
 	// Build opts (IssuesOptions), which contains filter information.
 	// Will eventually be used to retrieve issues relevant for the overview page.
@@ -422,30 +430,24 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 	// --------------------------------------------------------------------------
 
 	// Get repository IDs where User/Org/Team has access.
-	var team *organization.Team
-	var org *organization.Organization
-	if ctx.Org != nil {
-		org = ctx.Org.Organization
-		team = ctx.Org.Team
-	}
+	if ctx.Org != nil && ctx.Org.Organization != nil {
+		opts.Org = ctx.Org.Organization
+		opts.Team = ctx.Org.Team
 
-	isPullList := unitType == unit.TypePullRequests
-	opts := &issues_model.IssuesOptions{
-		IsPull:     optional.Some(isPullList),
-		SortType:   sortType,
-		IsArchived: optional.Some(false),
-		Org:        org,
-		Team:       team,
-		User:       ctx.Doer,
+		issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
+		if ctx.Written() {
+			return
+		}
 	}
 	// Get filter by author id & assignee id
-	// FIXME: this feature doesn't work at the moment, because frontend can't use a "user-remote-search" dropdown directly
 	// the existing "/posters" handlers doesn't work for this case, it is unable to list the related users correctly.
 	// In the future, we need something like github: "author:user1" to accept usernames directly.
 	posterUsername := ctx.FormString("poster")
+	ctx.Data["FilterPosterUsername"] = posterUsername
 	opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername)
-	// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
-	opts.AssigneeID, _ = strconv.ParseInt(ctx.FormString("assignee"), 10, 64)
+	assigneeUsername := ctx.FormString("assignee")
+	ctx.Data["FilterAssigneeUsername"] = assigneeUsername
+	opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
 
 	isFuzzy := ctx.FormBool("fuzzy")
 
@@ -471,8 +473,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 		UnitType:    unitType,
 		Archived:    optional.Some(false),
 	}
-	if team != nil {
-		repoOpts.TeamID = team.ID
+	if opts.Team != nil {
+		repoOpts.TeamID = opts.Team.ID
 	}
 	accessibleRepos := container.Set[int64]{}
 	{
@@ -500,9 +502,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 	case issues_model.FilterModeAll:
 	case issues_model.FilterModeYourRepositories:
 	case issues_model.FilterModeAssign:
-		opts.AssigneeID = ctx.Doer.ID
+		opts.AssigneeID = optional.Some(ctx.Doer.ID)
 	case issues_model.FilterModeCreate:
-		opts.PosterID = ctx.Doer.ID
+		opts.PosterID = optional.Some(ctx.Doer.ID)
 	case issues_model.FilterModeMention:
 		opts.MentionedID = ctx.Doer.ID
 	case issues_model.FilterModeReviewRequested:
@@ -584,10 +586,6 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 			// because the doer may create issues or be mentioned in any public repo.
 			// So we need search issues in all public repos.
 			o.AllPublic = ctx.Doer.ID == ctxUser.ID
-			// TODO: to make it work with poster/assignee filter, then these IDs should be kept
-			o.AssigneeID = nil
-			o.PosterID = nil
-
 			o.MentionID = nil
 			o.ReviewRequestedID = nil
 			o.ReviewedID = nil
@@ -645,10 +643,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 	ctx.Data["ViewType"] = viewType
 	ctx.Data["SortType"] = sortType
 	ctx.Data["IsShowClosed"] = isShowClosed
-	ctx.Data["SelectLabels"] = selectedLabels
 	ctx.Data["IsFuzzy"] = isFuzzy
-	ctx.Data["SearchFilterPosterID"] = util.Iif[any](opts.PosterID != 0, opts.PosterID, nil)
-	ctx.Data["SearchFilterAssigneeID"] = util.Iif[any](opts.AssigneeID != 0, opts.AssigneeID, nil)
 
 	if isShowClosed {
 		ctx.Data["State"] = "closed"
@@ -657,16 +652,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 	}
 
 	pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
-	pager.AddParamString("q", keyword)
-	pager.AddParamString("type", viewType)
-	pager.AddParamString("sort", sortType)
-	pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
-	pager.AddParamString("labels", selectedLabels)
-	pager.AddParamString("fuzzy", fmt.Sprint(isFuzzy))
-	pager.AddParamString("poster", posterUsername)
-	if opts.AssigneeID != 0 {
-		pager.AddParamString("assignee", fmt.Sprint(opts.AssigneeID))
-	}
+	pager.AddParamFromRequest(ctx.Req)
 	ctx.Data["Page"] = pager
 
 	ctx.HTML(http.StatusOK, tplIssues)
diff --git a/routers/web/web.go b/routers/web/web.go
index c87c01ea0f..72ee47bb4c 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1208,7 +1208,7 @@ func registerRoutes(m *web.Router) {
 					Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost)
 				m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate)
 			})
-			m.Get("/search", repo.ListIssues)
+			m.Get("/search", repo.SearchRepoIssuesJSON)
 		}, context.RepoMustNotBeArchived(), reqRepoIssueReader)
 
 		// FIXME: should use different URLs but mostly same logic for comments of issue and pull request.
diff --git a/services/context/pagination.go b/services/context/pagination.go
index fb2ef699ce..42117cf96d 100644
--- a/services/context/pagination.go
+++ b/services/context/pagination.go
@@ -6,6 +6,7 @@ package context
 import (
 	"fmt"
 	"html/template"
+	"net/http"
 	"net/url"
 	"strings"
 
@@ -32,6 +33,18 @@ func (p *Pagination) AddParamString(key, value string) {
 	p.urlParams = append(p.urlParams, urlParam)
 }
 
+func (p *Pagination) AddParamFromRequest(req *http.Request) {
+	for key, values := range req.URL.Query() {
+		if key == "page" || len(values) == 0 {
+			continue
+		}
+		for _, value := range values {
+			urlParam := fmt.Sprintf("%s=%v", key, url.QueryEscape(value))
+			p.urlParams = append(p.urlParams, urlParam)
+		}
+	}
+}
+
 // GetParams returns the configured URL params
 func (p *Pagination) GetParams() template.URL {
 	return template.URL(strings.Join(p.urlParams, "&"))
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index b2f48fe2c9..f5a48f7241 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -24,16 +24,19 @@
 		<input type="hidden" name="state" value="{{$.State}}">
 		{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.project_kind")}}
 	</form>
-	<!-- Sort -->
-	<div class="list-header-sort ui small dropdown type jump item">
-		<span class="text">
-			{{ctx.Locale.Tr "repo.issues.filter_sort"}}
-		</span>
-		{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-		<div class="menu">
-			<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
-			<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
-			<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
+
+	<div class="list-header-filters">
+		<!-- Sort -->
+		<div class="item ui small dropdown jump">
+			<span class="text">
+				{{ctx.Locale.Tr "repo.issues.filter_sort"}}
+			</span>
+			{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+			<div class="menu">
+				<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
+				<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
+				<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
+			</div>
 		</div>
 	</div>
 </div>
diff --git a/templates/repo/issue/filter_item_label.tmpl b/templates/repo/issue/filter_item_label.tmpl
index 67bfab6fb0..927328ba14 100644
--- a/templates/repo/issue/filter_item_label.tmpl
+++ b/templates/repo/issue/filter_item_label.tmpl
@@ -19,7 +19,7 @@
 			<span data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>{{svg "octicon-info"}}</span>
 		</label>
 		{{end}}
-		<span class="info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
+		<span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
 		<div class="divider"></div>
 		<a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
 		<a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
diff --git a/templates/repo/issue/filter_item_user_fetch.tmpl b/templates/repo/issue/filter_item_user_fetch.tmpl
index cab128a787..5fa8142354 100644
--- a/templates/repo/issue/filter_item_user_fetch.tmpl
+++ b/templates/repo/issue/filter_item_user_fetch.tmpl
@@ -2,13 +2,13 @@
 * QueryParamKey: eg: "poster", "assignee"
 * QueryLink
 * UserSearchUrl
-* SelectedUserId
+* SelectedUsername
 * TextFilterTitle
 */}}
 {{$queryLink := .QueryLink}}
 <div class="item ui dropdown custom user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.user_search_tooltip"}}"
 		data-search-url="{{$.UserSearchUrl}}"
-		data-selected-user-id="{{$.SelectedUserId}}"
+		data-selected-username="{{$.SelectedUsername}}"
 		data-action-jump-url="{{QueryBuild $queryLink $.QueryParamKey NIL}}&{{$.QueryParamKey}}={username}"
 >
 	{{$.TextFilterTitle}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl
index c78d23d51c..7612d93b21 100644
--- a/templates/repo/issue/filter_list.tmpl
+++ b/templates/repo/issue/filter_list.tmpl
@@ -4,7 +4,7 @@
 
 {{if not .Milestone}}
 <!-- Milestone -->
-<div class="ui {{if not (or .OpenMilestones .ClosedMilestones)}}disabled{{end}} dropdown jump item">
+<div class="item ui dropdown jump {{if not (or .OpenMilestones .ClosedMilestones)}}disabled{{end}}">
 	<span class="text">
 		{{ctx.Locale.Tr "repo.issues.filter_milestone"}}
 	</span>
@@ -42,7 +42,7 @@
 {{end}}
 
 <!-- Project -->
-<div class="ui{{if not (or .OpenProjects .ClosedProjects)}} disabled{{end}} dropdown jump item">
+<div class="item ui dropdown jump {{if not (or .OpenProjects .ClosedProjects)}}disabled{{end}}">
 	<span class="text">
 		{{ctx.Locale.Tr "repo.issues.filter_project"}}
 	</span>
@@ -84,7 +84,7 @@
 	"QueryParamKey" "poster"
 	"QueryLink" $queryLink
 	"UserSearchUrl" (Iif .Milestone (print $.RepoLink "/issues/posters") (print $.Link "/posters"))
-	"SelectedUserId" $.PosterUserID
+	"SelectedUsername" $.PosterUsername
 	"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
 }}
 
@@ -100,7 +100,7 @@
 
 {{if .IsSigned}}
 	<!-- Type -->
-	<div class="ui dropdown type jump item">
+	<div class="item ui dropdown jump">
 		<span class="text">
 			{{ctx.Locale.Tr "repo.issues.filter_type"}}
 		</span>
@@ -119,7 +119,7 @@
 {{end}}
 
 <!-- Sort -->
-<div class="list-header-sort ui small dropdown downward type jump item">
+<div class="item ui dropdown jump">
 	<span class="text">
 		{{ctx.Locale.Tr "repo.issues.filter_sort"}}
 	</span>
diff --git a/templates/repo/issue/milestone/filter_list.tmpl b/templates/repo/issue/milestone/filter_list.tmpl
index 430d3814ee..0740b86ac9 100644
--- a/templates/repo/issue/milestone/filter_list.tmpl
+++ b/templates/repo/issue/milestone/filter_list.tmpl
@@ -1,5 +1,5 @@
 <!-- Sort -->
-<div class="list-header-sort ui small dropdown type jump item">
+<div class="item ui small dropdown jump">
 	<span class="text">
 		{{ctx.Locale.Tr "repo.issues.filter_sort"}}
 	</span>
diff --git a/templates/repo/issue/search.tmpl b/templates/repo/issue/search.tmpl
index 1ab0dc74f3..07732ab5e7 100644
--- a/templates/repo/issue/search.tmpl
+++ b/templates/repo/issue/search.tmpl
@@ -3,7 +3,7 @@
 		<input type="hidden" name="state" value="{{$.State}}">
 		{{if not .PageIsMilestones}}
 			<input type="hidden" name="type" value="{{$.ViewType}}">
-			<input type="hidden" name="labels" value="{{.SelectLabels}}">
+			<input type="hidden" name="labels" value="{{$.SelectLabels}}">
 			<input type="hidden" name="milestone" value="{{$.MilestoneID}}">
 			<input type="hidden" name="project" value="{{$.ProjectID}}">
 			<input type="hidden" name="assignee" value="{{$.AssigneeID}}">
diff --git a/templates/user/dashboard/issues.tmpl b/templates/user/dashboard/issues.tmpl
index b9d63818fe..7f960a4709 100644
--- a/templates/user/dashboard/issues.tmpl
+++ b/templates/user/dashboard/issues.tmpl
@@ -4,7 +4,7 @@
 	<div class="ui container">
 		{{template "base/alert" .}}
 		<div class="flex-container">
-			{{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "fuzzy" $.IsFuzzy}}
+			{{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "labels" .SelectLabels "fuzzy" $.IsFuzzy}}
 			<div class="flex-container-nav">
 				<div class="ui secondary vertical filter menu tw-bg-transparent">
 					<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}">
@@ -36,7 +36,7 @@
 				</div>
 			</div>
 
-			{{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.SearchFilterPosterUsername "assignee" $.SearchFilterAssigneeID}}
+			{{$queryLinkWithFilter := QueryBuild $queryLink "poster" $.FilterPosterUsername "assignee" $.FilterAssigneeUsername}}
 			<div class="flex-container-main content">
 				<div class="list-header">
 					<div class="small-menu-items ui compact tiny menu list-header-toggle flex-items-block">
@@ -50,28 +50,51 @@
 						</a>
 					</div>
 					<form class="list-header-search ui form ignore-dirty">
-						<div class="ui small search fluid action input">
-							<input type="hidden" name="type" value="{{$.ViewType}}">
-							<input type="hidden" name="sort" value="{{$.SortType}}">
-							<input type="hidden" name="state" value="{{$.State}}">
-							{{template "shared/search/combo_fuzzy" dict "Value" $.Keyword "IsFuzzy" $.IsFuzzy "Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind")) "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
-						</div>
+						<input type="hidden" name="type" value="{{$.ViewType}}">
+						<input type="hidden" name="sort" value="{{$.SortType}}">
+						<input type="hidden" name="state" value="{{$.State}}">
+						{{template "shared/search/combo_fuzzy" dict "Value" $.Keyword "IsFuzzy" $.IsFuzzy "Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind")) "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
 					</form>
-					<!-- Sort -->
-					<div class="list-header-sort ui small dropdown type jump item">
-						<span class="text tw-whitespace-nowrap">
-							{{ctx.Locale.Tr "repo.issues.filter_sort"}}
-							{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-						</span>
-						<div class="menu">
-							<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "recentupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
-							<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
-							<a class="{{if eq .SortType "latest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "latest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a>
-							<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "oldest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
-							<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "mostcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a>
-							<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
-							<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
-							<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
+
+					<div class="list-header-filters">
+						{{if $.Labels}}
+							{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLinkWithFilter "SupportArchivedLabel" true}}
+						{{end}}
+
+						{{if ne $.ViewType "created_by"}}
+							{{template "repo/issue/filter_item_user_fetch" dict
+								"QueryParamKey" "poster"
+								"QueryLink" $queryLinkWithFilter
+								"SelectedUsername" $.FilterPosterUsername
+								"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster")
+							}}
+						{{end}}
+
+						{{if ne $.ViewType "assigned"}}
+							{{template "repo/issue/filter_item_user_fetch" dict
+								"QueryParamKey" "assignee"
+								"QueryLink" $queryLinkWithFilter
+								"SelectedUsername" $.FilterAssigneeUsername
+								"TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee")
+							}}
+						{{end}}
+
+						<!-- Sort -->
+						<div class="item ui small dropdown jump">
+							<span class="text tw-whitespace-nowrap">
+								{{ctx.Locale.Tr "repo.issues.filter_sort"}}
+								{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+							</span>
+							<div class="menu">
+								<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "recentupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
+								<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastupdate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
+								<a class="{{if eq .SortType "latest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "latest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a>
+								<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "oldest"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
+								<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "mostcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a>
+								<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "leastcomment"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
+								<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "nearduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
+								<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{QueryBuild $queryLinkWithFilter "sort" "farduedate"}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
+							</div>
 						</div>
 					</div>
 				</div>
diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl
index ad6eb25209..c0059d3cd4 100644
--- a/templates/user/dashboard/milestones.tmpl
+++ b/templates/user/dashboard/milestones.tmpl
@@ -52,20 +52,22 @@
 							<input type="hidden" name="state" value="{{$.State}}">
 						{{template "shared/search/combo" dict "Value" $.Keyword}}
 					</form>
-					<!-- Sort -->
-					<div class="list-header-sort ui dropdown type jump item">
-						<span class="text">
-							{{ctx.Locale.Tr "repo.issues.filter_sort"}}
-						</span>
-						{{svg "octicon-triangle-down" 14 "dropdown icon"}}
-						<div class="menu">
-							<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.earliest_due_data"}}</a>
-							<a class="{{if eq .SortType "furthestduedate"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.latest_due_date"}}</a>
-							<a class="{{if eq .SortType "leastcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_complete"}}</a>
-							<a class="{{if eq .SortType "mostcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_complete"}}</a>
-							<a class="{{if eq .SortType "mostissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_issues"}}</a>
-							<a class="{{if eq .SortType "leastissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_issues"}}</a>
-							<a class="{{if eq .SortType "name"}}active {{end}}item" href="{{$.Link}}?sort=name&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.name"}}</a>
+					<div class="list-header-filters">
+						<!-- Sort -->
+						<div class="item ui dropdown jump">
+							<span class="text">
+								{{ctx.Locale.Tr "repo.issues.filter_sort"}}
+							</span>
+							{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+							<div class="menu">
+								<a class="{{if or (eq .SortType "closestduedate") (not .SortType)}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=closestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.earliest_due_data"}}</a>
+								<a class="{{if eq .SortType "furthestduedate"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=furthestduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.latest_due_date"}}</a>
+								<a class="{{if eq .SortType "leastcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_complete"}}</a>
+								<a class="{{if eq .SortType "mostcomplete"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomplete&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_complete"}}</a>
+								<a class="{{if eq .SortType "mostissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.most_issues"}}</a>
+								<a class="{{if eq .SortType "leastissues"}}active {{end}}item" href="?repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastissues&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.least_issues"}}</a>
+								<a class="{{if eq .SortType "name"}}active {{end}}item" href="{{$.Link}}?sort=name&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.milestones.filter_sort.name"}}</a>
+							</div>
 						</div>
 					</div>
 				</div>
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 9e1def87a7..f5785c41a7 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -90,24 +90,6 @@
   left: 0;
 }
 
-.repository .filter.menu .ui.dropdown.label-filter .menu .info {
-  display: inline-block;
-  padding: 0.5rem 0;
-  font-size: 12px;
-  width: 100%;
-  white-space: nowrap;
-  margin-left: 10px;
-  margin-right: 8px;
-  text-align: left;
-}
-
-.repository .filter.menu .ui.dropdown.label-filter .menu .info code {
-  border: 1px solid var(--color-secondary);
-  border-radius: var(--border-radius);
-  padding: 1px 2px;
-  font-size: 11px;
-}
-
 /* For the secondary pointing menu, respect its own border-bottom */
 /* style reference: https://semantic-ui.com/collections/menu.html#pointing */
 .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) {
diff --git a/web_src/css/repo/issue-list.css b/web_src/css/repo/issue-list.css
index 4fafc7d6f8..bf8ff00b7e 100644
--- a/web_src/css/repo/issue-list.css
+++ b/web_src/css/repo/issue-list.css
@@ -73,3 +73,21 @@
   font-size: 12px;
   min-width: fit-content;
 }
+
+.label-filter-exclude-info {
+  display: inline-block;
+  padding: 0.5rem 0;
+  font-size: 12px;
+  width: 100%;
+  white-space: nowrap;
+  margin-left: 10px;
+  margin-right: 8px;
+  text-align: left;
+}
+
+.label-filter-exclude-info code {
+  border: 1px solid var(--color-secondary);
+  border-radius: var(--border-radius);
+  padding: 1px 2px;
+  font-size: 11px;
+}
diff --git a/web_src/css/repo/list-header.css b/web_src/css/repo/list-header.css
index 4440bba8df..e666e046d3 100644
--- a/web_src/css/repo/list-header.css
+++ b/web_src/css/repo/list-header.css
@@ -5,13 +5,6 @@
   gap: .5rem;
 }
 
-.list-header-sort {
-  display: flex;
-  align-items: center;
-  padding-left: 1rem;
-  padding-right: 1rem;
-}
-
 .list-header-search {
   display: flex;
   flex: 1;
@@ -21,8 +14,22 @@
   min-width: 200px; /* to enable flexbox wrapping on mobile */
 }
 
-.list-header-search .input {
+.list-header-search > .ui.input {
   flex: 1;
+  min-width: 100px !important;
+}
+
+.list-header-search > .ui.input .ui.dropdown {
+  min-width: auto !important;
+}
+
+.list-header-filters {
+  display: flex;
+  align-items: center;
+}
+
+.list-header-filters > .item {
+  padding: 5px 0 5px 10px;
 }
 
 @media (max-width: 767.98px) {
@@ -32,8 +39,7 @@
   .list-header-toggle {
     order: 1;
   }
-  .list-header-sort {
+  .list-header-filters {
     order: 2;
-    margin-left: auto;
   }
 }
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index a0550837ec..74d4362bfd 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -95,10 +95,9 @@ function initRepoIssueListCheckboxes() {
 function initDropdownUserRemoteSearch(el: Element) {
   let searchUrl = el.getAttribute('data-search-url');
   const actionJumpUrl = el.getAttribute('data-action-jump-url');
-  const selectedUserId = parseInt(el.getAttribute('data-selected-user-id'));
-  let selectedUsername = '';
-  if (!searchUrl.includes('?')) searchUrl += '?';
+  let selectedUsername = el.getAttribute('data-selected-username') || '';
   const $searchDropdown = fomanticQuery(el);
+  const elMenu = el.querySelector('.menu');
   const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input');
   const elItemFromInput = el.querySelector('.menu > .item-from-input');
 
@@ -110,17 +109,27 @@ function initDropdownUserRemoteSearch(el: Element) {
     },
   });
 
+  const selectUsername = (username: string) => {
+    queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
+    elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
+  };
+
   type ProcessedResult = {value: string, name: string};
   const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
   const syncItemFromInput = () => {
-    elItemFromInput.setAttribute('data-value', elSearchInput.value);
-    elItemFromInput.textContent = elSearchInput.value;
-    toggleElem(elItemFromInput, !processedResults.length);
+    const inputVal = elSearchInput.value.trim();
+    elItemFromInput.setAttribute('data-value', inputVal);
+    elItemFromInput.textContent = inputVal;
+    const showItemFromInput = !processedResults.length && inputVal !== '';
+    toggleElem(elItemFromInput, showItemFromInput);
+    selectUsername(showItemFromInput ? inputVal : selectedUsername);
   };
 
+  elSearchInput.value = selectedUsername;
   if (!searchUrl) {
     elSearchInput.addEventListener('input', syncItemFromInput);
   } else {
+    if (!searchUrl.includes('?')) searchUrl += '?';
     $searchDropdown.dropdown('setting', 'apiSettings', {
       cache: false,
       url: `${searchUrl}&q={query}`,
@@ -130,11 +139,10 @@ function initDropdownUserRemoteSearch(el: Element) {
         for (const item of resp.results) {
           let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
           if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
-          if (selectedUserId === item.user_id) selectedUsername = item.username;
+          if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
           processedResults.push({value: item.username, name: html});
         }
         resp.results = processedResults;
-        syncItemFromInput();
         return resp;
       },
     });
@@ -146,9 +154,8 @@ function initDropdownUserRemoteSearch(el: Element) {
   const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
   $searchDropdown.dropdown('internal', 'setup', dropdownSetup);
   dropdownSetup.menu = function (values) {
-    const menu = $searchDropdown.find('> .menu')[0];
     // remove old dynamic items
-    for (const el of menu.querySelectorAll(':scope > .dynamic-item')) {
+    for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
       el.remove();
     }
 
@@ -160,16 +167,11 @@ function initDropdownUserRemoteSearch(el: Element) {
       }
       const div = document.createElement('div');
       div.classList.add('divider', 'dynamic-item');
-      menu.append(div, ...newMenuItems);
+      elMenu.append(div, ...newMenuItems);
     }
     $searchDropdown.dropdown('refresh');
     // defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
-    setTimeout(() => {
-      for (const el of menu.querySelectorAll('.item.active, .item.selected')) {
-        el.classList.remove('active', 'selected');
-      }
-      menu.querySelector(`.item[data-value="${CSS.escape(selectedUsername)}"]`)?.classList.add('selected');
-    }, 0);
+    setTimeout(() => syncItemFromInput(), 0);
   };
 }
 
@@ -221,8 +223,12 @@ async function initIssuePinSort() {
 }
 
 export function initRepoIssueList() {
-  if (!document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) return;
-  initRepoIssueListCheckboxes();
-  queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
-  initIssuePinSort();
+  if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
+    initRepoIssueListCheckboxes();
+    queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
+    initIssuePinSort();
+  } else if (document.querySelector('.page-content.dashboard.issues')) {
+    // user or org home: issue list, pull request list
+    queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
+  }
 }