diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
index 7657d039..123c1466 100644
--- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx
+++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
@@ -15,6 +15,7 @@ import {
BookOpenText,
SquarePen,
CircleUser,
+ Search,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@@ -155,16 +156,27 @@ export function AppSidebar() {
-
- useModalStore.getState().open("create-issue")}
- >
-
-
-
- New issue
-
+
+
+ useModalStore.getState().open("search-issues")}
+ >
+
+
+ Search issues
+
+
+ useModalStore.getState().open("create-issue")}
+ >
+
+
+
+ New issue
+
+
diff --git a/apps/web/features/modals/registry.tsx b/apps/web/features/modals/registry.tsx
index faf2e63a..02b60c3d 100644
--- a/apps/web/features/modals/registry.tsx
+++ b/apps/web/features/modals/registry.tsx
@@ -3,6 +3,7 @@
import { useModalStore } from "./store";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
+import { SearchIssuesModal } from "./search-issues";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
@@ -14,6 +15,8 @@ export function ModalRegistry() {
return ;
case "create-issue":
return ;
+ case "search-issues":
+ return ;
default:
return null;
}
diff --git a/apps/web/features/modals/search-issues.tsx b/apps/web/features/modals/search-issues.tsx
new file mode 100644
index 00000000..d8740591
--- /dev/null
+++ b/apps/web/features/modals/search-issues.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import {
+ CommandDialog,
+ Command,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from "@/components/ui/command";
+import { Skeleton } from "@/components/ui/skeleton";
+import { StatusIcon } from "@/features/issues/components";
+import { api } from "@/shared/api";
+import type { Issue } from "@/shared/types";
+
+function SearchSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export function SearchIssuesModal({ onClose }: { onClose: () => void }) {
+ const router = useRouter();
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const debounceRef = useRef>(null);
+ const composingRef = useRef(false);
+
+ const search = useCallback(async (q: string) => {
+ if (!q.trim()) {
+ setResults([]);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await api.listIssues({ search: q.trim(), limit: 20 });
+ setResults(res.issues);
+ } catch {
+ setResults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const scheduleSearch = useCallback(
+ (q: string) => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (q.trim()) setLoading(true);
+ debounceRef.current = setTimeout(() => search(q), 300);
+ },
+ [search]
+ );
+
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, []);
+
+ const handleValueChange = (value: string) => {
+ setQuery(value);
+ if (!composingRef.current) {
+ scheduleSearch(value);
+ }
+ };
+
+ const handleCompositionStart = () => {
+ composingRef.current = true;
+ };
+
+ const handleCompositionEnd = (e: React.CompositionEvent) => {
+ composingRef.current = false;
+ scheduleSearch((e.target as HTMLInputElement).value);
+ };
+
+ const handleSelect = (issue: Issue) => {
+ onClose();
+ router.push(`/issues/${issue.id}`);
+ };
+
+ return (
+ {
+ if (!open) onClose();
+ }}
+ title="Search Issues"
+ description="Search issues by title"
+ className="top-[min(33%,12rem)]"
+ >
+
+
+
+ {!query.trim() ? (
+
+ Type to search issues by title
+
+ ) : loading ? (
+
+ ) : (
+ <>
+ No issues found
+
+ {results.map((issue) => (
+ handleSelect(issue)}
+ >
+
+ {issue.title}
+
+ {issue.identifier}
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/features/modals/store.ts b/apps/web/features/modals/store.ts
index f720be43..0a5a946d 100644
--- a/apps/web/features/modals/store.ts
+++ b/apps/web/features/modals/store.ts
@@ -2,7 +2,7 @@
import { create } from "zustand";
-type ModalType = "create-workspace" | "create-issue" | null;
+type ModalType = "create-workspace" | "create-issue" | "search-issues" | null;
interface ModalStore {
modal: ModalType;
diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts
index 65004b34..603be220 100644
--- a/apps/web/shared/api/client.ts
+++ b/apps/web/shared/api/client.ts
@@ -163,6 +163,7 @@ export class ApiClient {
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
+ if (params?.search) search.set("search", params.search);
return this.fetch(`/api/issues?${search}`);
}
diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts
index cdeeae4e..a06b3fdb 100644
--- a/apps/web/shared/types/api.ts
+++ b/apps/web/shared/types/api.ts
@@ -31,6 +31,7 @@ export interface ListIssuesParams {
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
+ search?: string;
}
export interface ListIssuesResponse {
diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go
index 0c5a0d6a..64123151 100644
--- a/server/internal/handler/issue.go
+++ b/server/internal/handler/issue.go
@@ -99,6 +99,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
+ var searchFilter pgtype.Text
+ if s := r.URL.Query().Get("search"); s != "" {
+ searchFilter = pgtype.Text{String: s, Valid: true}
+ }
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
@@ -107,6 +111,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
+ Search: searchFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go
index f899eb6e..208a3800 100644
--- a/server/pkg/db/generated/issue.sql.go
+++ b/server/pkg/db/generated/issue.sql.go
@@ -195,6 +195,7 @@ WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
AND ($6::uuid IS NULL OR assignee_id = $6)
+ AND ($7::text IS NULL OR title ILIKE '%' || $7 || '%')
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3
`
@@ -206,6 +207,7 @@ type ListIssuesParams struct {
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
+ Search pgtype.Text `json:"search"`
}
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
@@ -216,6 +218,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
arg.Status,
arg.Priority,
arg.AssigneeID,
+ arg.Search,
)
if err != nil {
return nil, err
diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql
index edc229c3..3082abab 100644
--- a/server/pkg/db/queries/issue.sql
+++ b/server/pkg/db/queries/issue.sql
@@ -4,6 +4,7 @@ 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'))
+ AND (sqlc.narg('search')::text IS NULL OR title ILIKE '%' || sqlc.narg('search') || '%')
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;