From eb8951bbb3829b26f0971d99cc6d98dc522cde86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:49:29 +0200 Subject: [PATCH 1/2] refactor(frontend): group components into feature folders Move the ~57 flat files in frontend/components into feature-based subfolders (shared, layout, investigations, watches, playbooks, repos, codefix, connections, mcp, docs) plus the existing sessions/wiki/inputs, and standardize every component-to-component import on the @/components// alias. Pure reorganization; no behavior change. Files moved with git mv to preserve history. --- frontend/app/(main)/docs/[section]/client.tsx | 2 +- frontend/app/(main)/docs/page.tsx | 2 +- frontend/app/(main)/investigations/client.tsx | 6 ++--- .../app/(main)/investigations/new/page.tsx | 2 +- frontend/app/(main)/investigations/page.tsx | 2 +- frontend/app/(main)/layout.tsx | 12 ++++----- frontend/app/(main)/mcp/page.tsx | 2 +- frontend/app/(main)/playbooks/page.tsx | 4 +-- frontend/app/(main)/repos/client.tsx | 6 ++--- frontend/app/(main)/repos/page.tsx | 2 +- frontend/app/(main)/watches/[id]/client.tsx | 10 +++---- .../app/(main)/watches/[id]/edit/client.tsx | 2 +- frontend/app/(main)/watches/client.tsx | 4 +-- frontend/app/(main)/watches/new/page.tsx | 2 +- .../{ => codefix}/CodefixPRStateProvider.tsx | 0 .../CodefixProposalCard.test.tsx | 4 +-- .../{ => codefix}/CodefixProposalCard.tsx | 2 +- .../components/{ => codefix}/PushPRModal.tsx | 4 +-- .../{ => connections}/ClusterPicker.tsx | 4 +-- .../ConnectionsPanel.test.tsx | 2 +- .../{ => connections}/ConnectionsPanel.tsx | 4 +-- frontend/components/{ => docs}/DocsView.tsx | 0 frontend/components/inputs/ClusterIdInput.tsx | 4 +-- .../components/inputs/SlackChannelInput.tsx | 2 +- .../ActivityPanel.test.tsx | 2 +- .../{ => investigations}/ActivityPanel.tsx | 6 ++--- .../InvestigationForm.test.tsx | 2 +- .../InvestigationForm.tsx | 4 +-- .../{ => investigations}/SessionView.test.tsx | 2 +- .../{ => investigations}/SessionView.tsx | 22 ++++++++-------- .../{ => investigations}/SessionWorkspace.tsx | 4 +-- frontend/components/{ => layout}/AppShell.tsx | 4 +-- .../components/{ => layout}/Sidebar.test.tsx | 2 +- frontend/components/{ => layout}/Sidebar.tsx | 12 ++++----- frontend/components/{ => layout}/TopNav.tsx | 0 .../components/{ => mcp}/MCPCatalogView.tsx | 2 +- .../components/{ => mcp}/MCPStatusBar.tsx | 0 .../{ => playbooks}/CommitsDropdown.tsx | 0 .../{ => playbooks}/DeleteTypeModal.tsx | 2 +- .../{ => playbooks}/EditorChatDrawer.tsx | 10 +++---- .../{ => playbooks}/NewPlaybookModal.tsx | 0 .../{ => playbooks}/NewTypeModal.tsx | 2 +- .../components/{ => playbooks}/NodeEditor.tsx | 2 +- .../PlaybookCorrelationPanel.tsx | 0 .../{ => playbooks}/PlaybookEditor.tsx | 26 +++++++++---------- .../{ => playbooks}/PlaybookGraph.tsx | 0 .../{ => playbooks}/PlaybookList.tsx | 6 ++--- .../ProposalBodyTabs.tsx | 4 +-- .../{ => playbooks}/ProposalCard.tsx | 4 +-- .../components/{ => playbooks}/SaveDialog.tsx | 2 +- .../components/{ => playbooks}/ToolPicker.tsx | 0 .../components/{ => playbooks}/YamlPanel.tsx | 4 +-- .../{ => repos}/LinkedReposPanel.tsx | 6 ++--- .../{ => repos}/RepoActivityPanel.tsx | 4 +-- .../{ => repos}/RepoArchitectureView.tsx | 6 ++--- .../{ => repos}/RepoSummaryStateProvider.tsx | 0 .../sessions/PushSessionPRModal.tsx | 6 ++--- frontend/components/sessions/SessionCard.tsx | 2 +- frontend/components/sessions/SessionDoc.tsx | 4 +-- .../sessions/SessionUpstreamHeader.tsx | 2 +- frontend/components/sessions/UpstreamHome.tsx | 4 +-- .../components/{ => shared}/CopyButton.tsx | 2 +- .../{ => shared}/EntityChipsInput.test.tsx | 2 +- .../{ => shared}/EntityChipsInput.tsx | 0 .../{ => shared}/FilterBuilder.test.tsx | 2 +- .../components/{ => shared}/FilterBuilder.tsx | 0 .../{ => shared}/FilterableList.tsx | 0 frontend/components/{ => shared}/Icons.tsx | 2 +- frontend/components/{ => shared}/Markdown.tsx | 0 .../components/{ => shared}/Paginator.tsx | 2 +- .../{ => shared}/SlackChannelPicker.tsx | 4 +-- frontend/components/{ => shared}/Spinner.tsx | 0 frontend/components/{ => shared}/ToolCard.tsx | 0 .../{ => watches}/AllWatchesSignalsPanel.tsx | 0 .../{ => watches}/SignalCard.test.tsx | 2 +- .../components/{ => watches}/SignalCard.tsx | 4 +-- .../{ => watches}/StartFromSignalDialog.tsx | 0 .../{ => watches}/WatchDetailHeader.tsx | 6 ++--- .../{ => watches}/WatchForm.test.tsx | 2 +- .../components/{ => watches}/WatchForm.tsx | 4 +-- .../{ => watches}/WatchIngestRunsPanel.tsx | 2 +- .../{ => watches}/WatchItemsPanel.tsx | 0 .../{ => watches}/WatchQueueStrip.tsx | 0 .../{ => watches}/WatchSignalsPanel.tsx | 2 +- .../{ => watches}/WatchStatusPill.tsx | 0 .../{ => watches}/WatchesList.test.tsx | 2 +- .../components/{ => watches}/WatchesList.tsx | 6 ++--- frontend/components/wiki/EntryRow.tsx | 2 +- .../components/wiki/NewWikiEntryModal.tsx | 4 +-- frontend/components/wiki/PushWikiPRModal.tsx | 4 +-- frontend/components/wiki/WikiBodyTab.tsx | 4 +-- frontend/components/wiki/WikiEditor.tsx | 10 +++---- .../components/{ => wiki}/WikiEntityGraph.tsx | 0 frontend/components/wiki/WikiGraphNodes.tsx | 2 +- frontend/components/wiki/WikiHome.tsx | 4 +-- frontend/components/wiki/WikiNodeEditor.tsx | 2 +- .../{ => wiki}/WikiProposalCard.test.tsx | 2 +- .../{ => wiki}/WikiProposalCard.tsx | 6 ++--- .../{ => wiki}/WikiProposalNotifier.test.tsx | 2 +- .../{ => wiki}/WikiProposalNotifier.tsx | 0 .../components/wiki/WikiUpstreamHeader.tsx | 2 +- 101 files changed, 165 insertions(+), 165 deletions(-) rename frontend/components/{ => codefix}/CodefixPRStateProvider.tsx (100%) rename frontend/components/{ => codefix}/CodefixProposalCard.test.tsx (95%) rename frontend/components/{ => codefix}/CodefixProposalCard.tsx (98%) rename frontend/components/{ => codefix}/PushPRModal.tsx (98%) rename frontend/components/{ => connections}/ClusterPicker.tsx (93%) rename frontend/components/{ => connections}/ConnectionsPanel.test.tsx (98%) rename frontend/components/{ => connections}/ConnectionsPanel.tsx (99%) rename frontend/components/{ => docs}/DocsView.tsx (100%) rename frontend/components/{ => investigations}/ActivityPanel.test.tsx (95%) rename frontend/components/{ => investigations}/ActivityPanel.tsx (98%) rename frontend/components/{ => investigations}/InvestigationForm.test.tsx (98%) rename frontend/components/{ => investigations}/InvestigationForm.tsx (98%) rename frontend/components/{ => investigations}/SessionView.test.tsx (99%) rename frontend/components/{ => investigations}/SessionView.tsx (98%) rename frontend/components/{ => investigations}/SessionWorkspace.tsx (98%) rename frontend/components/{ => layout}/AppShell.tsx (94%) rename frontend/components/{ => layout}/Sidebar.test.tsx (99%) rename frontend/components/{ => layout}/Sidebar.tsx (99%) rename frontend/components/{ => layout}/TopNav.tsx (100%) rename frontend/components/{ => mcp}/MCPCatalogView.tsx (99%) rename frontend/components/{ => mcp}/MCPStatusBar.tsx (100%) rename frontend/components/{ => playbooks}/CommitsDropdown.tsx (100%) rename frontend/components/{ => playbooks}/DeleteTypeModal.tsx (99%) rename frontend/components/{ => playbooks}/EditorChatDrawer.tsx (98%) rename frontend/components/{ => playbooks}/NewPlaybookModal.tsx (100%) rename frontend/components/{ => playbooks}/NewTypeModal.tsx (99%) rename frontend/components/{ => playbooks}/NodeEditor.tsx (99%) rename frontend/components/{ => playbooks}/PlaybookCorrelationPanel.tsx (100%) rename frontend/components/{ => playbooks}/PlaybookEditor.tsx (98%) rename frontend/components/{ => playbooks}/PlaybookGraph.tsx (100%) rename frontend/components/{ => playbooks}/PlaybookList.tsx (99%) rename frontend/components/{proposal => playbooks}/ProposalBodyTabs.tsx (97%) rename frontend/components/{ => playbooks}/ProposalCard.tsx (98%) rename frontend/components/{ => playbooks}/SaveDialog.tsx (98%) rename frontend/components/{ => playbooks}/ToolPicker.tsx (100%) rename frontend/components/{ => playbooks}/YamlPanel.tsx (96%) rename frontend/components/{ => repos}/LinkedReposPanel.tsx (99%) rename frontend/components/{ => repos}/RepoActivityPanel.tsx (98%) rename frontend/components/{ => repos}/RepoArchitectureView.tsx (99%) rename frontend/components/{ => repos}/RepoSummaryStateProvider.tsx (100%) rename frontend/components/{ => shared}/CopyButton.tsx (97%) rename frontend/components/{ => shared}/EntityChipsInput.test.tsx (96%) rename frontend/components/{ => shared}/EntityChipsInput.tsx (100%) rename frontend/components/{ => shared}/FilterBuilder.test.tsx (93%) rename frontend/components/{ => shared}/FilterBuilder.tsx (100%) rename frontend/components/{ => shared}/FilterableList.tsx (100%) rename frontend/components/{ => shared}/Icons.tsx (99%) rename frontend/components/{ => shared}/Markdown.tsx (100%) rename frontend/components/{ => shared}/Paginator.tsx (95%) rename frontend/components/{ => shared}/SlackChannelPicker.tsx (96%) rename frontend/components/{ => shared}/Spinner.tsx (100%) rename frontend/components/{ => shared}/ToolCard.tsx (100%) rename frontend/components/{ => watches}/AllWatchesSignalsPanel.tsx (100%) rename frontend/components/{ => watches}/SignalCard.test.tsx (96%) rename frontend/components/{ => watches}/SignalCard.tsx (97%) rename frontend/components/{ => watches}/StartFromSignalDialog.tsx (100%) rename frontend/components/{ => watches}/WatchDetailHeader.tsx (97%) rename frontend/components/{ => watches}/WatchForm.test.tsx (97%) rename frontend/components/{ => watches}/WatchForm.tsx (99%) rename frontend/components/{ => watches}/WatchIngestRunsPanel.tsx (99%) rename frontend/components/{ => watches}/WatchItemsPanel.tsx (100%) rename frontend/components/{ => watches}/WatchQueueStrip.tsx (100%) rename frontend/components/{ => watches}/WatchSignalsPanel.tsx (98%) rename frontend/components/{ => watches}/WatchStatusPill.tsx (100%) rename frontend/components/{ => watches}/WatchesList.test.tsx (93%) rename frontend/components/{ => watches}/WatchesList.tsx (96%) rename frontend/components/{ => wiki}/WikiEntityGraph.tsx (100%) rename frontend/components/{ => wiki}/WikiProposalCard.test.tsx (97%) rename frontend/components/{ => wiki}/WikiProposalCard.tsx (99%) rename frontend/components/{ => wiki}/WikiProposalNotifier.test.tsx (96%) rename frontend/components/{ => wiki}/WikiProposalNotifier.tsx (100%) 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/CodefixPRStateProvider.tsx b/frontend/components/codefix/CodefixPRStateProvider.tsx similarity index 100% rename from frontend/components/CodefixPRStateProvider.tsx rename to frontend/components/codefix/CodefixPRStateProvider.tsx diff --git a/frontend/components/CodefixProposalCard.test.tsx b/frontend/components/codefix/CodefixProposalCard.test.tsx similarity index 95% rename from frontend/components/CodefixProposalCard.test.tsx rename to frontend/components/codefix/CodefixProposalCard.test.tsx index d446130c..6df03216 100644 --- a/frontend/components/CodefixProposalCard.test.tsx +++ b/frontend/components/codefix/CodefixProposalCard.test.tsx @@ -1,8 +1,8 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { CodefixProposalCard } from "./CodefixProposalCard"; -import { CodefixPRStateStoreCtx } from "./CodefixPRStateProvider"; +import { CodefixProposalCard } from "@/components/codefix/CodefixProposalCard"; +import { CodefixPRStateStoreCtx } from "@/components/codefix/CodefixPRStateProvider"; import { api } from "@/lib/api"; const fixture = { diff --git a/frontend/components/CodefixProposalCard.tsx b/frontend/components/codefix/CodefixProposalCard.tsx similarity index 98% rename from frontend/components/CodefixProposalCard.tsx rename to frontend/components/codefix/CodefixProposalCard.tsx index 8ae4e25f..5a799227 100644 --- a/frontend/components/CodefixProposalCard.tsx +++ b/frontend/components/codefix/CodefixProposalCard.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { CodefixProposalPayload } from "@/lib/events"; -import { useCodefixPRState } from "./CodefixPRStateProvider"; +import { useCodefixPRState } from "@/components/codefix/CodefixPRStateProvider"; type Props = { payload: CodefixProposalPayload; diff --git a/frontend/components/PushPRModal.tsx b/frontend/components/codefix/PushPRModal.tsx similarity index 98% rename from frontend/components/PushPRModal.tsx rename to frontend/components/codefix/PushPRModal.tsx index 7eacea57..5142d7cd 100644 --- a/frontend/components/PushPRModal.tsx +++ b/frontend/components/codefix/PushPRModal.tsx @@ -7,8 +7,8 @@ import { type Capabilities, type PushPRResult, } from "@/lib/api"; -import { Spinner } from "./Spinner"; -import { CopyButton } from "./CopyButton"; +import { Spinner } from "@/components/shared/Spinner"; +import { CopyButton } from "@/components/shared/CopyButton"; type Props = { // Playbook id we're pushing — also seeds the default branch name. diff --git a/frontend/components/ClusterPicker.tsx b/frontend/components/connections/ClusterPicker.tsx similarity index 93% rename from frontend/components/ClusterPicker.tsx rename to frontend/components/connections/ClusterPicker.tsx index 29c5b3ba..d9c76a2c 100644 --- a/frontend/components/ClusterPicker.tsx +++ b/frontend/components/connections/ClusterPicker.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { api, ApiError, type Cluster } from "@/lib/api"; -import { Spinner } from "./Spinner"; -import { FilterableList } from "./FilterableList"; +import { Spinner } from "@/components/shared/Spinner"; +import { FilterableList } from "@/components/shared/FilterableList"; type Props = { onPick: (cluster: Cluster) => void; diff --git a/frontend/components/ConnectionsPanel.test.tsx b/frontend/components/connections/ConnectionsPanel.test.tsx similarity index 98% rename from frontend/components/ConnectionsPanel.test.tsx rename to frontend/components/connections/ConnectionsPanel.test.tsx index 393d93a0..56654359 100644 --- a/frontend/components/ConnectionsPanel.test.tsx +++ b/frontend/components/connections/ConnectionsPanel.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ConnectionsPanel } from "./ConnectionsPanel"; +import { ConnectionsPanel } from "@/components/connections/ConnectionsPanel"; import { api, type ConnectionStatus } from "@/lib/api"; import { DialogProvider } from "@/lib/dialog"; diff --git a/frontend/components/ConnectionsPanel.tsx b/frontend/components/connections/ConnectionsPanel.tsx similarity index 99% rename from frontend/components/ConnectionsPanel.tsx rename to frontend/components/connections/ConnectionsPanel.tsx index f069dbce..d5f69ac5 100644 --- a/frontend/components/ConnectionsPanel.tsx +++ b/frontend/components/connections/ConnectionsPanel.tsx @@ -15,8 +15,8 @@ import { GcpIcon, IncidentIoIcon, SlackIcon, -} from "./Icons"; -import { Spinner } from "./Spinner"; +} from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; // ConnectionsPanel sits at the bottom of the sidenav next to // LinkedReposPanel. The header strip is a single button: brand icons diff --git a/frontend/components/DocsView.tsx b/frontend/components/docs/DocsView.tsx similarity index 100% rename from frontend/components/DocsView.tsx rename to frontend/components/docs/DocsView.tsx diff --git a/frontend/components/inputs/ClusterIdInput.tsx b/frontend/components/inputs/ClusterIdInput.tsx index 4c9a2d03..c6bc3bdd 100644 --- a/frontend/components/inputs/ClusterIdInput.tsx +++ b/frontend/components/inputs/ClusterIdInput.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { api, ApiError, type Cluster } from "@/lib/api"; -import { FilterableList } from "@/components/FilterableList"; -import { Spinner } from "@/components/Spinner"; +import { FilterableList } from "@/components/shared/FilterableList"; +import { Spinner } from "@/components/shared/Spinner"; import type { InputProps, ScalarInputValue } from "./types"; import { interpolateHint } from "./types"; diff --git a/frontend/components/inputs/SlackChannelInput.tsx b/frontend/components/inputs/SlackChannelInput.tsx index b5673650..b347d2cb 100644 --- a/frontend/components/inputs/SlackChannelInput.tsx +++ b/frontend/components/inputs/SlackChannelInput.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { api, type ConnectionStatus } from "@/lib/api"; -import { SlackChannelPicker } from "@/components/SlackChannelPicker"; +import { SlackChannelPicker } from "@/components/shared/SlackChannelPicker"; import type { InputProps } from "./types"; import { interpolateHint } from "./types"; diff --git a/frontend/components/ActivityPanel.test.tsx b/frontend/components/investigations/ActivityPanel.test.tsx similarity index 95% rename from frontend/components/ActivityPanel.test.tsx rename to frontend/components/investigations/ActivityPanel.test.tsx index 71224b2f..e94d7603 100644 --- a/frontend/components/ActivityPanel.test.tsx +++ b/frontend/components/investigations/ActivityPanel.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { ActivityPanel } from "./ActivityPanel"; +import { ActivityPanel } from "@/components/investigations/ActivityPanel"; import type { Investigation } from "@/lib/api"; import type { TranscriptItem } from "@/lib/events"; diff --git a/frontend/components/ActivityPanel.tsx b/frontend/components/investigations/ActivityPanel.tsx similarity index 98% rename from frontend/components/ActivityPanel.tsx rename to frontend/components/investigations/ActivityPanel.tsx index 4496dbb6..1a0e6f47 100644 --- a/frontend/components/ActivityPanel.tsx +++ b/frontend/components/investigations/ActivityPanel.tsx @@ -3,9 +3,9 @@ import { useMemo, useState } from "react"; import type { Investigation } from "@/lib/api"; import { parseToolName, type TranscriptItem } from "@/lib/events"; -import { ChevronLeftIcon, ChevronRightIcon } from "./Icons"; -import { MCPStatusBar } from "./MCPStatusBar"; -import { toolAnchorId } from "./ToolCard"; +import { ChevronLeftIcon, ChevronRightIcon } from "@/components/shared/Icons"; +import { MCPStatusBar } from "@/components/mcp/MCPStatusBar"; +import { toolAnchorId } from "@/components/shared/ToolCard"; type Props = { investigation: Investigation; diff --git a/frontend/components/InvestigationForm.test.tsx b/frontend/components/investigations/InvestigationForm.test.tsx similarity index 98% rename from frontend/components/InvestigationForm.test.tsx rename to frontend/components/investigations/InvestigationForm.test.tsx index 976477e4..dc727423 100644 --- a/frontend/components/InvestigationForm.test.tsx +++ b/frontend/components/investigations/InvestigationForm.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { InvestigationForm } from "./InvestigationForm"; +import { InvestigationForm } from "@/components/investigations/InvestigationForm"; import { api } from "@/lib/api"; beforeEach(() => { diff --git a/frontend/components/InvestigationForm.tsx b/frontend/components/investigations/InvestigationForm.tsx similarity index 98% rename from frontend/components/InvestigationForm.tsx rename to frontend/components/investigations/InvestigationForm.tsx index 0ec48a33..46f23f8c 100644 --- a/frontend/components/InvestigationForm.tsx +++ b/frontend/components/investigations/InvestigationForm.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { api, type InputSchema, type PromOverride } from "@/lib/api"; -import { ArrowRightIcon } from "@/components/Icons"; -import { Spinner } from "@/components/Spinner"; +import { ArrowRightIcon } from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; import { TextInput } from "@/components/inputs/TextInput"; import { UrlInput } from "@/components/inputs/UrlInput"; import { TextareaInput } from "@/components/inputs/TextareaInput"; diff --git a/frontend/components/SessionView.test.tsx b/frontend/components/investigations/SessionView.test.tsx similarity index 99% rename from frontend/components/SessionView.test.tsx rename to frontend/components/investigations/SessionView.test.tsx index 35786131..6a2fe56a 100644 --- a/frontend/components/SessionView.test.tsx +++ b/frontend/components/investigations/SessionView.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { SessionView } from "./SessionView"; +import { SessionView } from "@/components/investigations/SessionView"; import { DialogProvider } from "@/lib/dialog"; import type { Investigation } from "@/lib/api"; import { reduce, type EventEnvelope } from "@/lib/events"; diff --git a/frontend/components/SessionView.tsx b/frontend/components/investigations/SessionView.tsx similarity index 98% rename from frontend/components/SessionView.tsx rename to frontend/components/investigations/SessionView.tsx index fe4436f6..ee557c8e 100644 --- a/frontend/components/SessionView.tsx +++ b/frontend/components/investigations/SessionView.tsx @@ -30,26 +30,26 @@ import { type TranscriptItem, } from "@/lib/events"; import { Bot } from "lucide-react"; -import { useAutoMode } from "../hooks/useAutoMode"; +import { useAutoMode } from "@/hooks/useAutoMode"; import { ArrowLeftIcon, CheckIcon, DocIcon, DownloadIcon, ExternalLinkIcon, -} from "./Icons"; -import { ArchiveButton } from "./sessions/ArchiveButton"; -import { PushSessionPRModal } from "./sessions/PushSessionPRModal"; -import { CopyButton } from "./CopyButton"; -import { Markdown } from "./Markdown"; -import { ProposalCard, type ProposalDraftPayload } from "./ProposalCard"; -import { Spinner } from "./Spinner"; -import { ToolCard, toolAnchorId } from "./ToolCard"; +} from "@/components/shared/Icons"; +import { ArchiveButton } from "@/components/sessions/ArchiveButton"; +import { PushSessionPRModal } from "@/components/sessions/PushSessionPRModal"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { Markdown } from "@/components/shared/Markdown"; +import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; +import { Spinner } from "@/components/shared/Spinner"; +import { ToolCard, toolAnchorId } from "@/components/shared/ToolCard"; import { WikiProposalCard, type WikiProposalPayload, -} from "./WikiProposalCard"; -import { CodefixProposalCard } from "./CodefixProposalCard"; +} from "@/components/wiki/WikiProposalCard"; +import { CodefixProposalCard } from "@/components/codefix/CodefixProposalCard"; import { extractSummarizeResult } from "@/lib/summarize"; // Stable empty-array sentinel for the optional `events` prop. Using a diff --git a/frontend/components/SessionWorkspace.tsx b/frontend/components/investigations/SessionWorkspace.tsx similarity index 98% rename from frontend/components/SessionWorkspace.tsx rename to frontend/components/investigations/SessionWorkspace.tsx index 0b17c0b3..428e4965 100644 --- a/frontend/components/SessionWorkspace.tsx +++ b/frontend/components/investigations/SessionWorkspace.tsx @@ -9,8 +9,8 @@ import { type TranscriptItem, } from "@/lib/events"; import { useStream } from "@/lib/stream"; -import { ActivityPanel, ActivityRail } from "./ActivityPanel"; -import { SessionView } from "./SessionView"; +import { ActivityPanel, ActivityRail } from "@/components/investigations/ActivityPanel"; +import { SessionView } from "@/components/investigations/SessionView"; type Props = { investigation: Investigation; diff --git a/frontend/components/AppShell.tsx b/frontend/components/layout/AppShell.tsx similarity index 94% rename from frontend/components/AppShell.tsx rename to frontend/components/layout/AppShell.tsx index ff9d3038..f717eca9 100644 --- a/frontend/components/AppShell.tsx +++ b/frontend/components/layout/AppShell.tsx @@ -1,7 +1,7 @@ "use client"; -import { TopNav } from "@/components/TopNav"; -import { Sidebar } from "@/components/Sidebar"; +import { TopNav } from "@/components/layout/TopNav"; +import { Sidebar } from "@/components/layout/Sidebar"; // AppShell renders the chrome around any view: the global top nav and // the global left sidebar. Both derive their state from the current diff --git a/frontend/components/Sidebar.test.tsx b/frontend/components/layout/Sidebar.test.tsx similarity index 99% rename from frontend/components/Sidebar.test.tsx rename to frontend/components/layout/Sidebar.test.tsx index 3cfc25d4..dcdeff33 100644 --- a/frontend/components/Sidebar.test.tsx +++ b/frontend/components/layout/Sidebar.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Sidebar } from "./Sidebar"; +import { Sidebar } from "@/components/layout/Sidebar"; import { api, type Investigation, type StreamEnvelope } from "@/lib/api"; import { DialogProvider } from "@/lib/dialog"; import type { StreamFilter } from "@/lib/stream-dispatch"; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx similarity index 99% rename from frontend/components/Sidebar.tsx rename to frontend/components/layout/Sidebar.tsx index a6354fe5..0d62d163 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -14,12 +14,12 @@ import { import { wikiApi, type WikiProposalListItem } from "@/lib/wiki-api"; import { useStream } from "@/lib/stream"; import { useDialog } from "@/lib/dialog"; -import { ConnectionsPanel } from "./ConnectionsPanel"; -import { CheckIcon, EditIcon, UnsyncedIcon } from "./Icons"; -import { LinkedReposPanel } from "./LinkedReposPanel"; -import { PlaybookCorrelationPanel } from "./PlaybookCorrelationPanel"; -import { Spinner } from "./Spinner"; -import { RepoActivityPanel } from "./RepoActivityPanel"; +import { ConnectionsPanel } from "@/components/connections/ConnectionsPanel"; +import { CheckIcon, EditIcon, UnsyncedIcon } from "@/components/shared/Icons"; +import { LinkedReposPanel } from "@/components/repos/LinkedReposPanel"; +import { PlaybookCorrelationPanel } from "@/components/playbooks/PlaybookCorrelationPanel"; +import { Spinner } from "@/components/shared/Spinner"; +import { RepoActivityPanel } from "@/components/repos/RepoActivityPanel"; import { extractOutgoing, findInverse } from "@/lib/playbook-relations"; import { getRecent } from "@/lib/recent-playbooks"; import { parsePlaybookYAML, type PlaybookListItem } from "@/lib/playbook"; diff --git a/frontend/components/TopNav.tsx b/frontend/components/layout/TopNav.tsx similarity index 100% rename from frontend/components/TopNav.tsx rename to frontend/components/layout/TopNav.tsx diff --git a/frontend/components/MCPCatalogView.tsx b/frontend/components/mcp/MCPCatalogView.tsx similarity index 99% rename from frontend/components/MCPCatalogView.tsx rename to frontend/components/mcp/MCPCatalogView.tsx index b34ffe1d..55396e38 100644 --- a/frontend/components/MCPCatalogView.tsx +++ b/frontend/components/mcp/MCPCatalogView.tsx @@ -8,7 +8,7 @@ import { serverDisplayLabel, type MCPCategory, } from "@/lib/mcps"; -import { Spinner } from "./Spinner"; +import { Spinner } from "@/components/shared/Spinner"; type Props = { // Optional server alias to scroll-focus on first render. Set when the diff --git a/frontend/components/MCPStatusBar.tsx b/frontend/components/mcp/MCPStatusBar.tsx similarity index 100% rename from frontend/components/MCPStatusBar.tsx rename to frontend/components/mcp/MCPStatusBar.tsx diff --git a/frontend/components/CommitsDropdown.tsx b/frontend/components/playbooks/CommitsDropdown.tsx similarity index 100% rename from frontend/components/CommitsDropdown.tsx rename to frontend/components/playbooks/CommitsDropdown.tsx diff --git a/frontend/components/DeleteTypeModal.tsx b/frontend/components/playbooks/DeleteTypeModal.tsx similarity index 99% rename from frontend/components/DeleteTypeModal.tsx rename to frontend/components/playbooks/DeleteTypeModal.tsx index c05ee094..30cf0e9a 100644 --- a/frontend/components/DeleteTypeModal.tsx +++ b/frontend/components/playbooks/DeleteTypeModal.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { api, ApiError } from "@/lib/api"; -import { Spinner } from "./Spinner"; +import { Spinner } from "@/components/shared/Spinner"; type Mode = "local" | "pr"; diff --git a/frontend/components/EditorChatDrawer.tsx b/frontend/components/playbooks/EditorChatDrawer.tsx similarity index 98% rename from frontend/components/EditorChatDrawer.tsx rename to frontend/components/playbooks/EditorChatDrawer.tsx index bec88f45..02a8aeaf 100644 --- a/frontend/components/EditorChatDrawer.tsx +++ b/frontend/components/playbooks/EditorChatDrawer.tsx @@ -26,11 +26,11 @@ import { } from "@/lib/events"; import { useStream } from "@/lib/stream"; import { buildLatestPayloadsByKey } from "@/lib/proposal-projection"; -import { Markdown } from "./Markdown"; -import { ProposalCard, type ProposalDraftPayload } from "./ProposalCard"; -import { Spinner } from "./Spinner"; -import { ToolCard } from "./ToolCard"; -import type { WikiProposalPayload } from "./WikiProposalCard"; +import { Markdown } from "@/components/shared/Markdown"; +import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; +import { Spinner } from "@/components/shared/Spinner"; +import { ToolCard } from "@/components/shared/ToolCard"; +import type { WikiProposalPayload } from "@/components/wiki/WikiProposalCard"; // Editor reducer wraps applyEvent with a synthetic "__reset__" action // so we can clear the transcript when the operator switches playbook diff --git a/frontend/components/NewPlaybookModal.tsx b/frontend/components/playbooks/NewPlaybookModal.tsx similarity index 100% rename from frontend/components/NewPlaybookModal.tsx rename to frontend/components/playbooks/NewPlaybookModal.tsx diff --git a/frontend/components/NewTypeModal.tsx b/frontend/components/playbooks/NewTypeModal.tsx similarity index 99% rename from frontend/components/NewTypeModal.tsx rename to frontend/components/playbooks/NewTypeModal.tsx index eac4bd31..02a29546 100644 --- a/frontend/components/NewTypeModal.tsx +++ b/frontend/components/playbooks/NewTypeModal.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { api, ApiError } from "@/lib/api"; -import { Spinner } from "./Spinner"; +import { Spinner } from "@/components/shared/Spinner"; type Props = { onClose: () => void; diff --git a/frontend/components/NodeEditor.tsx b/frontend/components/playbooks/NodeEditor.tsx similarity index 99% rename from frontend/components/NodeEditor.tsx rename to frontend/components/playbooks/NodeEditor.tsx index 1a561325..db4faff8 100644 --- a/frontend/components/NodeEditor.tsx +++ b/frontend/components/playbooks/NodeEditor.tsx @@ -3,7 +3,7 @@ import type { Branch, PlaybookNode, SuggestedCall } from "@/lib/playbook"; import type { ToolEntry } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { ToolPicker } from "./ToolPicker"; +import { ToolPicker } from "@/components/playbooks/ToolPicker"; type Props = { // null when no node is selected — show a hint to click a node. diff --git a/frontend/components/PlaybookCorrelationPanel.tsx b/frontend/components/playbooks/PlaybookCorrelationPanel.tsx similarity index 100% rename from frontend/components/PlaybookCorrelationPanel.tsx rename to frontend/components/playbooks/PlaybookCorrelationPanel.tsx diff --git a/frontend/components/PlaybookEditor.tsx b/frontend/components/playbooks/PlaybookEditor.tsx similarity index 98% rename from frontend/components/PlaybookEditor.tsx rename to frontend/components/playbooks/PlaybookEditor.tsx index 45966b60..128a1e1d 100644 --- a/frontend/components/PlaybookEditor.tsx +++ b/frontend/components/playbooks/PlaybookEditor.tsx @@ -11,10 +11,10 @@ import { import { useSearchParams } from "next/navigation"; import { api, ApiError, type Capabilities, type SyncState, type ToolEntry } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { EditorChatDrawer } from "./EditorChatDrawer"; -import { ProposalCard, type ProposalDraftPayload } from "./ProposalCard"; -import { type WikiProposalPayload } from "./WikiProposalCard"; -import { PushPRModal } from "./PushPRModal"; +import { EditorChatDrawer } from "@/components/playbooks/EditorChatDrawer"; +import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; +import { type WikiProposalPayload } from "@/components/wiki/WikiProposalCard"; +import { PushPRModal } from "@/components/codefix/PushPRModal"; import { dumpPlaybookYAML, emptyPlaybook, @@ -26,21 +26,21 @@ import { type PlaybookNode, validate, } from "@/lib/playbook"; -import { CommitsDropdown } from "./CommitsDropdown"; -import { EntityChipsInput } from "./EntityChipsInput"; -import { PLAYBOOK_DRAFT_TAGS_CHANGED, type PlaybookDraftTagsDetail } from "./PlaybookCorrelationPanel"; -import { SaveDialog } from "./SaveDialog"; -import { NodeEditor } from "./NodeEditor"; -import { PlaybookGraph } from "./PlaybookGraph"; -import { Spinner } from "./Spinner"; -import { YamlPanel } from "./YamlPanel"; +import { CommitsDropdown } from "@/components/playbooks/CommitsDropdown"; +import { EntityChipsInput } from "@/components/shared/EntityChipsInput"; +import { PLAYBOOK_DRAFT_TAGS_CHANGED, type PlaybookDraftTagsDetail } from "@/components/playbooks/PlaybookCorrelationPanel"; +import { SaveDialog } from "@/components/playbooks/SaveDialog"; +import { NodeEditor } from "@/components/playbooks/NodeEditor"; +import { PlaybookGraph } from "@/components/playbooks/PlaybookGraph"; +import { Spinner } from "@/components/shared/Spinner"; +import { YamlPanel } from "@/components/playbooks/YamlPanel"; import { ArrowLeftIcon, ChatBubbleIcon, GitHubIcon, RevertIcon, TrashIcon, -} from "./Icons"; +} from "@/components/shared/Icons"; import { BTN_DANGER, BTN_GATED, diff --git a/frontend/components/PlaybookGraph.tsx b/frontend/components/playbooks/PlaybookGraph.tsx similarity index 100% rename from frontend/components/PlaybookGraph.tsx rename to frontend/components/playbooks/PlaybookGraph.tsx diff --git a/frontend/components/PlaybookList.tsx b/frontend/components/playbooks/PlaybookList.tsx similarity index 99% rename from frontend/components/PlaybookList.tsx rename to frontend/components/playbooks/PlaybookList.tsx index e0e8c1d1..4bc94fa5 100644 --- a/frontend/components/PlaybookList.tsx +++ b/frontend/components/playbooks/PlaybookList.tsx @@ -20,9 +20,9 @@ import { SyncIcon, UnsyncedIcon, WarningIcon, -} from "./Icons"; -import { Spinner } from "./Spinner"; -import { DeleteTypeModal } from "./DeleteTypeModal"; +} from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; +import { DeleteTypeModal } from "@/components/playbooks/DeleteTypeModal"; type Props = { onOpen: (id: string) => void; diff --git a/frontend/components/proposal/ProposalBodyTabs.tsx b/frontend/components/playbooks/ProposalBodyTabs.tsx similarity index 97% rename from frontend/components/proposal/ProposalBodyTabs.tsx rename to frontend/components/playbooks/ProposalBodyTabs.tsx index 0ba20100..4129b579 100644 --- a/frontend/components/proposal/ProposalBodyTabs.tsx +++ b/frontend/components/playbooks/ProposalBodyTabs.tsx @@ -9,8 +9,8 @@ import { } from "react"; import dynamic from "next/dynamic"; import { parsePlaybookYAML, type Playbook } from "@/lib/playbook"; -import { PlaybookGraph } from "../PlaybookGraph"; -import type { ProposalDraftPayload } from "../ProposalCard"; +import { PlaybookGraph } from "@/components/playbooks/PlaybookGraph"; +import type { ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; // react-diff-viewer-continued is client-only and pulls in // styled-components. Dynamic import keeps SSR clean. diff --git a/frontend/components/ProposalCard.tsx b/frontend/components/playbooks/ProposalCard.tsx similarity index 98% rename from frontend/components/ProposalCard.tsx rename to frontend/components/playbooks/ProposalCard.tsx index e8d8ee3b..a5ccae70 100644 --- a/frontend/components/ProposalCard.tsx +++ b/frontend/components/playbooks/ProposalCard.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { api, ApiError } from "@/lib/api"; -import { Spinner } from "./Spinner"; -import { ProposalBodyTabs } from "./proposal/ProposalBodyTabs"; +import { Spinner } from "@/components/shared/Spinner"; +import { ProposalBodyTabs } from "@/components/playbooks/ProposalBodyTabs"; // Payload returned by the triagent-strategies/playbook_proposal_draft tool. // Keep this in lockstep with proposePlaybookDraftOut in diff --git a/frontend/components/SaveDialog.tsx b/frontend/components/playbooks/SaveDialog.tsx similarity index 98% rename from frontend/components/SaveDialog.tsx rename to frontend/components/playbooks/SaveDialog.tsx index 2bb3daef..8e1c0178 100644 --- a/frontend/components/SaveDialog.tsx +++ b/frontend/components/playbooks/SaveDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Spinner } from "./Spinner"; +import { Spinner } from "@/components/shared/Spinner"; type Props = { playbookID: string; diff --git a/frontend/components/ToolPicker.tsx b/frontend/components/playbooks/ToolPicker.tsx similarity index 100% rename from frontend/components/ToolPicker.tsx rename to frontend/components/playbooks/ToolPicker.tsx diff --git a/frontend/components/YamlPanel.tsx b/frontend/components/playbooks/YamlPanel.tsx similarity index 96% rename from frontend/components/YamlPanel.tsx rename to frontend/components/playbooks/YamlPanel.tsx index e65c8658..a39fb6e0 100644 --- a/frontend/components/YamlPanel.tsx +++ b/frontend/components/playbooks/YamlPanel.tsx @@ -1,7 +1,7 @@ "use client"; -import { CheckIcon } from "./Icons"; -import { CopyButton } from "./CopyButton"; +import { CheckIcon } from "@/components/shared/Icons"; +import { CopyButton } from "@/components/shared/CopyButton"; type Props = { yaml: string; diff --git a/frontend/components/LinkedReposPanel.tsx b/frontend/components/repos/LinkedReposPanel.tsx similarity index 99% rename from frontend/components/LinkedReposPanel.tsx rename to frontend/components/repos/LinkedReposPanel.tsx index 47f03ece..75c11218 100644 --- a/frontend/components/LinkedReposPanel.tsx +++ b/frontend/components/repos/LinkedReposPanel.tsx @@ -5,13 +5,13 @@ 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 { Spinner } from "@/components/shared/Spinner"; import { useRepoSummaryStatus, useRepoSummaryStore, repoKey, -} from "@/components/RepoSummaryStateProvider"; -import { GitHubIcon, WarningIcon } from "@/components/Icons"; +} from "@/components/repos/RepoSummaryStateProvider"; +import { GitHubIcon, WarningIcon } from "@/components/shared/Icons"; type Props = { // Active investigation, when one is open. Drives the read-only "linked diff --git a/frontend/components/RepoActivityPanel.tsx b/frontend/components/repos/RepoActivityPanel.tsx similarity index 98% rename from frontend/components/RepoActivityPanel.tsx rename to frontend/components/repos/RepoActivityPanel.tsx index ea7dcfd8..f096e675 100644 --- a/frontend/components/RepoActivityPanel.tsx +++ b/frontend/components/repos/RepoActivityPanel.tsx @@ -3,8 +3,8 @@ import { useEffect, useMemo, useState } from "react"; import { api, ApiError } from "@/lib/api"; import type { CodefixProposalListing } from "@/lib/events"; -import { ExternalLinkIcon, GitHubIcon } from "./Icons"; -import { Spinner } from "./Spinner"; +import { ExternalLinkIcon, GitHubIcon } from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; // RepoActivityPanel renders the list of GitHub issues + PRs the // agent opened across investigations. Lives in the repos sidenav. diff --git a/frontend/components/RepoArchitectureView.tsx b/frontend/components/repos/RepoArchitectureView.tsx similarity index 99% rename from frontend/components/RepoArchitectureView.tsx rename to frontend/components/repos/RepoArchitectureView.tsx index fbd85198..4c515a71 100644 --- a/frontend/components/RepoArchitectureView.tsx +++ b/frontend/components/repos/RepoArchitectureView.tsx @@ -10,14 +10,14 @@ import { type RepoSummary, type RepoSummaryEdits, } from "@/lib/api"; -import { ArrowLeftIcon, GitHubIcon } from "@/components/Icons"; +import { ArrowLeftIcon, GitHubIcon } from "@/components/shared/Icons"; import { useDialog } from "@/lib/dialog"; import { repoKey, useRepoSummaryStatus, useRepoSummaryStore, -} from "@/components/RepoSummaryStateProvider"; -import { Spinner } from "@/components/Spinner"; +} from "@/components/repos/RepoSummaryStateProvider"; +import { Spinner } from "@/components/shared/Spinner"; type Props = { owner: string; diff --git a/frontend/components/RepoSummaryStateProvider.tsx b/frontend/components/repos/RepoSummaryStateProvider.tsx similarity index 100% rename from frontend/components/RepoSummaryStateProvider.tsx rename to frontend/components/repos/RepoSummaryStateProvider.tsx diff --git a/frontend/components/sessions/PushSessionPRModal.tsx b/frontend/components/sessions/PushSessionPRModal.tsx index 698a56ff..7b0e371a 100644 --- a/frontend/components/sessions/PushSessionPRModal.tsx +++ b/frontend/components/sessions/PushSessionPRModal.tsx @@ -5,9 +5,9 @@ import { type Investigation, type SessionPushPRRequest, } from "@/lib/api"; -import { ExternalLinkIcon } from "../Icons"; -import { Spinner } from "../Spinner"; -import type { PushState } from "../SessionView"; +import { ExternalLinkIcon } from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; +import type { PushState } from "@/components/investigations/SessionView"; type Props = { investigation: Investigation; diff --git a/frontend/components/sessions/SessionCard.tsx b/frontend/components/sessions/SessionCard.tsx index 6eed1115..c684be9d 100644 --- a/frontend/components/sessions/SessionCard.tsx +++ b/frontend/components/sessions/SessionCard.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { SessionCard as SessionCardData, SyncStatePR } from "@/lib/api"; import { relativeTime } from "@/lib/relative-time"; -import { ExternalLinkIcon } from "../Icons"; +import { ExternalLinkIcon } from "@/components/shared/Icons"; export function SessionCard({ card }: { card: SessionCardData }) { // PR badge data flows from the per-card SyncState.pr sub-object — diff --git a/frontend/components/sessions/SessionDoc.tsx b/frontend/components/sessions/SessionDoc.tsx index 9f6e1454..a849e514 100644 --- a/frontend/components/sessions/SessionDoc.tsx +++ b/frontend/components/sessions/SessionDoc.tsx @@ -3,8 +3,8 @@ import { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { api, ApiError, type SessionDoc as SessionDocData } from "@/lib/api"; -import { Markdown } from "../Markdown"; -import { Spinner } from "../Spinner"; +import { Markdown } from "@/components/shared/Markdown"; +import { Spinner } from "@/components/shared/Spinner"; export function SessionDoc() { const pathname = usePathname() ?? ""; diff --git a/frontend/components/sessions/SessionUpstreamHeader.tsx b/frontend/components/sessions/SessionUpstreamHeader.tsx index 25964069..8e4726d9 100644 --- a/frontend/components/sessions/SessionUpstreamHeader.tsx +++ b/frontend/components/sessions/SessionUpstreamHeader.tsx @@ -7,7 +7,7 @@ import { ExternalLinkIcon, GitHubIcon, SyncIcon, -} from "@/components/Icons"; +} from "@/components/shared/Icons"; type SyncState = | { kind: "idle" } diff --git a/frontend/components/sessions/UpstreamHome.tsx b/frontend/components/sessions/UpstreamHome.tsx index 5cea337b..3bce00e2 100644 --- a/frontend/components/sessions/UpstreamHome.tsx +++ b/frontend/components/sessions/UpstreamHome.tsx @@ -6,8 +6,8 @@ import { ApiError, type SessionCard as SessionCardData, } from "@/lib/api"; -import { Paginator } from "../Paginator"; -import { Spinner } from "../Spinner"; +import { Paginator } from "@/components/shared/Paginator"; +import { Spinner } from "@/components/shared/Spinner"; import { SessionCard } from "./SessionCard"; import { SessionUpstreamHeader } from "./SessionUpstreamHeader"; diff --git a/frontend/components/CopyButton.tsx b/frontend/components/shared/CopyButton.tsx similarity index 97% rename from frontend/components/CopyButton.tsx rename to frontend/components/shared/CopyButton.tsx index 7b88277c..41ca5d71 100644 --- a/frontend/components/CopyButton.tsx +++ b/frontend/components/shared/CopyButton.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CheckIcon, CopyIcon } from "./Icons"; +import { CheckIcon, CopyIcon } from "@/components/shared/Icons"; type Tone = "zinc" | "amber" | "blue"; type Size = "sm" | "md"; diff --git a/frontend/components/EntityChipsInput.test.tsx b/frontend/components/shared/EntityChipsInput.test.tsx similarity index 96% rename from frontend/components/EntityChipsInput.test.tsx rename to frontend/components/shared/EntityChipsInput.test.tsx index 85e36619..c58ade86 100644 --- a/frontend/components/EntityChipsInput.test.tsx +++ b/frontend/components/shared/EntityChipsInput.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { EntityChipsInput } from "./EntityChipsInput"; +import { EntityChipsInput } from "@/components/shared/EntityChipsInput"; describe("EntityChipsInput", () => { it("adds a valid entity on Enter and clears the input", () => { diff --git a/frontend/components/EntityChipsInput.tsx b/frontend/components/shared/EntityChipsInput.tsx similarity index 100% rename from frontend/components/EntityChipsInput.tsx rename to frontend/components/shared/EntityChipsInput.tsx diff --git a/frontend/components/FilterBuilder.test.tsx b/frontend/components/shared/FilterBuilder.test.tsx similarity index 93% rename from frontend/components/FilterBuilder.test.tsx rename to frontend/components/shared/FilterBuilder.test.tsx index 3b7d4b6c..af41bae2 100644 --- a/frontend/components/FilterBuilder.test.tsx +++ b/frontend/components/shared/FilterBuilder.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { FilterBuilder } from "./FilterBuilder"; +import { FilterBuilder } from "@/components/shared/FilterBuilder"; describe("FilterBuilder", () => { it("adds and removes filter rows", () => { diff --git a/frontend/components/FilterBuilder.tsx b/frontend/components/shared/FilterBuilder.tsx similarity index 100% rename from frontend/components/FilterBuilder.tsx rename to frontend/components/shared/FilterBuilder.tsx diff --git a/frontend/components/FilterableList.tsx b/frontend/components/shared/FilterableList.tsx similarity index 100% rename from frontend/components/FilterableList.tsx rename to frontend/components/shared/FilterableList.tsx diff --git a/frontend/components/Icons.tsx b/frontend/components/shared/Icons.tsx similarity index 99% rename from frontend/components/Icons.tsx rename to frontend/components/shared/Icons.tsx index f6a72b47..716f0016 100644 --- a/frontend/components/Icons.tsx +++ b/frontend/components/shared/Icons.tsx @@ -1,6 +1,6 @@ // Icon set for the launcher UI. Most icons are re-exports from // lucide-react under the project's existing names, so the import -// surface — `import { CheckIcon, ExternalLinkIcon } from "@/components/Icons"` +// surface — `import { CheckIcon, ExternalLinkIcon } from "@/components/shared/Icons"` // — stays unchanged at every callsite. // // The GitHub mark stays as a hand-trimmed Octicon: lucide dropped diff --git a/frontend/components/Markdown.tsx b/frontend/components/shared/Markdown.tsx similarity index 100% rename from frontend/components/Markdown.tsx rename to frontend/components/shared/Markdown.tsx diff --git a/frontend/components/Paginator.tsx b/frontend/components/shared/Paginator.tsx similarity index 95% rename from frontend/components/Paginator.tsx rename to frontend/components/shared/Paginator.tsx index 33c178fd..69c653de 100644 --- a/frontend/components/Paginator.tsx +++ b/frontend/components/shared/Paginator.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, ArrowRightIcon } from "@/components/Icons"; +import { ArrowLeftIcon, ArrowRightIcon } from "@/components/shared/Icons"; type PaginatorProps = { offset: number; diff --git a/frontend/components/SlackChannelPicker.tsx b/frontend/components/shared/SlackChannelPicker.tsx similarity index 96% rename from frontend/components/SlackChannelPicker.tsx rename to frontend/components/shared/SlackChannelPicker.tsx index e28a272f..d7e37a81 100644 --- a/frontend/components/SlackChannelPicker.tsx +++ b/frontend/components/shared/SlackChannelPicker.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { api, ApiError, type SlackChannel } from "@/lib/api"; -import { Spinner } from "./Spinner"; -import { FilterableList } from "./FilterableList"; +import { Spinner } from "@/components/shared/Spinner"; +import { FilterableList } from "@/components/shared/FilterableList"; import { filterChannels } from "@/lib/slackChannels"; // SlackChannelPicker lists the channels the operator's slack token is a diff --git a/frontend/components/Spinner.tsx b/frontend/components/shared/Spinner.tsx similarity index 100% rename from frontend/components/Spinner.tsx rename to frontend/components/shared/Spinner.tsx diff --git a/frontend/components/ToolCard.tsx b/frontend/components/shared/ToolCard.tsx similarity index 100% rename from frontend/components/ToolCard.tsx rename to frontend/components/shared/ToolCard.tsx diff --git a/frontend/components/AllWatchesSignalsPanel.tsx b/frontend/components/watches/AllWatchesSignalsPanel.tsx similarity index 100% rename from frontend/components/AllWatchesSignalsPanel.tsx rename to frontend/components/watches/AllWatchesSignalsPanel.tsx diff --git a/frontend/components/SignalCard.test.tsx b/frontend/components/watches/SignalCard.test.tsx similarity index 96% rename from frontend/components/SignalCard.test.tsx rename to frontend/components/watches/SignalCard.test.tsx index d6bc67f8..09518a72 100644 --- a/frontend/components/SignalCard.test.tsx +++ b/frontend/components/watches/SignalCard.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { SignalCard } from "./SignalCard"; +import { SignalCard } from "@/components/watches/SignalCard"; import type { SignalRecord } from "@/lib/api"; // SignalCard uses useRouter from next/navigation; stub the module so diff --git a/frontend/components/SignalCard.tsx b/frontend/components/watches/SignalCard.tsx similarity index 97% rename from frontend/components/SignalCard.tsx rename to frontend/components/watches/SignalCard.tsx index 80c2e303..38b7c0a2 100644 --- a/frontend/components/SignalCard.tsx +++ b/frontend/components/watches/SignalCard.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import type { ItemRecord, SignalRecord } from "@/lib/api"; -import { ArrowRightIcon, ExternalLinkIcon } from "./Icons"; -import { StartFromSignalDialog } from "./StartFromSignalDialog"; +import { ArrowRightIcon, ExternalLinkIcon } from "@/components/shared/Icons"; +import { StartFromSignalDialog } from "@/components/watches/StartFromSignalDialog"; const outcomeAccent: Record = { disabled: "border-l-sky-400", diff --git a/frontend/components/StartFromSignalDialog.tsx b/frontend/components/watches/StartFromSignalDialog.tsx similarity index 100% rename from frontend/components/StartFromSignalDialog.tsx rename to frontend/components/watches/StartFromSignalDialog.tsx diff --git a/frontend/components/WatchDetailHeader.tsx b/frontend/components/watches/WatchDetailHeader.tsx similarity index 97% rename from frontend/components/WatchDetailHeader.tsx rename to frontend/components/watches/WatchDetailHeader.tsx index 9f08671b..9b79e599 100644 --- a/frontend/components/WatchDetailHeader.tsx +++ b/frontend/components/watches/WatchDetailHeader.tsx @@ -5,9 +5,9 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { watchesAPI, type Watch, type WatchSourceKind } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { ArrowLeftIcon, GitHubIcon, SlackIcon } from "./Icons"; -import { WatchStatusPill } from "./WatchStatusPill"; -import { Spinner } from "./Spinner"; +import { ArrowLeftIcon, GitHubIcon, SlackIcon } from "@/components/shared/Icons"; +import { WatchStatusPill } from "@/components/watches/WatchStatusPill"; +import { Spinner } from "@/components/shared/Spinner"; export function WatchDetailHeader({ watch, diff --git a/frontend/components/WatchForm.test.tsx b/frontend/components/watches/WatchForm.test.tsx similarity index 97% rename from frontend/components/WatchForm.test.tsx rename to frontend/components/watches/WatchForm.test.tsx index 384cb670..dd5b299e 100644 --- a/frontend/components/WatchForm.test.tsx +++ b/frontend/components/watches/WatchForm.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { api } from "@/lib/api"; -import { WatchForm } from "./WatchForm"; +import { WatchForm } from "@/components/watches/WatchForm"; describe("WatchForm", () => { beforeEach(() => { diff --git a/frontend/components/WatchForm.tsx b/frontend/components/watches/WatchForm.tsx similarity index 99% rename from frontend/components/WatchForm.tsx rename to frontend/components/watches/WatchForm.tsx index abc36994..aa520fe5 100644 --- a/frontend/components/WatchForm.tsx +++ b/frontend/components/watches/WatchForm.tsx @@ -3,8 +3,8 @@ import { useEffect, useState } from "react"; import { api, type ConnectionStatus, type Watch, type WatchFilter, type WatchSourceKind } from "@/lib/api"; import { formatDuration, parseDuration } from "@/lib/duration"; -import { FilterBuilder } from "./FilterBuilder"; -import { SlackChannelPicker } from "./SlackChannelPicker"; +import { FilterBuilder } from "@/components/shared/FilterBuilder"; +import { SlackChannelPicker } from "@/components/shared/SlackChannelPicker"; type FormShape = Omit; diff --git a/frontend/components/WatchIngestRunsPanel.tsx b/frontend/components/watches/WatchIngestRunsPanel.tsx similarity index 99% rename from frontend/components/WatchIngestRunsPanel.tsx rename to frontend/components/watches/WatchIngestRunsPanel.tsx index 5ce475dd..94e091d9 100644 --- a/frontend/components/WatchIngestRunsPanel.tsx +++ b/frontend/components/watches/WatchIngestRunsPanel.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { watchesAPI, type WatchIngestRunDetail, type WatchIngestRunListEntry } from "@/lib/api"; import { useStream } from "@/lib/stream"; -import { Spinner } from "./Spinner"; +import { Spinner } from "@/components/shared/Spinner"; // WatchIngestRunsPanel surfaces the per-poll ingestion-agent run log — // the answer to "I see items but no signals after a poll, why?". Each diff --git a/frontend/components/WatchItemsPanel.tsx b/frontend/components/watches/WatchItemsPanel.tsx similarity index 100% rename from frontend/components/WatchItemsPanel.tsx rename to frontend/components/watches/WatchItemsPanel.tsx diff --git a/frontend/components/WatchQueueStrip.tsx b/frontend/components/watches/WatchQueueStrip.tsx similarity index 100% rename from frontend/components/WatchQueueStrip.tsx rename to frontend/components/watches/WatchQueueStrip.tsx diff --git a/frontend/components/WatchSignalsPanel.tsx b/frontend/components/watches/WatchSignalsPanel.tsx similarity index 98% rename from frontend/components/WatchSignalsPanel.tsx rename to frontend/components/watches/WatchSignalsPanel.tsx index edff4472..d3de33ad 100644 --- a/frontend/components/WatchSignalsPanel.tsx +++ b/frontend/components/watches/WatchSignalsPanel.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import type { ItemRecord, SignalRecord } from "@/lib/api"; -import { SignalCard } from "./SignalCard"; +import { SignalCard } from "@/components/watches/SignalCard"; export function WatchSignalsPanel({ watchID, diff --git a/frontend/components/WatchStatusPill.tsx b/frontend/components/watches/WatchStatusPill.tsx similarity index 100% rename from frontend/components/WatchStatusPill.tsx rename to frontend/components/watches/WatchStatusPill.tsx diff --git a/frontend/components/WatchesList.test.tsx b/frontend/components/watches/WatchesList.test.tsx similarity index 93% rename from frontend/components/WatchesList.test.tsx rename to frontend/components/watches/WatchesList.test.tsx index 134ca90b..27b9b6d4 100644 --- a/frontend/components/WatchesList.test.tsx +++ b/frontend/components/watches/WatchesList.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { WatchesList } from "./WatchesList"; +import { WatchesList } from "@/components/watches/WatchesList"; import type { Watch } from "@/lib/api"; const w: Watch = { diff --git a/frontend/components/WatchesList.tsx b/frontend/components/watches/WatchesList.tsx similarity index 96% rename from frontend/components/WatchesList.tsx rename to frontend/components/watches/WatchesList.tsx index 312867e5..a0b2dffd 100644 --- a/frontend/components/WatchesList.tsx +++ b/frontend/components/watches/WatchesList.tsx @@ -3,9 +3,9 @@ import Link from "next/link"; import { useState } from "react"; import { watchesAPI, type Watch, type WatchSourceKind } from "@/lib/api"; -import { WatchStatusPill } from "./WatchStatusPill"; -import { GitHubIcon, SlackIcon } from "./Icons"; -import { Spinner } from "./Spinner"; +import { WatchStatusPill } from "@/components/watches/WatchStatusPill"; +import { GitHubIcon, SlackIcon } from "@/components/shared/Icons"; +import { Spinner } from "@/components/shared/Spinner"; export function WatchesList({ watches, diff --git a/frontend/components/wiki/EntryRow.tsx b/frontend/components/wiki/EntryRow.tsx index 4987424d..f9079550 100644 --- a/frontend/components/wiki/EntryRow.tsx +++ b/frontend/components/wiki/EntryRow.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import type { WikiEntryHit } from "@/lib/wiki-api"; -import { UnsyncedIcon } from "@/components/Icons"; +import { UnsyncedIcon } from "@/components/shared/Icons"; type Props = { hit: WikiEntryHit; diff --git a/frontend/components/wiki/NewWikiEntryModal.tsx b/frontend/components/wiki/NewWikiEntryModal.tsx index 6375017b..f6d2fbc2 100644 --- a/frontend/components/wiki/NewWikiEntryModal.tsx +++ b/frontend/components/wiki/NewWikiEntryModal.tsx @@ -9,8 +9,8 @@ import { type EditorSources, type Investigation, } from "@/lib/api"; -import { Spinner } from "@/components/Spinner"; -import { SlackChannelPicker } from "@/components/SlackChannelPicker"; +import { Spinner } from "@/components/shared/Spinner"; +import { SlackChannelPicker } from "@/components/shared/SlackChannelPicker"; import { sinceISOFromCreatedUnix } from "@/lib/slackChannels"; // NewWikiEntryModal kicks off a wiki entry the operator wants the agent diff --git a/frontend/components/wiki/PushWikiPRModal.tsx b/frontend/components/wiki/PushWikiPRModal.tsx index a77f9908..cb9b4e53 100644 --- a/frontend/components/wiki/PushWikiPRModal.tsx +++ b/frontend/components/wiki/PushWikiPRModal.tsx @@ -8,8 +8,8 @@ import { type WikiOpenPR, type WikiPushBundleResult, } from "@/lib/wiki-api"; -import { Spinner } from "@/components/Spinner"; -import { CopyButton } from "@/components/CopyButton"; +import { Spinner } from "@/components/shared/Spinner"; +import { CopyButton } from "@/components/shared/CopyButton"; // PushWikiPRModal opens an upstream PR that bundles every locally- // committed-but-not-pushed file. The editor builds the bundle (root + diff --git a/frontend/components/wiki/WikiBodyTab.tsx b/frontend/components/wiki/WikiBodyTab.tsx index fdaaa293..d3d449fb 100644 --- a/frontend/components/wiki/WikiBodyTab.tsx +++ b/frontend/components/wiki/WikiBodyTab.tsx @@ -8,8 +8,8 @@ // content lives in the parent's state. import { useState } from "react"; -import { Markdown } from "@/components/Markdown"; -import { CheckIcon, EditIcon } from "@/components/Icons"; +import { Markdown } from "@/components/shared/Markdown"; +import { CheckIcon, EditIcon } from "@/components/shared/Icons"; type Props = { // Header label shown at the top of the tab content. Pass diff --git a/frontend/components/wiki/WikiEditor.tsx b/frontend/components/wiki/WikiEditor.tsx index 5fffc6b7..0adb3ef0 100644 --- a/frontend/components/wiki/WikiEditor.tsx +++ b/frontend/components/wiki/WikiEditor.tsx @@ -30,14 +30,14 @@ import { } from "@/lib/wiki-api"; import { api, type Capabilities, type SyncState } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { Spinner } from "@/components/Spinner"; +import { Spinner } from "@/components/shared/Spinner"; import { ArrowLeftIcon, ChatBubbleIcon, GitHubIcon, RevertIcon, UnsyncedIcon, -} from "@/components/Icons"; +} from "@/components/shared/Icons"; import { BTN_BASE, BTN_GATED, @@ -53,12 +53,12 @@ import { } from "./WikiNodeEditor"; import { WikiBodyTab } from "./WikiBodyTab"; import { PushWikiPRModal } from "./PushWikiPRModal"; -import { EditorChatDrawer } from "@/components/EditorChatDrawer"; +import { EditorChatDrawer } from "@/components/playbooks/EditorChatDrawer"; import { WikiProposalCard, type WikiProposalPayload, -} from "@/components/WikiProposalCard"; -import { type ProposalDraftPayload } from "@/components/ProposalCard"; +} from "@/components/wiki/WikiProposalCard"; +import { type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; import { PROPOSE_WIKI_DRAFT_TOOL_NAME } from "@/lib/events"; // Parser for the wiki drawer. Validates the three required fields the diff --git a/frontend/components/WikiEntityGraph.tsx b/frontend/components/wiki/WikiEntityGraph.tsx similarity index 100% rename from frontend/components/WikiEntityGraph.tsx rename to frontend/components/wiki/WikiEntityGraph.tsx diff --git a/frontend/components/wiki/WikiGraphNodes.tsx b/frontend/components/wiki/WikiGraphNodes.tsx index a9471744..eaa7595b 100644 --- a/frontend/components/wiki/WikiGraphNodes.tsx +++ b/frontend/components/wiki/WikiGraphNodes.tsx @@ -5,7 +5,7 @@ import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon, -} from "@/components/Icons"; +} from "@/components/shared/Icons"; // ── Shared sizes — must match the dagre layout hints in WikiGraph.tsx ──────── diff --git a/frontend/components/wiki/WikiHome.tsx b/frontend/components/wiki/WikiHome.tsx index b5e93ca1..b1662154 100644 --- a/frontend/components/wiki/WikiHome.tsx +++ b/frontend/components/wiki/WikiHome.tsx @@ -6,12 +6,12 @@ import { wikiApi, ApiError } from "@/lib/wiki-api"; import type { WikiStats, WikiEntryHit } from "@/lib/wiki-api"; import { api, type Investigation } from "@/lib/api"; import { labelFor } from "@/lib/sidebar-label"; -import { Spinner } from "@/components/Spinner"; +import { Spinner } from "@/components/shared/Spinner"; import { WikiStats as WikiStatsStrip } from "./WikiStats"; import { WikiSearch, type WikiFilters, emptyFilters } from "./WikiSearch"; import { EntryList } from "./EntryList"; import { WikiUpstreamHeader } from "./WikiUpstreamHeader"; -import { Paginator } from "@/components/Paginator"; +import { Paginator } from "@/components/shared/Paginator"; // Page size for the entry list. Matches the other home-page rails // (sessions, upstream) so the operator sees a consistent cadence diff --git a/frontend/components/wiki/WikiNodeEditor.tsx b/frontend/components/wiki/WikiNodeEditor.tsx index 4d1f4e43..a745650d 100644 --- a/frontend/components/wiki/WikiNodeEditor.tsx +++ b/frontend/components/wiki/WikiNodeEditor.tsx @@ -7,7 +7,7 @@ // because wiki nodes have different schemas than playbook nodes. import type { WikiBacklink } from "@/lib/wiki-api"; -import { ChevronRightIcon } from "@/components/Icons"; +import { ChevronRightIcon } from "@/components/shared/Icons"; // IncidentDraft mirrors the shape held in WikiEditor's draft store. // Kept as a duplicate type here (rather than imported from the editor) diff --git a/frontend/components/WikiProposalCard.test.tsx b/frontend/components/wiki/WikiProposalCard.test.tsx similarity index 97% rename from frontend/components/WikiProposalCard.test.tsx rename to frontend/components/wiki/WikiProposalCard.test.tsx index 675855ba..e27bf556 100644 --- a/frontend/components/WikiProposalCard.test.tsx +++ b/frontend/components/wiki/WikiProposalCard.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { WikiProposalCard, type WikiProposalPayload } from "./WikiProposalCard"; +import { WikiProposalCard, type WikiProposalPayload } from "@/components/wiki/WikiProposalCard"; import { api, ApiError, type Capabilities } from "@/lib/api"; const payload: WikiProposalPayload = { diff --git a/frontend/components/WikiProposalCard.tsx b/frontend/components/wiki/WikiProposalCard.tsx similarity index 99% rename from frontend/components/WikiProposalCard.tsx rename to frontend/components/wiki/WikiProposalCard.tsx index d83d6fc4..b43010cb 100644 --- a/frontend/components/WikiProposalCard.tsx +++ b/frontend/components/wiki/WikiProposalCard.tsx @@ -10,8 +10,8 @@ import { type Capabilities, type WikiProposalApprovedResponse, } from "@/lib/api"; -import { Markdown } from "./Markdown"; -import { Spinner } from "./Spinner"; +import { Markdown } from "@/components/shared/Markdown"; +import { Spinner } from "@/components/shared/Spinner"; // react-diff-viewer-continued is client-only and pulls in styled-components, // so we lazy-import it via next/dynamic to keep the SSR pass clean. @@ -28,7 +28,7 @@ const DiffViewer = dynamic(() => import("react-diff-viewer-continued"), { // switches to the entities tab. const WikiEntityGraph = dynamic( () => - import("./WikiEntityGraph").then((m) => ({ default: m.WikiEntityGraph })), + import("@/components/wiki/WikiEntityGraph").then((m) => ({ default: m.WikiEntityGraph })), { ssr: false, loading: () => ( diff --git a/frontend/components/WikiProposalNotifier.test.tsx b/frontend/components/wiki/WikiProposalNotifier.test.tsx similarity index 96% rename from frontend/components/WikiProposalNotifier.test.tsx rename to frontend/components/wiki/WikiProposalNotifier.test.tsx index de696280..9504b35d 100644 --- a/frontend/components/WikiProposalNotifier.test.tsx +++ b/frontend/components/wiki/WikiProposalNotifier.test.tsx @@ -24,7 +24,7 @@ function emit(env: Partial & { kind: string }) { for (const s of subscribers) s.handler(env as StreamEnvelope); } -import { WikiProposalNotifier } from "./WikiProposalNotifier"; +import { WikiProposalNotifier } from "@/components/wiki/WikiProposalNotifier"; beforeEach(() => { subscribers.length = 0; diff --git a/frontend/components/WikiProposalNotifier.tsx b/frontend/components/wiki/WikiProposalNotifier.tsx similarity index 100% rename from frontend/components/WikiProposalNotifier.tsx rename to frontend/components/wiki/WikiProposalNotifier.tsx diff --git a/frontend/components/wiki/WikiUpstreamHeader.tsx b/frontend/components/wiki/WikiUpstreamHeader.tsx index 7a53736d..b4e599df 100644 --- a/frontend/components/wiki/WikiUpstreamHeader.tsx +++ b/frontend/components/wiki/WikiUpstreamHeader.tsx @@ -7,7 +7,7 @@ import { ExternalLinkIcon, GitHubIcon, SyncIcon, -} from "@/components/Icons"; +} from "@/components/shared/Icons"; type SyncState = | { kind: "idle" } From 7331bb4ea27aec17d47d2fe3c0902271b6e8b889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:10:05 +0200 Subject: [PATCH 2/2] refactor(frontend): split oversized component files into co-located siblings Break the ten largest component files into dot-namespaced sibling files in the same folder (reducers, helpers, types, and trailing leaf sub-components lifted out), leaving each main component body intact. Pure structural change; no behavior change. PlaybookEditor 1932 -> 1538 SessionView 1734 -> 1160 Sidebar 1284 -> 583 PlaybookList 998 -> 682 NodeEditor 754 -> 163 WikiProposalCard 730 -> 516 PlaybookGraph 622 -> 169 RepoArchitectureView 604 -> 357 LinkedReposPanel 601 -> 117 DocsView 517 -> 185 --- .../components/docs/DocsView.markdown.tsx | 156 ++++ frontend/components/docs/DocsView.nav.tsx | 115 +++ frontend/components/docs/DocsView.tsx | 337 +-------- frontend/components/docs/DocsView.utils.ts | 71 ++ .../investigations/SessionView.banners.tsx | 120 +++ .../investigations/SessionView.header.tsx | 204 +++++ .../investigations/SessionView.icons.tsx | 63 ++ .../investigations/SessionView.status.tsx | 205 +++++ .../components/investigations/SessionView.tsx | 582 +------------- frontend/components/layout/Sidebar.invRow.tsx | 223 ++++++ .../components/layout/Sidebar.proposals.tsx | 433 +++++++++++ frontend/components/layout/Sidebar.tsx | 709 +----------------- frontend/components/layout/Sidebar.utils.tsx | 63 ++ .../playbooks/NodeEditor.fields.tsx | 25 + .../playbooks/NodeEditor.inputs.tsx | 169 +++++ .../components/playbooks/NodeEditor.lists.tsx | 260 +++++++ .../playbooks/NodeEditor.pickers.tsx | 156 ++++ frontend/components/playbooks/NodeEditor.tsx | 599 +-------------- .../playbooks/PlaybookEditor.parts.tsx | 248 ++++++ .../playbooks/PlaybookEditor.reducer.ts | 172 +++++ .../components/playbooks/PlaybookEditor.tsx | 426 +---------- .../playbooks/PlaybookGraph.edges.tsx | 81 ++ .../playbooks/PlaybookGraph.layout.ts | 175 +++++ .../playbooks/PlaybookGraph.nodes.tsx | 210 ++++++ .../components/playbooks/PlaybookGraph.tsx | 461 +----------- .../playbooks/PlaybookList.badges.tsx | 116 +++ .../playbooks/PlaybookList.parts.tsx | 212 ++++++ .../components/playbooks/PlaybookList.tsx | 336 +-------- .../repos/LinkedReposPanel.list.tsx | 210 ++++++ .../repos/LinkedReposPanel.modal.tsx | 286 +++++++ .../components/repos/LinkedReposPanel.tsx | 491 +----------- .../repos/RepoArchitectureView.modals.tsx | 255 +++++++ .../components/repos/RepoArchitectureView.tsx | 256 +------ .../wiki/WikiProposalCard.files.tsx | 48 ++ .../wiki/WikiProposalCard.preview.tsx | 173 +++++ frontend/components/wiki/WikiProposalCard.tsx | 218 +----- 36 files changed, 4505 insertions(+), 4359 deletions(-) create mode 100644 frontend/components/docs/DocsView.markdown.tsx create mode 100644 frontend/components/docs/DocsView.nav.tsx create mode 100644 frontend/components/docs/DocsView.utils.ts create mode 100644 frontend/components/investigations/SessionView.banners.tsx create mode 100644 frontend/components/investigations/SessionView.header.tsx create mode 100644 frontend/components/investigations/SessionView.icons.tsx create mode 100644 frontend/components/investigations/SessionView.status.tsx create mode 100644 frontend/components/layout/Sidebar.invRow.tsx create mode 100644 frontend/components/layout/Sidebar.proposals.tsx create mode 100644 frontend/components/layout/Sidebar.utils.tsx create mode 100644 frontend/components/playbooks/NodeEditor.fields.tsx create mode 100644 frontend/components/playbooks/NodeEditor.inputs.tsx create mode 100644 frontend/components/playbooks/NodeEditor.lists.tsx create mode 100644 frontend/components/playbooks/NodeEditor.pickers.tsx create mode 100644 frontend/components/playbooks/PlaybookEditor.parts.tsx create mode 100644 frontend/components/playbooks/PlaybookEditor.reducer.ts create mode 100644 frontend/components/playbooks/PlaybookGraph.edges.tsx create mode 100644 frontend/components/playbooks/PlaybookGraph.layout.ts create mode 100644 frontend/components/playbooks/PlaybookGraph.nodes.tsx create mode 100644 frontend/components/playbooks/PlaybookList.badges.tsx create mode 100644 frontend/components/playbooks/PlaybookList.parts.tsx create mode 100644 frontend/components/repos/LinkedReposPanel.list.tsx create mode 100644 frontend/components/repos/LinkedReposPanel.modal.tsx create mode 100644 frontend/components/repos/RepoArchitectureView.modals.tsx create mode 100644 frontend/components/wiki/WikiProposalCard.files.tsx create mode 100644 frontend/components/wiki/WikiProposalCard.preview.tsx diff --git a/frontend/components/docs/DocsView.markdown.tsx b/frontend/components/docs/DocsView.markdown.tsx new file mode 100644 index 00000000..0af5ecf7 --- /dev/null +++ b/frontend/components/docs/DocsView.markdown.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { slugify, stringifyChildren } from "./DocsView.utils"; + +// 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. +export 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. +export 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} +
+
+ ); +} diff --git a/frontend/components/docs/DocsView.nav.tsx b/frontend/components/docs/DocsView.nav.tsx new file mode 100644 index 00000000..ff267670 --- /dev/null +++ b/frontend/components/docs/DocsView.nav.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { type Heading } from "./DocsView.utils"; + +// 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. +export 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. +export function ChevronIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/frontend/components/docs/DocsView.tsx b/frontend/components/docs/DocsView.tsx index be74ab7a..834a72c8 100644 --- a/frontend/components/docs/DocsView.tsx +++ b/frontend/components/docs/DocsView.tsx @@ -1,9 +1,10 @@ "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"; +import { SectionGroup } from "./DocsView.nav"; +import { DocsMarkdown } from "./DocsView.markdown"; +import { extractOutline } from "./DocsView.utils"; type Props = { // Section the operator picked from the docs sidebar. Driven by the @@ -182,335 +183,3 @@ export function DocsView({ active, onSectionChange }: Props) {
); } - -// 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/docs/DocsView.utils.ts b/frontend/components/docs/DocsView.utils.ts new file mode 100644 index 00000000..572626cd --- /dev/null +++ b/frontend/components/docs/DocsView.utils.ts @@ -0,0 +1,71 @@ +import type React from "react"; + +export 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. +export 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. +export 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. +export 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. +export 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/investigations/SessionView.banners.tsx b/frontend/components/investigations/SessionView.banners.tsx new file mode 100644 index 00000000..ed2d0886 --- /dev/null +++ b/frontend/components/investigations/SessionView.banners.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { takeover, restartAuto } from "@/lib/api"; +import { Bot } from "lucide-react"; + +// AutoModeBanner is the prominent "auto mode is driving — click to +// take over" banner that sits above the transcript while phase is +// started or resumed. The whole banner is a button so the operator +// can click anywhere on it; the chip on the right is purely visual +// follow-through. Disabled while the POST is in flight so a double +// click can't fire takeover twice. +export function AutoModeBanner({ investigationId }: { investigationId: string }) { + const [busy, setBusy] = useState(false); + return ( + + ); +} + +// AutoModeFinishedBanner is the terminal-state companion to AutoModeBanner. +// Renders above the transcript when phase is "finished" or "aborted" so the +// operator can see at a glance that the auto-operator has closed out — plus +// the rationale and a Restart affordance. Finished phase = emerald; aborted +// phase = red. The composer below is enabled regardless, so the operator can +// still add manual notes after the session has wrapped. +export function AutoModeFinishedBanner({ + investigationId, + phase, + reason, +}: { + investigationId: string; + phase: "finished" | "aborted"; + reason: string | null; +}) { + const [busy, setBusy] = useState(false); + const aborted = phase === "aborted"; + return ( +
+ +
+
+ {aborted ? "Auto mode aborted" : "Auto mode finished"} +
+
+ {reason ?? (aborted + ? "The operator agent stopped unexpectedly." + : "The operator agent closed out the session.")} +
+
+ +
+ ); +} diff --git a/frontend/components/investigations/SessionView.header.tsx b/frontend/components/investigations/SessionView.header.tsx new file mode 100644 index 00000000..1e2ae8cb --- /dev/null +++ b/frontend/components/investigations/SessionView.header.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { api, ApiError, type Investigation } from "@/lib/api"; +import { type SessionStatus } from "@/lib/events"; +import { labelFor } from "@/lib/sidebar-label"; +import { ArrowLeftIcon, DocIcon, DownloadIcon } from "@/components/shared/Icons"; +import { toolAnchorId } from "@/components/shared/ToolCard"; +import { StatusPill } from "./SessionView.status"; + +export function Header({ + investigation, + status, + latestSummaryToolId, +}: { + investigation: Investigation; + status: SessionStatus; + latestSummaryToolId: string | null; +}) { + return ( +
+
+ {/* Back link to the investigations home (mirrors the + playbook/wiki editors' "back to " affordance). */} + + + back to investigations + + + {investigation.originatingSignal && ( + + ← from watch / signal {investigation.originatingSignal.signalID.slice(0, 8)} + + )} + {/* Suppress the "imported from" badge when the resolver + reports synced — that case is "synced from upstream", + which the sidebar checkmark already conveys, and showing + both reads as "manually imported" which is misleading for + upstream pulls. The badge stays for true peer-share + imports (a teammate handed us a bundle that was never + pushed). Reads through SyncState rather than the raw + `pushed` flag so this view agrees with the sidebar — same + single oracle. */} + {investigation.importedFrom && + investigation.syncState.status !== "synced" && ( + + )} +
+
+ + {latestSummaryToolId && ( + + )} + +
+
+ ); +} + +// ViewLatestSummaryButton scrolls the chat to the most recent +// summarize tool call. The summary block uses the same toolAnchorId +// scheme as ToolCard so the activity panel's flash-on-jump treatment +// applies here too. +export function ViewLatestSummaryButton({ toolId }: { toolId: string }) { + const onClick = () => { + const el = document.getElementById(toolAnchorId(toolId)); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const flash = ["ring-2", "ring-amber-400/60", "ring-offset-2", "ring-offset-zinc-950"]; + el.classList.add(...flash); + window.setTimeout(() => el.classList.remove(...flash), 1500); + }; + return ( + + ); +} + +// ExportSessionButton downloads a share bundle for the active investigation. +// Sharing is allowed for live and archived sessions alike — the bundle is a +// snapshot of disk-state, so live sessions just snapshot whatever's been +// persisted up to now (the transcript is appended atomically per event). +export function ExportSessionButton({ investigationId }: { investigationId: string }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function exportBundle() { + if (busy) return; + setBusy(true); + setError(null); + try { + const { blob, filename } = await api.exportInvestigation(investigationId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // Revoke on the next tick so the browser has had a chance to start + // the download. Doing it synchronously cancels the download in some + // browsers. + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (e) { + setError(e instanceof ApiError ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( + + ); +} + +// HeaderTitle renders the session's display name + an optional namespace +// subtitle. Falls through labelFor so an unlabelled session reads as +// "New Investigation" (italic, dim) rather than a blank h2. +function HeaderTitle({ investigation }: { investigation: Investigation }) { + const lbl = labelFor(investigation); + return ( + <> +

+ {lbl.text} +

+ {investigation.namespace && ( +

+ {investigation.namespace} +

+ )} + + ); +} + +// ImportedFromBadge surfaces the provenance of a session that came in via +// share-bundle import. Subtle pill under the header subtitle so the +// receiver knows at a glance the transcript wasn't produced by their +// launcher. +function ImportedFromBadge({ + from, + at, +}: { + from: NonNullable; + at: string | undefined; +}) { + const when = at ? formatDate(at) : null; + const subject = from.namespace; + return ( +

+ + + imported from {subject} + {when ? ` on ${when}` : ""} + +

+ ); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/frontend/components/investigations/SessionView.icons.tsx b/frontend/components/investigations/SessionView.icons.tsx new file mode 100644 index 00000000..bbfc3a68 --- /dev/null +++ b/frontend/components/investigations/SessionView.icons.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Spinner } from "@/components/shared/Spinner"; + +export function PlayIcon({ className }: { className?: string }) { + return ( + // Filled right-pointing triangle. Slight horizontal offset (start + // at x=4, not x=3) optically centers the wedge inside the round + // chip — geometric centering would feel left-heavy because the + // tip is the visual anchor. + + + + ); +} + +// HandIcon is the "stop / take over" affordance on the auto-mode +// composer chip. A square outline reads as a "stop" glyph at this +// size; geometric centering is fine here (no asymmetric tip like the +// play wedge has). +export function HandIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// StopIcon is the chip used when streaming — replaces Send so the +// operator can interrupt the in-flight turn (ChatGPT-style). Same +// geometry as HandIcon but a separate component so its semantic +// purpose stays distinct in the source. +export function StopIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function Working() { + return ( +
+ + working… +
+ ); +} diff --git a/frontend/components/investigations/SessionView.status.tsx b/frontend/components/investigations/SessionView.status.tsx new file mode 100644 index 00000000..ae0f671a --- /dev/null +++ b/frontend/components/investigations/SessionView.status.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { Spinner } from "@/components/shared/Spinner"; +import { CheckIcon, ExternalLinkIcon } from "@/components/shared/Icons"; +import { relativeTime } from "@/lib/relative-time"; +import { formatCostUSD, formatTokens, totalTokens } from "@/lib/usage"; +import { type SessionStatus } from "@/lib/events"; + +export function StatusPill({ + status, + archived, +}: { + status: SessionStatus; + archived: boolean; +}) { + // Archived overrides the SSE-stream status — once a session is + // wound down the stream sits idle but "idle" reads as "ready for the + // next prompt", which is misleading. Show "archived" instead. + if (archived) { + return ( + + archived + + ); + } + switch (status) { + case "starting": + return ( + + starting + + ); + case "streaming": + return ( + + streaming + + ); + case "idle": + return ( + + idle + + ); + case "error": + return ( + + error + + ); + } +} + +// PushedBadge renders the "this session is on the upstream sessions +// repo" indicator. The PR's lifecycle (open / merged / closed) is +// pulled from `gh pr list` by the backend's RefreshPRStates and +// drives the colour + label here. +export function PushedBadge({ + pushUrl, + prState, + prMergedAt, + prClosedAt, +}: { + pushUrl?: string; + prState?: "open" | "merged" | "closed"; + prMergedAt?: string; + prClosedAt?: string; +}) { + if (!pushUrl) { + // Reconciled session: we know it's upstream (the slug exists in + // the local sessions clone) but we never captured the PR URL. + return ( + + + synced + + ); + } + const visual = pushedBadgeTone(prState); + const when = + prState === "merged" && prMergedAt + ? `merged ${relativeTime(prMergedAt)}` + : prState === "closed" && prClosedAt + ? `closed ${relativeTime(prClosedAt)}` + : ""; + const title = (when ? `${visual.title} (${when})` : visual.title) + ` — ${pushUrl}`; + return ( + + + {visual.label} + + + ); +} + +function pushedBadgeTone(state?: "open" | "merged" | "closed") { + switch (state) { + case "merged": + return { + label: "merged", + title: "PR merged", + classes: + "border-violet-900/60 bg-violet-950/30 text-violet-300 hover:border-violet-700 hover:text-violet-200", + }; + case "closed": + return { + label: "closed", + title: "PR closed without merging — push again to retry", + classes: + "border-zinc-700 bg-zinc-900/40 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200", + }; + case "open": + default: + return { + label: "PR open", + title: "PR open — review pending", + classes: + "border-emerald-900/60 bg-emerald-950/40 text-emerald-300 hover:border-emerald-700 hover:text-emerald-200", + }; + } +} + +// SessionActionButton renders one of the session-level trigger +// buttons in the action row above the chat textarea. Tone selects +// the colour scheme (emerald = wiki, sky = playbook, amber = codefix, +// rose = bug-report). Disabled state uses zinc with a not-allowed +// cursor; the hover tooltip carries either the explanatory disabled +// reason or the enabled-state hint. +export function SessionActionButton({ + tone, + enabled, + disabledReason, + enabledTitle, + onClick, + label, +}: { + tone: "emerald" | "sky" | "amber" | "rose"; + enabled: boolean; + disabledReason: string; + enabledTitle: string; + onClick: () => void; + label: string; +}) { + const enabledClasses = + tone === "emerald" + ? "rounded border border-emerald-700 bg-emerald-900/30 px-2.5 py-1 text-xs text-emerald-200 transition hover:border-emerald-500 hover:bg-emerald-900/50" + : tone === "sky" + ? "rounded border border-sky-700 bg-sky-900/30 px-2.5 py-1 text-xs text-sky-200 transition hover:border-sky-500 hover:bg-sky-900/50" + : tone === "amber" + ? "rounded border border-amber-700 bg-amber-900/30 px-2.5 py-1 text-xs text-amber-200 transition hover:border-amber-500 hover:bg-amber-900/50" + : "rounded border border-rose-700 bg-rose-900/30 px-2.5 py-1 text-xs text-rose-200 transition hover:border-rose-500 hover:bg-rose-900/50"; + return ( + + ); +} + +// UsageReadout renders the per-session token + cost total at the right +// end of the action row, above the chat textarea. Reads the running +// aggregate the launcher stamps onto Investigation snapshots (summed +// from every result envelope claude emitted). Hidden when nothing has +// been spent yet so a fresh session doesn't show a stray "0 tok · $0.00". +export function UsageReadout({ + usage, + costUsd, +}: { + usage?: { inputTokens?: number; outputTokens?: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }; + costUsd?: number; +}) { + const tokens = totalTokens(usage); + const cost = costUsd ?? 0; + if (tokens === 0 && cost === 0) return null; + return ( + + {formatTokens(tokens)} tok · {formatCostUSD(cost)} + + ); +} diff --git a/frontend/components/investigations/SessionView.tsx b/frontend/components/investigations/SessionView.tsx index ee557c8e..b9f0741c 100644 --- a/frontend/components/investigations/SessionView.tsx +++ b/frontend/components/investigations/SessionView.tsx @@ -1,7 +1,6 @@ "use client"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -import Link from "next/link"; import { api, ApiError, @@ -14,9 +13,6 @@ import { type SessionPushPRResult, } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { labelFor } from "@/lib/sidebar-label"; -import { relativeTime } from "@/lib/relative-time"; -import { formatCostUSD, formatTokens, totalTokens } from "@/lib/usage"; import { isCreateGithubIssueToolName, isDraftPrToolName, @@ -31,13 +27,6 @@ import { } from "@/lib/events"; import { Bot } from "lucide-react"; import { useAutoMode } from "@/hooks/useAutoMode"; -import { - ArrowLeftIcon, - CheckIcon, - DocIcon, - DownloadIcon, - ExternalLinkIcon, -} from "@/components/shared/Icons"; import { ArchiveButton } from "@/components/sessions/ArchiveButton"; import { PushSessionPRModal } from "@/components/sessions/PushSessionPRModal"; import { CopyButton } from "@/components/shared/CopyButton"; @@ -51,6 +40,10 @@ import { } from "@/components/wiki/WikiProposalCard"; import { CodefixProposalCard } from "@/components/codefix/CodefixProposalCard"; import { extractSummarizeResult } from "@/lib/summarize"; +import { AutoModeBanner, AutoModeFinishedBanner } from "./SessionView.banners"; +import { PlayIcon, HandIcon, StopIcon, Working } from "./SessionView.icons"; +import { Header } from "./SessionView.header"; +import { PushedBadge, SessionActionButton, UsageReadout } from "./SessionView.status"; // Stable empty-array sentinel for the optional `events` prop. Using a // module-level constant rather than `[]` inline keeps useAutoMode's @@ -120,121 +113,6 @@ type Props = { onServerRehydrateStateConsumed?: () => void; }; -// AutoModeBanner is the prominent "auto mode is driving — click to -// take over" banner that sits above the transcript while phase is -// started or resumed. The whole banner is a button so the operator -// can click anywhere on it; the chip on the right is purely visual -// follow-through. Disabled while the POST is in flight so a double -// click can't fire takeover twice. -function AutoModeBanner({ investigationId }: { investigationId: string }) { - const [busy, setBusy] = useState(false); - return ( - - ); -} - -// AutoModeFinishedBanner is the terminal-state companion to AutoModeBanner. -// Renders above the transcript when phase is "finished" or "aborted" so the -// operator can see at a glance that the auto-operator has closed out — plus -// the rationale and a Restart affordance. Finished phase = emerald; aborted -// phase = red. The composer below is enabled regardless, so the operator can -// still add manual notes after the session has wrapped. -function AutoModeFinishedBanner({ - investigationId, - phase, - reason, -}: { - investigationId: string; - phase: "finished" | "aborted"; - reason: string | null; -}) { - const [busy, setBusy] = useState(false); - const aborted = phase === "aborted"; - return ( -
- -
-
- {aborted ? "Auto mode aborted" : "Auto mode finished"} -
-
- {reason ?? (aborted - ? "The operator agent stopped unexpectedly." - : "The operator agent closed out the session.")} -
-
- -
- ); -} - // SessionView renders the chat: header, scrollable transcript, follow-up // textarea. Transcript state and the SSE connection live in the parent // SessionWorkspace so the activity panel can share them. @@ -956,458 +834,6 @@ export function SessionView({ ); } -function Header({ - investigation, - status, - latestSummaryToolId, -}: { - investigation: Investigation; - status: SessionStatus; - latestSummaryToolId: string | null; -}) { - return ( -
-
- {/* Back link to the investigations home (mirrors the - playbook/wiki editors' "back to " affordance). */} - - - back to investigations - - - {investigation.originatingSignal && ( - - ← from watch / signal {investigation.originatingSignal.signalID.slice(0, 8)} - - )} - {/* Suppress the "imported from" badge when the resolver - reports synced — that case is "synced from upstream", - which the sidebar checkmark already conveys, and showing - both reads as "manually imported" which is misleading for - upstream pulls. The badge stays for true peer-share - imports (a teammate handed us a bundle that was never - pushed). Reads through SyncState rather than the raw - `pushed` flag so this view agrees with the sidebar — same - single oracle. */} - {investigation.importedFrom && - investigation.syncState.status !== "synced" && ( - - )} -
-
- - {latestSummaryToolId && ( - - )} - -
-
- ); -} - -// ViewLatestSummaryButton scrolls the chat to the most recent -// summarize tool call. The summary block uses the same toolAnchorId -// scheme as ToolCard so the activity panel's flash-on-jump treatment -// applies here too. -function ViewLatestSummaryButton({ toolId }: { toolId: string }) { - const onClick = () => { - const el = document.getElementById(toolAnchorId(toolId)); - if (!el) return; - el.scrollIntoView({ behavior: "smooth", block: "center" }); - const flash = ["ring-2", "ring-amber-400/60", "ring-offset-2", "ring-offset-zinc-950"]; - el.classList.add(...flash); - window.setTimeout(() => el.classList.remove(...flash), 1500); - }; - return ( - - ); -} - -// ExportSessionButton downloads a share bundle for the active investigation. -// Sharing is allowed for live and archived sessions alike — the bundle is a -// snapshot of disk-state, so live sessions just snapshot whatever's been -// persisted up to now (the transcript is appended atomically per event). -function ExportSessionButton({ investigationId }: { investigationId: string }) { - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - async function exportBundle() { - if (busy) return; - setBusy(true); - setError(null); - try { - const { blob, filename } = await api.exportInvestigation(investigationId); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - // Revoke on the next tick so the browser has had a chance to start - // the download. Doing it synchronously cancels the download in some - // browsers. - window.setTimeout(() => URL.revokeObjectURL(url), 0); - } catch (e) { - setError(e instanceof ApiError ? e.message : String(e)); - } finally { - setBusy(false); - } - } - - return ( - - ); -} - -// HeaderTitle renders the session's display name + an optional namespace -// subtitle. Falls through labelFor so an unlabelled session reads as -// "New Investigation" (italic, dim) rather than a blank h2. -function HeaderTitle({ investigation }: { investigation: Investigation }) { - const lbl = labelFor(investigation); - return ( - <> -

- {lbl.text} -

- {investigation.namespace && ( -

- {investigation.namespace} -

- )} - - ); -} - -// ImportedFromBadge surfaces the provenance of a session that came in via -// share-bundle import. Subtle pill under the header subtitle so the -// receiver knows at a glance the transcript wasn't produced by their -// launcher. -function ImportedFromBadge({ - from, - at, -}: { - from: NonNullable; - at: string | undefined; -}) { - const when = at ? formatDate(at) : null; - const subject = from.namespace; - return ( -

- - - imported from {subject} - {when ? ` on ${when}` : ""} - -

- ); -} - -function formatDate(iso: string): string { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -function StatusPill({ - status, - archived, -}: { - status: SessionStatus; - archived: boolean; -}) { - // Archived overrides the SSE-stream status — once a session is - // wound down the stream sits idle but "idle" reads as "ready for the - // next prompt", which is misleading. Show "archived" instead. - if (archived) { - return ( - - archived - - ); - } - switch (status) { - case "starting": - return ( - - starting - - ); - case "streaming": - return ( - - streaming - - ); - case "idle": - return ( - - idle - - ); - case "error": - return ( - - error - - ); - } -} - -// PushedBadge renders the "this session is on the upstream sessions -// repo" indicator. The PR's lifecycle (open / merged / closed) is -// pulled from `gh pr list` by the backend's RefreshPRStates and -// drives the colour + label here. -function PushedBadge({ - pushUrl, - prState, - prMergedAt, - prClosedAt, -}: { - pushUrl?: string; - prState?: "open" | "merged" | "closed"; - prMergedAt?: string; - prClosedAt?: string; -}) { - if (!pushUrl) { - // Reconciled session: we know it's upstream (the slug exists in - // the local sessions clone) but we never captured the PR URL. - return ( - - - synced - - ); - } - const visual = pushedBadgeTone(prState); - const when = - prState === "merged" && prMergedAt - ? `merged ${relativeTime(prMergedAt)}` - : prState === "closed" && prClosedAt - ? `closed ${relativeTime(prClosedAt)}` - : ""; - const title = (when ? `${visual.title} (${when})` : visual.title) + ` — ${pushUrl}`; - return ( - - - {visual.label} - - - ); -} - -function pushedBadgeTone(state?: "open" | "merged" | "closed") { - switch (state) { - case "merged": - return { - label: "merged", - title: "PR merged", - classes: - "border-violet-900/60 bg-violet-950/30 text-violet-300 hover:border-violet-700 hover:text-violet-200", - }; - case "closed": - return { - label: "closed", - title: "PR closed without merging — push again to retry", - classes: - "border-zinc-700 bg-zinc-900/40 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200", - }; - case "open": - default: - return { - label: "PR open", - title: "PR open — review pending", - classes: - "border-emerald-900/60 bg-emerald-950/40 text-emerald-300 hover:border-emerald-700 hover:text-emerald-200", - }; - } -} - -// SessionActionButton renders one of the session-level trigger -// buttons in the action row above the chat textarea. Tone selects -// the colour scheme (emerald = wiki, sky = playbook, amber = codefix, -// rose = bug-report). Disabled state uses zinc with a not-allowed -// cursor; the hover tooltip carries either the explanatory disabled -// reason or the enabled-state hint. -function SessionActionButton({ - tone, - enabled, - disabledReason, - enabledTitle, - onClick, - label, -}: { - tone: "emerald" | "sky" | "amber" | "rose"; - enabled: boolean; - disabledReason: string; - enabledTitle: string; - onClick: () => void; - label: string; -}) { - const enabledClasses = - tone === "emerald" - ? "rounded border border-emerald-700 bg-emerald-900/30 px-2.5 py-1 text-xs text-emerald-200 transition hover:border-emerald-500 hover:bg-emerald-900/50" - : tone === "sky" - ? "rounded border border-sky-700 bg-sky-900/30 px-2.5 py-1 text-xs text-sky-200 transition hover:border-sky-500 hover:bg-sky-900/50" - : tone === "amber" - ? "rounded border border-amber-700 bg-amber-900/30 px-2.5 py-1 text-xs text-amber-200 transition hover:border-amber-500 hover:bg-amber-900/50" - : "rounded border border-rose-700 bg-rose-900/30 px-2.5 py-1 text-xs text-rose-200 transition hover:border-rose-500 hover:bg-rose-900/50"; - return ( - - ); -} - -// UsageReadout renders the per-session token + cost total at the right -// end of the action row, above the chat textarea. Reads the running -// aggregate the launcher stamps onto Investigation snapshots (summed -// from every result envelope claude emitted). Hidden when nothing has -// been spent yet so a fresh session doesn't show a stray "0 tok · $0.00". -function UsageReadout({ - usage, - costUsd, -}: { - usage?: { inputTokens?: number; outputTokens?: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }; - costUsd?: number; -}) { - const tokens = totalTokens(usage); - const cost = costUsd ?? 0; - if (tokens === 0 && cost === 0) return null; - return ( - - {formatTokens(tokens)} tok · {formatCostUSD(cost)} - - ); -} - -function PlayIcon({ className }: { className?: string }) { - return ( - // Filled right-pointing triangle. Slight horizontal offset (start - // at x=4, not x=3) optically centers the wedge inside the round - // chip — geometric centering would feel left-heavy because the - // tip is the visual anchor. - - - - ); -} - -// HandIcon is the "stop / take over" affordance on the auto-mode -// composer chip. A square outline reads as a "stop" glyph at this -// size; geometric centering is fine here (no asymmetric tip like the -// play wedge has). -function HandIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -// StopIcon is the chip used when streaming — replaces Send so the -// operator can interrupt the in-flight turn (ChatGPT-style). Same -// geometry as HandIcon but a separate component so its semantic -// purpose stays distinct in the source. -function StopIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function Working() { - return ( -
- - working… -
- ); -} - // StateDivider renders an auto_mode_state phase transition as an // inline divider chip — a horizontal pink rule with a centered // "— auto mode —" pill. Folded out of auto_mode_state diff --git a/frontend/components/layout/Sidebar.invRow.tsx b/frontend/components/layout/Sidebar.invRow.tsx new file mode 100644 index 00000000..2e778b5e --- /dev/null +++ b/frontend/components/layout/Sidebar.invRow.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState } from "react"; +import { Bot } from "lucide-react"; +import { api, ApiError, type Investigation } from "@/lib/api"; +import { useDialog } from "@/lib/dialog"; +import { labelFor } from "@/lib/sidebar-label"; +import { EditIcon } from "@/components/shared/Icons"; +import { formatCostUSD, formatTokens, totalTokens } from "@/lib/usage"; +import { SidebarSyncIcon, formatRelative } from "./Sidebar.utils"; + +export type InvRowProps = { + inv: Investigation; + active: boolean; + onSelect: (id: string) => void; + onDelete: (id: string, ev: React.MouseEvent) => void; + onRenamed: (updated: Investigation) => void; +}; + +export function InvRow({ inv, active, onSelect, onDelete, onRenamed }: InvRowProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(inv.label ?? ""); + const [saving, setSaving] = useState(false); + const lbl = labelFor(inv); + const dialog = useDialog(); + + function startEdit(e: React.MouseEvent) { + e.stopPropagation(); + setDraft(inv.label ?? ""); + setEditing(true); + } + + async function commit() { + const trimmed = draft.trim(); + if (!trimmed) { + setEditing(false); + return; + } + if (trimmed === (inv.label ?? "").trim()) { + setEditing(false); + return; + } + setSaving(true); + try { + const updated = await api.setInvestigationLabel(inv.id, trimmed); + onRenamed(updated); + setEditing(false); + } catch (err) { + // Stay in edit mode so the operator can fix and retry; surface + // the failure as a toast so blur-triggered failures don't get + // swallowed silently. + const detail = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + dialog.notify({ + kind: "error", + title: "Rename failed", + body: detail, + ttlMs: 6000, + }); + } finally { + setSaving(false); + } + } + + return ( +
  • + {/* role=button (not a + )} + {!editing && ( + + )} +
  • + + + + ); +} + +export function StatusLine({ inv }: { inv: Investigation }) { + let label = "ready"; + let cls = "text-zinc-500"; + if (inv.archived) { + label = "archived"; + cls = "text-zinc-600"; + } else if (inv.streaming) { + label = "streaming"; + cls = "text-sky-400"; + } else if (inv.started) { + label = "idle"; + cls = "text-emerald-400"; + } + const tokens = totalTokens(inv.usage); + const cost = inv.costUsd ?? 0; + const showUsage = tokens > 0 || cost > 0; + return ( +
    + {label} + · + + {inv.namespace && ( + <> + · + + {inv.namespace} + + + )} + {showUsage && ( + + {formatTokens(tokens)} tok · {formatCostUSD(cost)} + + )} +
    + ); +} diff --git a/frontend/components/layout/Sidebar.proposals.tsx b/frontend/components/layout/Sidebar.proposals.tsx new file mode 100644 index 00000000..b90cfca8 --- /dev/null +++ b/frontend/components/layout/Sidebar.proposals.tsx @@ -0,0 +1,433 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { api, ApiError, type PlaybookProposalListItem } from "@/lib/api"; +import { wikiApi, type WikiProposalListItem } from "@/lib/wiki-api"; +import { useDialog } from "@/lib/dialog"; + +export function RelatedSection({ + recent, + outgoing, + inverse, + onPick, +}: { + recent: string[]; + outgoing: { delegates: string[]; handoffs: string[] }; + inverse: { delegatedFrom: string[]; handoffsFrom: string[] }; + onPick: (id: string) => void; +}) { + const [open, setOpen] = useState(true); + const groups: Array<{ title: string; ids: string[]; subtitle?: string }> = []; + if (recent.length > 0) groups.push({ title: "Recent", ids: recent }); + if (outgoing.delegates.length > 0) + groups.push({ title: "Delegates", ids: outgoing.delegates, subtitle: "this playbook walks" }); + if (outgoing.handoffs.length > 0) + groups.push({ title: "Handoffs", ids: outgoing.handoffs, subtitle: "this playbook hands to" }); + if (inverse.delegatedFrom.length > 0) + groups.push({ title: "Delegated by", ids: inverse.delegatedFrom, subtitle: "playbooks that walk this" }); + if (inverse.handoffsFrom.length > 0) + groups.push({ title: "Handed to from", ids: inverse.handoffsFrom, subtitle: "playbooks that hand to this" }); + + return ( +
    + + {open && ( +
    + {groups.map((g) => ( +
    +
    + {g.title} + {g.subtitle && ( + — {g.subtitle} + )} +
    +
      + {g.ids.map((id) => ( +
    • + +
    • + ))} +
    +
    + ))} +
    + )} +
    + ); +} + +// WikiPendingProposals lists wiki drafts that haven't been approved or +// declined yet. Lets an operator who tabbed out of the editor jump +// straight back to the AI proposal tab on the matching entry. Listens +// for c1:wiki-approved (the approve flow's window event) and a custom +// c1:wiki-proposals-changed event so decline/refresh elsewhere +// invalidates the list in-place. +export function WikiPendingProposals({ + refreshNonce, +}: { + refreshNonce?: number; +}) { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const dialog = useDialog(); + + // Filter against slug and (optional) title. The proposal list shape + // doesn't carry the draft body, so this is a name/title match — not a + // full-text content search. Case-insensitive substring is enough at + // the scale the section is bounded to. + const visibleItems = useMemo(() => { + if (!items) return null; + const q = filter.trim().toLowerCase(); + if (q === "") return items; + return items.filter((p) => { + if (p.slug.toLowerCase().includes(q)) return true; + if (p.title && p.title.toLowerCase().includes(q)) return true; + return false; + }); + }, [items, filter]); + + useEffect(() => { + let cancelled = false; + const refetch = () => { + wikiApi + .listProposals() + .then((res) => { + if (!cancelled) { + setItems(res.proposals); + setError(null); + } + }) + .catch((err) => { + if (cancelled) return; + // 503 = wiki not configured. Treat as "no proposals" rather + // than an error — the home view explains the config gap. + if (err instanceof ApiError && err.status === 503) { + setItems([]); + return; + } + setError(err instanceof ApiError ? err.message : String(err)); + }); + }; + refetch(); + const onChange = () => refetch(); + window.addEventListener("c1:wiki-approved", onChange); + window.addEventListener("c1:wiki-proposals-changed", onChange); + return () => { + cancelled = true; + window.removeEventListener("c1:wiki-approved", onChange); + window.removeEventListener("c1:wiki-proposals-changed", onChange); + }; + }, [refreshNonce]); + + async function deleteProposal(p: WikiProposalListItem, e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + const ok = await dialog.confirm({ + title: "Discard this proposal?", + body: `The draft for ${p.slug} will be removed from the local proposals dir. The agent's chat card will switch to "declined".`, + confirmLabel: "Discard", + danger: true, + }); + if (!ok) return; + try { + await api.declineWikiProposal(p.proposal_id); + setItems((prev) => + prev?.filter((i) => i.proposal_id !== p.proposal_id) ?? prev, + ); + window.dispatchEvent(new CustomEvent("c1:wiki-proposals-changed")); + } catch (err) { + await dialog.alert({ + title: "Discard failed", + body: err instanceof ApiError ? err.message : String(err), + }); + } + } + + return ( +
    +
    + Active proposals + {items && items.length > 0 && ( + + ({visibleItems && visibleItems.length !== items.length + ? `${visibleItems.length}/${items.length}` + : items.length}) + + )} +
    + {error && ( +
    + {error} +
    + )} + {items && items.length === 0 && !error && ( +

    No pending proposals.

    + )} + {items && items.length > 0 && ( + setFilter(e.target.value)} + placeholder="Filter by name or title…" + className="mb-1.5 w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:border-zinc-700 focus:outline-none" + /> + )} + {visibleItems && visibleItems.length === 0 && items && items.length > 0 && ( +

    + No proposals match "{filter}". +

    + )} + {visibleItems && visibleItems.length > 0 && ( +
      + {visibleItems.map((p) => ( +
    • + +
      + + + + {p.slug} + + + {p.is_new ? ( + + new + + ) : ( + + update + + )} + +
      + {p.title && ( +
      + {p.title} +
      + )} + +
    • + ))} +
    + )} +
    + ); +} + +// PlaybookPendingProposals lists playbook drafts that haven't been +// approved or declined yet. Mirrors WikiPendingProposals so an operator +// who tabbed away from a proposal can re-open it from the sidenav. +// Listens for c1:playbook-proposals-changed (fired by approve/decline +// flows) so the list invalidates in-place. +export function PlaybookPendingProposals({ + refreshNonce, +}: { + refreshNonce?: number; +}) { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const dialog = useDialog(); + + const visibleItems = useMemo(() => { + if (!items) return null; + const q = filter.trim().toLowerCase(); + if (q === "") return items; + return items.filter((p) => { + if (p.playbook_id.toLowerCase().includes(q)) return true; + if (p.description && p.description.toLowerCase().includes(q)) return true; + if (p.type.toLowerCase().includes(q)) return true; + return false; + }); + }, [items, filter]); + + useEffect(() => { + let cancelled = false; + const refetch = () => { + api + .listPlaybookProposals() + .then((res) => { + if (!cancelled) { + setItems(res.proposals); + setError(null); + } + }) + .catch((err) => { + if (cancelled) return; + // 503 = playbooks dir not configured. Treat as "no proposals" + // — the main pane already surfaces the config gap. + if (err instanceof ApiError && err.status === 503) { + setItems([]); + return; + } + setError(err instanceof ApiError ? err.message : String(err)); + }); + }; + refetch(); + const onChange = () => refetch(); + window.addEventListener("c1:playbook-proposals-changed", onChange); + return () => { + cancelled = true; + window.removeEventListener("c1:playbook-proposals-changed", onChange); + }; + }, [refreshNonce]); + + async function deleteProposal( + p: PlaybookProposalListItem, + e: React.MouseEvent, + ) { + e.preventDefault(); + e.stopPropagation(); + const ok = await dialog.confirm({ + title: "Discard this proposal?", + body: `The draft for ${p.playbook_id} will be removed from the local proposals dir. The agent's chat card will switch to "declined".`, + confirmLabel: "Discard", + danger: true, + }); + if (!ok) return; + try { + await api.declinePlaybookProposal(p.proposal_id); + setItems((prev) => + prev?.filter((i) => i.proposal_id !== p.proposal_id) ?? prev, + ); + window.dispatchEvent(new CustomEvent("c1:playbook-proposals-changed")); + } catch (err) { + await dialog.alert({ + title: "Discard failed", + body: err instanceof ApiError ? err.message : String(err), + }); + } + } + + // Render nothing while loading / when there are no proposals so the + // sidenav stays compact for the common case. The wiki equivalent + // does render an empty-state line, but on the playbooks page the + // sidenav already carries a couple of explanatory paragraphs, and + // an extra "No pending proposals." row pushes them down for no win. + if (!items || items.length === 0) { + return error ? ( +
    +
    + Active proposals +
    +
    + {error} +
    +
    + ) : null; + } + + return ( +
    +
    + Active proposals + + ({visibleItems && visibleItems.length !== items.length + ? `${visibleItems.length}/${items.length}` + : items.length}) + +
    + setFilter(e.target.value)} + placeholder="Filter by id, type or description…" + className="mb-1.5 w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:border-zinc-700 focus:outline-none" + /> + {visibleItems && visibleItems.length === 0 && ( +

    + No proposals match "{filter}". +

    + )} + {visibleItems && visibleItems.length > 0 && ( +
      + {visibleItems.map((p) => ( +
    • + +
      + + + + {p.playbook_id} + + + {p.is_new ? ( + + new + + ) : ( + + update + + )} + +
      +
      + {p.type} + {p.description && ( + <> + · + {p.description} + + )} +
      + +
    • + ))} +
    + )} +
    + ); +} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 0d62d163..94c0e8a8 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -3,19 +3,14 @@ import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; -import { Bot } from "lucide-react"; import { api, ApiError, type Investigation, - type PlaybookProposalListItem, - type SyncState, } from "@/lib/api"; -import { wikiApi, type WikiProposalListItem } from "@/lib/wiki-api"; import { useStream } from "@/lib/stream"; import { useDialog } from "@/lib/dialog"; import { ConnectionsPanel } from "@/components/connections/ConnectionsPanel"; -import { CheckIcon, EditIcon, UnsyncedIcon } from "@/components/shared/Icons"; import { LinkedReposPanel } from "@/components/repos/LinkedReposPanel"; import { PlaybookCorrelationPanel } from "@/components/playbooks/PlaybookCorrelationPanel"; import { Spinner } from "@/components/shared/Spinner"; @@ -23,10 +18,10 @@ import { RepoActivityPanel } from "@/components/repos/RepoActivityPanel"; import { extractOutgoing, findInverse } from "@/lib/playbook-relations"; import { getRecent } from "@/lib/recent-playbooks"; import { parsePlaybookYAML, type PlaybookListItem } from "@/lib/playbook"; -import { labelFor, matchesQuery } from "@/lib/sidebar-label"; -import { formatCostUSD, formatTokens, totalTokens } from "@/lib/usage"; - -type SidebarView = "investigations" | "watches" | "playbooks" | "mcp" | "wiki" | "repos"; +import { matchesQuery } from "@/lib/sidebar-label"; +import { InvRow } from "./Sidebar.invRow"; +import { WikiPendingProposals, PlaybookPendingProposals, RelatedSection } from "./Sidebar.proposals"; +import { sidebarViewFromPath, type SidebarView } from "./Sidebar.utils"; type Props = { activeId: string | null; @@ -47,176 +42,6 @@ type Props = { view?: SidebarView; }; -type InvRowProps = { - inv: Investigation; - active: boolean; - onSelect: (id: string) => void; - onDelete: (id: string, ev: React.MouseEvent) => void; - onRenamed: (updated: Investigation) => void; -}; - -function InvRow({ inv, active, onSelect, onDelete, onRenamed }: InvRowProps) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(inv.label ?? ""); - const [saving, setSaving] = useState(false); - const lbl = labelFor(inv); - const dialog = useDialog(); - - function startEdit(e: React.MouseEvent) { - e.stopPropagation(); - setDraft(inv.label ?? ""); - setEditing(true); - } - - async function commit() { - const trimmed = draft.trim(); - if (!trimmed) { - setEditing(false); - return; - } - if (trimmed === (inv.label ?? "").trim()) { - setEditing(false); - return; - } - setSaving(true); - try { - const updated = await api.setInvestigationLabel(inv.id, trimmed); - onRenamed(updated); - setEditing(false); - } catch (err) { - // Stay in edit mode so the operator can fix and retry; surface - // the failure as a toast so blur-triggered failures don't get - // swallowed silently. - const detail = - err instanceof ApiError - ? err.message - : err instanceof Error - ? err.message - : String(err); - dialog.notify({ - kind: "error", - title: "Rename failed", - body: detail, - ttlMs: 6000, - }); - } finally { - setSaving(false); - } - } - - return ( -
  • - {/* role=button (not a - )} - {!editing && ( - - )} - - - -
  • - ); -} - export function Sidebar(props: Props) { return ( @@ -756,529 +581,3 @@ function SidebarInner({ ); } - -// SidebarSyncIcon renders the per-session sync indicator from the -// resolver's authoritative SyncState. Encapsulating the icon-mapping -// here (rather than inlining the conditional) means the upstream-list -// pill, the editor badge, and any future sync-aware view all read -// from the same status enum and stay in lockstep visually. -// -// Status → glyph map: -// - synced → CheckIcon (violet for merged PR, emerald otherwise) -// - closed → UnsyncedIcon (operator must re-push; the closed PR -// doesn't represent the session being on main) -// - local-only → UnsyncedIcon (never pushed) -// - unknown → UnsyncedIcon (degraded state; better than a -// potentially stale check) -// - upstream-only is not reachable for sidebar entries (sidebar -// only renders local Investigations) but we render the unsynced -// glyph for it defensively. -function SidebarSyncIcon({ syncState }: { syncState: SyncState }) { - if (syncState.status === "synced") { - const merged = syncState.pr?.state === "merged"; - return ( - - ); - } - return ( - - ); -} - -function StatusLine({ inv }: { inv: Investigation }) { - let label = "ready"; - let cls = "text-zinc-500"; - if (inv.archived) { - label = "archived"; - cls = "text-zinc-600"; - } else if (inv.streaming) { - label = "streaming"; - cls = "text-sky-400"; - } else if (inv.started) { - label = "idle"; - cls = "text-emerald-400"; - } - const tokens = totalTokens(inv.usage); - const cost = inv.costUsd ?? 0; - const showUsage = tokens > 0 || cost > 0; - return ( -
    - {label} - · - - {inv.namespace && ( - <> - · - - {inv.namespace} - - - )} - {showUsage && ( - - {formatTokens(tokens)} tok · {formatCostUSD(cost)} - - )} -
    - ); -} - -function formatRelative(iso: string): string { - const d = new Date(iso); - const diffSec = (Date.now() - d.getTime()) / 1000; - if (diffSec < 60) return "just now"; - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; - if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; - if (diffSec < 86400 * 7) return `${Math.floor(diffSec / 86400)}d ago`; - return d.toLocaleDateString(); -} - -function sidebarViewFromPath(pathname: string): SidebarView { - if (pathname.startsWith("/wiki")) return "wiki"; - if (pathname.startsWith("/watches")) return "watches"; - if (pathname.startsWith("/playbooks")) return "playbooks"; - if (pathname.startsWith("/repos")) return "repos"; - if (pathname.startsWith("/mcp")) return "mcp"; - if (pathname.startsWith("/investigations")) return "investigations"; - return "investigations"; -} - -function RelatedSection({ - recent, - outgoing, - inverse, - onPick, -}: { - recent: string[]; - outgoing: { delegates: string[]; handoffs: string[] }; - inverse: { delegatedFrom: string[]; handoffsFrom: string[] }; - onPick: (id: string) => void; -}) { - const [open, setOpen] = useState(true); - const groups: Array<{ title: string; ids: string[]; subtitle?: string }> = []; - if (recent.length > 0) groups.push({ title: "Recent", ids: recent }); - if (outgoing.delegates.length > 0) - groups.push({ title: "Delegates", ids: outgoing.delegates, subtitle: "this playbook walks" }); - if (outgoing.handoffs.length > 0) - groups.push({ title: "Handoffs", ids: outgoing.handoffs, subtitle: "this playbook hands to" }); - if (inverse.delegatedFrom.length > 0) - groups.push({ title: "Delegated by", ids: inverse.delegatedFrom, subtitle: "playbooks that walk this" }); - if (inverse.handoffsFrom.length > 0) - groups.push({ title: "Handed to from", ids: inverse.handoffsFrom, subtitle: "playbooks that hand to this" }); - - return ( -
    - - {open && ( -
    - {groups.map((g) => ( -
    -
    - {g.title} - {g.subtitle && ( - — {g.subtitle} - )} -
    -
      - {g.ids.map((id) => ( -
    • - -
    • - ))} -
    -
    - ))} -
    - )} -
    - ); -} - -// WikiPendingProposals lists wiki drafts that haven't been approved or -// declined yet. Lets an operator who tabbed out of the editor jump -// straight back to the AI proposal tab on the matching entry. Listens -// for c1:wiki-approved (the approve flow's window event) and a custom -// c1:wiki-proposals-changed event so decline/refresh elsewhere -// invalidates the list in-place. -function WikiPendingProposals({ - refreshNonce, -}: { - refreshNonce?: number; -}) { - const [items, setItems] = useState(null); - const [error, setError] = useState(null); - const [filter, setFilter] = useState(""); - const dialog = useDialog(); - - // Filter against slug and (optional) title. The proposal list shape - // doesn't carry the draft body, so this is a name/title match — not a - // full-text content search. Case-insensitive substring is enough at - // the scale the section is bounded to. - const visibleItems = useMemo(() => { - if (!items) return null; - const q = filter.trim().toLowerCase(); - if (q === "") return items; - return items.filter((p) => { - if (p.slug.toLowerCase().includes(q)) return true; - if (p.title && p.title.toLowerCase().includes(q)) return true; - return false; - }); - }, [items, filter]); - - useEffect(() => { - let cancelled = false; - const refetch = () => { - wikiApi - .listProposals() - .then((res) => { - if (!cancelled) { - setItems(res.proposals); - setError(null); - } - }) - .catch((err) => { - if (cancelled) return; - // 503 = wiki not configured. Treat as "no proposals" rather - // than an error — the home view explains the config gap. - if (err instanceof ApiError && err.status === 503) { - setItems([]); - return; - } - setError(err instanceof ApiError ? err.message : String(err)); - }); - }; - refetch(); - const onChange = () => refetch(); - window.addEventListener("c1:wiki-approved", onChange); - window.addEventListener("c1:wiki-proposals-changed", onChange); - return () => { - cancelled = true; - window.removeEventListener("c1:wiki-approved", onChange); - window.removeEventListener("c1:wiki-proposals-changed", onChange); - }; - }, [refreshNonce]); - - async function deleteProposal(p: WikiProposalListItem, e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - const ok = await dialog.confirm({ - title: "Discard this proposal?", - body: `The draft for ${p.slug} will be removed from the local proposals dir. The agent's chat card will switch to "declined".`, - confirmLabel: "Discard", - danger: true, - }); - if (!ok) return; - try { - await api.declineWikiProposal(p.proposal_id); - setItems((prev) => - prev?.filter((i) => i.proposal_id !== p.proposal_id) ?? prev, - ); - window.dispatchEvent(new CustomEvent("c1:wiki-proposals-changed")); - } catch (err) { - await dialog.alert({ - title: "Discard failed", - body: err instanceof ApiError ? err.message : String(err), - }); - } - } - - return ( -
    -
    - Active proposals - {items && items.length > 0 && ( - - ({visibleItems && visibleItems.length !== items.length - ? `${visibleItems.length}/${items.length}` - : items.length}) - - )} -
    - {error && ( -
    - {error} -
    - )} - {items && items.length === 0 && !error && ( -

    No pending proposals.

    - )} - {items && items.length > 0 && ( - setFilter(e.target.value)} - placeholder="Filter by name or title…" - className="mb-1.5 w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:border-zinc-700 focus:outline-none" - /> - )} - {visibleItems && visibleItems.length === 0 && items && items.length > 0 && ( -

    - No proposals match "{filter}". -

    - )} - {visibleItems && visibleItems.length > 0 && ( -
      - {visibleItems.map((p) => ( -
    • - -
      - - - - {p.slug} - - - {p.is_new ? ( - - new - - ) : ( - - update - - )} - -
      - {p.title && ( -
      - {p.title} -
      - )} - -
    • - ))} -
    - )} -
    - ); -} - -// PlaybookPendingProposals lists playbook drafts that haven't been -// approved or declined yet. Mirrors WikiPendingProposals so an operator -// who tabbed away from a proposal can re-open it from the sidenav. -// Listens for c1:playbook-proposals-changed (fired by approve/decline -// flows) so the list invalidates in-place. -function PlaybookPendingProposals({ - refreshNonce, -}: { - refreshNonce?: number; -}) { - const [items, setItems] = useState(null); - const [error, setError] = useState(null); - const [filter, setFilter] = useState(""); - const dialog = useDialog(); - - const visibleItems = useMemo(() => { - if (!items) return null; - const q = filter.trim().toLowerCase(); - if (q === "") return items; - return items.filter((p) => { - if (p.playbook_id.toLowerCase().includes(q)) return true; - if (p.description && p.description.toLowerCase().includes(q)) return true; - if (p.type.toLowerCase().includes(q)) return true; - return false; - }); - }, [items, filter]); - - useEffect(() => { - let cancelled = false; - const refetch = () => { - api - .listPlaybookProposals() - .then((res) => { - if (!cancelled) { - setItems(res.proposals); - setError(null); - } - }) - .catch((err) => { - if (cancelled) return; - // 503 = playbooks dir not configured. Treat as "no proposals" - // — the main pane already surfaces the config gap. - if (err instanceof ApiError && err.status === 503) { - setItems([]); - return; - } - setError(err instanceof ApiError ? err.message : String(err)); - }); - }; - refetch(); - const onChange = () => refetch(); - window.addEventListener("c1:playbook-proposals-changed", onChange); - return () => { - cancelled = true; - window.removeEventListener("c1:playbook-proposals-changed", onChange); - }; - }, [refreshNonce]); - - async function deleteProposal( - p: PlaybookProposalListItem, - e: React.MouseEvent, - ) { - e.preventDefault(); - e.stopPropagation(); - const ok = await dialog.confirm({ - title: "Discard this proposal?", - body: `The draft for ${p.playbook_id} will be removed from the local proposals dir. The agent's chat card will switch to "declined".`, - confirmLabel: "Discard", - danger: true, - }); - if (!ok) return; - try { - await api.declinePlaybookProposal(p.proposal_id); - setItems((prev) => - prev?.filter((i) => i.proposal_id !== p.proposal_id) ?? prev, - ); - window.dispatchEvent(new CustomEvent("c1:playbook-proposals-changed")); - } catch (err) { - await dialog.alert({ - title: "Discard failed", - body: err instanceof ApiError ? err.message : String(err), - }); - } - } - - // Render nothing while loading / when there are no proposals so the - // sidenav stays compact for the common case. The wiki equivalent - // does render an empty-state line, but on the playbooks page the - // sidenav already carries a couple of explanatory paragraphs, and - // an extra "No pending proposals." row pushes them down for no win. - if (!items || items.length === 0) { - return error ? ( -
    -
    - Active proposals -
    -
    - {error} -
    -
    - ) : null; - } - - return ( -
    -
    - Active proposals - - ({visibleItems && visibleItems.length !== items.length - ? `${visibleItems.length}/${items.length}` - : items.length}) - -
    - setFilter(e.target.value)} - placeholder="Filter by id, type or description…" - className="mb-1.5 w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:border-zinc-700 focus:outline-none" - /> - {visibleItems && visibleItems.length === 0 && ( -

    - No proposals match "{filter}". -

    - )} - {visibleItems && visibleItems.length > 0 && ( -
      - {visibleItems.map((p) => ( -
    • - -
      - - - - {p.playbook_id} - - - {p.is_new ? ( - - new - - ) : ( - - update - - )} - -
      -
      - {p.type} - {p.description && ( - <> - · - {p.description} - - )} -
      - -
    • - ))} -
    - )} -
    - ); -} diff --git a/frontend/components/layout/Sidebar.utils.tsx b/frontend/components/layout/Sidebar.utils.tsx new file mode 100644 index 00000000..1edb9f7b --- /dev/null +++ b/frontend/components/layout/Sidebar.utils.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { type SyncState } from "@/lib/api"; +import { CheckIcon, UnsyncedIcon } from "@/components/shared/Icons"; + +export type SidebarView = "investigations" | "watches" | "playbooks" | "mcp" | "wiki" | "repos"; + +// SidebarSyncIcon renders the per-session sync indicator from the +// resolver's authoritative SyncState. Encapsulating the icon-mapping +// here (rather than inlining the conditional) means the upstream-list +// pill, the editor badge, and any future sync-aware view all read +// from the same status enum and stay in lockstep visually. +// +// Status → glyph map: +// - synced → CheckIcon (violet for merged PR, emerald otherwise) +// - closed → UnsyncedIcon (operator must re-push; the closed PR +// doesn't represent the session being on main) +// - local-only → UnsyncedIcon (never pushed) +// - unknown → UnsyncedIcon (degraded state; better than a +// potentially stale check) +// - upstream-only is not reachable for sidebar entries (sidebar +// only renders local Investigations) but we render the unsynced +// glyph for it defensively. +export function SidebarSyncIcon({ syncState }: { syncState: SyncState }) { + if (syncState.status === "synced") { + const merged = syncState.pr?.state === "merged"; + return ( + + ); + } + return ( + + ); +} + +export function formatRelative(iso: string): string { + const d = new Date(iso); + const diffSec = (Date.now() - d.getTime()) / 1000; + if (diffSec < 60) return "just now"; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + if (diffSec < 86400 * 7) return `${Math.floor(diffSec / 86400)}d ago`; + return d.toLocaleDateString(); +} + +export function sidebarViewFromPath(pathname: string): SidebarView { + if (pathname.startsWith("/wiki")) return "wiki"; + if (pathname.startsWith("/watches")) return "watches"; + if (pathname.startsWith("/playbooks")) return "playbooks"; + if (pathname.startsWith("/repos")) return "repos"; + if (pathname.startsWith("/mcp")) return "mcp"; + if (pathname.startsWith("/investigations")) return "investigations"; + return "investigations"; +} diff --git a/frontend/components/playbooks/NodeEditor.fields.tsx b/frontend/components/playbooks/NodeEditor.fields.tsx new file mode 100644 index 00000000..afb26f45 --- /dev/null +++ b/frontend/components/playbooks/NodeEditor.fields.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type * as React from "react"; + +export function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +export const inputClass = + "w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1.5 text-sm text-zinc-100 placeholder-zinc-600 focus:border-zinc-600 focus:outline-none disabled:opacity-60"; +export const argInputClass = + "w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-xs text-zinc-100 placeholder-zinc-600 focus:border-zinc-600 focus:outline-none disabled:opacity-60"; diff --git a/frontend/components/playbooks/NodeEditor.inputs.tsx b/frontend/components/playbooks/NodeEditor.inputs.tsx new file mode 100644 index 00000000..b5a9133a --- /dev/null +++ b/frontend/components/playbooks/NodeEditor.inputs.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { ToolEntry } from "@/lib/api"; +import { argInputClass } from "./NodeEditor.fields"; + +// AvailableInputs renders the picked tool's input shape as a list +// of opt-in rows. Required inputs are auto-prefilled when the tool +// is selected (see SuggestedCallList's onChange); this surface lists +// the optional ones (and re-adds dropped requireds) with a one-click +// "+ add" affordance per row. Rows that are already present in +// `args` are dimmed and disabled. Tools without an entry in the +// catalog (e.g. an unknown server) render nothing — the operator +// can still type free-form args via ArgsEditor below. +export function AvailableInputs({ + tool, + args, + readOnly, + onAdd, +}: { + tool?: ToolEntry; + args: Record; + readOnly: boolean; + onAdd: (name: string) => void; +}) { + if (!tool || !tool.inputs || tool.inputs.length === 0) { + return null; + } + return ( +
    +
    + available inputs +
    +
      + {tool.inputs.map((inp) => { + const present = inp.name in args; + return ( +
    • +
      + + {inp.name} + + + {inp.type} + + {inp.required && ( + + required + + )} + {!present && !readOnly && ( + + )} + {present && ( + + added + + )} +
      + {inp.description && ( +

      + {inp.description} +

      + )} +
    • + ); + })} +
    +
    + ); +} + +export function ArgsEditor({ + value, + readOnly, + onChange, +}: { + value: Record; + readOnly: boolean; + onChange: (next: Record) => void; +}) { + const entries = Object.entries(value); + return ( +
    + {entries.length === 0 && ( +

    No args.

    + )} + {entries.map(([k, v], i) => ( + // arg name + value get their own line each so neither gets + // squashed into a sliver. The remove (✕) sits beside the key + // input — closest to the field it identifies. +
    +
    + { + const next: Record = {}; + entries.forEach(([kk, vv], j) => { + next[i === j ? e.target.value : kk] = vv; + }); + onChange(next); + }} + className={`${argInputClass} flex-1`} + /> + {!readOnly && ( + + )} +
    + { + const next: Record = { ...value }; + const raw = e.target.value; + // Heuristic: if it parses as JSON, store the parsed value; + // otherwise treat as a plain string (matches how YAML + // unquoted strings round-trip). + try { + next[k] = JSON.parse(raw); + } catch { + next[k] = raw; + } + onChange(next); + }} + className={`${argInputClass} w-full`} + /> +
    + ))} + {!readOnly && ( + + )} +
    + ); +} diff --git a/frontend/components/playbooks/NodeEditor.lists.tsx b/frontend/components/playbooks/NodeEditor.lists.tsx new file mode 100644 index 00000000..a78755c1 --- /dev/null +++ b/frontend/components/playbooks/NodeEditor.lists.tsx @@ -0,0 +1,260 @@ +"use client"; + +import type { Branch, SuggestedCall } from "@/lib/playbook"; +import type { ToolEntry } from "@/lib/api"; +import { ToolPicker } from "@/components/playbooks/ToolPicker"; +import { argInputClass } from "./NodeEditor.fields"; +import { AvailableInputs, ArgsEditor } from "./NodeEditor.inputs"; + +export function SuggestedCallList({ + calls, + catalog, + readOnly, + onChange, +}: { + calls: SuggestedCall[]; + catalog: ToolEntry[]; + readOnly: boolean; + onChange: (next: SuggestedCall[]) => void; +}) { + return ( +
    + {calls.length === 0 && ( +

    No suggested calls.

    + )} + {calls.map((c, i) => ( +
    + { + const arr = [...calls]; + // Auto-prefill required inputs when the tool first + // gets picked (or changes) — operators almost always + // need to fill those in, so saving the manual click + // for each is a real ergonomic win. Optional inputs + // stay opt-in below. + const tool = catalog.find((t) => `${t.server}/${t.name}` === next); + const prefilled: Record = {}; + for (const inp of tool?.inputs ?? []) { + if (inp.required) prefilled[inp.name] = ""; + } + arr[i] = { + ...arr[i], + tool: next, + args: Object.keys(prefilled).length > 0 ? prefilled : undefined, + }; + onChange(arr); + }} + /> + `${t.server}/${t.name}` === c.tool, + )} + args={c.args ?? {}} + readOnly={readOnly} + onAdd={(name) => { + const arr = [...calls]; + const next = { ...(arr[i].args ?? {}), [name]: "" }; + arr[i] = { ...arr[i], args: next }; + onChange(arr); + }} + /> + { + const arr = [...calls]; + arr[i] = { + ...arr[i], + args: Object.keys(args).length === 0 ? undefined : args, + }; + onChange(arr); + }} + /> + {!readOnly && ( +
    + + + +
    + )} +
    + ))} + {!readOnly && ( + + )} +
    + ); +} + +export function StringList({ + values, + readOnly, + onChange, + placeholder, +}: { + values: string[]; + readOnly: boolean; + onChange: (next: string[]) => void; + placeholder?: string; +}) { + return ( +
    + {values.length === 0 && ( +

    No entries.

    + )} + {values.map((v, i) => ( +
    + { + const next = [...values]; + next[i] = e.target.value; + onChange(next); + }} + className={`${argInputClass} flex-1`} + /> + {!readOnly && ( + + )} +
    + ))} + {!readOnly && ( + + )} +
    + ); +} + +export function BranchList({ + branches, + allNodeIds, + readOnly, + onChange, +}: { + branches: Branch[]; + allNodeIds: string[]; + readOnly: boolean; + onChange: (next: Branch[]) => void; +}) { + return ( +
    + {branches.length === 0 && ( +

    + No outgoing branches — terminal node. +

    + )} + {branches.map((b, i) => ( +
    + { + const next = [...branches]; + next[i] = { ...next[i], condition: e.target.value }; + onChange(next); + }} + className={argInputClass} + /> + + {!readOnly && ( +
    + +
    + )} +
    + ))} + {!readOnly && ( + + )} +
    + ); +} diff --git a/frontend/components/playbooks/NodeEditor.pickers.tsx b/frontend/components/playbooks/NodeEditor.pickers.tsx new file mode 100644 index 00000000..3a58762e --- /dev/null +++ b/frontend/components/playbooks/NodeEditor.pickers.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { argInputClass } from "./NodeEditor.fields"; + +// HandoffList renders the list of cross-playbook target ids on a +// terminal node. Each entry is a dropdown bound to the loaded +// playbook-id set (so typos can't sneak in for the common case); +// dangling ids — a saved playbook references a target that's since +// been renamed or deleted — surface as a red "(dangling)" option so +// the operator can fix or remove them. The "open" affordance navigates +// the editor to that playbook without leaving the page. +export function HandoffList({ + values, + allPlaybookIds, + readOnly, + onOpen, + onChange, +}: { + values: string[]; + allPlaybookIds: string[]; + readOnly: boolean; + onOpen: (id: string) => void; + onChange: (next: string[]) => void; +}) { + return ( +
    + {values.length === 0 && ( +

    + No handoffs. Add one when this terminal hands off to another playbook + (operator + agent get a clickable link instead of buried prose). +

    + )} + {values.map((v, i) => { + const dangling = v !== "" && !allPlaybookIds.includes(v); + return ( +
    + + {v && !dangling && ( + + )} + {!readOnly && ( + + )} +
    + ); + })} + {!readOnly && ( + + )} +
    + ); +} + +// DelegateToPicker renders a single-target playbook-id dropdown for the +// delegate_to field. Shape mirrors HandoffList's row format (dropdown + +// optional "open →" + clear button) but constrained to a single value +// rather than a list, since delegate_to is `string?` not `string[]`. +export function DelegateToPicker({ + value, + allPlaybookIds, + readOnly, + onOpen, + onChange, +}: { + value: string; + allPlaybookIds: string[]; + readOnly: boolean; + onOpen: (id: string) => void; + onChange: (next: string) => void; +}) { + const dangling = value !== "" && !allPlaybookIds.includes(value); + return ( +
    +

    + Sub-flow: walk another playbook to its non-handoff terminal, then + resume this node's next. + Findings recorded inside the sub-flow flow into the same session. +

    +
    + + {value && !dangling && ( + + )} + {!readOnly && value && ( + + )} +
    +
    + ); +} diff --git a/frontend/components/playbooks/NodeEditor.tsx b/frontend/components/playbooks/NodeEditor.tsx index db4faff8..8c129e3a 100644 --- a/frontend/components/playbooks/NodeEditor.tsx +++ b/frontend/components/playbooks/NodeEditor.tsx @@ -1,9 +1,11 @@ "use client"; -import type { Branch, PlaybookNode, SuggestedCall } from "@/lib/playbook"; +import type { PlaybookNode } from "@/lib/playbook"; import type { ToolEntry } from "@/lib/api"; import { useDialog } from "@/lib/dialog"; -import { ToolPicker } from "@/components/playbooks/ToolPicker"; +import { Field, inputClass } from "./NodeEditor.fields"; +import { SuggestedCallList, StringList, BranchList } from "./NodeEditor.lists"; +import { HandoffList, DelegateToPicker } from "./NodeEditor.pickers"; type Props = { // null when no node is selected — show a hint to click a node. @@ -159,596 +161,3 @@ export function NodeEditor({ ); } - -function SuggestedCallList({ - calls, - catalog, - readOnly, - onChange, -}: { - calls: SuggestedCall[]; - catalog: ToolEntry[]; - readOnly: boolean; - onChange: (next: SuggestedCall[]) => void; -}) { - return ( -
    - {calls.length === 0 && ( -

    No suggested calls.

    - )} - {calls.map((c, i) => ( -
    - { - const arr = [...calls]; - // Auto-prefill required inputs when the tool first - // gets picked (or changes) — operators almost always - // need to fill those in, so saving the manual click - // for each is a real ergonomic win. Optional inputs - // stay opt-in below. - const tool = catalog.find((t) => `${t.server}/${t.name}` === next); - const prefilled: Record = {}; - for (const inp of tool?.inputs ?? []) { - if (inp.required) prefilled[inp.name] = ""; - } - arr[i] = { - ...arr[i], - tool: next, - args: Object.keys(prefilled).length > 0 ? prefilled : undefined, - }; - onChange(arr); - }} - /> - `${t.server}/${t.name}` === c.tool, - )} - args={c.args ?? {}} - readOnly={readOnly} - onAdd={(name) => { - const arr = [...calls]; - const next = { ...(arr[i].args ?? {}), [name]: "" }; - arr[i] = { ...arr[i], args: next }; - onChange(arr); - }} - /> - { - const arr = [...calls]; - arr[i] = { - ...arr[i], - args: Object.keys(args).length === 0 ? undefined : args, - }; - onChange(arr); - }} - /> - {!readOnly && ( -
    - - - -
    - )} -
    - ))} - {!readOnly && ( - - )} -
    - ); -} - -// AvailableInputs renders the picked tool's input shape as a list -// of opt-in rows. Required inputs are auto-prefilled when the tool -// is selected (see SuggestedCallList's onChange); this surface lists -// the optional ones (and re-adds dropped requireds) with a one-click -// "+ add" affordance per row. Rows that are already present in -// `args` are dimmed and disabled. Tools without an entry in the -// catalog (e.g. an unknown server) render nothing — the operator -// can still type free-form args via ArgsEditor below. -function AvailableInputs({ - tool, - args, - readOnly, - onAdd, -}: { - tool?: ToolEntry; - args: Record; - readOnly: boolean; - onAdd: (name: string) => void; -}) { - if (!tool || !tool.inputs || tool.inputs.length === 0) { - return null; - } - return ( -
    -
    - available inputs -
    -
      - {tool.inputs.map((inp) => { - const present = inp.name in args; - return ( -
    • -
      - - {inp.name} - - - {inp.type} - - {inp.required && ( - - required - - )} - {!present && !readOnly && ( - - )} - {present && ( - - added - - )} -
      - {inp.description && ( -

      - {inp.description} -

      - )} -
    • - ); - })} -
    -
    - ); -} - -function ArgsEditor({ - value, - readOnly, - onChange, -}: { - value: Record; - readOnly: boolean; - onChange: (next: Record) => void; -}) { - const entries = Object.entries(value); - return ( -
    - {entries.length === 0 && ( -

    No args.

    - )} - {entries.map(([k, v], i) => ( - // arg name + value get their own line each so neither gets - // squashed into a sliver. The remove (✕) sits beside the key - // input — closest to the field it identifies. -
    -
    - { - const next: Record = {}; - entries.forEach(([kk, vv], j) => { - next[i === j ? e.target.value : kk] = vv; - }); - onChange(next); - }} - className={`${argInputClass} flex-1`} - /> - {!readOnly && ( - - )} -
    - { - const next: Record = { ...value }; - const raw = e.target.value; - // Heuristic: if it parses as JSON, store the parsed value; - // otherwise treat as a plain string (matches how YAML - // unquoted strings round-trip). - try { - next[k] = JSON.parse(raw); - } catch { - next[k] = raw; - } - onChange(next); - }} - className={`${argInputClass} w-full`} - /> -
    - ))} - {!readOnly && ( - - )} -
    - ); -} - -function StringList({ - values, - readOnly, - onChange, - placeholder, -}: { - values: string[]; - readOnly: boolean; - onChange: (next: string[]) => void; - placeholder?: string; -}) { - return ( -
    - {values.length === 0 && ( -

    No entries.

    - )} - {values.map((v, i) => ( -
    - { - const next = [...values]; - next[i] = e.target.value; - onChange(next); - }} - className={`${argInputClass} flex-1`} - /> - {!readOnly && ( - - )} -
    - ))} - {!readOnly && ( - - )} -
    - ); -} - -function BranchList({ - branches, - allNodeIds, - readOnly, - onChange, -}: { - branches: Branch[]; - allNodeIds: string[]; - readOnly: boolean; - onChange: (next: Branch[]) => void; -}) { - return ( -
    - {branches.length === 0 && ( -

    - No outgoing branches — terminal node. -

    - )} - {branches.map((b, i) => ( -
    - { - const next = [...branches]; - next[i] = { ...next[i], condition: e.target.value }; - onChange(next); - }} - className={argInputClass} - /> - - {!readOnly && ( -
    - -
    - )} -
    - ))} - {!readOnly && ( - - )} -
    - ); -} - -// HandoffList renders the list of cross-playbook target ids on a -// terminal node. Each entry is a dropdown bound to the loaded -// playbook-id set (so typos can't sneak in for the common case); -// dangling ids — a saved playbook references a target that's since -// been renamed or deleted — surface as a red "(dangling)" option so -// the operator can fix or remove them. The "open" affordance navigates -// the editor to that playbook without leaving the page. -function HandoffList({ - values, - allPlaybookIds, - readOnly, - onOpen, - onChange, -}: { - values: string[]; - allPlaybookIds: string[]; - readOnly: boolean; - onOpen: (id: string) => void; - onChange: (next: string[]) => void; -}) { - return ( -
    - {values.length === 0 && ( -

    - No handoffs. Add one when this terminal hands off to another playbook - (operator + agent get a clickable link instead of buried prose). -

    - )} - {values.map((v, i) => { - const dangling = v !== "" && !allPlaybookIds.includes(v); - return ( -
    - - {v && !dangling && ( - - )} - {!readOnly && ( - - )} -
    - ); - })} - {!readOnly && ( - - )} -
    - ); -} - -// DelegateToPicker renders a single-target playbook-id dropdown for the -// delegate_to field. Shape mirrors HandoffList's row format (dropdown + -// optional "open →" + clear button) but constrained to a single value -// rather than a list, since delegate_to is `string?` not `string[]`. -function DelegateToPicker({ - value, - allPlaybookIds, - readOnly, - onOpen, - onChange, -}: { - value: string; - allPlaybookIds: string[]; - readOnly: boolean; - onOpen: (id: string) => void; - onChange: (next: string) => void; -}) { - const dangling = value !== "" && !allPlaybookIds.includes(value); - return ( -
    -

    - Sub-flow: walk another playbook to its non-handoff terminal, then - resume this node's next. - Findings recorded inside the sub-flow flow into the same session. -

    -
    - - {value && !dangling && ( - - )} - {!readOnly && value && ( - - )} -
    -
    - ); -} - -function Field({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -const inputClass = - "w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1.5 text-sm text-zinc-100 placeholder-zinc-600 focus:border-zinc-600 focus:outline-none disabled:opacity-60"; -const argInputClass = - "w-full rounded border border-zinc-800 bg-zinc-950 px-2 py-1 text-xs text-zinc-100 placeholder-zinc-600 focus:border-zinc-600 focus:outline-none disabled:opacity-60"; diff --git a/frontend/components/playbooks/PlaybookEditor.parts.tsx b/frontend/components/playbooks/PlaybookEditor.parts.tsx new file mode 100644 index 00000000..e9951cde --- /dev/null +++ b/frontend/components/playbooks/PlaybookEditor.parts.tsx @@ -0,0 +1,248 @@ +import { type Capabilities } from "@/lib/api"; +import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; +import { ChatBubbleIcon, GitHubIcon } from "@/components/shared/Icons"; +import { + BTN_GATED, + BTN_SECONDARY, + BTN_SECONDARY_ACTIVE, +} from "@/lib/buttons"; +import { type SourceTag } from "./PlaybookEditor.reducer"; + +export function ViewTab({ + active, + onClick, + label, + badge, + trailing, + disabled, +}: { + active: boolean; + onClick: () => void; + label: string; + badge?: React.ReactNode; + // trailing renders inside the same tab "chip" but to the right of + // the label — used for the proposal tab's discard ✕. Kept inside + // the chip rather than separate so the visual association is + // unambiguous. + trailing?: React.ReactNode; + disabled?: boolean; +}) { + return ( +
    { + if (disabled) return; + onClick(); + }} + > + {label} + {badge} + {trailing} +
    + ); +} + +export function ProposalTabBody({ + payload, + onResolved, +}: { + payload: ProposalDraftPayload | undefined; + onResolved: ( + p: ProposalDraftPayload, + kind: "approved" | "declined", + ) => void; +}) { + if (!payload) { + return ( +
    + Proposal no longer pending — pick another tab. +
    + ); + } + return ( +
    + { + /* refinements go through the chat composer */ + }} + onResolved={(kind) => onResolved(payload, kind)} + /> +
    + ); +} + +export function ProposalTab({ + payload, + active, + onClick, + onDiscard, +}: { + payload: ProposalDraftPayload; + active: boolean; + onClick: () => void; + onDiscard: (p: ProposalDraftPayload) => void | Promise; +}) { + return ( +
    + AI: {payload.playbook_id} + +
    + ); +} + +export function ChatToggleButton({ + open, + onClick, + label = "chat", +}: { + open: boolean; + onClick: () => void; + label?: string; +}) { + return ( + + ); +} + +// PushPRButton renders the "push as PR" action and explains via a +// tooltip when it's disabled (gh missing, repo path missing, validation +// errors). Disabled-but-visible is intentional — operators should +// understand the feature exists and what to fix to unlock it, rather +// than wondering why a button is missing. System (locked) playbooks +// don't render this at all — the parent gates it. +export function PushPRButton({ + capabilities, + disabled, + noUpstreamDiff, + onClick, +}: { + capabilities: Capabilities | null; + disabled: boolean; + // True when the operator's draft body matches upstream (with + // active+version stripped). Pushing would create a no-op PR, so we + // lock the button and explain why. + noUpstreamDiff: boolean; + onClick: () => void; +}) { + let blockReason: string | null = null; + if (!capabilities) { + blockReason = "checking capabilities…"; + } else if (!capabilities.gh.authenticated) { + blockReason = capabilities.gh.reason ?? "gh CLI unavailable"; + } else if (!capabilities.repoPath.valid) { + blockReason = capabilities.repoPath.reason ?? "repo path not configured"; + } else if (!(capabilities.repoPath.repo ?? "")) { + // Local-only mode: the playbooks dir is a usable git checkout but + // has no upstream remote, so a PR has nowhere to go. + blockReason = "no upstream playbooks repo configured — set defaults.playbooks_repo in your profile and restart the launcher"; + } else if (disabled) { + blockReason = "fix the validation errors first"; + } else if (noUpstreamDiff) { + blockReason = "no diff against upstream — edit the playbook before pushing"; + } + const isDisabled = blockReason !== null; + return ( + + ); +} + +export function SourceBadge({ source }: { source: SourceTag }) { + // Provenance only. "plugin" = upstream library (overridable); + // "system" = launcher-bundled meta (locked); "user"/"override" = + // local; "broken" stays as-is. The "is this synced with remote?" + // question is answered separately by the unsynced cloud-up icon + // on the playbook list card. + const label = + source === "plugin" + ? "remote" + : source === "system" + ? "system" + : source === "broken" + ? "broken" + : "local"; + const cls = { + plugin: "bg-zinc-800 text-zinc-300", + // System metas are launcher-owned — slate sky tone signals "this + // is the framework, not your edits". + system: "bg-sky-900/50 text-sky-200", + user: "bg-emerald-900/50 text-emerald-300", + override: "bg-emerald-900/50 text-emerald-300", + broken: "bg-red-900/60 text-red-300", + }[source]; + return ( + + {label} + + ); +} diff --git a/frontend/components/playbooks/PlaybookEditor.reducer.ts b/frontend/components/playbooks/PlaybookEditor.reducer.ts new file mode 100644 index 00000000..27d93d6b --- /dev/null +++ b/frontend/components/playbooks/PlaybookEditor.reducer.ts @@ -0,0 +1,172 @@ +import { type ProposalDraftPayload } from "@/components/playbooks/ProposalCard"; +import { + type Playbook, + type PlaybookCommit, + type PlaybookListItem, + type PlaybookNode, +} from "@/lib/playbook"; + +// Parser for the playbook drawer. Validates the three required fields +// the editor's UI relies on, dedupes tabs by playbook_id (latest draft +// for each id wins). +export function parsePlaybookProposal( + raw: unknown, +): { key: string; payload: ProposalDraftPayload } | null { + const r = raw as Partial; + if ( + typeof r?.proposal_id !== "string" || + typeof r?.playbook_id !== "string" || + typeof r?.new_yaml !== "string" + ) { + return null; + } + return { key: r.playbook_id, payload: r as ProposalDraftPayload }; +} + +// isStubbedDraft returns true when the draft has not been substantively +// edited from the empty template — i.e., the operator typed at most an +// id (which we strip from the comparison). The `active` field is also +// ignored because it's stamped at write time, not authored by the +// operator. Used by the __new approval flow to decide whether to +// navigate to a freshly-saved sibling proposal: if the draft is still +// the stub, navigation isn't disruptive (no operator work to lose). +export function isStubbedDraft(draft: Playbook, original: Playbook): boolean { + const stripIDActive = (p: Playbook) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, active, ...rest } = p; + return rest; + }; + return JSON.stringify(stripIDActive(draft)) === JSON.stringify(stripIDActive(original)); +} + +export type LoadState = + | { kind: "loading" } + | { + kind: "loaded"; + original: Playbook; + source: SourceTag; + // True when the playbook ships bundled with the launcher + // (system tier). Disables save / delete / type-change / + // push-PR — locked entries are owned by the launcher binary, + // not the operator. + locked: boolean; + isNew: boolean; + disabled: boolean; + // Commit history for the commits dropdown (up to 50, merged + // across upstream + local repos). Populated from the detail + // response; empty for brand-new (unsaved) playbooks. + commits: PlaybookCommit[]; + // The sha the operator is currently viewing. Undefined means + // HEAD (latest). Resets to undefined after a save. + viewedCommit?: string; + // Raw upstream-clone YAML when this id has an upstream + // counterpart. Empty for user-only playbooks. The push-PR gate + // compares the operator's draft against this — a draft that + // matches upstream produces a no-op PR, so the button locks. + upstreamYAML?: string; + } + | { kind: "error"; message: string }; + +export type SourceTag = "plugin" | "system" | "user" | "override" | "broken"; + +// Reducer-style updates against the editable Playbook. Each field-level +// edit dispatches a partial that's merged in. Node operations (rename, +// delete, add) take dedicated actions because they touch multiple fields. +export type Action = + | { type: "set"; next: Playbook } + | { + type: "setMeta"; + meta: Partial< + Pick< + Playbook, + | "id" + | "symptom" + | "description" + | "entrypoint" + | "type" + | "active" + | "services" + | "errors" + | "symptoms" + > + >; + } + | { type: "setNode"; nodeId: string; node: PlaybookNode } + | { type: "renameNode"; oldId: string; newId: string } + | { type: "addNode"; nodeId: string } + | { type: "deleteNode"; nodeId: string }; + +// pickActiveCommitSha returns the sha of the commit that wrote the +// currently-active YAML for a playbook, derived from the merged +// commits list + the source slot. Newest-first ordering means the +// first source-matching commit is the active one. Returns undefined +// when there's no candidate (e.g. a system playbook that lives only +// in the launcher binary, or a fresh playbook with no history). +export function pickActiveCommitSha( + commits: PlaybookCommit[], + source: PlaybookListItem["source"], +): string | undefined { + // user / override: latest commit on the local user-playbooks repo + // is what's on disk and active. + // plugin: latest commit on the upstream-playbooks repo wins. + // system / broken / disabled: no commit history we can pin to. + let want: PlaybookCommit["source"] | undefined; + if (source === "user" || source === "override") want = "local"; + else if (source === "plugin") want = "upstream"; + if (!want) return undefined; + const m = commits.find((c) => c.source === want); + return m?.sha; +} + +export type ViewMode = + | { kind: "graph" } + | { kind: "yaml" } + | { kind: "proposal"; proposalId: string }; + +export function reduce(state: Playbook, action: Action): Playbook { + switch (action.type) { + case "set": + return action.next; + case "setMeta": + return { ...state, ...action.meta }; + case "setNode": + return { + ...state, + nodes: { ...state.nodes, [action.nodeId]: action.node }, + }; + case "renameNode": { + if (action.oldId === action.newId || !action.newId) return state; + if (state.nodes[action.newId]) return state; // collision; ignored + const nextNodes: Record = {}; + for (const [id, node] of Object.entries(state.nodes)) { + nextNodes[id === action.oldId ? action.newId : id] = { + ...node, + next: node.next?.map((b) => + b.goto === action.oldId ? { ...b, goto: action.newId } : b, + ), + }; + } + return { + ...state, + nodes: nextNodes, + entrypoint: + state.entrypoint === action.oldId ? action.newId : state.entrypoint, + }; + } + case "addNode": { + if (state.nodes[action.nodeId]) return state; + return { + ...state, + nodes: { + ...state.nodes, + [action.nodeId]: { description: "" }, + }, + }; + } + case "deleteNode": { + const nextNodes: Record = { ...state.nodes }; + delete nextNodes[action.nodeId]; + return { ...state, nodes: nextNodes }; + } + } +} diff --git a/frontend/components/playbooks/PlaybookEditor.tsx b/frontend/components/playbooks/PlaybookEditor.tsx index 128a1e1d..7748dd60 100644 --- a/frontend/components/playbooks/PlaybookEditor.tsx +++ b/frontend/components/playbooks/PlaybookEditor.tsx @@ -22,8 +22,6 @@ import { parsePlaybookYAML, type Playbook, type PlaybookCommit, - type PlaybookListItem, - type PlaybookNode, validate, } from "@/lib/playbook"; import { CommitsDropdown } from "@/components/playbooks/CommitsDropdown"; @@ -37,7 +35,6 @@ import { YamlPanel } from "@/components/playbooks/YamlPanel"; import { ArrowLeftIcon, ChatBubbleIcon, - GitHubIcon, RevertIcon, TrashIcon, } from "@/components/shared/Icons"; @@ -46,42 +43,24 @@ import { BTN_GATED, BTN_PRIMARY, BTN_SECONDARY, - BTN_SECONDARY_ACTIVE, } from "@/lib/buttons"; import { pushRecent as recordRecentPlaybook } from "@/lib/recent-playbooks"; - -// Parser for the playbook drawer. Validates the three required fields -// the editor's UI relies on, dedupes tabs by playbook_id (latest draft -// for each id wins). -function parsePlaybookProposal( - raw: unknown, -): { key: string; payload: ProposalDraftPayload } | null { - const r = raw as Partial; - if ( - typeof r?.proposal_id !== "string" || - typeof r?.playbook_id !== "string" || - typeof r?.new_yaml !== "string" - ) { - return null; - } - return { key: r.playbook_id, payload: r as ProposalDraftPayload }; -} - -// isStubbedDraft returns true when the draft has not been substantively -// edited from the empty template — i.e., the operator typed at most an -// id (which we strip from the comparison). The `active` field is also -// ignored because it's stamped at write time, not authored by the -// operator. Used by the __new approval flow to decide whether to -// navigate to a freshly-saved sibling proposal: if the draft is still -// the stub, navigation isn't disruptive (no operator work to lose). -function isStubbedDraft(draft: Playbook, original: Playbook): boolean { - const stripIDActive = (p: Playbook) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, active, ...rest } = p; - return rest; - }; - return JSON.stringify(stripIDActive(draft)) === JSON.stringify(stripIDActive(original)); -} +import { + isStubbedDraft, + parsePlaybookProposal, + pickActiveCommitSha, + reduce, + type LoadState, + type ViewMode, +} from "./PlaybookEditor.reducer"; +import { + ChatToggleButton, + ProposalTab, + ProposalTabBody, + PushPRButton, + SourceBadge, + ViewTab, +} from "./PlaybookEditor.parts"; type Props = { // The id from the URL. "__new" creates a fresh template. @@ -96,138 +75,6 @@ type Props = { onOpenPlaybook: (id: string) => void; }; -type LoadState = - | { kind: "loading" } - | { - kind: "loaded"; - original: Playbook; - source: SourceTag; - // True when the playbook ships bundled with the launcher - // (system tier). Disables save / delete / type-change / - // push-PR — locked entries are owned by the launcher binary, - // not the operator. - locked: boolean; - isNew: boolean; - disabled: boolean; - // Commit history for the commits dropdown (up to 50, merged - // across upstream + local repos). Populated from the detail - // response; empty for brand-new (unsaved) playbooks. - commits: PlaybookCommit[]; - // The sha the operator is currently viewing. Undefined means - // HEAD (latest). Resets to undefined after a save. - viewedCommit?: string; - // Raw upstream-clone YAML when this id has an upstream - // counterpart. Empty for user-only playbooks. The push-PR gate - // compares the operator's draft against this — a draft that - // matches upstream produces a no-op PR, so the button locks. - upstreamYAML?: string; - } - | { kind: "error"; message: string }; - -type SourceTag = "plugin" | "system" | "user" | "override" | "broken"; - -// Reducer-style updates against the editable Playbook. Each field-level -// edit dispatches a partial that's merged in. Node operations (rename, -// delete, add) take dedicated actions because they touch multiple fields. -type Action = - | { type: "set"; next: Playbook } - | { - type: "setMeta"; - meta: Partial< - Pick< - Playbook, - | "id" - | "symptom" - | "description" - | "entrypoint" - | "type" - | "active" - | "services" - | "errors" - | "symptoms" - > - >; - } - | { type: "setNode"; nodeId: string; node: PlaybookNode } - | { type: "renameNode"; oldId: string; newId: string } - | { type: "addNode"; nodeId: string } - | { type: "deleteNode"; nodeId: string }; - -// pickActiveCommitSha returns the sha of the commit that wrote the -// currently-active YAML for a playbook, derived from the merged -// commits list + the source slot. Newest-first ordering means the -// first source-matching commit is the active one. Returns undefined -// when there's no candidate (e.g. a system playbook that lives only -// in the launcher binary, or a fresh playbook with no history). -function pickActiveCommitSha( - commits: PlaybookCommit[], - source: PlaybookListItem["source"], -): string | undefined { - // user / override: latest commit on the local user-playbooks repo - // is what's on disk and active. - // plugin: latest commit on the upstream-playbooks repo wins. - // system / broken / disabled: no commit history we can pin to. - let want: PlaybookCommit["source"] | undefined; - if (source === "user" || source === "override") want = "local"; - else if (source === "plugin") want = "upstream"; - if (!want) return undefined; - const m = commits.find((c) => c.source === want); - return m?.sha; -} - -type ViewMode = - | { kind: "graph" } - | { kind: "yaml" } - | { kind: "proposal"; proposalId: string }; - -function reduce(state: Playbook, action: Action): Playbook { - switch (action.type) { - case "set": - return action.next; - case "setMeta": - return { ...state, ...action.meta }; - case "setNode": - return { - ...state, - nodes: { ...state.nodes, [action.nodeId]: action.node }, - }; - case "renameNode": { - if (action.oldId === action.newId || !action.newId) return state; - if (state.nodes[action.newId]) return state; // collision; ignored - const nextNodes: Record = {}; - for (const [id, node] of Object.entries(state.nodes)) { - nextNodes[id === action.oldId ? action.newId : id] = { - ...node, - next: node.next?.map((b) => - b.goto === action.oldId ? { ...b, goto: action.newId } : b, - ), - }; - } - return { - ...state, - nodes: nextNodes, - entrypoint: - state.entrypoint === action.oldId ? action.newId : state.entrypoint, - }; - } - case "addNode": { - if (state.nodes[action.nodeId]) return state; - return { - ...state, - nodes: { - ...state.nodes, - [action.nodeId]: { description: "" }, - }, - }; - } - case "deleteNode": { - const nextNodes: Record = { ...state.nodes }; - delete nextNodes[action.nodeId]; - return { ...state, nodes: nextNodes }; - } - } -} - export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) { const [load, setLoad] = useState({ kind: "loading" }); // Bumped after a successful save so the load effect re-fetches the @@ -1689,244 +1536,3 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) ); } - -function ViewTab({ - active, - onClick, - label, - badge, - trailing, - disabled, -}: { - active: boolean; - onClick: () => void; - label: string; - badge?: React.ReactNode; - // trailing renders inside the same tab "chip" but to the right of - // the label — used for the proposal tab's discard ✕. Kept inside - // the chip rather than separate so the visual association is - // unambiguous. - trailing?: React.ReactNode; - disabled?: boolean; -}) { - return ( -
    { - if (disabled) return; - onClick(); - }} - > - {label} - {badge} - {trailing} -
    - ); -} - -function ProposalTabBody({ - payload, - onResolved, -}: { - payload: ProposalDraftPayload | undefined; - onResolved: ( - p: ProposalDraftPayload, - kind: "approved" | "declined", - ) => void; -}) { - if (!payload) { - return ( -
    - Proposal no longer pending — pick another tab. -
    - ); - } - return ( -
    - { - /* refinements go through the chat composer */ - }} - onResolved={(kind) => onResolved(payload, kind)} - /> -
    - ); -} - -function ProposalTab({ - payload, - active, - onClick, - onDiscard, -}: { - payload: ProposalDraftPayload; - active: boolean; - onClick: () => void; - onDiscard: (p: ProposalDraftPayload) => void | Promise; -}) { - return ( -
    - AI: {payload.playbook_id} - -
    - ); -} - -function ChatToggleButton({ - open, - onClick, - label = "chat", -}: { - open: boolean; - onClick: () => void; - label?: string; -}) { - return ( - - ); -} - -// PushPRButton renders the "push as PR" action and explains via a -// tooltip when it's disabled (gh missing, repo path missing, validation -// errors). Disabled-but-visible is intentional — operators should -// understand the feature exists and what to fix to unlock it, rather -// than wondering why a button is missing. System (locked) playbooks -// don't render this at all — the parent gates it. -function PushPRButton({ - capabilities, - disabled, - noUpstreamDiff, - onClick, -}: { - capabilities: Capabilities | null; - disabled: boolean; - // True when the operator's draft body matches upstream (with - // active+version stripped). Pushing would create a no-op PR, so we - // lock the button and explain why. - noUpstreamDiff: boolean; - onClick: () => void; -}) { - let blockReason: string | null = null; - if (!capabilities) { - blockReason = "checking capabilities…"; - } else if (!capabilities.gh.authenticated) { - blockReason = capabilities.gh.reason ?? "gh CLI unavailable"; - } else if (!capabilities.repoPath.valid) { - blockReason = capabilities.repoPath.reason ?? "repo path not configured"; - } else if (!(capabilities.repoPath.repo ?? "")) { - // Local-only mode: the playbooks dir is a usable git checkout but - // has no upstream remote, so a PR has nowhere to go. - blockReason = "no upstream playbooks repo configured — set defaults.playbooks_repo in your profile and restart the launcher"; - } else if (disabled) { - blockReason = "fix the validation errors first"; - } else if (noUpstreamDiff) { - blockReason = "no diff against upstream — edit the playbook before pushing"; - } - const isDisabled = blockReason !== null; - return ( - - ); -} - -function SourceBadge({ source }: { source: SourceTag }) { - // Provenance only. "plugin" = upstream library (overridable); - // "system" = launcher-bundled meta (locked); "user"/"override" = - // local; "broken" stays as-is. The "is this synced with remote?" - // question is answered separately by the unsynced cloud-up icon - // on the playbook list card. - const label = - source === "plugin" - ? "remote" - : source === "system" - ? "system" - : source === "broken" - ? "broken" - : "local"; - const cls = { - plugin: "bg-zinc-800 text-zinc-300", - // System metas are launcher-owned — slate sky tone signals "this - // is the framework, not your edits". - system: "bg-sky-900/50 text-sky-200", - user: "bg-emerald-900/50 text-emerald-300", - override: "bg-emerald-900/50 text-emerald-300", - broken: "bg-red-900/60 text-red-300", - }[source]; - return ( - - {label} - - ); -} - - diff --git a/frontend/components/playbooks/PlaybookGraph.edges.tsx b/frontend/components/playbooks/PlaybookGraph.edges.tsx new file mode 100644 index 00000000..5fb00752 --- /dev/null +++ b/frontend/components/playbooks/PlaybookGraph.edges.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + Position, + type Edge, + type EdgeProps, +} from "@xyflow/react"; +import type { EdgeDiff } from "@/lib/playbook-diff"; + +// ConditionEdgeData rides on each edge so the custom edge can render the +// full condition prose as wrapping HTML rather than a single SVG . +export type ConditionEdgeData = { + condition: string; + dangling: boolean; + diffStatus: EdgeDiff | null; +}; + +export const edgeTypes = { + condition: ConditionEdge, +}; + +// ConditionEdge replaces xyflow's default SVG label (single line, no +// wrap) with an HTML label rendered via EdgeLabelRenderer. The label is +// width-capped, wraps at word boundaries, and exposes the full condition +// on hover via the browser's native title tooltip — long branch +// conditions (~80-200 chars in practice) become legible without zoom. +export function ConditionEdge(props: EdgeProps>) { + const { id, sourceX, sourceY, targetX, targetY, style, markerEnd, data } = + props; + const [path, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition: Position.Bottom, + targetX, + targetY, + targetPosition: Position.Top, + }); + const tone = data?.dangling + ? "border-red-900/70 bg-red-950/85 text-red-200" + : data?.diffStatus === "added" + ? "border-emerald-700/70 bg-emerald-950/85 text-emerald-200" + : data?.diffStatus === "retargeted" + ? "border-amber-700/70 bg-amber-950/85 text-amber-200" + : data?.diffStatus === "removed" + ? "border-red-800/70 bg-red-950/70 text-red-300/80" + : "border-zinc-700/70 bg-zinc-950/90 text-zinc-300"; + return ( + <> + + {data?.condition && ( + +
    *:hover rule in + // globals.css so the portal wrapper also rises above peer + // labels, not just our own div. + className={ + "group max-w-[140px] cursor-default overflow-hidden rounded border px-1.5 py-0.5 text-xs leading-snug shadow-sm transition-all duration-150 " + + "whitespace-nowrap text-ellipsis " + + "hover:z-[1000] hover:max-w-[280px] hover:whitespace-pre-wrap hover:break-words hover:px-2 hover:py-1 " + + tone + } + > + {data.condition} +
    +
    + )} + + ); +} diff --git a/frontend/components/playbooks/PlaybookGraph.layout.ts b/frontend/components/playbooks/PlaybookGraph.layout.ts new file mode 100644 index 00000000..946dfbdc --- /dev/null +++ b/frontend/components/playbooks/PlaybookGraph.layout.ts @@ -0,0 +1,175 @@ +import { MarkerType, type Edge, type Node } from "@xyflow/react"; +import dagre from "dagre"; +import type { Playbook } from "@/lib/playbook"; +import { diffPlaybooks, edgeId, type EdgeDiff } from "@/lib/playbook-diff"; +import { NODE_WIDTH, NODE_HEIGHT, type PlaybookNodeData } from "./PlaybookGraph.nodes"; +import type { ConditionEdgeData } from "./PlaybookGraph.edges"; + +// Edge stroke palette by diff status. Dangling targets keep their +// existing red-900 dashed look (distinct from "removed" red-700) so +// the operator can tell "this points nowhere in the proposal" apart +// from "this branch was dropped". +const EDGE_STROKE: Record = { + added: "#10b981", // emerald-500 + retargeted: "#f59e0b", // amber-500 + removed: "#b91c1c", // red-700 + unchanged: "#52525b", // zinc-600 +}; + +// buildGraph converts the playbook into xyflow nodes + edges and runs +// dagre top-to-bottom layout. When basePlaybook is supplied the graph +// renders the union of both playbooks with diff status tagged on each +// node/edge. When omitted (existing call sites) the diff status is null +// everywhere and the output is identical to the original implementation. +export function buildGraph( + playbook: Playbook, + selectedId: string | null, + onOpenPlaybook: ((id: string) => void) | undefined, + basePlaybook?: Playbook, +): { nodes: Node[]; edges: Edge[] } { + const diff = diffPlaybooks(playbook, basePlaybook); + const hasBase = basePlaybook !== undefined; + + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + // Generous spacing: branch labels are wrappable HTML capped at 220px + // and can grow several lines — give dagre enough vertical breathing + // room (ranksep) and horizontal separation (nodesep) so labels rarely + // overlap their neighbours. + g.setGraph({ rankdir: "TB", nodesep: 80, ranksep: 140, marginx: 24, marginy: 24 }); + + // Union of node ids — proposed wins for content on + // unchanged/modified/added; base wins on removed (so the ghost + // still shows the original description on hover). + const baseNodes = basePlaybook?.nodes ?? {}; + const propNodes = playbook.nodes; + const allIds = Array.from( + new Set([...Object.keys(baseNodes), ...Object.keys(propNodes)]), + ); + + for (const id of allIds) { + const status = diff.nodes.get(id) ?? null; + const body = status === "removed" ? baseNodes[id] : propNodes[id] ?? baseNodes[id]; + const handoffCount = body.handoff?.length ?? 0; + const hasDelegate = !!body.delegate_to; + // Each row of chips below the description adds ~18px. Handoff and + // delegate render in separate rows so they stack — both contribute. + const extra = (handoffCount > 0 ? 18 : 0) + (hasDelegate ? 18 : 0); + g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT + extra }); + } + + // Edge union — for each (source, index) slot, render exactly one + // edge per side that owns it (proposed for unchanged/retargeted/added, + // base for removed). This mirrors the diffPlaybooks edge map. + const edges: Edge[] = []; + const buildSlot = ( + source: string, + index: number, + branch: { condition: string; goto: string }, + status: EdgeDiff, + ) => { + const id = edgeId(source, index, branch.goto); + // We treat a missing target *in the proposed playbook* as + // dangling. For removed edges, "dangling" is computed against the + // base playbook — its source/target both come from base. + const targetMap = status === "removed" ? baseNodes : propNodes; + const dangling = !targetMap[branch.goto]; + if (!dangling) { + g.setEdge(source, branch.goto); + } + const baseStroke = dangling + ? "#7f1d1d" + : hasBase && status !== "unchanged" + ? EDGE_STROKE[status] + : EDGE_STROKE.unchanged; + const dashed = dangling || (hasBase && status === "removed"); + const reducedOpacity = hasBase && status === "removed" ? 0.6 : 1; + edges.push({ + id, + source, + target: dangling ? source : branch.goto, + type: "condition", + data: { + condition: branch.condition, + dangling, + diffStatus: hasBase ? status : null, + } satisfies ConditionEdgeData, + style: { + stroke: baseStroke, + strokeDasharray: dashed ? "4 3" : undefined, + opacity: reducedOpacity, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: baseStroke, + }, + }); + }; + + for (const src of allIds) { + const baseNexts = baseNodes[src]?.next ?? []; + const propNexts = propNodes[src]?.next ?? []; + const slotCount = Math.max(baseNexts.length, propNexts.length); + for (let i = 0; i < slotCount; i++) { + const b = baseNexts[i]; + const p = propNexts[i]; + // Pick which side renders this slot: + // - present in proposed → render proposed (added/retargeted/unchanged) + // - only in base → render base (removed) + // The diff helper's edges map is already keyed by the same edge id + // we'd compute here, so we look up the status rather than re-deriving + // it. This keeps the slot-identity rules (source + index, with key + // taking proposed-or-base goto correctly) in one place. + const branch = p ?? b; + if (!branch) continue; + const id = edgeId(src, i, branch.goto); + const status: EdgeDiff = hasBase + ? diff.edges.get(id) ?? "unchanged" + : "unchanged"; + buildSlot(src, i, branch, status); + } + } + + dagre.layout(g); + + const nodes: Node[] = allIds.map((id) => { + const status = diff.nodes.get(id) ?? null; + const body = status === "removed" ? baseNodes[id] : propNodes[id] ?? baseNodes[id]; + const layout = g.node(id); + const data: PlaybookNodeData = { + label: id, + description: body.description ?? "", + // Entry/terminal reflect the *proposed* state for surviving + // nodes. Removed nodes carry their base entry/terminal status — + // useful for the rare case where the operator removed an + // entrypoint (then the proposed entrypoint should also flag). + isEntry: + status === "removed" + ? id === basePlaybook?.entrypoint + : id === playbook.entrypoint, + isTerminal: !!body.terminal_advice, + isSelected: id === selectedId, + handoff: body.handoff ?? [], + delegateTo: body.delegate_to, + onOpenPlaybook, + diffStatus: hasBase ? status : null, + modifiedFields: diff.modifiedFields.get(id) ?? [], + wasEntry: + hasBase && + diff.entrypointChanged && + id === basePlaybook?.entrypoint && + id !== playbook.entrypoint, + }; + return { + id, + type: "playbook", + data: data as unknown as Record, + position: { + x: (layout?.x ?? 0) - NODE_WIDTH / 2, + y: (layout?.y ?? 0) - NODE_HEIGHT / 2, + }, + }; + }); + + return { nodes, edges }; +} diff --git a/frontend/components/playbooks/PlaybookGraph.nodes.tsx b/frontend/components/playbooks/PlaybookGraph.nodes.tsx new file mode 100644 index 00000000..8c3a7166 --- /dev/null +++ b/frontend/components/playbooks/PlaybookGraph.nodes.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import type { NodeDiff } from "@/lib/playbook-diff"; + +// Stable layout node sizes — dagre lays things out using these as bounding +// boxes; the actual node body honours them via fixed-width CSS so the +// final render aligns with the layout pass. +// +// Compact height (header only): the description expands in place on hover +// via a grid-rows animation, so it doesn't need layout space. +export const NODE_WIDTH = 220; +export const NODE_HEIGHT = 38; + +export type PlaybookNodeData = { + label: string; + description: string; + isEntry: boolean; + isTerminal: boolean; + isSelected: boolean; + handoff: string[]; + // delegate_to target on this node — undefined for non-delegate nodes. + // Surfaces in the rendered node as a sky-blue chip + clickable target + // button, parallel to the terminal/handoff treatment. + delegateTo: string | undefined; + // Click handler for individual handoff chips. Stored on node data so + // the custom node component can call it without a context — the + // graph container threads it through buildGraph below. + onOpenPlaybook?: (id: string) => void; + // Diff overlay — null when no base provided. The renderer uses + // these to apply ring/chip/desat styling on top of the existing + // entry/terminal/selected treatment. + diffStatus: NodeDiff | null; + // Names of fields that changed (only when diffStatus === "modified"). + // Drives the modified-fields list appended to the description popover. + modifiedFields: string[]; + // True when this node was the *base* entrypoint and the proposed + // entrypoint is different. Drives a "was entry" amber chip. + wasEntry: boolean; +}; + +export const nodeTypes = { + playbook: PlaybookNodeView, +}; + +export function PlaybookNodeView({ data }: NodeProps>) { + // Ring precedence: selected > diff status > entry > terminal > delegate. + // Removed nodes still respect a sky selection ring so the operator + // can click into them. Delegate uses sky-700 to parallel the handoff + // chip palette and signal "this node hops into a sub-flow before + // continuing"; sky-400 stays reserved for selection so the two read + // distinctly. + const isDelegate = !!data.delegateTo; + const diffRing = diffRingClass(data.diffStatus); + const ring = data.isSelected + ? "ring-2 ring-sky-400/80" + : diffRing + ? diffRing + : data.isEntry + ? "ring-2 ring-emerald-500/40" + : data.isTerminal + ? "ring-2 ring-amber-600/40" + : isDelegate + ? "ring-2 ring-sky-700/50" + : ""; + + // "Removed" nodes desaturate the body and strikethrough the label + // to read as ghosts in the layout. Sky-selection ring still + // overrides desat opacity at the parent level so a click reads. + const removed = data.diffStatus === "removed"; + const bodyOpacity = removed ? "opacity-60" : ""; + + return ( +
    + +
    + + {data.label} + +
    + {data.isEntry && ( + + entry + + )} + {data.wasEntry && ( + + was entry + + )} + {data.isTerminal && ( + + terminal + + )} + {isDelegate && ( + + delegate + + )} + +
    +
    + {data.handoff.length > 0 && ( +
    + {data.handoff.map((id) => ( + + ))} +
    + )} + {data.delegateTo && ( +
    + +
    + )} + {(data.description || data.modifiedFields.length > 0) && ( + // grid-cols-[minmax(0,1fr)] pins the implicit column to the node's + // 220px body — without it, a long unbreakable token in the + // description (URL, identifier) would expand the auto column past + // the node and visibly stick out to the right of the screen. +
    +
    +
    + {data.description} + {data.modifiedFields.length > 0 && ( +
    + changed: {data.modifiedFields.join(", ")} +
    + )} +
    +
    +
    + )} + +
    + ); +} + +export function diffRingClass(status: NodeDiff | null): string { + switch (status) { + case "added": + return "ring-2 ring-emerald-500/70"; + case "modified": + return "ring-2 ring-amber-500/70"; + case "removed": + // Tailwind core has no dashed ring utility, so we use outline + // (which supports `outline-dashed`) and pull it inside the + // border with a negative offset so the ring sits on the box edge. + return "outline outline-2 outline-dashed outline-red-700/70 outline-offset-[-2px]"; + default: + return ""; + } +} + +export function DiffChip({ status }: { status: NodeDiff | null }) { + if (!status || status === "unchanged") return null; + const tone = + status === "added" + ? "bg-emerald-900/60 text-emerald-200" + : status === "modified" + ? "bg-amber-900/60 text-amber-200" + : "bg-red-900/60 text-red-200"; + return ( + + {status} + + ); +} diff --git a/frontend/components/playbooks/PlaybookGraph.tsx b/frontend/components/playbooks/PlaybookGraph.tsx index 31c17a1b..eb815250 100644 --- a/frontend/components/playbooks/PlaybookGraph.tsx +++ b/frontend/components/playbooks/PlaybookGraph.tsx @@ -2,28 +2,20 @@ import { useEffect, useMemo } from "react"; import { - BaseEdge, Background, Controls, - EdgeLabelRenderer, - getBezierPath, - MarkerType, ReactFlow, ReactFlowProvider, - type Edge, - type EdgeProps, - type Node, - type NodeProps, - Handle, - Position, useNodesState, useEdgesState, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import dagre from "dagre"; import type { Playbook } from "@/lib/playbook"; -import { diffPlaybooks, edgeId, type NodeDiff, type EdgeDiff } from "@/lib/playbook-diff"; +import { type NodeDiff, type EdgeDiff } from "@/lib/playbook-diff"; import { useClickToFocus } from "@/lib/use-click-to-focus"; +import { nodeTypes, type PlaybookNodeData } from "./PlaybookGraph.nodes"; +import { edgeTypes, type ConditionEdgeData } from "./PlaybookGraph.edges"; +import { buildGraph } from "./PlaybookGraph.layout"; type Props = { playbook: Playbook; @@ -40,26 +32,6 @@ type Props = { basePlaybook?: Playbook; }; -// Stable layout node sizes — dagre lays things out using these as bounding -// boxes; the actual node body honours them via fixed-width CSS so the -// final render aligns with the layout pass. -// -// Compact height (header only): the description expands in place on hover -// via a grid-rows animation, so it doesn't need layout space. -const NODE_WIDTH = 220; -const NODE_HEIGHT = 38; - -// Edge stroke palette by diff status. Dangling targets keep their -// existing red-900 dashed look (distinct from "removed" red-700) so -// the operator can tell "this points nowhere in the proposal" apart -// from "this branch was dropped". -const EDGE_STROKE: Record = { - added: "#10b981", // emerald-500 - retargeted: "#f59e0b", // amber-500 - removed: "#b91c1c", // red-700 - unchanged: "#52525b", // zinc-600 -}; - // PlaybookGraph wraps the playbook → xyflow conversion and the dagre // auto-layout pass. Every change to `playbook` re-runs the layout from // scratch — we don't try to preserve manual node positions because the @@ -161,203 +133,6 @@ function PlaybookGraphInner({ ); } -type PlaybookNodeData = { - label: string; - description: string; - isEntry: boolean; - isTerminal: boolean; - isSelected: boolean; - handoff: string[]; - // delegate_to target on this node — undefined for non-delegate nodes. - // Surfaces in the rendered node as a sky-blue chip + clickable target - // button, parallel to the terminal/handoff treatment. - delegateTo: string | undefined; - // Click handler for individual handoff chips. Stored on node data so - // the custom node component can call it without a context — the - // graph container threads it through buildGraph below. - onOpenPlaybook?: (id: string) => void; - // Diff overlay — null when no base provided. The renderer uses - // these to apply ring/chip/desat styling on top of the existing - // entry/terminal/selected treatment. - diffStatus: NodeDiff | null; - // Names of fields that changed (only when diffStatus === "modified"). - // Drives the modified-fields list appended to the description popover. - modifiedFields: string[]; - // True when this node was the *base* entrypoint and the proposed - // entrypoint is different. Drives a "was entry" amber chip. - wasEntry: boolean; -}; - -const nodeTypes = { - playbook: PlaybookNodeView, -}; - -function PlaybookNodeView({ data }: NodeProps>) { - // Ring precedence: selected > diff status > entry > terminal > delegate. - // Removed nodes still respect a sky selection ring so the operator - // can click into them. Delegate uses sky-700 to parallel the handoff - // chip palette and signal "this node hops into a sub-flow before - // continuing"; sky-400 stays reserved for selection so the two read - // distinctly. - const isDelegate = !!data.delegateTo; - const diffRing = diffRingClass(data.diffStatus); - const ring = data.isSelected - ? "ring-2 ring-sky-400/80" - : diffRing - ? diffRing - : data.isEntry - ? "ring-2 ring-emerald-500/40" - : data.isTerminal - ? "ring-2 ring-amber-600/40" - : isDelegate - ? "ring-2 ring-sky-700/50" - : ""; - - // "Removed" nodes desaturate the body and strikethrough the label - // to read as ghosts in the layout. Sky-selection ring still - // overrides desat opacity at the parent level so a click reads. - const removed = data.diffStatus === "removed"; - const bodyOpacity = removed ? "opacity-60" : ""; - - return ( -
    - -
    - - {data.label} - -
    - {data.isEntry && ( - - entry - - )} - {data.wasEntry && ( - - was entry - - )} - {data.isTerminal && ( - - terminal - - )} - {isDelegate && ( - - delegate - - )} - -
    -
    - {data.handoff.length > 0 && ( -
    - {data.handoff.map((id) => ( - - ))} -
    - )} - {data.delegateTo && ( -
    - -
    - )} - {(data.description || data.modifiedFields.length > 0) && ( - // grid-cols-[minmax(0,1fr)] pins the implicit column to the node's - // 220px body — without it, a long unbreakable token in the - // description (URL, identifier) would expand the auto column past - // the node and visibly stick out to the right of the screen. -
    -
    -
    - {data.description} - {data.modifiedFields.length > 0 && ( -
    - changed: {data.modifiedFields.join(", ")} -
    - )} -
    -
    -
    - )} - -
    - ); -} - -function diffRingClass(status: NodeDiff | null): string { - switch (status) { - case "added": - return "ring-2 ring-emerald-500/70"; - case "modified": - return "ring-2 ring-amber-500/70"; - case "removed": - // Tailwind core has no dashed ring utility, so we use outline - // (which supports `outline-dashed`) and pull it inside the - // border with a negative offset so the ring sits on the box edge. - return "outline outline-2 outline-dashed outline-red-700/70 outline-offset-[-2px]"; - default: - return ""; - } -} - -function DiffChip({ status }: { status: NodeDiff | null }) { - if (!status || status === "unchanged") return null; - const tone = - status === "added" - ? "bg-emerald-900/60 text-emerald-200" - : status === "modified" - ? "bg-amber-900/60 text-amber-200" - : "bg-red-900/60 text-red-200"; - return ( - - {status} - - ); -} - function DiffLegend({ presentNodeStatuses, presentEdgeStatuses, @@ -392,231 +167,3 @@ function DiffLegend({ ); } - -// ConditionEdgeData rides on each edge so the custom edge can render the -// full condition prose as wrapping HTML rather than a single SVG . -type ConditionEdgeData = { - condition: string; - dangling: boolean; - diffStatus: EdgeDiff | null; -}; - -const edgeTypes = { - condition: ConditionEdge, -}; - -// ConditionEdge replaces xyflow's default SVG label (single line, no -// wrap) with an HTML label rendered via EdgeLabelRenderer. The label is -// width-capped, wraps at word boundaries, and exposes the full condition -// on hover via the browser's native title tooltip — long branch -// conditions (~80-200 chars in practice) become legible without zoom. -function ConditionEdge(props: EdgeProps>) { - const { id, sourceX, sourceY, targetX, targetY, style, markerEnd, data } = - props; - const [path, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - sourcePosition: Position.Bottom, - targetX, - targetY, - targetPosition: Position.Top, - }); - const tone = data?.dangling - ? "border-red-900/70 bg-red-950/85 text-red-200" - : data?.diffStatus === "added" - ? "border-emerald-700/70 bg-emerald-950/85 text-emerald-200" - : data?.diffStatus === "retargeted" - ? "border-amber-700/70 bg-amber-950/85 text-amber-200" - : data?.diffStatus === "removed" - ? "border-red-800/70 bg-red-950/70 text-red-300/80" - : "border-zinc-700/70 bg-zinc-950/90 text-zinc-300"; - return ( - <> - - {data?.condition && ( - -
    *:hover rule in - // globals.css so the portal wrapper also rises above peer - // labels, not just our own div. - className={ - "group max-w-[140px] cursor-default overflow-hidden rounded border px-1.5 py-0.5 text-xs leading-snug shadow-sm transition-all duration-150 " + - "whitespace-nowrap text-ellipsis " + - "hover:z-[1000] hover:max-w-[280px] hover:whitespace-pre-wrap hover:break-words hover:px-2 hover:py-1 " + - tone - } - > - {data.condition} -
    -
    - )} - - ); -} - -// buildGraph converts the playbook into xyflow nodes + edges and runs -// dagre top-to-bottom layout. When basePlaybook is supplied the graph -// renders the union of both playbooks with diff status tagged on each -// node/edge. When omitted (existing call sites) the diff status is null -// everywhere and the output is identical to the original implementation. -function buildGraph( - playbook: Playbook, - selectedId: string | null, - onOpenPlaybook: ((id: string) => void) | undefined, - basePlaybook?: Playbook, -): { nodes: Node[]; edges: Edge[] } { - const diff = diffPlaybooks(playbook, basePlaybook); - const hasBase = basePlaybook !== undefined; - - const g = new dagre.graphlib.Graph(); - g.setDefaultEdgeLabel(() => ({})); - // Generous spacing: branch labels are wrappable HTML capped at 220px - // and can grow several lines — give dagre enough vertical breathing - // room (ranksep) and horizontal separation (nodesep) so labels rarely - // overlap their neighbours. - g.setGraph({ rankdir: "TB", nodesep: 80, ranksep: 140, marginx: 24, marginy: 24 }); - - // Union of node ids — proposed wins for content on - // unchanged/modified/added; base wins on removed (so the ghost - // still shows the original description on hover). - const baseNodes = basePlaybook?.nodes ?? {}; - const propNodes = playbook.nodes; - const allIds = Array.from( - new Set([...Object.keys(baseNodes), ...Object.keys(propNodes)]), - ); - - for (const id of allIds) { - const status = diff.nodes.get(id) ?? null; - const body = status === "removed" ? baseNodes[id] : propNodes[id] ?? baseNodes[id]; - const handoffCount = body.handoff?.length ?? 0; - const hasDelegate = !!body.delegate_to; - // Each row of chips below the description adds ~18px. Handoff and - // delegate render in separate rows so they stack — both contribute. - const extra = (handoffCount > 0 ? 18 : 0) + (hasDelegate ? 18 : 0); - g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT + extra }); - } - - // Edge union — for each (source, index) slot, render exactly one - // edge per side that owns it (proposed for unchanged/retargeted/added, - // base for removed). This mirrors the diffPlaybooks edge map. - const edges: Edge[] = []; - const buildSlot = ( - source: string, - index: number, - branch: { condition: string; goto: string }, - status: EdgeDiff, - ) => { - const id = edgeId(source, index, branch.goto); - // We treat a missing target *in the proposed playbook* as - // dangling. For removed edges, "dangling" is computed against the - // base playbook — its source/target both come from base. - const targetMap = status === "removed" ? baseNodes : propNodes; - const dangling = !targetMap[branch.goto]; - if (!dangling) { - g.setEdge(source, branch.goto); - } - const baseStroke = dangling - ? "#7f1d1d" - : hasBase && status !== "unchanged" - ? EDGE_STROKE[status] - : EDGE_STROKE.unchanged; - const dashed = dangling || (hasBase && status === "removed"); - const reducedOpacity = hasBase && status === "removed" ? 0.6 : 1; - edges.push({ - id, - source, - target: dangling ? source : branch.goto, - type: "condition", - data: { - condition: branch.condition, - dangling, - diffStatus: hasBase ? status : null, - } satisfies ConditionEdgeData, - style: { - stroke: baseStroke, - strokeDasharray: dashed ? "4 3" : undefined, - opacity: reducedOpacity, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: baseStroke, - }, - }); - }; - - for (const src of allIds) { - const baseNexts = baseNodes[src]?.next ?? []; - const propNexts = propNodes[src]?.next ?? []; - const slotCount = Math.max(baseNexts.length, propNexts.length); - for (let i = 0; i < slotCount; i++) { - const b = baseNexts[i]; - const p = propNexts[i]; - // Pick which side renders this slot: - // - present in proposed → render proposed (added/retargeted/unchanged) - // - only in base → render base (removed) - // The diff helper's edges map is already keyed by the same edge id - // we'd compute here, so we look up the status rather than re-deriving - // it. This keeps the slot-identity rules (source + index, with key - // taking proposed-or-base goto correctly) in one place. - const branch = p ?? b; - if (!branch) continue; - const id = edgeId(src, i, branch.goto); - const status: EdgeDiff = hasBase - ? diff.edges.get(id) ?? "unchanged" - : "unchanged"; - buildSlot(src, i, branch, status); - } - } - - dagre.layout(g); - - const nodes: Node[] = allIds.map((id) => { - const status = diff.nodes.get(id) ?? null; - const body = status === "removed" ? baseNodes[id] : propNodes[id] ?? baseNodes[id]; - const layout = g.node(id); - const data: PlaybookNodeData = { - label: id, - description: body.description ?? "", - // Entry/terminal reflect the *proposed* state for surviving - // nodes. Removed nodes carry their base entry/terminal status — - // useful for the rare case where the operator removed an - // entrypoint (then the proposed entrypoint should also flag). - isEntry: - status === "removed" - ? id === basePlaybook?.entrypoint - : id === playbook.entrypoint, - isTerminal: !!body.terminal_advice, - isSelected: id === selectedId, - handoff: body.handoff ?? [], - delegateTo: body.delegate_to, - onOpenPlaybook, - diffStatus: hasBase ? status : null, - modifiedFields: diff.modifiedFields.get(id) ?? [], - wasEntry: - hasBase && - diff.entrypointChanged && - id === basePlaybook?.entrypoint && - id !== playbook.entrypoint, - }; - return { - id, - type: "playbook", - data: data as unknown as Record, - position: { - x: (layout?.x ?? 0) - NODE_WIDTH / 2, - y: (layout?.y ?? 0) - NODE_HEIGHT / 2, - }, - }; - }); - - return { nodes, edges }; -} diff --git a/frontend/components/playbooks/PlaybookList.badges.tsx b/frontend/components/playbooks/PlaybookList.badges.tsx new file mode 100644 index 00000000..303dc630 --- /dev/null +++ b/frontend/components/playbooks/PlaybookList.badges.tsx @@ -0,0 +1,116 @@ +import { type PlaybookListItem } from "@/lib/api"; + +// SourceBadge answers "where does the active version live?": +// "remote" = upstream-only (the operator hasn't authored or +// overridden it locally), "local" = there's a user file (whether +// it's a fresh id or an override of an upstream one). Collapsed +// from the previous user/override split because the more useful +// distinction (does it match remote?) is carried by the unsynced +// warning icon next to the id. +export function SourceBadge({ source }: { source: PlaybookListItem["source"] }) { + const { label, cls } = sourceBadgeStyle(source); + return ( + + {label} + + ); +} + +function sourceBadgeStyle(source: PlaybookListItem["source"]): { + label: string; + cls: string; +} { + switch (source) { + case "plugin": + return { label: "remote", cls: "bg-zinc-800 text-zinc-300" }; + case "system": + // Sky tone for system metas — visually distinct from "remote + // upstream library" so the lock signal reads at a glance. + return { label: "system", cls: "bg-sky-900/50 text-sky-200" }; + case "user": + case "override": + return { label: "local", cls: "bg-emerald-900/50 text-emerald-300" }; + case "broken": + return { label: "broken", cls: "bg-red-900/60 text-red-300" }; + } +} + +// StatusPill is the always-visible "active / disabled" affirmation. +// Lives next to the SourceBadge so every card carries one of two +// signals — no more reading the absence of a disabled badge as +// "active". Mirrors the editor's StatusBadge visually (switch-track +// + label) so the same playbook reads the same way in the list and +// in the open editor — only the editor variant is interactive. +export function StatusPill({ disabled }: { disabled: boolean }) { + return ( + + + + + + {disabled ? "disabled" : "active"} + + + ); +} + +// TypeBadge renders the playbook's type (the directory it lives in +// upstream — investigation, general, system, …). Always visible so +// every card carries its category at a glance, with the source badge +// next to it answering "remote vs local" separately. Colour-codes +// the canonical types so investigation stays subtle and other slots +// stand out, but unknown values render in a neutral pill so newly +// added types Just Work. +export function TypeBadge({ type }: { type?: PlaybookListItem["type"] }) { + const name = type || "investigation"; + const cls = + name === "investigation" + ? "bg-zinc-800 text-zinc-300" + : name === "general" + ? "bg-sky-900/50 text-sky-300" + : "bg-indigo-900/50 text-indigo-300"; + return ( + + {name} + + ); +} diff --git a/frontend/components/playbooks/PlaybookList.parts.tsx b/frontend/components/playbooks/PlaybookList.parts.tsx new file mode 100644 index 00000000..0be1634d --- /dev/null +++ b/frontend/components/playbooks/PlaybookList.parts.tsx @@ -0,0 +1,212 @@ +import { + ExternalLinkIcon, + GitHubIcon, + SyncIcon, +} from "@/components/shared/Icons"; +import { type PlaybooksUpstreamStatus } from "@/lib/api"; +import { type UpstreamRepoSyncState } from "./PlaybookList"; + +// Pagination is a compact prev/next + "page X of Y" control rendered +// below the playbook grid. Hidden when the filtered count fits on a +// single page; otherwise sits flush against the cards above. +export function Pagination({ + page, + pageSize, + total, + totalPages, + onChange, +}: { + page: number; + pageSize: number; + total: number; + totalPages: number; + onChange: (next: number) => void; +}) { + const start = page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, total); + const canPrev = page > 0; + const canNext = page < totalPages - 1; + return ( +
    + + showing {start}–{end} of{" "} + {total} + +
    + + + {page + 1} / {totalPages} + + +
    +
    + ); +} + +// UpstreamFooter is the three-div strip anchored to the right of the +// page header, mirroring the wiki layout. Layout (left → right): +// +// 1. last synced: +// 2. synced from: (clickable, opens GitHub) +// 3. [sync icon] sync (badge with N when remote has commits ahead) +// +// Padded apart with a gap class so each pieces stays its own +// readable block instead of running together. Errors stack below. +export function UpstreamFooter({ + upstream, + loaded, + syncState, + onSync, +}: { + upstream: PlaybooksUpstreamStatus | null; + loaded: boolean; + syncState: UpstreamRepoSyncState; + onSync: () => void | Promise; +}) { + if (!loaded) { + return ; + } + if (!upstream) { + return null; + } + const canSync = + upstream.gitCheckout && !!upstream.repo && syncState.kind !== "syncing"; + const repoUrl = upstream.repo + ? `https://github.com/${upstream.repo}` + : null; + const remoteAhead = upstream.remoteAhead ?? 0; + return ( +
    +
    + + last synced:{" "} + + {upstream.lastSynced ? relativeTime(upstream.lastSynced) : "never"} + + + {repoUrl ? ( + + + + synced from:{" "} + {upstream.repo} + + + + ) : ( + + synced from: (unset) + + )} + +
    + {remoteAhead > 0 && syncState.kind !== "syncing" && ( +
    + upstream has {remoteAhead} commit{remoteAhead === 1 ? "" : "s"} not + yet applied locally — click sync to pull. +
    + )} + {upstream.remoteAheadError && remoteAhead === 0 && ( +
    + (couldn't check upstream: {upstream.remoteAheadError}) +
    + )} + {syncState.kind === "error" && ( +
    + {syncState.message} +
    + )} +
    + ); +} + +// UpstreamFooterSkeleton reserves the space the loaded footer +// occupies while the upstream-status fetch is in flight. Three +// pulsing placeholders matching the loaded layout's three slots +// (last synced, synced from, sync button) keep the surrounding +// chrome from jumping when the real footer lands ~1s after page +// load. +function UpstreamFooterSkeleton() { + return ( +
    +
    +
    +
    +
    +
    +
    + ); +} + +// relativeTime renders an ISO-8601 timestamp as "5m ago" / "2h ago" +// / "3d ago". Falls back to the raw string for parse failures so the +// operator still sees something useful. +function relativeTime(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const seconds = Math.floor((Date.now() - t) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; +} diff --git a/frontend/components/playbooks/PlaybookList.tsx b/frontend/components/playbooks/PlaybookList.tsx index 4bc94fa5..bfd856cd 100644 --- a/frontend/components/playbooks/PlaybookList.tsx +++ b/frontend/components/playbooks/PlaybookList.tsx @@ -15,14 +15,20 @@ import { } from "@/lib/playbook"; import { CopyIcon, - ExternalLinkIcon, - GitHubIcon, - SyncIcon, UnsyncedIcon, WarningIcon, } from "@/components/shared/Icons"; import { Spinner } from "@/components/shared/Spinner"; import { DeleteTypeModal } from "@/components/playbooks/DeleteTypeModal"; +import { + Pagination, + UpstreamFooter, +} from "./PlaybookList.parts"; +import { + SourceBadge, + StatusPill, + TypeBadge, +} from "./PlaybookList.badges"; type Props = { onOpen: (id: string) => void; @@ -53,7 +59,7 @@ const PAGE_SIZE = 8; // so a reader of `p.syncState.status` (per-playbook drift) and // `setSyncState({kind: "syncing"})` (button state) doesn't have to // guess which is which. -type UpstreamRepoSyncState = +export type UpstreamRepoSyncState = | { kind: "idle" } | { kind: "syncing" } | { kind: "error"; message: string }; @@ -674,325 +680,3 @@ export function PlaybookList({ onOpen, onNew, refreshNonce, onMutated }: Props) ); } - -// Pagination is a compact prev/next + "page X of Y" control rendered -// below the playbook grid. Hidden when the filtered count fits on a -// single page; otherwise sits flush against the cards above. -function Pagination({ - page, - pageSize, - total, - totalPages, - onChange, -}: { - page: number; - pageSize: number; - total: number; - totalPages: number; - onChange: (next: number) => void; -}) { - const start = page * pageSize + 1; - const end = Math.min((page + 1) * pageSize, total); - const canPrev = page > 0; - const canNext = page < totalPages - 1; - return ( -
    - - showing {start}–{end} of{" "} - {total} - -
    - - - {page + 1} / {totalPages} - - -
    -
    - ); -} - -// UpstreamFooter is the three-div strip anchored to the right of the -// page header, mirroring the wiki layout. Layout (left → right): -// -// 1. last synced: -// 2. synced from: (clickable, opens GitHub) -// 3. [sync icon] sync (badge with N when remote has commits ahead) -// -// Padded apart with a gap class so each pieces stays its own -// readable block instead of running together. Errors stack below. -function UpstreamFooter({ - upstream, - loaded, - syncState, - onSync, -}: { - upstream: PlaybooksUpstreamStatus | null; - loaded: boolean; - syncState: UpstreamRepoSyncState; - onSync: () => void | Promise; -}) { - if (!loaded) { - return ; - } - if (!upstream) { - return null; - } - const canSync = - upstream.gitCheckout && !!upstream.repo && syncState.kind !== "syncing"; - const repoUrl = upstream.repo - ? `https://github.com/${upstream.repo}` - : null; - const remoteAhead = upstream.remoteAhead ?? 0; - return ( -
    -
    - - last synced:{" "} - - {upstream.lastSynced ? relativeTime(upstream.lastSynced) : "never"} - - - {repoUrl ? ( - - - - synced from:{" "} - {upstream.repo} - - - - ) : ( - - synced from: (unset) - - )} - -
    - {remoteAhead > 0 && syncState.kind !== "syncing" && ( -
    - upstream has {remoteAhead} commit{remoteAhead === 1 ? "" : "s"} not - yet applied locally — click sync to pull. -
    - )} - {upstream.remoteAheadError && remoteAhead === 0 && ( -
    - (couldn't check upstream: {upstream.remoteAheadError}) -
    - )} - {syncState.kind === "error" && ( -
    - {syncState.message} -
    - )} -
    - ); -} - -// UpstreamFooterSkeleton reserves the space the loaded footer -// occupies while the upstream-status fetch is in flight. Three -// pulsing placeholders matching the loaded layout's three slots -// (last synced, synced from, sync button) keep the surrounding -// chrome from jumping when the real footer lands ~1s after page -// load. -function UpstreamFooterSkeleton() { - return ( -
    -
    -
    -
    -
    -
    -
    - ); -} - -// relativeTime renders an ISO-8601 timestamp as "5m ago" / "2h ago" -// / "3d ago". Falls back to the raw string for parse failures so the -// operator still sees something useful. -function relativeTime(iso: string): string { - const t = Date.parse(iso); - if (Number.isNaN(t)) return iso; - const seconds = Math.floor((Date.now() - t) / 1000); - if (seconds < 60) return `${seconds}s ago`; - const m = Math.floor(seconds / 60); - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - if (h < 48) return `${h}h ago`; - const d = Math.floor(h / 24); - return `${d}d ago`; -} - -// SourceBadge answers "where does the active version live?": -// "remote" = upstream-only (the operator hasn't authored or -// overridden it locally), "local" = there's a user file (whether -// it's a fresh id or an override of an upstream one). Collapsed -// from the previous user/override split because the more useful -// distinction (does it match remote?) is carried by the unsynced -// warning icon next to the id. -function SourceBadge({ source }: { source: PlaybookListItem["source"] }) { - const { label, cls } = sourceBadgeStyle(source); - return ( - - {label} - - ); -} - -function sourceBadgeStyle(source: PlaybookListItem["source"]): { - label: string; - cls: string; -} { - switch (source) { - case "plugin": - return { label: "remote", cls: "bg-zinc-800 text-zinc-300" }; - case "system": - // Sky tone for system metas — visually distinct from "remote - // upstream library" so the lock signal reads at a glance. - return { label: "system", cls: "bg-sky-900/50 text-sky-200" }; - case "user": - case "override": - return { label: "local", cls: "bg-emerald-900/50 text-emerald-300" }; - case "broken": - return { label: "broken", cls: "bg-red-900/60 text-red-300" }; - } -} - -// StatusPill is the always-visible "active / disabled" affirmation. -// Lives next to the SourceBadge so every card carries one of two -// signals — no more reading the absence of a disabled badge as -// "active". Mirrors the editor's StatusBadge visually (switch-track -// + label) so the same playbook reads the same way in the list and -// in the open editor — only the editor variant is interactive. -function StatusPill({ disabled }: { disabled: boolean }) { - return ( - - - - - - {disabled ? "disabled" : "active"} - - - ); -} - -// TypeBadge renders the playbook's type (the directory it lives in -// upstream — investigation, general, system, …). Always visible so -// every card carries its category at a glance, with the source badge -// next to it answering "remote vs local" separately. Colour-codes -// the canonical types so investigation stays subtle and other slots -// stand out, but unknown values render in a neutral pill so newly -// added types Just Work. -function TypeBadge({ type }: { type?: PlaybookListItem["type"] }) { - const name = type || "investigation"; - const cls = - name === "investigation" - ? "bg-zinc-800 text-zinc-300" - : name === "general" - ? "bg-sky-900/50 text-sky-300" - : "bg-indigo-900/50 text-indigo-300"; - return ( - - {name} - - ); -} - - diff --git a/frontend/components/repos/LinkedReposPanel.list.tsx b/frontend/components/repos/LinkedReposPanel.list.tsx new file mode 100644 index 00000000..e0b65472 --- /dev/null +++ b/frontend/components/repos/LinkedReposPanel.list.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api, ApiError, type LinkedRepo, type RepoSummaryStatus } from "@/lib/api"; +import { onReposChanged } from "@/lib/repos-events"; +import { Spinner } from "@/components/shared/Spinner"; +import { + useRepoSummaryStatus, + useRepoSummaryStore, + repoKey, +} from "@/components/repos/RepoSummaryStateProvider"; +import { GitHubIcon, WarningIcon } from "@/components/shared/Icons"; + +// 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. +export 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: +

    + + + ); +} + +export 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}`)}`, + ) + } + /> + ))} +
    + ); +} + +export 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 ( +
  • + +
  • + ); +} + +export 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 ( + + + + ); +} diff --git a/frontend/components/repos/LinkedReposPanel.modal.tsx b/frontend/components/repos/LinkedReposPanel.modal.tsx new file mode 100644 index 00000000..103b3b54 --- /dev/null +++ b/frontend/components/repos/LinkedReposPanel.modal.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api, ApiError, type LinkedRepo } from "@/lib/api"; +import { notifyReposChanged, onReposChanged } from "@/lib/repos-events"; +import { useDialog } from "@/lib/dialog"; +import { Spinner } from "@/components/shared/Spinner"; +import { MIN_DESCRIPTION_LENGTH } from "./LinkedReposPanel"; + +export 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" + /> +