From f812245b80599edde5a6ddc8a3487d02779208cf Mon Sep 17 00:00:00 2001 From: woertedetiankong <47322297+woertedetiankong@users.noreply.github.com> Date: Tue, 12 May 2026 07:20:49 -0700 Subject: [PATCH] Add plan context map sidebar --- packages/editor/App.tsx | 75 +++++ packages/shared/extract-code-paths.test.ts | 7 +- packages/shared/extract-code-paths.ts | 39 ++- .../components/sidebar/PlanContextBrowser.tsx | 256 ++++++++++++++++++ .../components/sidebar/SidebarContainer.tsx | 40 ++- .../ui/components/sidebar/SidebarTabs.tsx | 26 ++ packages/ui/hooks/useSidebar.ts | 2 +- packages/ui/utils/planContext.test.ts | 137 ++++++++++ packages/ui/utils/planContext.ts | 131 +++++++++ 9 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 packages/ui/components/sidebar/PlanContextBrowser.tsx create mode 100644 packages/ui/utils/planContext.test.ts create mode 100644 packages/ui/utils/planContext.ts diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c48b7eb2f..002208ff9 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { toast, Toaster } from 'sonner'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; +import { parseCodePath } from '@plannotator/shared/code-file'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { HtmlViewer } from '@plannotator/ui/components/html-viewer'; @@ -59,8 +60,10 @@ import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotat import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; +import { useValidatedCodePaths, type ValidationEntry } from '@plannotator/ui/hooks/useValidatedCodePaths'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; +import { extractPlanContextFiles, type PlanContextFile } from '@plannotator/ui/utils/planContext'; import { generateId } from '@plannotator/ui/utils/generateId'; import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; @@ -354,6 +357,42 @@ const App: React.FC = () => { }, [activeDocBaseDir]), }); + const rawPlanContextFiles = useMemo( + () => extractPlanContextFiles(blocks), + [blocks], + ); + const planContextValidation = useValidatedCodePaths(markdown); + const planContextValidationByPath = useMemo(() => { + const next = new Map(); + for (const [candidate, validation] of planContextValidation.validated) { + const path = parseCodePath(candidate).filePath; + const existing = next.get(path); + if (!existing || validation.status === 'found') { + next.set(path, validation); + } + } + return next; + }, [planContextValidation.validated]); + const planContextFiles = useMemo( + () => rawPlanContextFiles.map((file) => { + if (!planContextValidation.ready) return file; + const validation = planContextValidationByPath.get(file.path); + if (!validation) return file; + if (validation.status === 'found') { + return { + ...file, + resolvedPath: validation.resolved, + validationStatus: validation.status, + }; + } + return { + ...file, + validationStatus: validation.status, + }; + }), + [rawPlanContextFiles, planContextValidation.ready, planContextValidationByPath], + ); + // Archive browser const archive = useArchive({ markdown, viewerRef, linkedDocHook, @@ -365,6 +404,22 @@ const App: React.FC = () => { isPlanDiffActive, }), [archive.archiveMode, isPlanDiffActive]); + const showContextTab = useMemo( + () => planContextFiles.length > 0 + && !archive.archiveMode + && !annotateMode + && !linkedDocHook.isActive + && renderAs === 'markdown' + && !isPlanDiffActive, + [planContextFiles.length, archive.archiveMode, annotateMode, linkedDocHook.isActive, renderAs, isPlanDiffActive], + ); + + useEffect(() => { + if (sidebar.isOpen && sidebar.activeTab === 'context' && !showContextTab) { + sidebar.open('toc'); + } + }, [sidebar.isOpen, sidebar.activeTab, sidebar.open, showContextTab]); + const enterViewMode = useCallback((type: WideModeType) => { if (!canUseWideMode) return; if (wideModeType === null) { @@ -1280,6 +1335,22 @@ const App: React.FC = () => { // This is just a placeholder for future custom logic }; + const handleContextNavigate = useCallback((blockId: string) => { + const target = document.querySelector(`[data-block-id="${blockId}"]`); + if (!target || !scrollViewport) return; + + const headerOffset = 80; + const containerRect = scrollViewport.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const offsetPosition = + scrollViewport.scrollTop + targetRect.top - containerRect.top - headerOffset; + + scrollViewport.scrollTo({ + top: offsetPosition, + behavior: 'smooth', + }); + }, [scrollViewport]); + const annotationsOutput = useMemo(() => { const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( @@ -1697,6 +1768,7 @@ const App: React.FC = () => { onToggleTab={toggleSidebarTab} hasDiff={planDiff.hasPreviousVersion} showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} + showContextTab={showContextTab} showFilesTab={showFilesTab && !archive.archiveMode} hasFileAnnotations={hasFileAnnotations} className="hidden lg:flex absolute left-0 top-0 z-10" @@ -1721,6 +1793,9 @@ const App: React.FC = () => { linkedDocFilepath={linkedDocHook.filepath} onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined} backLabel={backLabel} + showContextTab={showContextTab} + planContextFiles={planContextFiles} + onContextNavigate={handleContextNavigate} showFilesTab={showFilesTab && !archive.archiveMode} fileAnnotationCounts={fileAnnotationCounts} highlightedFiles={highlightedFiles} diff --git a/packages/shared/extract-code-paths.test.ts b/packages/shared/extract-code-paths.test.ts index 045c205f4..ed0cdbb21 100644 --- a/packages/shared/extract-code-paths.test.ts +++ b/packages/shared/extract-code-paths.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { extractCandidateCodePaths } from "./extract-code-paths"; +import { extractCandidateCodePathMentions, extractCandidateCodePaths } from "./extract-code-paths"; describe("extractCandidateCodePaths", () => { test("extracts backtick code-file paths", () => { @@ -19,6 +19,11 @@ describe("extractCandidateCodePaths", () => { expect(extractCandidateCodePaths(md)).toEqual(["src/foo.ts"]); }); + test("keeps repeated references for mention summaries", () => { + const md = "`src/foo.ts` and src/foo.ts again"; + expect(extractCandidateCodePathMentions(md)).toEqual(["src/foo.ts", "src/foo.ts"]); + }); + test("strips line anchors", () => { const md = "see `src/foo.ts#L42`"; expect(extractCandidateCodePaths(md)).toEqual(["src/foo.ts"]); diff --git a/packages/shared/extract-code-paths.ts b/packages/shared/extract-code-paths.ts index c4ef380fd..151dcc4b6 100644 --- a/packages/shared/extract-code-paths.ts +++ b/packages/shared/extract-code-paths.ts @@ -24,23 +24,37 @@ const BACKTICK_SPAN = /`([^`\n]+)`/g; * Hash anchors (`#L42`) are stripped from results to match the renderer's * `cleanPath` transform. Returns deduped candidate strings. */ -export function extractCandidateCodePaths(markdown: string): string[] { +function collectCandidateCodePaths(markdown: string, dedupe: boolean): string[] { const stripped = markdown .replace(FENCED_CODE_BLOCK, "") .replace(HTML_COMMENT, ""); - const candidates = new Set(); + const seen = new Set(); + const candidates: string[] = []; + + const addCandidate = (candidate: string) => { + const clean = candidate.replace(/#.*$/, ""); + if (dedupe) { + if (seen.has(clean)) return; + seen.add(clean); + } + candidates.push(clean); + }; let m: RegExpExecArray | null; const backtickRe = new RegExp(BACKTICK_SPAN.source, "g"); while ((m = backtickRe.exec(stripped)) !== null) { const inner = m[1].trim(); if (isCodeFilePath(inner)) { - candidates.add(inner.replace(/#.*$/, "")); + addCandidate(inner); } } - for (const line of stripped.split("\n")) { + const strippedForBareScan = stripped.replace(BACKTICK_SPAN, (match) => + " ".repeat(match.length), + ); + + for (const line of strippedForBareScan.split("\n")) { const urlRanges: Array<[number, number]> = []; const urlRe = new RegExp(URL_REGEX.source, "g"); while ((m = urlRe.exec(line)) !== null) { @@ -58,9 +72,22 @@ export function extractCandidateCodePaths(markdown: string): string[] { ); if (overlapsUrl) continue; if (!isCodeFilePathStrict(m[0])) continue; - candidates.add(m[0].replace(/#.*$/, "")); + addCandidate(m[0]); } } - return Array.from(candidates); + return candidates; +} + +export function extractCandidateCodePaths(markdown: string): string[] { + return collectCandidateCodePaths(markdown, true); +} + +/** + * Extract candidate code-file path mentions without deduplication. + * Uses the same stripping and validation rules as `extractCandidateCodePaths`, + * but keeps repeated mentions so UI summaries can show frequency. + */ +export function extractCandidateCodePathMentions(markdown: string): string[] { + return collectCandidateCodePaths(markdown, false); } diff --git a/packages/ui/components/sidebar/PlanContextBrowser.tsx b/packages/ui/components/sidebar/PlanContextBrowser.tsx new file mode 100644 index 000000000..f210e482e --- /dev/null +++ b/packages/ui/components/sidebar/PlanContextBrowser.tsx @@ -0,0 +1,256 @@ +/** + * PlanContextBrowser — code-file mentions extracted from the current plan. + * + * This is an orientation map, not authoritative change metadata. Status badges + * are best-effort hints from explicit plan headings such as "Modified files". + */ + +import React, { useEffect, useMemo, useState } from "react"; +import type { PlanContextFile, PlanContextStatus } from "../../utils/planContext"; +import { CountBadge } from "./CountBadge"; + +interface PlanContextBrowserProps { + files: PlanContextFile[]; + onNavigate: (blockId: string) => void; +} + +interface ContextTreeNode { + type: "folder" | "file"; + name: string; + path: string; + mentionCount: number; + children?: ContextTreeNode[]; + file?: PlanContextFile; +} + +interface MutableContextFolder { + children: Map; +} + +const STATUS_LABEL: Record = { + mentioned: "Mentioned", + modified: "Modified", + created: "Created", + deleted: "Deleted", +}; + +const STATUS_CLASS: Record = { + mentioned: "border-border/70 bg-muted/60 text-muted-foreground", + modified: "border-warning/35 bg-warning/10 text-warning", + created: "border-success/35 bg-success/10 text-success", + deleted: "border-destructive/35 bg-destructive/10 text-destructive", +}; + +function buildContextTree(files: PlanContextFile[]): ContextTreeNode[] { + const root: MutableContextFolder = { children: new Map() }; + + for (const file of files) { + const segments = file.path.split("/").filter(Boolean); + if (segments.length === 0) continue; + + let current = root; + for (const segment of segments.slice(0, -1)) { + const existing = current.children.get(segment); + if (existing && !(existing as PlanContextFile).firstBlockId) { + current = existing as MutableContextFolder; + } else { + const next: MutableContextFolder = { children: new Map() }; + current.children.set(segment, next); + current = next; + } + } + + current.children.set(segments[segments.length - 1], file); + } + + return folderToNodes(root, ""); +} + +function folderToNodes(folder: MutableContextFolder, parentPath: string): ContextTreeNode[] { + const folders: ContextTreeNode[] = []; + const fileNodes: ContextTreeNode[] = []; + + for (const [name, value] of folder.children) { + const path = parentPath ? `${parentPath}/${name}` : name; + if ((value as PlanContextFile).firstBlockId) { + const file = value as PlanContextFile; + fileNodes.push({ + type: "file", + name, + path: file.path, + mentionCount: file.mentionCount, + file, + }); + continue; + } + + const children = folderToNodes(value as MutableContextFolder, path); + folders.push({ + type: "folder", + name, + path, + children, + mentionCount: children.reduce((sum, child) => sum + child.mentionCount, 0), + }); + } + + folders.sort((a, b) => a.name.localeCompare(b.name)); + fileNodes.sort((a, b) => a.name.localeCompare(b.name)); + return [...folders, ...fileNodes]; +} + +function getFolderPaths(nodes: ContextTreeNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.type !== "folder") continue; + paths.push(node.path); + if (node.children) paths.push(...getFolderPaths(node.children)); + } + return paths; +} + +export const PlanContextBrowser: React.FC = ({ + files, + onNavigate, +}) => { + const tree = useMemo(() => buildContextTree(files), [files]); + const folderPaths = useMemo(() => getFolderPaths(tree), [tree]); + const [expandedFolders, setExpandedFolders] = useState>(() => new Set(folderPaths)); + + useEffect(() => { + setExpandedFolders(new Set(folderPaths)); + }, [folderPaths]); + + const totalMentions = files.reduce((sum, file) => sum + file.mentionCount, 0); + const unresolvedCount = files.filter((file) => file.validationStatus === "missing").length; + + if (files.length === 0) { + return ( +
+ No code-file references found in this plan. +
+ ); + } + + return ( +
+
+
+
+

+ Plan Context +

+

+ {files.length} file{files.length === 1 ? "" : "s"} · {totalMentions} mention{totalMentions === 1 ? "" : "s"} +

+
+ {unresolvedCount > 0 && ( + + {unresolvedCount} unresolved + + )} +
+
+
+ {tree.map((node) => ( + { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }} + onNavigate={onNavigate} + /> + ))} +
+
+ ); +}; + +const ContextNode: React.FC<{ + node: ContextTreeNode; + depth: number; + expandedFolders: Set; + onToggleFolder: (path: string) => void; + onNavigate: (blockId: string) => void; +}> = ({ node, depth, expandedFolders, onToggleFolder, onNavigate }) => { + const paddingLeft = 8 + depth * 12; + + if (node.type === "folder") { + const isExpanded = expandedFolders.has(node.path); + return ( + <> + + {isExpanded && node.children?.map((child) => ( + + ))} + + ); + } + + const file = node.file!; + const isMissing = file.validationStatus === "missing"; + const statusTitle = `${STATUS_LABEL[file.status]}${file.sectionTitle ? ` in ${file.sectionTitle}` : ""}`; + + return ( + + ); +}; diff --git a/packages/ui/components/sidebar/SidebarContainer.tsx b/packages/ui/components/sidebar/SidebarContainer.tsx index 5d82b0dc8..1cbf119d8 100644 --- a/packages/ui/components/sidebar/SidebarContainer.tsx +++ b/packages/ui/components/sidebar/SidebarContainer.tsx @@ -1,7 +1,8 @@ /** * SidebarContainer — Shared sidebar shell * - * Houses the Table of Contents, Version Browser, File Browser, and Archive Browser views. + * Houses the Table of Contents, Version Browser, Plan Context, File Browser, + * and Archive Browser views. * Tab bar at top switches between them. */ @@ -13,8 +14,10 @@ import type { UseFileBrowserReturn } from "../../hooks/useFileBrowser"; import { TableOfContents } from "../TableOfContents"; import { VersionBrowser } from "./VersionBrowser"; import { FileBrowser } from "./FileBrowser"; +import { PlanContextBrowser } from "./PlanContextBrowser"; import { ArchiveBrowser, type ArchivedPlan } from "./ArchiveBrowser"; import { OverlayScrollArea } from "../OverlayScrollArea"; +import type { PlanContextFile } from "../../utils/planContext"; interface SidebarContainerProps { activeTab: SidebarTab; @@ -29,6 +32,10 @@ interface SidebarContainerProps { linkedDocFilepath?: string | null; onLinkedDocBack?: () => void; backLabel?: string; + // Plan Context props + showContextTab?: boolean; + planContextFiles?: PlanContextFile[]; + onContextNavigate?: (blockId: string) => void; // File Browser props showFilesTab?: boolean; fileAnnotationCounts?: Map; @@ -72,6 +79,9 @@ export const SidebarContainer: React.FC = ({ linkedDocFilepath, onLinkedDocBack, backLabel, + showContextTab, + planContextFiles, + onContextNavigate, showFilesTab, fileAnnotationCounts, highlightedFiles, @@ -147,6 +157,28 @@ export const SidebarContainer: React.FC = ({ label="Versions" /> )} + {showContextTab && ( + onTabChange("context")} + icon={ + + + + } + label="Context" + /> + )} {showFilesTab && ( = ({ onFetchVersions={onFetchVersions} /> )} + {activeTab === "context" && showContextTab && ( + {})} + /> + )} {activeTab === "files" && showFilesTab && fileBrowser && ( void; hasDiff: boolean; showVersionsTab?: boolean; + showContextTab?: boolean; showFilesTab?: boolean; hasFileAnnotations?: boolean; className?: string; @@ -23,6 +24,7 @@ export const SidebarTabs: React.FC = ({ onToggleTab, hasDiff, showVersionsTab, + showContextTab, showFilesTab, hasFileAnnotations, className, @@ -80,6 +82,30 @@ export const SidebarTabs: React.FC = ({ )} + {/* Context tab */} + {showContextTab && ( + + )} + {/* Files tab */} {showFilesTab && (