diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index d4f9f1172..2367bf036 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -54,8 +54,8 @@ const preloadLanesPage = lanesRoute.preload; const filesRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../files/FilesTab").then((m) => ({ default: m.FilesTab })) ); -const FilesPage = filesRoute.Component; -const preloadFilesPage = filesRoute.preload; +const FilesTab = filesRoute.Component; +const preloadFilesTab = filesRoute.preload; const workRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../terminals/TerminalsPage").then((m) => ({ default: m.TerminalsPage })) ); @@ -425,7 +425,7 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string } /> - {React.createElement(FilesPage as React.ComponentType<{ active?: boolean }>, routeProps)} + {React.createElement(FilesTab as React.ComponentType<{ active?: boolean }>, routeProps)} } /> { void preloadTerminalsPage().catch(() => undefined); void preloadLanesPage().catch(() => undefined); - void preloadFilesPage().catch(() => undefined); + void preloadFilesTab().catch(() => undefined); void preloadCtoPage().catch(() => undefined); }; const idleWindow = window as Window & { diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index a315c2b7a..3d854952a 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -117,23 +117,6 @@ vi.mock("../terminals/TerminalsPage", async () => { }; }); -vi.mock("../files/FilesPage", async () => { - const Router = await vi.importActual("react-router-dom") as typeof RouterNamespace; - - return { - FilesPage: () => { - const navigate = Router.useNavigate(); - return ( -
- -
- ); - }, - }; -}); - vi.mock("../files/FilesTab", async () => { const Router = await vi.importActual("react-router-dom") as typeof RouterNamespace; diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx index 8dc777e30..3a827bd68 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -10,10 +10,12 @@ import { Sparkle, Warning, } from "@phosphor-icons/react"; -import ReactMarkdown from "react-markdown"; +import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeSanitize from "rehype-sanitize"; import { BranchIcon } from "../ui/vcsIcons"; +import { buildChatMarkdownComponents } from "../chat/chatMarkdown"; +import { openUrlInAdeBrowser } from "../../lib/openExternal"; import type { CtoGetLinearIssuePickerDataResult, @@ -78,6 +80,14 @@ const SELECTION_STORAGE_PREFIX = "ade.linear.quickView.selection.v1:"; const SELECTION_STORAGE_MAX = 100; const LINEAR_BROWSER_CACHE_STALE_MS = 90_000; const LINEAR_BROWSER_CACHE_MAX_SEARCHES = 16; +// 100 is the Linear API ceiling (linearClient clamps `first` to 100), so it is +// both the largest first page we can fetch and the chunk size each +// infinite-scroll page pulls. +const ISSUE_PAGE_SIZE = 100; +// Stop auto-loading on scroll once this many issues are in memory; past this the +// user opts into more via an explicit button, so huge workspaces don't silently +// load thousands of un-virtualized rows. +const AUTO_LOAD_MAX_ISSUES = 500; const DEFAULT_FILTERS: LinearIssueBrowserFilters = { projectId: "", @@ -179,7 +189,7 @@ function buildIssueSearchArgs( assigneeId: filters.assigneeId || null, priority: filters.priority ? Number(filters.priority) : null, query: filters.query.trim() || null, - first: 50, + first: ISSUE_PAGE_SIZE, after, includeArchived: false, }; @@ -192,7 +202,7 @@ function searchCacheKey(args: CtoSearchLinearIssuesArgs): string { assigneeId: args.assigneeId ?? null, priority: args.priority ?? null, query: args.query ?? null, - first: args.first ?? 50, + first: args.first ?? ISSUE_PAGE_SIZE, after: args.after ?? null, includeArchived: args.includeArchived ?? false, }); @@ -407,6 +417,62 @@ function isConnectionError(message: string): boolean { return /token|oauth|auth|connect|settings|linear/i.test(message); } +// Reuse the app's chat markdown stack (Shiki code, scrollable tables, wrapped +// text) for issue descriptions, but with clean document-style headings instead +// of the chat surface's mono/uppercase ones, and Linear-accent links that open +// in the ADE browser. +const LINEAR_MARKDOWN_COMPONENTS: Components = buildChatMarkdownComponents("neutral", { + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + h4: ({ children }) => ( +

{children}

+ ), + a: ({ href, children }) => ( + { + event.preventDefault(); + if (typeof href === "string" && href.trim() !== "") { + openUrlInAdeBrowser(href); + } + }} + className="font-medium text-[color:var(--color-accent,#A78BFA)] underline underline-offset-2 transition-opacity hover:opacity-80" + > + {children} + + ), + img: (props) => { + const { src, alt, title } = props as { src?: string; alt?: string; title?: string }; + if (!src) return null; + return ( + {alt + ); + }, +}); + +function LinearMarkdown({ children }: { children: string }) { + return ( +
+ + {children} + +
+ ); +} + export function LinearIssueBrowser({ projectRoot, featuredIssue, @@ -469,9 +535,17 @@ export function LinearIssueBrowser({ const [issues, setIssues] = useState(() => readCachedSearch(cacheKey, safeLoadFilters(projectRoot))?.issues ?? []); const [pageInfo, setPageInfo] = useState<{ hasNextPage: boolean; endCursor: string | null }>(() => readCachedSearch(cacheKey, safeLoadFilters(projectRoot))?.pageInfo ?? emptyPageInfo()); const pageInfoRef = useRef(pageInfo); + const issuesScrollRef = useRef(null); + const loadMoreSentinelRef = useRef(null); + // Holds the freshest auto-load closure so the IntersectionObserver (set up + // once per list mount) always reads current state without re-subscribing. + const autoLoadMoreRef = useRef<() => void>(() => {}); const [loadingQuickView, setLoadingQuickView] = useState(false); const [loadingCatalog, setLoadingCatalog] = useState(false); const [loadingIssues, setLoadingIssues] = useState(false); + // True only while an infinite-scroll "append" fetch is in flight, so the + // bottom-of-list spinner doesn't appear during a filter-change reload. + const [appendingMore, setAppendingMore] = useState(false); const [localActionIssueId, setLocalActionIssueId] = useState(null); const [selectedIssueId, setSelectedIssueId] = useState(featuredIssue?.id ?? null); const [selectedIssueIds, setSelectedIssueIds] = useState>(() => safeLoadSelection(projectRoot)); @@ -636,6 +710,8 @@ export function LinearIssueBrowser({ setPageInfo(cachedResult.pageInfo); } setLoadingIssues(force || append || !cachedResult); + if (append) setAppendingMore(true); + else setAppendingMore(false); setError(null); const promise = cached?.promise ?? cto.searchLinearIssues(args); entry.searches.set(key, { @@ -658,7 +734,10 @@ export function LinearIssueBrowser({ } }) .finally(() => { - if (searchRequestIdRef.current === requestId) setLoadingIssues(false); + if (searchRequestIdRef.current === requestId) { + setLoadingIssues(false); + if (append) setAppendingMore(false); + } }); }, [cacheKey, filters]); @@ -699,6 +778,35 @@ export function LinearIssueBrowser({ ...sorted.filter((issue) => issue.id !== featuredIssue.id), ]; }, [featuredIssue, sorted]); + const hasIssues = displayIssues.length > 0; + const canAutoLoadIssues = typeof IntersectionObserver !== "undefined"; + + // Refreshed every render so the observer below always sees current state. + autoLoadMoreRef.current = () => { + if (loadingIssues) return; + if (!pageInfoRef.current.hasNextPage) return; + if (issues.length >= AUTO_LOAD_MAX_ISSUES) return; + searchIssues(true); + }; + + // Infinite scroll: auto-fetch the next page when the sentinel at the end of + // the list nears the viewport. Re-subscribes only when the list mounts/empties + // (the sentinel and scroll root are otherwise stable), so paging never tears + // down the observer. + useEffect(() => { + if (!canAutoLoadIssues) return; + const root = issuesScrollRef.current; + const sentinel = loadMoreSentinelRef.current; + if (!root || !sentinel) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) autoLoadMoreRef.current(); + }, + { root, rootMargin: "400px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [canAutoLoadIssues, hasIssues]); useEffect(() => { const normalized = requestedIssueIdentifier?.trim().toUpperCase() ?? ""; @@ -884,7 +992,7 @@ export function LinearIssueBrowser({ ) : null} -
+