diff --git a/frontend/app/(main)/docs/[section]/client.tsx b/frontend/app/(main)/docs/[section]/client.tsx index 2339a6f5..5203ede7 100644 --- a/frontend/app/(main)/docs/[section]/client.tsx +++ b/frontend/app/(main)/docs/[section]/client.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { DocsView } from "@/components/DocsView"; +import { DocsView } from "@/components/docs/DocsView"; import { SECTION_IDS, type SectionID } from "@/lib/docs-sections"; const VALID_SECTIONS = new Set(SECTION_IDS); diff --git a/frontend/app/(main)/docs/page.tsx b/frontend/app/(main)/docs/page.tsx index 17a62ad1..7fe7cd99 100644 --- a/frontend/app/(main)/docs/page.tsx +++ b/frontend/app/(main)/docs/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { DocsView } from "@/components/DocsView"; +import { DocsView } from "@/components/docs/DocsView"; // /docs renders the docs surface defaulted to the "overview" section. // Picking another section in DocsView's left rail navigates to the diff --git a/frontend/app/(main)/investigations/client.tsx b/frontend/app/(main)/investigations/client.tsx index 48d2cffa..3a671270 100644 --- a/frontend/app/(main)/investigations/client.tsx +++ b/frontend/app/(main)/investigations/client.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { SessionWorkspace } from "@/components/SessionWorkspace"; -import { Spinner } from "@/components/Spinner"; -import { ArrowLeftIcon } from "@/components/Icons"; +import { SessionWorkspace } from "@/components/investigations/SessionWorkspace"; +import { Spinner } from "@/components/shared/Spinner"; +import { ArrowLeftIcon } from "@/components/shared/Icons"; import { api, ApiError, type Investigation } from "@/lib/api"; type LoadState = diff --git a/frontend/app/(main)/investigations/new/page.tsx b/frontend/app/(main)/investigations/new/page.tsx index 9416d7a3..97b8a91d 100644 --- a/frontend/app/(main)/investigations/new/page.tsx +++ b/frontend/app/(main)/investigations/new/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { InvestigationForm, type FormSubmission } from "@/components/InvestigationForm"; +import { InvestigationForm, type FormSubmission } from "@/components/investigations/InvestigationForm"; import { api, ApiError } from "@/lib/api"; // Step-state for the *new investigation* flow at /investigations/new. Once diff --git a/frontend/app/(main)/investigations/page.tsx b/frontend/app/(main)/investigations/page.tsx index 9aafd501..11583bfa 100644 --- a/frontend/app/(main)/investigations/page.tsx +++ b/frontend/app/(main)/investigations/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { InvestigationPageClient } from "./client"; -import { Spinner } from "@/components/Spinner"; +import { Spinner } from "@/components/shared/Spinner"; // Next.js requires useSearchParams (used in InvestigationPageClient) to be // wrapped in a Suspense boundary for output: "export" static builds. diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index 39252a11..f00f678f 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { AppShell } from "@/components/AppShell"; -import { NewPlaybookModal } from "@/components/NewPlaybookModal"; -import { NewTypeModal } from "@/components/NewTypeModal"; +import { AppShell } from "@/components/layout/AppShell"; +import { NewPlaybookModal } from "@/components/playbooks/NewPlaybookModal"; +import { NewTypeModal } from "@/components/playbooks/NewTypeModal"; import { NewWikiEntryModal } from "@/components/wiki/NewWikiEntryModal"; -import { RepoSummaryStateProvider } from "@/components/RepoSummaryStateProvider"; -import { CodefixPRStateProvider } from "@/components/CodefixPRStateProvider"; -import { WikiProposalNotifier } from "@/components/WikiProposalNotifier"; +import { RepoSummaryStateProvider } from "@/components/repos/RepoSummaryStateProvider"; +import { CodefixPRStateProvider } from "@/components/codefix/CodefixPRStateProvider"; +import { WikiProposalNotifier } from "@/components/wiki/WikiProposalNotifier"; import { StreamProvider } from "@/lib/stream"; import { api } from "@/lib/api"; diff --git a/frontend/app/(main)/mcp/page.tsx b/frontend/app/(main)/mcp/page.tsx index f566df0d..a531712f 100644 --- a/frontend/app/(main)/mcp/page.tsx +++ b/frontend/app/(main)/mcp/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useEffect } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { MCPCatalogView } from "@/components/MCPCatalogView"; +import { MCPCatalogView } from "@/components/mcp/MCPCatalogView"; export default function MCPPage() { return ( diff --git a/frontend/app/(main)/playbooks/page.tsx b/frontend/app/(main)/playbooks/page.tsx index 93ec7d89..c8098165 100644 --- a/frontend/app/(main)/playbooks/page.tsx +++ b/frontend/app/(main)/playbooks/page.tsx @@ -2,8 +2,8 @@ import { Suspense, useEffect, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; -import { PlaybookList } from "@/components/PlaybookList"; -import { PlaybookEditor } from "@/components/PlaybookEditor"; +import { PlaybookList } from "@/components/playbooks/PlaybookList"; +import { PlaybookEditor } from "@/components/playbooks/PlaybookEditor"; import { PLAYBOOK_TYPES_CHANGED } from "../layout"; export default function PlaybooksPage() { diff --git a/frontend/app/(main)/repos/client.tsx b/frontend/app/(main)/repos/client.tsx index 3f2db879..a84eb757 100644 --- a/frontend/app/(main)/repos/client.tsx +++ b/frontend/app/(main)/repos/client.tsx @@ -8,15 +8,15 @@ import { type LinkedRepo, type RepoSummaryStatus, } from "@/lib/api"; -import { GitHubIcon, WarningIcon } from "@/components/Icons"; -import { Spinner } from "@/components/Spinner"; +import { GitHubIcon, WarningIcon } from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; import { notifyReposChanged, onReposChanged } from "@/lib/repos-events"; import { useDialog } from "@/lib/dialog"; import { repoKey, useRepoSummaryStatus, useRepoSummaryStore, -} from "@/components/RepoSummaryStateProvider"; +} from "@/components/repos/RepoSummaryStateProvider"; // ReposIndexClient is the top-level /repos page: a flat list of every // linked repo (defaults first, then user-added) with its summary diff --git a/frontend/app/(main)/repos/page.tsx b/frontend/app/(main)/repos/page.tsx index e54f0107..8ac99e16 100644 --- a/frontend/app/(main)/repos/page.tsx +++ b/frontend/app/(main)/repos/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useEffect } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ReposIndexClient } from "./client"; -import { RepoArchitectureView } from "@/components/RepoArchitectureView"; +import { RepoArchitectureView } from "@/components/repos/RepoArchitectureView"; export default function ReposPage() { return ( diff --git a/frontend/app/(main)/watches/[id]/client.tsx b/frontend/app/(main)/watches/[id]/client.tsx index 067fc31a..e4d46fb5 100644 --- a/frontend/app/(main)/watches/[id]/client.tsx +++ b/frontend/app/(main)/watches/[id]/client.tsx @@ -5,11 +5,11 @@ import { usePathname } from "next/navigation"; import { watchesAPI, type Watch, type SignalRecord, type ItemRecord } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; import { useStream } from "@/lib/stream"; -import { WatchDetailHeader } from "@/components/WatchDetailHeader"; -import { WatchSignalsPanel } from "@/components/WatchSignalsPanel"; -import { WatchItemsPanel } from "@/components/WatchItemsPanel"; -import { WatchQueueStrip } from "@/components/WatchQueueStrip"; -import { WatchIngestRunsPanel } from "@/components/WatchIngestRunsPanel"; +import { WatchDetailHeader } from "@/components/watches/WatchDetailHeader"; +import { WatchSignalsPanel } from "@/components/watches/WatchSignalsPanel"; +import { WatchItemsPanel } from "@/components/watches/WatchItemsPanel"; +import { WatchQueueStrip } from "@/components/watches/WatchQueueStrip"; +import { WatchIngestRunsPanel } from "@/components/watches/WatchIngestRunsPanel"; // Static export rewrites every /watches// path to the same shell // (generateStaticParams returns the "_" placeholder), so useParams() is diff --git a/frontend/app/(main)/watches/[id]/edit/client.tsx b/frontend/app/(main)/watches/[id]/edit/client.tsx index 713a4c05..f493f178 100644 --- a/frontend/app/(main)/watches/[id]/edit/client.tsx +++ b/frontend/app/(main)/watches/[id]/edit/client.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { WatchForm } from "@/components/WatchForm"; +import { WatchForm } from "@/components/watches/WatchForm"; import { watchesAPI, type Watch } from "@/lib/api"; // See note in ../client.tsx: useParams() returns the build-time diff --git a/frontend/app/(main)/watches/client.tsx b/frontend/app/(main)/watches/client.tsx index 0369c02e..69376fe6 100644 --- a/frontend/app/(main)/watches/client.tsx +++ b/frontend/app/(main)/watches/client.tsx @@ -1,8 +1,8 @@ "use client"; import { useWatches } from "@/lib/use-watches"; -import { WatchesList } from "@/components/WatchesList"; -import { AllWatchesSignalsPanel } from "@/components/AllWatchesSignalsPanel"; +import { WatchesList } from "@/components/watches/WatchesList"; +import { AllWatchesSignalsPanel } from "@/components/watches/AllWatchesSignalsPanel"; export function WatchesClient() { const { watches, refresh } = useWatches(); diff --git a/frontend/app/(main)/watches/new/page.tsx b/frontend/app/(main)/watches/new/page.tsx index 8ef7fd1f..aa408d10 100644 --- a/frontend/app/(main)/watches/new/page.tsx +++ b/frontend/app/(main)/watches/new/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { WatchForm } from "@/components/WatchForm"; +import { WatchForm } from "@/components/watches/WatchForm"; import { watchesAPI } from "@/lib/api"; export default function NewWatchPage() { diff --git a/frontend/components/DocsView.tsx b/frontend/components/DocsView.tsx deleted file mode 100644 index be74ab7a..00000000 --- a/frontend/components/DocsView.tsx +++ /dev/null @@ -1,516 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { SECTIONS, type SectionID } from "@/lib/docs-sections"; - -type Props = { - // Section the operator picked from the docs sidebar. Driven by the - // page-level URL state so deep links work. - active: SectionID; - onSectionChange: (next: SectionID) => void; -}; - -// DocsView is the top-level docs page. Two-rail layout: -// - Left rail: the four top-level sections (investigations, mcp, -// playbooks, wiki) plus an auto-generated outline of the -// selected section's H2/H3 headings. -// - Main pane: the markdown for the selected section, fetched from -// /docs/.md (a static asset bundled into the launcher's -// embedded frontend). -// -// Markdown source-of-truth lives at repo-root docs/content/.md. -// scripts/sync-docs.mjs mirrors them into frontend/public/docs/ at -// build time so the static export serves them at /docs/.md. The -// separate GitHub Pages docs site under docs/site/ consumes the same -// markdown directly. -export function DocsView({ active, onSectionChange }: Props) { - const [markdown, setMarkdown] = useState(null); - const [err, setErr] = useState(null); - // The slug of the heading currently in the top-of-viewport band. - // Drives the active-row highlight in the sidenav outline. Reset on - // section switch so the highlight matches the new content. - const [activeSlug, setActiveSlug] = useState(null); - // Whether the active section's inline outline is manually collapsed. - // Clicking the already-active section header toggles this; switching - // to a different section auto-resets it to false (a fresh section - // always opens expanded). - const [outlineCollapsed, setOutlineCollapsed] = useState(false); - // Force-reset the in-pane scroll position on section switch so the - // operator never lands halfway down a long doc by mistake. - const scrollRef = useRef(null); - - useEffect(() => { - let cancelled = false; - setMarkdown(null); - setErr(null); - setActiveSlug(null); - setOutlineCollapsed(false); - fetch(`/docs/${active}.md`) - .then(async (res) => { - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - return res.text(); - }) - .then((text) => { - if (cancelled) return; - setMarkdown(text); - // Reset scroll on section switch — useEffect runs after the - // markdown renders, so the scrollRef is populated. - if (scrollRef.current) scrollRef.current.scrollTop = 0; - }) - .catch((e) => { - if (cancelled) return; - setErr(e instanceof Error ? e.message : String(e)); - }); - return () => { - cancelled = true; - }; - }, [active]); - - // Outline derived from the markdown's H2 + H3 headings. Re-runs - // whenever the markdown changes; cheap regex scan, no parser needed - // since we control the source. - const outline = useMemo(() => extractOutline(markdown ?? ""), [markdown]); - - // Track which heading the operator is currently looking at and - // highlight it in the sidenav. Uses an IntersectionObserver scoped - // to the scroll container, with a tall negative bottom margin so - // only the heading nearest the top of the viewport counts as - // "active". Re-arms whenever the markdown changes (new section -> - // new set of heading nodes). - useEffect(() => { - if (markdown === null) return; - const scrollEl = scrollRef.current; - if (!scrollEl) return; - const headings = Array.from( - scrollEl.querySelectorAll("h2[id], h3[id]"), - ); - if (headings.length === 0) return; - - // Seed: until the user scrolls, treat the first heading as active - // so the outline isn't blank on initial render. - setActiveSlug(headings[0].id); - - const visible = new Set(); - const observer = new IntersectionObserver( - (entries) => { - for (const e of entries) { - const slug = (e.target as HTMLElement).id; - if (e.isIntersecting) visible.add(slug); - else visible.delete(slug); - } - // Pick the topmost heading currently in the band — headings is - // in document order, so the first visible id wins. - let next: string | null = null; - for (const h of headings) { - if (visible.has(h.id)) { - next = h.id; - break; - } - } - if (next) setActiveSlug(next); - }, - { - root: scrollEl, - // Counts a heading as "active" only while it sits in the top - // ~25% of the viewport. Without the negative bottom margin - // every heading on a tall pane would be intersecting and the - // highlight would jitter. - rootMargin: "0px 0px -75% 0px", - threshold: 0, - }, - ); - for (const h of headings) observer.observe(h); - return () => observer.disconnect(); - }, [markdown]); - - return ( -
- {/* Left rail: nested section list. The currently-open section - expands its heading outline inline beneath its row; the - others stay collapsed. Picking another section auto-collapses - the previous one. Clicking the active section header toggles - its outline (collapsed ↔ expanded) so operators can free up - vertical space without leaving the page. - w-72 to fit two-line section blurbs without truncating. */} - - - {/* Main pane: scrollable markdown with id'd headings so the - outline anchors land on them. */} -
-
- {err && ( -
- Could not load /docs/{active}.md: {err} -
- )} - {markdown === null && !err && ( -
loading…
- )} - {markdown !== null && } -
-
-
- ); -} - -// SectionGroup is one row of the docs nav: the section header -// (label + subtitle, clickable to switch sections) with an inline -// nested outline of H2/H3 headings rendered beneath when this -// section is the active one. Non-active sections collapse to just -// the header — only one outline is ever visible at a time. -function SectionGroup({ - label, - subtitle, - active, - expanded, - onClick, - outline, - activeSlug, -}: { - label: string; - subtitle: string; - // True when this section is the page's currently-selected one. Drives - // the row's highlighted background regardless of outline collapse - // state — collapsing the outline shouldn't visually demote the row. - active: boolean; - // True when this section's outline should render. Always implies - // active; the gap between the two flags is what makes the active - // section collapsible without losing its "you are here" highlight. - expanded: boolean; - onClick: () => void; - outline: Heading[]; - // Slug of the heading currently in the operator's viewport. Drives - // the highlighted-row state on the matching outline entry. Null - // when this section isn't active or no heading is in view yet. - activeSlug: string | null; -}) { - return ( -
- - {expanded && outline.length > 0 && ( - // Nested heading outline. Indented to align with the section - // label, with a left rule so the parent-child relationship - // reads at a glance even when the operator scrolls past - // the section header. -
    - {outline.map((h) => { - const isActive = h.slug === activeSlug; - const indent = h.level === 3 ? "pl-5" : ""; - // Active row gets a stronger text colour + a subtle pill - // so the operator can see at a glance which subsection - // the main pane is currently on. Hover styles still apply - // so non-active rows visibly respond to the cursor. - const tone = isActive - ? "bg-zinc-800/80 text-zinc-100" - : h.level === 3 - ? "text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200" - : "text-zinc-300 hover:bg-zinc-900 hover:text-zinc-200"; - return ( -
  • - - {h.text} - -
  • - ); - })} -
- )} -
- ); -} - -// ChevronIcon is the rotate-on-open caret used for the section -// headers. Inline SVG (not the unicode glyph) so it picks up -// currentColor on the zinc UI without OS-emoji-font hijack. -function ChevronIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -// Mermaid renders a fenced ```mermaid``` block as an SVG diagram. -// Mermaid is large (~700KB after minification) so we dynamic-import -// it inside the effect — the docs view is opt-in already, and this -// keeps the rest of the launcher's bundle tight for operators who -// never touch the docs. -// -// One mermaid.initialize() call per process; subsequent renders -// reuse the running config. Theme is dark to match the rest of the -// UI; securityLevel: "loose" is required for some diagram types -// (sequence, flowchart with html labels) to render at all under -// our embed-as-static-export deployment. -function Mermaid({ chart }: { chart: string }) { - const [svg, setSvg] = useState(""); - const [err, setErr] = useState(null); - // Stable id per instance so concurrent diagrams don't fight over - // the same DOM target inside mermaid.render's hidden scratch node. - const idRef = useRef( - "mermaid-" + Math.random().toString(36).slice(2, 10), - ); - - useEffect(() => { - let cancelled = false; - setErr(null); - setSvg(""); - (async () => { - try { - const mermaid = (await import("mermaid")).default; - mermaid.initialize({ - startOnLoad: false, - theme: "dark", - securityLevel: "loose", - // Slightly bigger default font so labels read at the - // pane width the docs use (max-w-3xl ≈ 768px). Mermaid's - // default 14px reads thin against zinc backgrounds. - fontSize: 14, - }); - const { svg } = await mermaid.render(idRef.current, chart); - if (cancelled) return; - setSvg(svg); - } catch (e) { - if (cancelled) return; - setErr(e instanceof Error ? e.message : String(e)); - } - })(); - return () => { - cancelled = true; - }; - }, [chart]); - - if (err) { - return ( -
-        Could not render diagram: {err}
-        {"\n\n"}
-        {chart}
-      
- ); - } - if (!svg) { - return ( -
- rendering diagram… -
- ); - } - return ( - // The SVG comes from mermaid.render, not user-supplied content - // — the chart string is from a static .md file we ship. Wrapper - // styles centre the diagram and let it scroll horizontally on - // narrow viewports (some flowcharts are wider than the prose - // column). -
- ); -} - -// DocsMarkdown wraps ReactMarkdown with documentation-flavoured -// styling — bigger spacing than the chat Markdown component, real -// heading sizes, anchor-friendly id'd headings. -// -// Heading components stamp an id derived from the text content so the -// outline's `#slug` anchors actually scroll to them. Slug logic must -// match extractOutline exactly so the two surfaces stay in sync. -function DocsMarkdown({ text }: { text: string }) { - return ( -
- ( -

{children}

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

{children}

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

{children}

- ), - // Intercept fenced ```mermaid``` blocks and render them - // through the mermaid library instead of as a code block. - // Anything else (json/yaml/bash/...) falls through to - // ReactMarkdown's default code rendering. - code: (props) => { - const { className, children } = props as { - className?: string; - children?: React.ReactNode; - }; - const lang = (className ?? "").replace(/^language-/, ""); - if (lang === "mermaid") { - return ; - } - return {children}; - }, - // Block code opts out of prose typography so the inline-code - // pill styling (bg-zinc-800 / rounded / px-1) doesn't leak - // onto the inner and fragment its background across - // line wraps. Styled directly here. - pre: ({ children }) => ( -
-              {children}
-            
- ), - }} - > - {text} -
-
- ); -} - -type Heading = { level: 2 | 3; text: string; slug: string }; - -// extractOutline pulls H2 + H3 headings from raw markdown. Skips H1 -// (always the page title — the outline doesn't need to repeat it) and -// H4+ (too noisy for a sidebar). Code-block-aware: lines inside ``` -// fences don't count, so a YAML example with `## something` doesn't -// pollute the outline. -function extractOutline(md: string): Heading[] { - const out: Heading[] = []; - let inFence = false; - for (const raw of md.split("\n")) { - const line = raw; - if (line.startsWith("```")) { - inFence = !inFence; - continue; - } - if (inFence) continue; - const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line); - if (!m) continue; - const level = m[1].length === 2 ? 2 : 3; - const text = stripInlineMarkdown(m[2].trim()); - out.push({ level, text, slug: slugify(text) }); - } - return out; -} - -// stripInlineMarkdown unwraps `[text](url)` to `text` and strips -// surrounding `*`/`_` emphasis and backticks so heading labels render -// as plain prose in the sidebar outline. Mirrors what react-markdown -// does to the heading body — the outline-side label and the rendered -// heading should read identically. Only handles the constructs we -// actually use in heading lines; not a full markdown parser. -function stripInlineMarkdown(s: string): string { - return s - .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1") - .replace(/_([^_]+)_/g, "$1"); -} - -// slugify makes a URL-safe anchor target. Same algorithm both -// outline-side and heading-component-side; if you change one, change -// the other or the anchors break silently. -function slugify(s: string): string { - return s - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .trim() - .replace(/\s+/g, "-") - .slice(0, 60); -} - -// stringifyChildren extracts a flat string from React markdown -// children — react-markdown passes a mix of strings + elements (e.g. -// inline code spans inside a heading). We only need the visible text -// for slug derivation. -function stringifyChildren(children: React.ReactNode): string { - if (children === null || children === undefined) return ""; - if (typeof children === "string") return children; - if (typeof children === "number") return String(children); - if (Array.isArray(children)) return children.map(stringifyChildren).join(""); - if (typeof children === "object" && "props" in children) { - return stringifyChildren( - (children as { props: { children?: React.ReactNode } }).props.children, - ); - } - return ""; -} diff --git a/frontend/components/LinkedReposPanel.tsx b/frontend/components/LinkedReposPanel.tsx deleted file mode 100644 index 47f03ece..00000000 --- a/frontend/components/LinkedReposPanel.tsx +++ /dev/null @@ -1,600 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { api, ApiError, type Investigation, type LinkedRepo, type RepoSummaryStatus } from "@/lib/api"; -import { notifyReposChanged, onReposChanged } from "@/lib/repos-events"; -import { useDialog } from "@/lib/dialog"; -import { Spinner } from "./Spinner"; -import { - useRepoSummaryStatus, - useRepoSummaryStore, - repoKey, -} from "@/components/RepoSummaryStateProvider"; -import { GitHubIcon, WarningIcon } from "@/components/Icons"; - -type Props = { - // Active investigation, when one is open. Drives the read-only "linked - // for this session" list. Null when no investigation is active — the - // panel still renders with the manage button so users can curate the - // list before starting a new investigation. - investigation: Investigation | null; - // Bumped by the parent on session changes so we re-fetch the global - // repo lists if the user navigates away and back. - refreshNonce?: number; -}; - -const EXPANDED_KEY = "triagent.linkedrepos.expanded"; - -// MIN_DESCRIPTION_LENGTH mirrors repos.MinDescriptionLength on the Go side. -// Hard-coded constant rather than fetched from the server to avoid a -// network round-trip on form mount; if the backend ever raises the -// minimum, update here too. The backend rejection is the source of truth. -const MIN_DESCRIPTION_LENGTH = 30; - -function readExpanded(): boolean { - if (typeof window === "undefined") return false; - return window.localStorage.getItem(EXPANDED_KEY) === "1"; -} - -export function LinkedReposPanel({ investigation, refreshNonce }: Props) { - const [manageOpen, setManageOpen] = useState(false); - // Collapsed by default so the panel only takes the header strip - // (~40px) at the bottom of the sidebar; expanding it overlays - // upward into the history list. Persisted so an operator who keeps - // it pinned open doesn't have to re-toggle on every reload. - const [expanded, setExpanded] = useState(readExpanded); - - useEffect(() => { - if (typeof window === "undefined") return; - window.localStorage.setItem(EXPANDED_KEY, expanded ? "1" : "0"); - }, [expanded]); - - // Listen for the launcher-wide "open the manage-repos modal" event. - // The /repos page's primary "+ add repository" sidebar action - // (dispatched from (main)/layout.tsx onNew) fires this so we don't - // have to lift the modal's state up to the layout. - useEffect(() => { - const handler = () => setManageOpen(true); - window.addEventListener("triagent:open-manage-repos", handler); - return () => - window.removeEventListener("triagent:open-manage-repos", handler); - }, []); - - return ( -
- {/* Header strip is the only chrome the panel takes when collapsed. - The whole row toggles expansion; the inner "manage" target - stops propagation so it opens the modal without flipping the - collapse state. */} - - - {expanded && ( -
- {investigation ? ( - <> -

- linked to this investigation: -

- - - ) : ( - - )} -
- )} - - {manageOpen && ( - setManageOpen(false)} - refreshNonce={refreshNonce} - /> - )} -
- ); -} - -// PendingReposList is the no-active-investigation sidebar variant: it -// shows the resolved defaults + user_repos that *will* be linked once an -// investigation starts, so the operator can confirm their list before -// kicking one off. Same shape as ActiveReposList; differs only in -// its hint copy and that it pulls from /api/repos rather than the -// frozen Investigation.linkedRepos snapshot. -function PendingReposList({ refreshNonce }: { refreshNonce?: number }) { - const [repos, setRepos] = useState(null); - const [err, setErr] = useState(null); - // Refetch when a repo is added/removed from any surface (the manage - // modal here, or the /repos page) so the sidebar list stays in sync. - const [changeNonce, setChangeNonce] = useState(0); - useEffect(() => onReposChanged(() => setChangeNonce((n) => n + 1)), []); - - useEffect(() => { - let cancelled = false; - api - .listRepos() - .then((r) => { - if (cancelled) return; - // Defaults first, then user repos — matches launcher resolution - // order so the sidebar mirrors what the next investigation gets. - setRepos([...r.defaults, ...r.user]); - setErr(null); - }) - .catch((e) => { - if (cancelled) return; - setErr(e instanceof ApiError ? e.message : String(e)); - }); - return () => { - cancelled = true; - }; - }, [refreshNonce, changeNonce]); - - if (err) { - return ( -

- couldn't load repos: {err} -

- ); - } - if (repos === null) { - return ( -
- loading… -
- ); - } - if (repos.length === 0) { - return ( -

- no repos configured. add some via "manage". -

- ); - } - return ( - <> -

- will be linked when you start an investigation: -

- - - ); -} - -function ActiveReposList({ linkedRepos }: { linkedRepos: LinkedRepo[] }) { - const router = useRouter(); - const store = useRepoSummaryStore(); - - // Seed the store for each repo on mount so the row renders the - // correct status icon before any SSE event fires. We don't await - // the responses — the rows render with no icon and update in place - // as fetches complete. Keyed effect so re-renders after a list - // change still trigger fresh fetches (cheap; the endpoint reads - // frontmatter only). - useEffect(() => { - let cancelled = false; - for (const r of linkedRepos) { - api - .getRepoSummaryStatus(r.owner, r.name) - .then((s) => { - if (cancelled) return; - store.upsert(repoKey(r.owner, r.name), s); - }) - .catch(() => { - /* non-fatal — row stays without an icon */ - }); - } - return () => { - cancelled = true; - }; - }, [linkedRepos, store]); - - if (linkedRepos.length === 0) { - return ( -

- none linked to this session. -

- ); - } - return ( -
    - {linkedRepos.map((r) => ( - - router.push( - `/repos?repo=${encodeURIComponent(`${r.owner}/${r.name}`)}`, - ) - } - /> - ))} -
- ); -} - -function RepoRowMinimal({ - repo, - onClick, -}: { - repo: LinkedRepo; - onClick: () => void; -}) { - const status = useRepoSummaryStatus(repo.owner, repo.name); - // Tooltip: owner/name on the first line, the connection-time - // description on the second. Keeps the row line-itself terse while - // preserving the description the operator authored at add time. - const tooltip = - repo.description && repo.description.length > 0 - ? `${repo.owner}/${repo.name} — ${repo.description}` - : `${repo.owner}/${repo.name}`; - return ( -
  • - -
  • - ); -} - -function RepoStatusGlyph({ status }: { status: RepoSummaryStatus | undefined }) { - if (!status) return ; // placeholder for layout stability - if (status.inFlight) { - return ( - - ); - } - if (status.error) { - return ( - - ⨯ - - ); - } - if (status.exists) { - return ( - - ✓ - - ); - } - // No cached summary yet. Surface this explicitly — without an icon - // here the row looks identical to a healthy one and operators won't - // know to trigger generation. Click-through to the repo page lets - // them refresh. - return ( - - - - ); -} - -function ManageReposModal({ - onClose, - refreshNonce, -}: { - onClose: () => void; - refreshNonce?: number; -}) { - const [defaults, setDefaults] = useState(null); - const [user, setUser] = useState(null); - const [loadErr, setLoadErr] = useState(null); - const [busy, setBusy] = useState(false); - const [actionErr, setActionErr] = useState(null); - const dialog = useDialog(); - - // Add-form state. - const [owner, setOwner] = useState(""); - const [name, setName] = useState(""); - const [alias, setAlias] = useState(""); - const [description, setDescription] = useState(""); - - // changeNonce reloads the modal both on its own add/remove and on - // changes made elsewhere (e.g. the /repos page) while it's open. - const [changeNonce, setChangeNonce] = useState(0); - useEffect(() => onReposChanged(() => setChangeNonce((n) => n + 1)), []); - - function reload() { - setLoadErr(null); - api - .listRepos() - .then((r) => { - setDefaults(r.defaults); - setUser(r.user); - }) - .catch((e) => setLoadErr(e instanceof ApiError ? e.message : String(e))); - } - - useEffect(() => { - reload(); - }, [refreshNonce, changeNonce]); - - async function add(e: React.FormEvent) { - e.preventDefault(); - if (!owner.trim() || !name.trim()) return; - if (description.trim().length < MIN_DESCRIPTION_LENGTH) return; - setBusy(true); - setActionErr(null); - try { - await api.addRepo({ - owner: owner.trim(), - name: name.trim(), - alias: alias.trim() || undefined, - description: description.trim(), - }); - setOwner(""); - setName(""); - setAlias(""); - setDescription(""); - notifyReposChanged(); - } catch (err) { - setActionErr(err instanceof ApiError ? err.message : String(err)); - } finally { - setBusy(false); - } - } - - async function remove(r: LinkedRepo) { - const ok = await dialog.confirm({ - title: `Remove ${r.owner}/${r.name}?`, - body: "Future investigations will no longer spawn an MCP server for this repo. Past investigations keep their snapshot.", - confirmLabel: "Remove", - danger: true, - }); - if (!ok) return; - setBusy(true); - setActionErr(null); - try { - await api.removeRepo(r.owner, r.name); - notifyReposChanged(); - } catch (err) { - setActionErr(err instanceof ApiError ? err.message : String(err)); - } finally { - setBusy(false); - } - } - - return ( -
    -
    -
    -

    - Linked GitHub repositories -

    - -
    - -

    - Each linked repo gets its own MCP server in new investigations. - Defaults are read-only; entries you add here persist across launcher - restarts. -

    - - {loadErr && ( -
    - {loadErr} -
    - )} - - {defaults === null && !loadErr && ( -
    - loading… -
    - )} - - {defaults && defaults.length > 0 && ( -
    -
      - {defaults.map((r) => ( - - ))} -
    -
    - )} - - {user && ( -
    - {user.length === 0 ? ( -

    - you haven't added any repos yet. -

    - ) : ( -
      - {user.map((r) => ( - remove(r)} - busy={busy} - /> - ))} -
    - )} -
    - )} - -
    -
    -
    - setOwner(e.target.value)} - className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:border-zinc-600 focus:outline-none" - required - /> - setName(e.target.value)} - className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:border-zinc-600 focus:outline-none" - required - /> -
    - setAlias(e.target.value)} - className="w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:border-zinc-600 focus:outline-none" - /> -