+
{/* Filter */}
diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx
index 45165384..2f46c415 100644
--- a/apps/web/features/issues/components/issues-page.tsx
+++ b/apps/web/features/issues/components/issues-page.tsx
@@ -1,32 +1,56 @@
"use client";
-import { useCallback, useEffect, useMemo } from "react";
+import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
+import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
-import type { IssueStatus } from "@/shared/types";
+import type { Issue, IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
import { filterIssues } from "@/features/issues/utils/filter";
-import { BOARD_STATUSES } from "@/features/issues/config";
+import {
+ filterIssuesBySearch,
+ getSearchConstrainedStatuses,
+ parseIssueSearch,
+} from "@/features/issues/utils/search";
+import { ALL_STATUSES, BOARD_STATUSES } from "@/features/issues/config";
import { useWorkspaceStore } from "@/features/workspace";
import { WorkspaceAvatar } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { issueListOptions } from "@core/issues/queries";
import { useUpdateIssue } from "@core/issues/mutations";
+import { api } from "@/shared/api";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { IssuesHeader } from "./issues-header";
import { BoardView } from "./board-view";
import { ListView } from "./list-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
+function mergeIssuesById(base: Issue[] | null, live: Issue[]): Issue[] {
+ const byId = new Map();
+
+ for (const issue of base ?? []) {
+ byId.set(issue.id, issue);
+ }
+
+ for (const issue of live) {
+ byId.set(issue.id, issue);
+ }
+
+ return Array.from(byId.values());
+}
+
export function IssuesPage() {
const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
-
+ const searchParams = useSearchParams();
+ const urlSearchQuery = searchParams.get("q") ?? "";
const workspace = useWorkspaceStore((s) => s.workspace);
+ const members = useWorkspaceStore((s) => s.members);
+ const agents = useWorkspaceStore((s) => s.agents);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
@@ -34,38 +58,171 @@ export function IssuesPage() {
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
+ const [searchInputValue, setSearchInputValue] = useState(urlSearchQuery);
+ const [searchQuery, setSearchQueryState] = useState(urlSearchQuery);
+ const isSearchComposingRef = useRef(false);
+ const [searchPool, setSearchPool] = useState(null);
+ const [searchPoolWorkspaceId, setSearchPoolWorkspaceId] = useState(null);
+ const [searchLoading, setSearchLoading] = useState(false);
+ const deferredSearchQuery = useDeferredValue(searchQuery);
+ const hasActiveSearch = deferredSearchQuery.trim().length > 0;
+ const hasActiveFilters =
+ statusFilters.length > 0 ||
+ priorityFilters.length > 0 ||
+ assigneeFilters.length > 0 ||
+ includeNoAssignee ||
+ creatorFilters.length > 0;
+
+ const replaceSearchUrl = useCallback((nextQuery: string) => {
+ const params = new URLSearchParams(window.location.search);
+ if (nextQuery.trim()) {
+ params.set("q", nextQuery);
+ } else {
+ params.delete("q");
+ }
+
+ const next = params.toString();
+ window.history.replaceState(null, "", next ? `/issues?${next}` : "/issues");
+ }, []);
+
+ const applySearchQuery = useCallback((nextQuery: string) => {
+ setSearchQueryState(nextQuery);
+ replaceSearchUrl(nextQuery);
+ }, [replaceSearchUrl]);
+
+ const handleSearchInputChange = useCallback((nextQuery: string) => {
+ setSearchInputValue(nextQuery);
+ if (!isSearchComposingRef.current) {
+ applySearchQuery(nextQuery);
+ }
+ }, [applySearchQuery]);
+
+ const handleSearchCompositionStart = useCallback(() => {
+ isSearchComposingRef.current = true;
+ }, []);
+
+ const handleSearchCompositionEnd = useCallback((nextQuery: string) => {
+ isSearchComposingRef.current = false;
+ setSearchInputValue(nextQuery);
+ applySearchQuery(nextQuery);
+ }, [applySearchQuery]);
useEffect(() => {
initFilterWorkspaceSync();
}, []);
+ useEffect(() => {
+ setSearchInputValue(urlSearchQuery);
+ setSearchQueryState(urlSearchQuery);
+ }, [urlSearchQuery]);
+
+ useEffect(() => {
+ setSearchPool(null);
+ setSearchPoolWorkspaceId(null);
+ }, [workspace?.id]);
+
+ useEffect(() => {
+ if (!hasActiveSearch || !workspace?.id) return;
+ if (searchPool && searchPoolWorkspaceId === workspace.id) return;
+
+ let cancelled = false;
+ setSearchLoading(true);
+
+ api.listIssues({ all: true })
+ .then((res) => {
+ if (cancelled) return;
+ setSearchPool(res.issues);
+ setSearchPoolWorkspaceId(workspace.id);
+ })
+ .catch((error) => {
+ if (cancelled) return;
+ console.error(error);
+ toast.error("Failed to search all issues");
+ })
+ .finally(() => {
+ if (!cancelled) setSearchLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]);
+
useEffect(() => {
useIssueSelectionStore.getState().clear();
- }, [viewMode, scope]);
+ }, [viewMode, scope, deferredSearchQuery]);
+
+ const parsedSearch = useMemo(
+ () => parseIssueSearch(deferredSearchQuery, { members, agents }),
+ [agents, deferredSearchQuery, members],
+ );
+
+ const searchableIssues = useMemo(() => {
+ if (!hasActiveSearch) return allIssues;
+ if (searchPoolWorkspaceId !== workspace?.id) return allIssues;
+ return mergeIssuesById(searchPool, allIssues);
+ }, [allIssues, hasActiveSearch, searchPool, searchPoolWorkspaceId, workspace?.id]);
// Scope pre-filter: narrow by assignee type
const scopedIssues = useMemo(() => {
if (scope === "members")
- return allIssues.filter((i) => i.assignee_type === "member");
+ return searchableIssues.filter((i) => i.assignee_type === "member");
if (scope === "agents")
- return allIssues.filter((i) => i.assignee_type === "agent");
- return allIssues;
- }, [allIssues, scope]);
+ return searchableIssues.filter((i) => i.assignee_type === "agent");
+ return searchableIssues;
+ }, [scope, searchableIssues]);
+
+ const filteredIssues = useMemo(
+ () =>
+ filterIssues(scopedIssues, {
+ statusFilters,
+ priorityFilters,
+ assigneeFilters,
+ includeNoAssignee,
+ creatorFilters,
+ }),
+ [
+ assigneeFilters,
+ creatorFilters,
+ includeNoAssignee,
+ priorityFilters,
+ scopedIssues,
+ statusFilters,
+ ],
+ );
const issues = useMemo(
- () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
- [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
+ () => filterIssuesBySearch(filteredIssues, parsedSearch, { members, agents }),
+ [agents, filteredIssues, members, parsedSearch],
);
const visibleStatuses = useMemo(() => {
- if (statusFilters.length > 0)
- return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
+ const explicitSearchStatuses = getSearchConstrainedStatuses(parsedSearch);
+ const explicitStatuses =
+ statusFilters.length > 0 || explicitSearchStatuses
+ ? ALL_STATUSES.filter((status) => {
+ if (statusFilters.length > 0 && !statusFilters.includes(status)) {
+ return false;
+ }
+ if (explicitSearchStatuses && !explicitSearchStatuses.includes(status)) {
+ return false;
+ }
+ return true;
+ })
+ : null;
+
+ if (explicitStatuses) return explicitStatuses;
+ if (hasActiveSearch) {
+ const resultStatuses = new Set(issues.map((issue) => issue.status));
+ return ALL_STATUSES.filter((status) => resultStatuses.has(status));
+ }
return BOARD_STATUSES;
- }, [statusFilters]);
+ }, [hasActiveSearch, issues, parsedSearch, statusFilters]);
const hiddenStatuses = useMemo(() => {
+ if (hasActiveSearch) return [];
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
- }, [visibleStatuses]);
+ }, [hasActiveSearch, visibleStatuses]);
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
@@ -127,22 +284,38 @@ export function IssuesPage() {
{/* Header 2: Scope tabs + filters */}
-
+
{/* Content: scrollable */}
- {scopedIssues.length === 0 ? (
+ {scopedIssues.length === 0 && !hasActiveSearch && !hasActiveFilters ? (
No issues yet
Create an issue to get started.
+ ) : issues.length === 0 ? (
+
+
+
No issues match this search
+
+ Try `#123`, `status:done`, `assignee:alice`, or looser keywords.
+
+
) : (
{viewMode === "board" ? (
= {}): Issue {
+ return {
+ id: "i-1",
+ workspace_id: "ws-1",
+ number: 1,
+ identifier: "MUL-1",
+ title: "Test issue",
+ description: null,
+ status: "todo",
+ priority: "medium",
+ assignee_type: null,
+ assignee_id: null,
+ creator_type: "member",
+ creator_id: "u-1",
+ parent_issue_id: null,
+ position: 0,
+ due_date: null,
+ created_at: "2025-01-01T00:00:00Z",
+ updated_at: "2025-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+const members: Pick[] = [
+ { user_id: "u-1", name: "Alice Chen" },
+ { user_id: "u-2", name: "Bob Wong" },
+];
+
+const agents: Pick[] = [
+ { id: "a-1", name: "Fixer Bot" },
+ { id: "a-2", name: "Review Agent" },
+];
+
+const context = {
+ members,
+ agents,
+ now: new Date("2026-04-08T10:00:00Z"),
+};
+
+const issues: Issue[] = [
+ makeIssue({
+ id: "1",
+ number: 11,
+ identifier: "MUL-11",
+ title: "Fix login redirect loop",
+ description: "Users bounce back to the sign-in page.",
+ status: "todo",
+ priority: "high",
+ assignee_type: "member",
+ assignee_id: "u-1",
+ }),
+ makeIssue({
+ id: "2",
+ number: 12,
+ identifier: "MUL-12",
+ title: "Improve issue search",
+ description: "Search title, description, and actor names.",
+ status: "in_progress",
+ priority: "urgent",
+ assignee_type: "agent",
+ assignee_id: "a-1",
+ creator_type: "agent",
+ creator_id: "a-2",
+ due_date: "2026-04-08T12:00:00Z",
+ }),
+ makeIssue({
+ id: "3",
+ number: 18,
+ identifier: "MUL-18",
+ title: "Archive old tickets",
+ description: "",
+ status: "done",
+ priority: "low",
+ creator_id: "u-2",
+ due_date: "2026-04-07T09:00:00Z",
+ }),
+];
+
+describe("parseIssueSearch", () => {
+ it("extracts structured filters and quoted text", () => {
+ const parsed = parseIssueSearch(
+ 'status:todo priority:high assignee:"Alice" "redirect loop"',
+ context,
+ );
+
+ expect(parsed.statusFilters).toEqual(["todo"]);
+ expect(parsed.priorityFilters).toEqual(["high"]);
+ expect(parsed.assigneeFilters).toEqual([{ type: "member", id: "u-1" }]);
+ expect(parsed.textTerms).toEqual(["redirect loop"]);
+ });
+
+ it("recognizes issue numbers and lifecycle shortcuts", () => {
+ const parsed = parseIssueSearch("#18 is:closed", context);
+
+ expect(parsed.issueNumber).toBe(18);
+ expect(parsed.lifecycle).toBe("closed");
+ expect(getSearchConstrainedStatuses(parsed)).toEqual(["done", "cancelled"]);
+ });
+});
+
+describe("filterIssuesBySearch", () => {
+ it("matches free text across title, description, identifier, and actor names", () => {
+ const parsed = parseIssueSearch("Fixer review MUL-12", context);
+ const result = filterIssuesBySearch(issues, parsed, context);
+
+ expect(result.map((issue) => issue.id)).toEqual(["2"]);
+ });
+
+ it("filters by assignee, creator, and explicit status tokens", () => {
+ const parsed = parseIssueSearch("assignee:Fixer creator:Review status:in-progress", context);
+ const result = filterIssuesBySearch(issues, parsed, context);
+
+ expect(result.map((issue) => issue.id)).toEqual(["2"]);
+ });
+
+ it("filters by due state and description presence", () => {
+ const todayParsed = parseIssueSearch("due:today has:description", context);
+ const overdueParsed = parseIssueSearch("due:overdue", context);
+
+ expect(filterIssuesBySearch(issues, todayParsed, context).map((issue) => issue.id)).toEqual(["2"]);
+ expect(filterIssuesBySearch(issues, overdueParsed, context).map((issue) => issue.id)).toEqual(["3"]);
+ });
+
+ it("supports unassigned and closed search flows", () => {
+ const parsed = parseIssueSearch("is:closed is:unassigned", context);
+ const result = filterIssuesBySearch(issues, parsed, context);
+
+ expect(result.map((issue) => issue.id)).toEqual(["3"]);
+ });
+});
diff --git a/apps/web/features/issues/utils/search.ts b/apps/web/features/issues/utils/search.ts
new file mode 100644
index 00000000..a950b2ad
--- /dev/null
+++ b/apps/web/features/issues/utils/search.ts
@@ -0,0 +1,454 @@
+import type {
+ Agent,
+ Issue,
+ IssuePriority,
+ IssueStatus,
+ MemberWithUser,
+} from "@/shared/types";
+import { PRIORITY_CONFIG, STATUS_CONFIG, ALL_STATUSES } from "@/features/issues/config";
+import type { ActorFilterValue } from "@/features/issues/stores/view-store";
+
+const CLOSED_STATUSES = new Set(["done", "cancelled"]);
+
+const TOKEN_REGEX = /"([^"]+)"|(\S+)/g;
+
+const STATUS_ALIASES: Record = {
+ backlog: "backlog",
+ todo: "todo",
+ "to do": "todo",
+ "in progress": "in_progress",
+ inprogress: "in_progress",
+ progress: "in_progress",
+ "in review": "in_review",
+ inreview: "in_review",
+ review: "in_review",
+ done: "done",
+ blocked: "blocked",
+ cancelled: "cancelled",
+ canceled: "cancelled",
+};
+
+const PRIORITY_ALIASES: Record = {
+ urgent: "urgent",
+ high: "high",
+ medium: "medium",
+ normal: "medium",
+ low: "low",
+ none: "none",
+ "no priority": "none",
+};
+
+export type IssueSearchLifecycle = "all" | "open" | "closed";
+export type IssueSearchDueState = "any" | "overdue" | "today" | "none" | "upcoming";
+export type IssueSearchAssigneeState = "any" | "assigned" | "unassigned";
+
+export interface IssueSearchContext {
+ members: Pick[];
+ agents: Pick[];
+ now?: Date;
+}
+
+export interface ParsedIssueSearch {
+ raw: string;
+ textTerms: string[];
+ issueNumber: number | null;
+ statusFilters: IssueStatus[];
+ priorityFilters: IssuePriority[];
+ assigneeFilters: ActorFilterValue[];
+ creatorFilters: ActorFilterValue[];
+ assigneeState: IssueSearchAssigneeState;
+ lifecycle: IssueSearchLifecycle;
+ dueState: IssueSearchDueState;
+ hasDescription: boolean | null;
+ forceEmpty: boolean;
+}
+
+function normalizeSearchText(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[_-]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function unwrapQuotedValue(value: string): string {
+ const trimmed = value.trim();
+ if (
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
+ ) {
+ return trimmed.slice(1, -1).trim();
+ }
+ return trimmed;
+}
+
+function addUniqueValue(items: T[], value: T) {
+ if (!items.includes(value)) items.push(value);
+}
+
+function addUniqueActorFilters(target: ActorFilterValue[], next: ActorFilterValue[]) {
+ for (const actor of next) {
+ const exists = target.some(
+ (item) => item.type === actor.type && item.id === actor.id,
+ );
+ if (!exists) target.push(actor);
+ }
+}
+
+function parseIssueNumberToken(token: string): number | null {
+ const hashMatch = token.match(/^#(\d+)$/);
+ if (hashMatch) return Number(hashMatch[1]);
+
+ const identifierMatch = token.match(/^[a-z][a-z0-9]*-(\d+)$/i);
+ if (identifierMatch) return Number(identifierMatch[1]);
+
+ return null;
+}
+
+function resolveStatus(value: string): IssueStatus | null {
+ return STATUS_ALIASES[normalizeSearchText(value)] ?? null;
+}
+
+function resolvePriority(value: string): IssuePriority | null {
+ return PRIORITY_ALIASES[normalizeSearchText(value)] ?? null;
+}
+
+function resolveActors(
+ value: string,
+ context: IssueSearchContext,
+): ActorFilterValue[] {
+ const query = normalizeSearchText(value);
+ if (!query) return [];
+
+ const matches: ActorFilterValue[] = [];
+
+ for (const member of context.members) {
+ if (normalizeSearchText(member.name).includes(query)) {
+ matches.push({ type: "member", id: member.user_id });
+ }
+ }
+
+ for (const agent of context.agents) {
+ if (normalizeSearchText(agent.name).includes(query)) {
+ matches.push({ type: "agent", id: agent.id });
+ }
+ }
+
+ return matches;
+}
+
+function getActorName(
+ type: Issue["creator_type"] | Issue["assignee_type"],
+ id: string | null,
+ context: IssueSearchContext,
+): string {
+ if (!type || !id) return "";
+ if (type === "member") {
+ return context.members.find((member) => member.user_id === id)?.name ?? "";
+ }
+ return context.agents.find((agent) => agent.id === id)?.name ?? "";
+}
+
+function buildIssueHaystack(issue: Issue, context: IssueSearchContext): string {
+ const assigneeName = getActorName(issue.assignee_type, issue.assignee_id, context);
+ const creatorName = getActorName(issue.creator_type, issue.creator_id, context);
+
+ return normalizeSearchText(
+ [
+ issue.identifier,
+ `#${issue.number}`,
+ String(issue.number),
+ issue.title,
+ issue.description ?? "",
+ issue.status,
+ STATUS_CONFIG[issue.status].label,
+ issue.priority,
+ PRIORITY_CONFIG[issue.priority].label,
+ assigneeName,
+ creatorName,
+ ].join(" "),
+ );
+}
+
+function isSameLocalDay(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}
+
+export function tokenizeIssueSearch(query: string): string[] {
+ const tokens: string[] = [];
+ for (const match of query.matchAll(TOKEN_REGEX)) {
+ const value = (match[1] ?? match[2] ?? "").trim();
+ if (value) tokens.push(value);
+ }
+ return tokens;
+}
+
+export function parseIssueSearch(
+ query: string,
+ context: IssueSearchContext,
+): ParsedIssueSearch {
+ const parsed: ParsedIssueSearch = {
+ raw: query,
+ textTerms: [],
+ issueNumber: null,
+ statusFilters: [],
+ priorityFilters: [],
+ assigneeFilters: [],
+ creatorFilters: [],
+ assigneeState: "any",
+ lifecycle: "all",
+ dueState: "any",
+ hasDescription: null,
+ forceEmpty: false,
+ };
+
+ for (const token of tokenizeIssueSearch(query)) {
+ const issueNumber = parseIssueNumberToken(token);
+ if (issueNumber !== null) {
+ if (parsed.issueNumber !== null && parsed.issueNumber !== issueNumber) {
+ parsed.forceEmpty = true;
+ } else {
+ parsed.issueNumber = issueNumber;
+ }
+ continue;
+ }
+
+ if (token.startsWith("@")) {
+ const matches = resolveActors(token.slice(1), context);
+ if (matches.length === 0) {
+ parsed.forceEmpty = true;
+ } else {
+ addUniqueActorFilters(parsed.assigneeFilters, matches);
+ }
+ continue;
+ }
+
+ const separatorIndex = token.indexOf(":");
+ if (separatorIndex <= 0) {
+ parsed.textTerms.push(token);
+ continue;
+ }
+
+ const key = token.slice(0, separatorIndex).toLowerCase();
+ const rawValue = unwrapQuotedValue(token.slice(separatorIndex + 1));
+ if (!rawValue) {
+ parsed.textTerms.push(token);
+ continue;
+ }
+
+ switch (key) {
+ case "status":
+ case "state": {
+ const status = resolveStatus(rawValue);
+ if (!status) {
+ parsed.forceEmpty = true;
+ break;
+ }
+ addUniqueValue(parsed.statusFilters, status);
+ break;
+ }
+ case "priority":
+ case "p": {
+ const priority = resolvePriority(rawValue);
+ if (!priority) {
+ parsed.forceEmpty = true;
+ break;
+ }
+ addUniqueValue(parsed.priorityFilters, priority);
+ break;
+ }
+ case "assignee":
+ case "assigned": {
+ const normalizedValue = normalizeSearchText(rawValue);
+ if (normalizedValue === "none" || normalizedValue === "unassigned") {
+ parsed.assigneeState = "unassigned";
+ break;
+ }
+ const matches = resolveActors(rawValue, context);
+ if (matches.length === 0) {
+ parsed.forceEmpty = true;
+ break;
+ }
+ addUniqueActorFilters(parsed.assigneeFilters, matches);
+ break;
+ }
+ case "creator":
+ case "author":
+ case "by": {
+ const matches = resolveActors(rawValue, context);
+ if (matches.length === 0) {
+ parsed.forceEmpty = true;
+ break;
+ }
+ addUniqueActorFilters(parsed.creatorFilters, matches);
+ break;
+ }
+ case "is": {
+ const normalizedValue = normalizeSearchText(rawValue);
+ if (normalizedValue === "open") {
+ parsed.lifecycle = "open";
+ } else if (normalizedValue === "closed") {
+ parsed.lifecycle = "closed";
+ } else if (normalizedValue === "assigned") {
+ parsed.assigneeState = "assigned";
+ } else if (normalizedValue === "unassigned") {
+ parsed.assigneeState = "unassigned";
+ } else {
+ parsed.forceEmpty = true;
+ }
+ break;
+ }
+ case "has": {
+ const normalizedValue = normalizeSearchText(rawValue);
+ if (normalizedValue === "description" || normalizedValue === "desc") {
+ parsed.hasDescription = true;
+ } else {
+ parsed.forceEmpty = true;
+ }
+ break;
+ }
+ case "due": {
+ const normalizedValue = normalizeSearchText(rawValue);
+ if (
+ normalizedValue === "today" ||
+ normalizedValue === "overdue" ||
+ normalizedValue === "none" ||
+ normalizedValue === "upcoming"
+ ) {
+ parsed.dueState = normalizedValue;
+ } else {
+ parsed.forceEmpty = true;
+ }
+ break;
+ }
+ default:
+ parsed.textTerms.push(token);
+ break;
+ }
+ }
+
+ return parsed;
+}
+
+export function getSearchConstrainedStatuses(
+ parsed: ParsedIssueSearch,
+): IssueStatus[] | null {
+ if (parsed.statusFilters.length > 0) {
+ return ALL_STATUSES.filter((status) => parsed.statusFilters.includes(status));
+ }
+ if (parsed.lifecycle === "open") {
+ return ALL_STATUSES.filter((status) => !CLOSED_STATUSES.has(status));
+ }
+ if (parsed.lifecycle === "closed") {
+ return ALL_STATUSES.filter((status) => CLOSED_STATUSES.has(status));
+ }
+ return null;
+}
+
+export function filterIssuesBySearch(
+ issues: Issue[],
+ parsed: ParsedIssueSearch,
+ context: IssueSearchContext,
+): Issue[] {
+ if (parsed.forceEmpty) return [];
+
+ return issues.filter((issue) => {
+ if (parsed.issueNumber !== null && issue.number !== parsed.issueNumber) {
+ return false;
+ }
+
+ if (parsed.lifecycle === "open" && CLOSED_STATUSES.has(issue.status)) {
+ return false;
+ }
+
+ if (parsed.lifecycle === "closed" && !CLOSED_STATUSES.has(issue.status)) {
+ return false;
+ }
+
+ if (
+ parsed.statusFilters.length > 0 &&
+ !parsed.statusFilters.includes(issue.status)
+ ) {
+ return false;
+ }
+
+ if (
+ parsed.priorityFilters.length > 0 &&
+ !parsed.priorityFilters.includes(issue.priority)
+ ) {
+ return false;
+ }
+
+ if (parsed.assigneeState === "assigned" && !issue.assignee_id) {
+ return false;
+ }
+
+ if (parsed.assigneeState === "unassigned" && issue.assignee_id) {
+ return false;
+ }
+
+ if (parsed.assigneeFilters.length > 0) {
+ if (!issue.assignee_type || !issue.assignee_id) return false;
+ const matchesAssignee = parsed.assigneeFilters.some(
+ (assignee) =>
+ assignee.type === issue.assignee_type &&
+ assignee.id === issue.assignee_id,
+ );
+ if (!matchesAssignee) return false;
+ }
+
+ if (parsed.creatorFilters.length > 0) {
+ const matchesCreator = parsed.creatorFilters.some(
+ (creator) =>
+ creator.type === issue.creator_type && creator.id === issue.creator_id,
+ );
+ if (!matchesCreator) return false;
+ }
+
+ if (
+ parsed.hasDescription === true &&
+ (!issue.description || issue.description.trim().length === 0)
+ ) {
+ return false;
+ }
+
+ if (parsed.dueState === "none" && issue.due_date) {
+ return false;
+ }
+
+ if (parsed.dueState !== "any" && parsed.dueState !== "none") {
+ if (!issue.due_date) return false;
+ const dueDate = new Date(issue.due_date);
+ const now = parsedDateNow(context);
+
+ if (parsed.dueState === "today" && !isSameLocalDay(dueDate, now)) {
+ return false;
+ }
+
+ if (parsed.dueState === "overdue" && dueDate.getTime() >= now.getTime()) {
+ return false;
+ }
+
+ if (parsed.dueState === "upcoming") {
+ if (dueDate.getTime() <= now.getTime() || isSameLocalDay(dueDate, now)) {
+ return false;
+ }
+ }
+ }
+
+ if (parsed.textTerms.length === 0) return true;
+
+ const haystack = buildIssueHaystack(issue, context);
+ return parsed.textTerms.every((term) =>
+ haystack.includes(normalizeSearchText(term)),
+ );
+ });
+}
+
+function parsedDateNow(context: IssueSearchContext): Date {
+ return context.now ? new Date(context.now) : new Date();
+}
diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts
index 2c3a4207..bf75f756 100644
--- a/apps/web/shared/api/client.ts
+++ b/apps/web/shared/api/client.ts
@@ -167,6 +167,7 @@ export class ApiClient {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
+ if (params?.all) search.set("all", "true");
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.status) search.set("status", params.status);
diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts
index 39e4d712..3be965d9 100644
--- a/apps/web/shared/types/api.ts
+++ b/apps/web/shared/types/api.ts
@@ -28,6 +28,7 @@ export interface UpdateIssueRequest {
export interface ListIssuesParams {
limit?: number;
offset?: number;
+ all?: boolean;
workspace_id?: string;
status?: IssueStatus;
priority?: IssuePriority;
diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go
index 8b798bc9..f714ef56 100644
--- a/server/internal/handler/handler_test.go
+++ b/server/internal/handler/handler_test.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "strconv"
"strings"
"testing"
@@ -299,6 +300,57 @@ func TestCommentCRUD(t *testing.T) {
testHandler.DeleteIssue(w, req)
}
+func TestListIssuesAllIgnoresPagination(t *testing.T) {
+ createdIDs := make([]string, 0, 3)
+
+ for i := 0; i < 3; i++ {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Searchable issue " + strconv.Itoa(i+1),
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+ createdIDs = append(createdIDs, issue.ID)
+ }
+
+ t.Cleanup(func() {
+ for _, issueID := range createdIDs {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/issues/"+issueID, nil)
+ req = withURLParam(req, "id", issueID)
+ testHandler.DeleteIssue(w, req)
+ }
+ })
+
+ w := httptest.NewRecorder()
+ req := newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID+"&all=true&limit=1", nil)
+ testHandler.ListIssues(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("ListIssues(all=true): expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var listResp struct {
+ Issues []IssueResponse `json:"issues"`
+ Total int `json:"total"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
+ t.Fatalf("ListIssues(all=true): decode response: %v", err)
+ }
+
+ if len(listResp.Issues) < 3 {
+ t.Fatalf("ListIssues(all=true): expected at least 3 issues, got %d", len(listResp.Issues))
+ }
+
+ if listResp.Total != len(listResp.Issues) {
+ t.Fatalf("ListIssues(all=true): expected total %d, got %d", len(listResp.Issues), listResp.Total)
+ }
+}
+
func TestAgentCRUD(t *testing.T) {
// List agents
w := httptest.NewRecorder()
diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go
index af7fc638..2dbb5444 100644
--- a/server/internal/handler/issue.go
+++ b/server/internal/handler/issue.go
@@ -103,6 +103,31 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
return
}
+ if r.URL.Query().Get("all") == "true" {
+ issues, err := h.Queries.ListAllIssues(ctx, db.ListAllIssuesParams{
+ WorkspaceID: wsUUID,
+ Status: statusFilterFromQuery(r),
+ Priority: priorityFilter,
+ AssigneeID: assigneeFilter,
+ })
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to list issues")
+ return
+ }
+
+ prefix := h.getIssuePrefix(ctx, wsUUID)
+ resp := make([]IssueResponse, len(issues))
+ for i, issue := range issues {
+ resp[i] = issueToResponse(issue, prefix)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "issues": resp,
+ "total": len(resp),
+ })
+ return
+ }
+
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
@@ -116,10 +141,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
}
- var statusFilter pgtype.Text
- if s := r.URL.Query().Get("status"); s != "" {
- statusFilter = pgtype.Text{String: s, Valid: true}
- }
+ statusFilter := statusFilterFromQuery(r)
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: wsUUID,
@@ -157,6 +179,14 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
})
}
+func statusFilterFromQuery(r *http.Request) pgtype.Text {
+ var statusFilter pgtype.Text
+ if s := r.URL.Query().Get("status"); s != "" {
+ statusFilter = pgtype.Text{String: s, Valid: true}
+ }
+ return statusFilter
+}
+
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go
index 97ec6788..f943ddef 100644
--- a/server/pkg/db/generated/issue.sql.go
+++ b/server/pkg/db/generated/issue.sql.go
@@ -216,6 +216,66 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
return i, err
}
+const listAllIssues = `-- name: ListAllIssues :many
+SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
+WHERE workspace_id = $1
+ AND ($2::text IS NULL OR status = $2)
+ AND ($3::text IS NULL OR priority = $3)
+ AND ($4::uuid IS NULL OR assignee_id = $4)
+ORDER BY position ASC, created_at DESC
+`
+
+type ListAllIssuesParams struct {
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Status pgtype.Text `json:"status"`
+ Priority pgtype.Text `json:"priority"`
+ AssigneeID pgtype.UUID `json:"assignee_id"`
+}
+
+func (q *Queries) ListAllIssues(ctx context.Context, arg ListAllIssuesParams) ([]Issue, error) {
+ rows, err := q.db.Query(ctx, listAllIssues,
+ arg.WorkspaceID,
+ arg.Status,
+ arg.Priority,
+ arg.AssigneeID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Issue{}
+ for rows.Next() {
+ var i Issue
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Title,
+ &i.Description,
+ &i.Status,
+ &i.Priority,
+ &i.AssigneeType,
+ &i.AssigneeID,
+ &i.CreatorType,
+ &i.CreatorID,
+ &i.ParentIssueID,
+ &i.AcceptanceCriteria,
+ &i.ContextRefs,
+ &i.Position,
+ &i.DueDate,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Number,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listIssues = `-- name: ListIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1
diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql
index c8821ffb..662a45d2 100644
--- a/server/pkg/db/queries/issue.sql
+++ b/server/pkg/db/queries/issue.sql
@@ -7,6 +7,14 @@ WHERE workspace_id = $1
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
+-- name: ListAllIssues :many
+SELECT * FROM issue
+WHERE workspace_id = $1
+ AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
+ AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
+ AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
+ORDER BY position ASC, created_at DESC;
+
-- name: GetIssue :one
SELECT * FROM issue
WHERE id = $1;