Files
kandev/apps/web/app/linear/linear-page-client.tsx
T
João Salavisa fc01fa37cb fix(linear): match issue identifier (ENG-123) in search (#1340)
* fix(linear): match issue identifier (ENG-123) in search

Linear's `searchableContent` filter never matches the issue identifier
(team key + number), so a query like `ENG-123` from the Linear view
returned no results. When the query parses as a `TEAM-NUM` identifier,
OR-combine the content match with `{team.key.eq, number.eq}` so the
exact issue surfaces while other filters keep AND-joining. Lowercase
inputs (`eng-123`) are normalized to the upper-case team key.

Fixes #1339

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(linear): mention ID in search box placeholder

Reflect the backend's new identifier-aware search by hinting at it in
the search input on `/linear`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(linear): explain why identifier search keeps both OR branches

claude[bot] flagged the searchableContent branch in the identifier OR
as functionally dead and the surrounding comment as a sentence fragment.
The branch is intentional — it catches cross-references like "duplicate
of ENG-123" pasted into another issue's title or description, which the
exact team+number branch would miss. Rewrite the comment to explain
that, fixing the fragment in the same change.

* fix(linear): use canonical identifier form in searchableContent OR branch

greptile flagged that the `searchableContent.contains` branch in the
identifier OR was sent verbatim from user input. When the user typed
`eng-123`, Linear's case-sensitive `contains` filter would miss
cross-references written in the canonical form `ENG-123`. Normalise
the contains value to the same upper-cased identifier we use for the
exact `team+number` branch, and lock that behaviour in
`TestBuildIssueFilter_IdentifierLowercase`.

* fix(linear): use containsIgnoreCase for searchableContent

@carlosflorencio flagged that canonicalising the identifier OR branch
to upper case meant lowercase literal references (e.g. "see eng-123"
pasted into a description) no longer matched. The previous round had
swapped the other way at greptile's request to fix uppercase
references. Both directions are right — the underlying issue is that
Linear's `contains` filter is case-sensitive. Switch the whole
`searchableContent` filter (identifier OR branch and the non-identifier
free-text path) to `containsIgnoreCase`, so casing of the user input
and of the indexed text no longer matters in either direction.

Free-text search is now case-insensitive too, which is the intuitive
default for a search box.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 19:15:52 +01:00

523 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IconExternalLink, IconHexagon, IconPlus, IconSearch } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { PageTopbar } from "@/components/page-topbar";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import {
formatRelative,
LinearErrorMessage,
stateBadgeClass,
} from "@/components/linear/linear-issue-common";
import { LinearIssueDialog } from "@/components/linear/linear-issue-dialog";
import { LinearQuickTaskLauncher } from "@/components/linear/linear-quick-task-launcher";
import { getLinearConfig, listLinearTeams, searchLinearIssues } from "@/lib/api/domains/linear-api";
import type { LinearIssue, LinearTeam } from "@/lib/types/linear";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
const PAGE_SIZE = 25;
type LinearPageClientProps = {
workspaceId?: string;
workflows: Workflow[];
steps: WorkflowStep[];
};
function NotConfiguredNotice() {
return (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear is not configured.{" "}
<Link
href="/settings/integrations/linear"
className="underline font-medium cursor-pointer"
>
Configure Linear
</Link>{" "}
to see your issues here.
</AlertDescription>
</Alert>
</div>
);
}
function useLinearPageData(workspaceId?: string) {
const [loaded, setLoaded] = useState(false);
const [configured, setConfigured] = useState(false);
const [teams, setTeams] = useState<LinearTeam[]>([]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!workspaceId) {
setLoaded(true);
return;
}
try {
const cfg = await getLinearConfig();
if (cancelled) return;
const ok = !!cfg && cfg.hasSecret;
setConfigured(ok);
if (ok) {
try {
const list = await listLinearTeams();
if (!cancelled) setTeams(list.teams ?? []);
} catch {
// Non-fatal: team filter just stays empty.
}
}
} finally {
if (!cancelled) setLoaded(true);
}
}
void load();
return () => {
cancelled = true;
};
}, [workspaceId]);
return { loaded, configured, teams };
}
type SearchState = {
items: LinearIssue[];
loading: boolean;
error: string | null;
page: number;
pageSize: number;
isLast: boolean;
goNext: () => void;
goPrev: () => void;
};
// useIssueSearch is page-based: it caches each page's cursor in `tokensRef` so
// the user can step backward without re-querying from the first page. Linear
// returns no total count, so the UI shows a row range plus a Page N indicator.
function useIssueSearch(
workspaceId: string | undefined,
query: string,
teamKey: string,
assigned: string,
): SearchState {
const [items, setItems] = useState<LinearIssue[]>([]);
const [page, setPage] = useState(1);
const [isLast, setIsLast] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// tokens[i] is the page_token for page i+1; tokens[0] is always "".
const tokensRef = useRef<string[]>([""]);
const reqRef = useRef(0);
const fetchPage = useCallback(
(p: number) =>
searchLinearIssues({
query: query || undefined,
teamKey: teamKey || undefined,
assigned: assigned || undefined,
pageToken: tokensRef.current[p - 1] || undefined,
maxResults: PAGE_SIZE,
}),
[query, teamKey, assigned],
);
const run = useCallback(
async (p: number) => {
if (!workspaceId) return;
const reqId = ++reqRef.current;
setLoading(true);
setError(null);
try {
const res = await fetchPage(p);
if (reqId !== reqRef.current) return;
setItems(res.issues ?? []);
setIsLast(res.isLast ?? true);
if (!res.isLast && res.nextPageToken) {
tokensRef.current[p] = res.nextPageToken;
}
} catch (err) {
if (reqId !== reqRef.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (reqId === reqRef.current) setLoading(false);
}
},
[workspaceId, fetchPage],
);
// Reset cursor stack and snap back to page 1 when filters change.
useEffect(() => {
tokensRef.current = [""];
setPage(1);
}, [workspaceId, query, teamKey, assigned]);
// 250ms debounce keeps the keyboard input from firing a request per char.
useEffect(() => {
if (!workspaceId) return;
const id = setTimeout(() => void run(page), 250);
return () => clearTimeout(id);
}, [run, page, workspaceId]);
return {
items,
loading,
error,
page,
pageSize: PAGE_SIZE,
isLast,
goNext: () => {
if (!isLast) setPage((p) => p + 1);
},
goPrev: () => {
setPage((p) => Math.max(1, p - 1));
},
};
}
function AssigneeCell({ issue }: { issue: LinearIssue }) {
if (!issue.assigneeName) {
return <span className="text-xs text-muted-foreground">Unassigned</span>;
}
return (
<div className="flex items-center gap-1.5 min-w-0">
<Avatar size="sm" className="size-5">
{issue.assigneeIcon && <AvatarImage src={issue.assigneeIcon} alt={issue.assigneeName} />}
<AvatarFallback className="text-[10px]">{issue.assigneeName.charAt(0)}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground truncate">{issue.assigneeName}</span>
</div>
);
}
function IssueRow({
issue,
onOpen,
onStartTask,
}: {
issue: LinearIssue;
onOpen: (i: LinearIssue) => void;
onStartTask: (i: LinearIssue) => void;
}) {
const relative = formatRelative(issue.updated);
return (
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
<button
type="button"
onClick={() => onOpen(issue)}
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
title="Open issue details"
>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier}</span>
{issue.priorityLabel && <span>· {issue.priorityLabel}</span>}
</div>
<div className="text-sm font-medium truncate" title={issue.title}>
{issue.title}
</div>
<div className="flex items-center gap-2 flex-wrap">
{issue.stateName && (
<Badge variant="outline" className={stateBadgeClass(issue.stateCategory)}>
{issue.stateName}
</Badge>
)}
<AssigneeCell issue={issue} />
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
<a href={issue.url} target="_blank" rel="noreferrer" title="Open in Linear">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<Button
size="sm"
variant="outline"
className="cursor-pointer h-7 px-2 gap-1 text-xs"
onClick={() => onStartTask(issue)}
>
<IconPlus className="h-3.5 w-3.5" />
Start task
</Button>
</div>
</div>
);
}
function FilterControls({
query,
setQuery,
teamKey,
setTeamKey,
assigned,
setAssigned,
teams,
}: {
query: string;
setQuery: (v: string) => void;
teamKey: string;
setTeamKey: (v: string) => void;
assigned: string;
setAssigned: (v: string) => void;
teams: LinearTeam[];
}) {
return (
<div className="flex flex-wrap items-center gap-2 px-6 py-3 border-b">
<div className="relative flex-1 min-w-[280px]">
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by ID, title, or description"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
</div>
<Select
value={teamKey || "__all__"}
onValueChange={(v) => setTeamKey(v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All teams</SelectItem>
{teams.map((t) => (
<SelectItem key={t.id} value={t.key}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={assigned || "__any__"}
onValueChange={(v) => setAssigned(v === "__any__" ? "" : v)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__any__">Any assignee</SelectItem>
<SelectItem value="me">Assigned to me</SelectItem>
<SelectItem value="unassigned">Unassigned</SelectItem>
</SelectContent>
</Select>
</div>
);
}
function PaginationBar({
page,
pageSize,
itemCount,
isLast,
onNext,
onPrev,
}: {
page: number;
pageSize: number;
itemCount: number;
isLast: boolean;
onNext: () => void;
onPrev: () => void;
}) {
if (page === 1 && isLast) return null;
const start = (page - 1) * pageSize + 1;
const end = (page - 1) * pageSize + itemCount;
const prevDisabled = page <= 1;
const nextDisabled = isLast;
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{itemCount === 0 ? "No results" : `${start}–${end}`}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (!prevDisabled) onPrev();
}}
aria-disabled={prevDisabled}
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm tabular-nums">Page {page}</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (!nextDisabled) onNext();
}}
aria-disabled={nextDisabled}
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-full">
<PageTopbar title="Linear" icon={<IconHexagon className="h-4 w-4" />} />
{children}
</div>
);
}
function DisabledNotice() {
return (
<div className="p-6 max-w-2xl">
<Alert>
<AlertDescription>
Linear integration is disabled.{" "}
<Link
href="/settings/integrations/linear"
className="underline font-medium cursor-pointer"
>
Re-enable it in settings
</Link>
.
</AlertDescription>
</Alert>
</div>
);
}
function ResultsArea({
search,
empty,
onOpen,
onStartTask,
}: {
search: SearchState;
empty: boolean;
onOpen: (issue: LinearIssue) => void;
onStartTask: (issue: LinearIssue) => void;
}) {
return (
<div className="flex-1 overflow-y-auto px-6 py-3">
{search.error && !search.loading && (
<div className="py-8 flex justify-center">
<LinearErrorMessage error={search.error} />
</div>
)}
{!search.error && empty && (
<div className="text-sm text-muted-foreground py-12 text-center">
No issues match your filters.
</div>
)}
{search.items.map((issue) => (
<IssueRow key={issue.id} issue={issue} onOpen={onOpen} onStartTask={onStartTask} />
))}
{search.loading && search.items.length === 0 && (
<div className="text-sm text-muted-foreground py-12 text-center">Loading issues…</div>
)}
</div>
);
}
export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageClientProps) {
const available = useLinearAvailable();
const { loaded, configured, teams } = useLinearPageData(workspaceId);
const [query, setQuery] = useState("");
const [teamKey, setTeamKey] = useState("");
const [assigned, setAssigned] = useState("me");
const search = useIssueSearch(workspaceId, query, teamKey, assigned);
const [openIssue, setOpenIssue] = useState<LinearIssue | null>(null);
const [launchIssue, setLaunchIssue] = useState<LinearIssue | null>(null);
const empty = useMemo(
() => loaded && configured && !search.loading && search.items.length === 0,
[loaded, configured, search.loading, search.items.length],
);
if (!workspaceId) {
return (
<PageShell>
<NotConfiguredNotice />
</PageShell>
);
}
if (loaded && !configured) {
return (
<PageShell>
<NotConfiguredNotice />
</PageShell>
);
}
if (!available && loaded && configured) {
return (
<PageShell>
<DisabledNotice />
</PageShell>
);
}
return (
<PageShell>
<FilterControls
query={query}
setQuery={setQuery}
teamKey={teamKey}
setTeamKey={setTeamKey}
assigned={assigned}
setAssigned={setAssigned}
teams={teams}
/>
<ResultsArea
search={search}
empty={empty}
onOpen={setOpenIssue}
onStartTask={setLaunchIssue}
/>
<PaginationBar
page={search.page}
pageSize={search.pageSize}
itemCount={search.items.length}
isLast={search.isLast}
onNext={search.goNext}
onPrev={search.goPrev}
/>
<LinearIssueDialog
open={openIssue !== null}
onOpenChange={(open) => !open && setOpenIssue(null)}
workspaceId={workspaceId}
identifier={openIssue?.identifier ?? null}
initialIssue={openIssue}
onStartTask={setLaunchIssue}
/>
<LinearQuickTaskLauncher
workspaceId={workspaceId}
workflows={workflows}
steps={steps}
issue={launchIssue}
onClose={() => setLaunchIssue(null)}
/>
</PageShell>
);
}