diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..922aab8 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "codestory" + +[setup] +script = "cargo check" diff --git a/AGENTS.md b/AGENTS.md index 1886539..158ae4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure & Module Organization - Rust workspace is defined in `Cargo.toml`; crates live under `crates/`. - Runtime stack: `crates/codestory-server` (Axum API + SSE + static SPA hosting) and `codestory-ui` (Vite + React + TypeScript). -- Core crates: `codestory-core`, `codestory-events`, `codestory-storage`, `codestory-index`, `codestory-search`, `codestory-graph`, `codestory-app`, `codestory-api`, `codestory-project`, `codestory-cli`. +- Core crates: `codestory-core`, `codestory-events`, `codestory-storage`, `codestory-index`, `codestory-search`, `codestory-app`, `codestory-api`, `codestory-project`, `codestory-cli`. - Runtime artifacts: `codestory.db`, `codestory_ui.json`; build outputs in `target/` and `codestory-ui/dist/`. ## Architecture Overview diff --git a/Cargo.lock b/Cargo.lock index c33268f..5c967dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,21 +215,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitflags" version = "2.11.0" @@ -445,7 +430,6 @@ dependencies = [ "anyhow", "codestory-core", "codestory-events", - "codestory-graph", "codestory-index", "codestory-project", "codestory-storage", @@ -501,20 +485,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "codestory-graph" -version = "0.1.0" -dependencies = [ - "anyhow", - "codestory-core", - "codestory-events", - "proptest", - "rayon", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "codestory-index" version = "0.1.0" @@ -1637,31 +1607,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" version = "1.0.44" @@ -1684,18 +1629,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -1705,17 +1640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1727,15 +1652,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_distr" version = "0.4.3" @@ -1743,16 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", + "rand", ] [[package]] @@ -1893,18 +1800,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - [[package]] name = "ryu" version = "1.0.23" @@ -2708,12 +2603,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicase" version = "2.9.0" @@ -2774,15 +2663,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 875b67b..8064039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/codestory-storage", "crates/codestory-index", "crates/codestory-search", - "crates/codestory-graph", "crates/codestory-server", "crates/codestory-cli", "crates/codestory-events", @@ -23,7 +22,6 @@ codestory-app = { path = "crates/codestory-app" } codestory-storage = { path = "crates/codestory-storage" } codestory-index = { path = "crates/codestory-index" } codestory-search = { path = "crates/codestory-search" } -codestory-graph = { path = "crates/codestory-graph" } codestory-events = { path = "crates/codestory-events" } codestory-project = { path = "crates/codestory-project" } codestory-bench = { path = "crates/codestory-bench" } diff --git a/README.md b/README.md index 01a78f7..aef9ff1 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ cargo run -p codestory-server -- --types-only --types-out codestory-ui/src/gener - `codestory-index`: tree-sitter + semantic resolution indexing pipeline - `codestory-storage`: SQLite schema and query layer - `codestory-search`: search primitives over indexed data - - `codestory-graph`: graph shaping/layout helpers - `codestory-api`: API DTOs and identifiers shared with frontend - `codestory-app`: headless orchestrator - `codestory-server`: Axum API + SSE + optional static file serving diff --git a/codestory-ui/src/App.tsx b/codestory-ui/src/App.tsx index e5e3036..2f60b6a 100644 --- a/codestory-ui/src/App.tsx +++ b/codestory-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { api } from "./api/client"; import { @@ -8,6 +8,9 @@ import { toMonacoModelPath, type AgentConnectionState, } from "./app/layoutPersistence"; +import { loadFeatureFlags, saveFeatureFlags, type FeatureFlagState } from "./app/featureFlags"; +import { trackAnalyticsEvent } from "./app/analytics"; +import { UI_CONTRACT, UI_LAYOUT_SCHEMA_STORAGE_KEY } from "./app/uiContract"; import type { PendingSymbolFocus } from "./app/types"; import { useEditorDecorations } from "./app/useEditorDecorations"; import { useProjectLifecycle } from "./app/useProjectLifecycle"; @@ -16,11 +19,24 @@ import { useSymbolFocus } from "./app/useSymbolFocus"; import { useTrailActions } from "./app/useTrailActions"; import { BookmarkManager } from "./components/BookmarkManager"; import { CodePane, type CodeEdgeContext } from "./components/CodePane"; +import { CommandPalette, type CommandPaletteCommand } from "./components/CommandPalette"; import { GraphPane } from "./components/GraphPane"; +import { InvestigateFocusSwitcher } from "./components/InvestigateFocusSwitcher"; import { PendingFocusDialog } from "./components/PendingFocusDialog"; import { ResponsePane } from "./components/ResponsePane"; import { StatusStrip } from "./components/StatusStrip"; import { TopBar } from "./components/TopBar"; +import { StarterCard } from "./features/onboarding/StarterCard"; +import { SettingsPage } from "./features/settings/SettingsPage"; +import { SpacesPanel } from "./features/spaces/SpacesPanel"; +import { + createSpace, + deleteSpace, + listSpaces, + loadSpace, + updateSpace, + type InvestigationSpace, +} from "./features/spaces"; import type { AgentAnswerDto, AgentConnectionSettingsDto, @@ -33,6 +49,15 @@ import type { } from "./generated/api"; import { isTruncatedUmlGraph, languageForPath } from "./graph/GraphViewport"; import { defaultTrailUiConfig, type TrailUiConfig } from "./graph/trailConfig"; +import { AppShell, type AppShellSection } from "./layout/AppShell"; +import { + INVESTIGATE_FOCUS_MODE_KEY, + LEGACY_WORKSPACE_LAYOUT_PRESET_KEY, + migrateLegacyWorkspacePreset, + normalizeInvestigateFocusMode, + investigateFocusModeLabel, + type InvestigateFocusMode, +} from "./layout/layoutPresets"; export default function App() { const [projectPath, setProjectPath] = useState(() => { @@ -80,6 +105,33 @@ export default function App() { const [projectRevision, setProjectRevision] = useState(0); const [bookmarkManagerOpen, setBookmarkManagerOpen] = useState(false); const [bookmarkSeed, setBookmarkSeed] = useState<{ nodeId: string; label: string } | null>(null); + const [activeSection, setActiveSection] = useState("investigate"); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [featureFlags, setFeatureFlags] = useState(() => loadFeatureFlags()); + const [investigateMode, setInvestigateMode] = useState(() => { + if (typeof window === "undefined") { + return "graph"; + } + const current = window.localStorage.getItem(INVESTIGATE_FOCUS_MODE_KEY); + if (current) { + return normalizeInvestigateFocusMode(current); + } + const legacy = migrateLegacyWorkspacePreset( + window.localStorage.getItem(LEGACY_WORKSPACE_LAYOUT_PRESET_KEY), + ); + return legacy ?? "graph"; + }); + const [hasCompletedIndex, setHasCompletedIndex] = useState(false); + const [askedFirstQuestion, setAskedFirstQuestion] = useState(false); + const [inspectedSource, setInspectedSource] = useState(false); + const [spaces, setSpaces] = useState(() => listSpaces()); + const [activeSpaceId, setActiveSpaceId] = useState(null); + + const firstAskTrackedRef = useRef(false); + const firstNodeSelectTrackedRef = useRef(false); + const firstSaveTrackedRef = useRef(false); + const firstTrailTrackedRef = useRef(false); + const previousIndexProgressRef = useRef<{ current: number; total: number } | null>(null); const isDirty = draftText !== savedText; const activeGraph = activeGraphId ? (graphMap[activeGraphId] ?? null) : null; @@ -107,6 +159,29 @@ export default function App() { trailConfig.targetId, ]); + useEffect(() => { + window.localStorage.setItem(INVESTIGATE_FOCUS_MODE_KEY, investigateMode); + window.localStorage.setItem(UI_LAYOUT_SCHEMA_STORAGE_KEY, String(UI_CONTRACT.schemaVersion)); + }, [investigateMode]); + + useEffect(() => { + saveFeatureFlags(featureFlags); + }, [featureFlags]); + + useEffect(() => { + const previous = previousIndexProgressRef.current; + if (previous !== null && indexProgress === null) { + setHasCompletedIndex(true); + } + previousIndexProgressRef.current = indexProgress; + }, [indexProgress]); + + useEffect(() => { + if (activeFilePath && projectOpen) { + setInspectedSource(true); + } + }, [activeFilePath, projectOpen]); + const { queueAutoIncrementalIndex, handleOpenProject, handleIndex } = useProjectLifecycle({ projectPath, projectOpen, @@ -144,6 +219,21 @@ export default function App() { path: activeFilePath, text: draftText, }); + const isFirstSave = !firstSaveTrackedRef.current; + if (isFirstSave) { + firstSaveTrackedRef.current = true; + } + trackAnalyticsEvent( + "file_saved", + { + file_path: activeFilePath, + bytes_written: response.bytes_written, + is_first: isFirstSave, + }, + { + projectPath, + }, + ); setSavedText(draftText); setStatus(`Saved ${activeFilePath} (${response.bytes_written} bytes).`); try { @@ -162,7 +252,15 @@ export default function App() { } finally { setIsSaving(false); } - }, [activeFilePath, draftText, isDirty, isSaving, projectOpen, queueAutoIncrementalIndex]); + }, [ + activeFilePath, + draftText, + isDirty, + isSaving, + projectOpen, + projectPath, + queueAutoIncrementalIndex, + ]); const upsertGraph = useCallback((graph: GraphArtifactDto, activate = false) => { setGraphMap((prev) => ({ @@ -270,10 +368,28 @@ export default function App() { } }, []); const handlePrompt = useCallback(async () => { - if (prompt.trim().length === 0) { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt.length === 0) { return; } + const isFirstAsk = !firstAskTrackedRef.current; + if (isFirstAsk) { + firstAskTrackedRef.current = true; + } + setAskedFirstQuestion(true); + trackAnalyticsEvent( + "ask_submitted", + { + prompt_length: trimmedPrompt.length, + tab: selectedTab, + is_first: isFirstAsk, + }, + { + projectPath, + }, + ); + const command = agentConnection.command?.trim(); const connection: AgentConnectionSettingsDto = { backend: agentConnection.backend, @@ -283,7 +399,7 @@ export default function App() { setIsBusy(true); try { const answer = await api.ask({ - prompt, + prompt: trimmedPrompt, retrieval_profile: retrievalProfile, focus_node_id: activeNodeDetails?.id, max_results: 10, @@ -312,7 +428,15 @@ export default function App() { } finally { setIsBusy(false); } - }, [activeNodeDetails?.id, agentConnection, focusSymbol, prompt, retrievalProfile]); + }, [ + activeNodeDetails?.id, + agentConnection, + focusSymbol, + projectPath, + prompt, + retrievalProfile, + selectedTab, + ]); const toggleNode = useCallback( async (node: SymbolSummaryDto) => { @@ -362,6 +486,650 @@ export default function App() { draftText, }); + const focusSymbolFromUi = useCallback( + (symbolId: string, label: string, source: "graph" | "explorer" | "bookmark") => { + const isFirstNodeSelection = !firstNodeSelectTrackedRef.current; + if (isFirstNodeSelection) { + firstNodeSelectTrackedRef.current = true; + } + + trackAnalyticsEvent( + "node_selected", + { + node_id: symbolId, + source, + is_first: isFirstNodeSelection, + }, + { + projectPath, + }, + ); + + focusSymbol(symbolId, label); + }, + [focusSymbol, projectPath], + ); + + const handleRunTrailWithAnalytics = useCallback(async () => { + const shouldTrack = Boolean(activeNodeDetails?.id) && !trailDisabledReason; + await runTrail(); + if (!shouldTrack) { + return; + } + + const isFirstTrailRun = !firstTrailTrackedRef.current; + if (isFirstTrailRun) { + firstTrailTrackedRef.current = true; + } + + trackAnalyticsEvent( + "trail_run", + { + mode: trailConfig.mode, + edge_filter_count: trailConfig.edgeFilter.length, + has_target_symbol: Boolean(trailConfig.targetId), + is_first: isFirstTrailRun, + }, + { + projectPath, + }, + ); + }, [activeNodeDetails?.id, projectPath, runTrail, trailConfig, trailDisabledReason]); + + const openProjectFromUi = useCallback(async () => { + setHasCompletedIndex(false); + setAskedFirstQuestion(false); + setInspectedSource(false); + await handleOpenProject(); + setActiveSection("investigate"); + setInvestigateMode("graph"); + }, [handleOpenProject]); + + const runIndexFromUi = useCallback( + async (mode: "Full" | "Incremental") => { + await handleIndex(mode); + setActiveSection("investigate"); + setInvestigateMode("graph"); + }, + [handleIndex], + ); + + const runRecommendedIndex = useCallback(async () => { + await runIndexFromUi("Incremental"); + }, [runIndexFromUi]); + + const seedFirstQuestion = useCallback(async () => { + setPrompt("Give me a quick architecture walkthrough of this repository."); + setActiveSection("investigate"); + setInvestigateMode("ask"); + }, []); + + const jumpToSourceInspection = useCallback(async () => { + setActiveSection("investigate"); + setInvestigateMode("code"); + if (activeNodeDetails?.id) { + focusSymbol(activeNodeDetails.id, activeNodeDetails.display_name); + return; + } + const firstRoot = rootSymbols[0]; + if (firstRoot) { + focusSymbol(firstRoot.id, firstRoot.label); + } + }, [activeNodeDetails?.display_name, activeNodeDetails?.id, focusSymbol, rootSymbols]); + + const focusGraphSearchInput = useCallback(() => { + const searchInput = document.querySelector(".graph-search-input"); + searchInput?.focus(); + setInvestigateMode("graph"); + setActiveSection("investigate"); + }, []); + + const setAgentBackend = useCallback((backend: AgentConnectionState["backend"]) => { + setAgentConnection((previous) => ({ + ...previous, + backend, + })); + }, []); + + const updateFeatureFlag = useCallback((flag: keyof FeatureFlagState, value: boolean) => { + setFeatureFlags((previous) => ({ + ...previous, + [flag]: value, + })); + }, []); + + const refreshSpaces = useCallback(() => { + setSpaces(listSpaces()); + }, []); + + const createSpaceFromCurrentContext = useCallback( + (name: string, notes: string) => { + const created = createSpace({ + name: name.trim().length > 0 ? name : `Investigation ${new Date().toLocaleString()}`, + prompt: prompt.trim().length > 0 ? prompt : "Untitled investigation prompt", + activeGraphId, + activeSymbolId: activeNodeDetails?.id ?? null, + notes, + owner: "local-user", + }); + setActiveSpaceId(created.id); + refreshSpaces(); + setStatus(`Saved space "${created.name}".`); + }, + [activeGraphId, activeNodeDetails?.id, prompt, refreshSpaces], + ); + + const loadSpaceIntoWorkspace = useCallback( + (spaceId: string) => { + const space = loadSpace(spaceId); + if (!space) { + setStatus("Requested space was not found."); + return; + } + setPrompt(space.prompt); + if (space.activeGraphId && graphMap[space.activeGraphId]) { + setActiveGraphId(space.activeGraphId); + } + if (space.activeSymbolId) { + focusSymbol(space.activeSymbolId, space.activeSymbolId); + } + setActiveSpaceId(space.id); + setActiveSection("investigate"); + setStatus(`Loaded space "${space.name}".`); + trackAnalyticsEvent( + "library_space_reopened", + { + space_id: space.id, + }, + { + projectPath, + }, + ); + updateSpace(space.id, { notes: space.notes ?? "" }); + refreshSpaces(); + }, + [focusSymbol, graphMap, projectPath, refreshSpaces], + ); + + const removeSpaceById = useCallback( + (spaceId: string) => { + const removed = deleteSpace(spaceId); + if (!removed) { + setStatus("Space was already removed."); + return; + } + if (activeSpaceId === spaceId) { + setActiveSpaceId(null); + } + refreshSpaces(); + setStatus("Deleted saved space."); + }, + [activeSpaceId, refreshSpaces], + ); + + const invokeCommand = useCallback( + (commandId: string, run: () => void | Promise) => { + trackAnalyticsEvent( + "command_invoked", + { + command_id: commandId, + }, + { + projectPath, + }, + ); + return run(); + }, + [projectPath], + ); + + const handleInvestigateModeChange = useCallback( + (mode: InvestigateFocusMode) => { + if (mode === investigateMode) { + return; + } + trackAnalyticsEvent( + "investigate_mode_switched", + { + from_mode: investigateMode, + to_mode: mode, + }, + { + projectPath, + }, + ); + setInvestigateMode(mode); + setActiveSection("investigate"); + setStatus(`Focus mode set to ${investigateFocusModeLabel(mode)}.`); + }, + [investigateMode, projectPath], + ); + + const commandPaletteCommands = useMemo( + () => [ + { + id: "open-project", + label: "Open Project", + detail: "Open the path currently in the top bar", + keywords: ["project", "setup", "workspace"], + disabled: isBusy, + run: () => invokeCommand("open-project", openProjectFromUi), + }, + { + id: "index-incremental", + label: "Run Incremental Index", + detail: "Refresh changed files only", + keywords: ["index", "incremental", "refresh"], + disabled: isBusy || !projectOpen, + run: () => invokeCommand("index-incremental", () => runIndexFromUi("Incremental")), + }, + { + id: "index-full", + label: "Run Full Index", + detail: "Rebuild graph and symbol index", + keywords: ["index", "full", "rebuild"], + disabled: isBusy || !projectOpen, + run: () => invokeCommand("index-full", () => runIndexFromUi("Full")), + }, + { + id: "ask-agent", + label: "Ask Agent", + detail: "Submit the active prompt", + keywords: ["ask", "agent", "prompt"], + disabled: isBusy || prompt.trim().length === 0, + run: () => + invokeCommand("ask-agent", () => { + setInvestigateMode("ask"); + void handlePrompt(); + }), + }, + { + id: "focus-graph-search", + label: "Focus Graph Search", + detail: "Jump to graph search input", + keywords: ["graph", "search", "find"], + run: () => + invokeCommand("focus-graph-search", () => { + setInvestigateMode("graph"); + focusGraphSearchInput(); + }), + }, + { + id: "run-trail", + label: "Run Trail Query", + detail: "Execute trail from selected root symbol", + keywords: ["trail", "graph", "path"], + disabled: Boolean(trailDisabledReason), + run: () => + invokeCommand("run-trail", () => { + setInvestigateMode("graph"); + void handleRunTrailWithAnalytics(); + }), + }, + { + id: "focus-ask", + label: "Focus Ask Mode", + detail: "Show only the Ask pane", + keywords: ["ask", "focus", "mode"], + disabled: investigateMode === "ask", + run: () => + invokeCommand("focus-ask", () => { + handleInvestigateModeChange("ask"); + }), + }, + { + id: "focus-graph", + label: "Focus Graph Mode", + detail: "Show only the Graph pane", + keywords: ["graph", "focus", "mode"], + disabled: investigateMode === "graph", + run: () => + invokeCommand("focus-graph", () => { + handleInvestigateModeChange("graph"); + }), + }, + { + id: "focus-code", + label: "Focus Code Mode", + detail: "Show only the Code pane", + keywords: ["code", "focus", "mode"], + disabled: investigateMode === "code", + run: () => + invokeCommand("focus-code", () => { + handleInvestigateModeChange("code"); + }), + }, + { + id: "open-bookmarks", + label: "Open Bookmark Manager", + detail: "Browse and manage saved symbols", + keywords: ["bookmark", "library", "saved"], + run: () => + invokeCommand("open-bookmarks", () => { + setBookmarkManagerOpen(true); + setActiveSection("investigate"); + }), + }, + { + id: "goto-investigate", + label: "Open Investigate Section", + detail: "Navigate shell to Investigate", + keywords: ["investigate", "workspace", "section"], + disabled: activeSection === "investigate", + run: () => + invokeCommand("goto-investigate", () => { + setActiveSection("investigate"); + }), + }, + { + id: "goto-library", + label: "Open Library Section", + detail: "Navigate shell to Library", + keywords: ["library", "spaces", "section"], + disabled: activeSection === "library", + run: () => + invokeCommand("goto-library", () => { + setActiveSection("library"); + }), + }, + { + id: "goto-settings", + label: "Open Settings Section", + detail: "Navigate shell to Settings", + keywords: ["settings", "preferences", "section"], + disabled: activeSection === "settings", + run: () => + invokeCommand("goto-settings", () => { + setActiveSection("settings"); + }), + }, + { + id: "save-space", + label: "Save Investigation Space", + detail: "Store current prompt and focus for reuse", + keywords: ["space", "save", "library"], + disabled: !featureFlags.spacesLibrary, + run: () => + invokeCommand("save-space", () => { + createSpaceFromCurrentContext("", ""); + setActiveSection("library"); + }), + }, + { + id: "toggle-ux-reset", + label: featureFlags.uxResetV2 ? "Disable UX Reset" : "Enable UX Reset", + detail: "Rollback switch for staged rollout", + keywords: ["feature flag", "rollback", "shell"], + run: () => + invokeCommand("toggle-ux-reset", () => { + updateFeatureFlag("uxResetV2", !featureFlags.uxResetV2); + }), + }, + ], + [ + activeSection, + createSpaceFromCurrentContext, + featureFlags.spacesLibrary, + featureFlags.uxResetV2, + focusGraphSearchInput, + handleInvestigateModeChange, + handlePrompt, + handleRunTrailWithAnalytics, + investigateMode, + invokeCommand, + isBusy, + openProjectFromUi, + projectOpen, + prompt, + runIndexFromUi, + trailDisabledReason, + updateFeatureFlag, + ], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setCommandPaletteOpen((previous) => !previous); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const responsePaneView = ( + { + setAgentConnection((prev) => ({ + ...prev, + command, + })); + }} + onAskAgent={() => { + void handlePrompt(); + }} + isBusy={isBusy} + projectOpen={projectOpen} + agentAnswer={agentAnswer} + graphMap={graphMap} + onActivateGraph={setActiveGraphId} + rootSymbols={rootSymbols} + childrenByNode={childrenByNode} + expandedNodes={expandedNodes} + onToggleNode={toggleNode} + onFocusSymbol={(symbolId, label) => { + focusSymbolFromUi(symbolId, label, "explorer"); + }} + activeSymbolId={activeNodeDetails?.id ?? null} + /> + ); + + const graphPaneView = ( + { + if (searchHits.length > 0) { + setSearchOpen(true); + } + }} + onSearchBlur={() => { + window.setTimeout(() => setSearchOpen(false), 140); + }} + isSearching={isSearching} + searchOpen={searchOpen} + searchHits={searchHits} + searchIndex={searchIndex} + onSearchHitHover={setSearchIndex} + onSearchHitActivate={activateSearchHit} + projectOpen={projectOpen} + projectRevision={projectRevision} + graphOrder={graphOrder} + activeGraphId={activeGraphId} + graphMap={graphMap} + onActivateGraph={setActiveGraphId} + onSelectNode={(nodeId, label) => { + focusSymbolFromUi(nodeId, label, "graph"); + }} + onSelectEdge={(selection) => { + void selectEdge(selection); + }} + trailConfig={trailConfig} + trailRunning={isTrailRunning} + trailDisabledReason={trailDisabledReason} + hasActiveRoot={Boolean(activeNodeDetails?.id)} + activeRootLabel={activeNodeDetails?.display_name ?? null} + onOpenNodeInNewTab={(nodeId, label) => { + void openNeighborhoodInNewTab(nodeId, label); + }} + onNavigateBack={navigateGraphBack} + onNavigateForward={navigateGraphForward} + onShowDefinitionInIde={(nodeId) => { + void showDefinitionInIde(nodeId); + }} + onBookmarkNode={(nodeId, label) => { + setBookmarkSeed({ nodeId, label }); + setBookmarkManagerOpen(true); + }} + onOpenContainingFolder={(path) => { + void openContainingFolder(path); + }} + onOpenBookmarkManager={() => { + if (activeNodeDetails?.id) { + setBookmarkSeed({ + nodeId: activeNodeDetails.id, + label: activeNodeDetails.display_name, + }); + } + setBookmarkManagerOpen(true); + }} + onGraphStatusMessage={setStatus} + onTrailConfigChange={updateTrailConfig} + onRunTrail={() => { + void handleRunTrailWithAnalytics(); + }} + onResetTrailDefaults={resetTrailConfig} + /> + ); + + const codePaneView = ( + { + void selectOccurrenceByIndex(index); + }} + onNextOccurrence={() => { + void selectNextOccurrence(); + }} + onPreviousOccurrence={() => { + void selectPreviousOccurrence(); + }} + codeLanguage={codeLanguage} + draftText={draftText} + onDraftChange={setDraftText} + onEditorMount={handleEditorMount} + /> + ); + + const legacyWorkspaceView = ( +
+ {responsePaneView} + {graphPaneView} + {codePaneView} +
+ ); + + const focusedPane = + investigateMode === "graph" + ? graphPaneView + : investigateMode === "code" + ? codePaneView + : responsePaneView; + + const focusedWorkspaceView = ( +
+ {featureFlags.onboardingStarter ? ( + 0} + askedFirstQuestion={askedFirstQuestion} + inspectedSource={inspectedSource} + onOpenProject={openProjectFromUi} + onRunIndex={runRecommendedIndex} + onSeedQuestion={seedFirstQuestion} + onInspectSource={jumpToSourceInspection} + onPrimaryAction={(action) => { + trackAnalyticsEvent( + "starter_card_cta_clicked", + { + action, + }, + { + projectPath, + }, + ); + }} + /> + ) : null} + +
+ +
+ + +
+
+ +
{focusedPane}
+
+ ); + + const workspaceView = featureFlags.singlePaneInvestigate + ? focusedWorkspaceView + : legacyWorkspaceView; + + const sectionContent: Partial> = { + library: featureFlags.spacesLibrary ? ( + + ) : ( +
+

Spaces Disabled

+

Enable spaces in Settings to save and reopen investigations.

+
+ ), + settings: , + }; + return (
{ - void handleOpenProject(); + void openProjectFromUi(); }} onIndex={(mode) => { - void handleIndex(mode); + void runIndexFromUi(mode); }} /> -
- { - setAgentConnection((prev) => ({ - ...prev, - backend, - })); - }} - agentCommand={agentConnection.command ?? ""} - onAgentCommandChange={(command) => { - setAgentConnection((prev) => ({ - ...prev, - command, - })); - }} - onAskAgent={() => { - void handlePrompt(); - }} - isBusy={isBusy} - projectOpen={projectOpen} - agentAnswer={agentAnswer} - graphMap={graphMap} - onActivateGraph={setActiveGraphId} - rootSymbols={rootSymbols} - childrenByNode={childrenByNode} - expandedNodes={expandedNodes} - onToggleNode={toggleNode} - onFocusSymbol={focusSymbol} - activeSymbolId={activeNodeDetails?.id ?? null} - /> - - { - if (searchHits.length > 0) { - setSearchOpen(true); - } - }} - onSearchBlur={() => { - window.setTimeout(() => setSearchOpen(false), 140); - }} - isSearching={isSearching} - searchOpen={searchOpen} - searchHits={searchHits} - searchIndex={searchIndex} - onSearchHitHover={setSearchIndex} - onSearchHitActivate={activateSearchHit} - projectOpen={projectOpen} - projectRevision={projectRevision} - graphOrder={graphOrder} - activeGraphId={activeGraphId} - graphMap={graphMap} - onActivateGraph={setActiveGraphId} - onSelectNode={(nodeId, label) => { - focusSymbol(nodeId, label); - }} - onSelectEdge={(selection) => { - void selectEdge(selection); - }} - trailConfig={trailConfig} - trailRunning={isTrailRunning} - trailDisabledReason={trailDisabledReason} - hasActiveRoot={Boolean(activeNodeDetails?.id)} - activeRootLabel={activeNodeDetails?.display_name ?? null} - onOpenNodeInNewTab={(nodeId, label) => { - void openNeighborhoodInNewTab(nodeId, label); - }} - onNavigateBack={navigateGraphBack} - onNavigateForward={navigateGraphForward} - onShowDefinitionInIde={(nodeId) => { - void showDefinitionInIde(nodeId); - }} - onBookmarkNode={(nodeId, label) => { - setBookmarkSeed({ nodeId, label }); - setBookmarkManagerOpen(true); - }} - onOpenContainingFolder={(path) => { - void openContainingFolder(path); - }} - onOpenBookmarkManager={() => { - if (activeNodeDetails?.id) { - setBookmarkSeed({ - nodeId: activeNodeDetails.id, - label: activeNodeDetails.display_name, - }); - } - setBookmarkManagerOpen(true); - }} - onGraphStatusMessage={setStatus} - onTrailConfigChange={updateTrailConfig} - onRunTrail={() => { - void runTrail(); - }} - onResetTrailDefaults={resetTrailConfig} - /> - - { - void selectOccurrenceByIndex(index); - }} - onNextOccurrence={() => { - void selectNextOccurrence(); - }} - onPreviousOccurrence={() => { - void selectPreviousOccurrence(); - }} - codeLanguage={codeLanguage} - draftText={draftText} - onDraftChange={setDraftText} - onEditorMount={handleEditorMount} + {featureFlags.uxResetV2 ? ( + -
+ ) : ( + legacyWorkspaceView + )} setBookmarkManagerOpen(false)} onFocusSymbol={(nodeId, label) => { setBookmarkManagerOpen(false); - focusSymbol(nodeId, label); + focusSymbolFromUi(nodeId, label, "bookmark"); }} onStatus={setStatus} + onPromoteBookmarkToSpace={(bookmark) => { + createSpaceFromCurrentContext( + `Bookmark - ${bookmark.node_label}`, + bookmark.comment ?? "", + ); + setStatus(`Promoted "${bookmark.node_label}" to a space.`); + setActiveSection("library"); + }} /> + setCommandPaletteOpen(false)} + />
); } diff --git a/codestory-ui/src/app/analytics.ts b/codestory-ui/src/app/analytics.ts new file mode 100644 index 0000000..ea49448 --- /dev/null +++ b/codestory-ui/src/app/analytics.ts @@ -0,0 +1,143 @@ +const SESSION_STORAGE_KEY = "codestory:analytics-session-id"; +const ANALYTICS_CHANNEL = "codestory:analytics"; + +type AnalyticsEventName = + | "project_opened" + | "index_started" + | "index_completed" + | "ask_submitted" + | "node_selected" + | "file_saved" + | "trail_run" + | "command_invoked" + | "starter_card_cta_clicked" + | "investigate_mode_switched" + | "advanced_drawer_opened" + | "library_space_reopened"; + +type AnalyticsOptions = { + projectPath?: string | null; +}; + +export type AnalyticsEnvelope = { + event_id: string; + event: AnalyticsEventName; + timestamp: string; + session_id: string; + project_id: string | null; + payload: Record; +}; + +export type AnalyticsEmitter = (event: AnalyticsEnvelope) => void; + +let testEmitter: AnalyticsEmitter | null = null; +let memorySessionId: string | null = null; + +function randomHexSegment(length: number): string { + let result = ""; + for (let index = 0; index < length; index += 1) { + result += Math.floor(Math.random() * 16).toString(16); + } + return result; +} + +function fallbackId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${randomHexSegment(10)}`; +} + +function createId(prefix: string): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return fallbackId(prefix); +} + +function resolveSessionId(): string { + if (typeof window === "undefined") { + if (!memorySessionId) { + memorySessionId = createId("session"); + } + return memorySessionId; + } + + const existing = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (existing && existing.trim().length > 0) { + return existing; + } + + const next = createId("session"); + window.localStorage.setItem(SESSION_STORAGE_KEY, next); + return next; +} + +function fnv1aHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(36); +} + +export function toProjectId(projectPath?: string | null): string | null { + if (!projectPath) { + return null; + } + + const normalized = projectPath.trim().replace(/\\/g, "/").toLowerCase(); + if (normalized.length === 0) { + return null; + } + + return `project-${fnv1aHash(normalized)}`; +} + +function analyticsEnabled(): boolean { + return import.meta.env.VITE_DISABLE_ANALYTICS !== "true"; +} + +export function createAnalyticsEvent( + event: AnalyticsEventName, + payload: Record, + options?: AnalyticsOptions, +): AnalyticsEnvelope { + return { + event_id: createId("evt"), + event, + timestamp: new Date().toISOString(), + session_id: resolveSessionId(), + project_id: toProjectId(options?.projectPath), + payload, + }; +} + +function emitAnalyticsEvent(event: AnalyticsEnvelope): void { + if (testEmitter) { + testEmitter(event); + return; + } + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(ANALYTICS_CHANNEL, { + detail: event, + }), + ); + } +} + +export function trackAnalyticsEvent( + event: AnalyticsEventName, + payload: Record, + options?: AnalyticsOptions, +): void { + if (!analyticsEnabled()) { + return; + } + + emitAnalyticsEvent(createAnalyticsEvent(event, payload, options)); +} + +export function setAnalyticsEmitterForTests(emitter: AnalyticsEmitter | null): void { + testEmitter = emitter; +} diff --git a/codestory-ui/src/app/featureFlags.ts b/codestory-ui/src/app/featureFlags.ts new file mode 100644 index 0000000..8697f1e --- /dev/null +++ b/codestory-ui/src/app/featureFlags.ts @@ -0,0 +1,73 @@ +export type FeatureFlagState = { + uxResetV2: boolean; + onboardingStarter: boolean; + singlePaneInvestigate: boolean; + spacesLibrary: boolean; +}; + +export const FEATURE_FLAGS_STORAGE_KEY = "codestory:feature-flags:v2"; + +const DEFAULT_FLAGS: FeatureFlagState = { + uxResetV2: true, + onboardingStarter: true, + singlePaneInvestigate: true, + spacesLibrary: true, +}; + +function getStorage(): Storage | null { + if (typeof window === "undefined") { + return null; + } + return window.localStorage; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function normalizeFeatureFlags(raw: unknown): FeatureFlagState { + if (!isRecord(raw)) { + return DEFAULT_FLAGS; + } + const legacyModernShell = + typeof raw.modernShell === "boolean" ? raw.modernShell : DEFAULT_FLAGS.uxResetV2; + const legacyOnboarding = + typeof raw.onboarding === "boolean" ? raw.onboarding : DEFAULT_FLAGS.onboardingStarter; + + return { + uxResetV2: typeof raw.uxResetV2 === "boolean" ? raw.uxResetV2 : legacyModernShell, + onboardingStarter: + typeof raw.onboardingStarter === "boolean" ? raw.onboardingStarter : legacyOnboarding, + singlePaneInvestigate: + typeof raw.singlePaneInvestigate === "boolean" + ? raw.singlePaneInvestigate + : DEFAULT_FLAGS.singlePaneInvestigate, + spacesLibrary: + typeof raw.spacesLibrary === "boolean" ? raw.spacesLibrary : DEFAULT_FLAGS.spacesLibrary, + }; +} + +export function loadFeatureFlags(): FeatureFlagState { + const storage = getStorage(); + if (!storage) { + return DEFAULT_FLAGS; + } + const raw = storage.getItem(FEATURE_FLAGS_STORAGE_KEY); + if (!raw) { + return DEFAULT_FLAGS; + } + try { + const parsed = JSON.parse(raw) as unknown; + return normalizeFeatureFlags(parsed); + } catch { + return DEFAULT_FLAGS; + } +} + +export function saveFeatureFlags(flags: FeatureFlagState): void { + const storage = getStorage(); + if (!storage) { + return; + } + storage.setItem(FEATURE_FLAGS_STORAGE_KEY, JSON.stringify(flags)); +} diff --git a/codestory-ui/src/app/layoutPersistence.ts b/codestory-ui/src/app/layoutPersistence.ts index a8368ca..074728b 100644 --- a/codestory-ui/src/app/layoutPersistence.ts +++ b/codestory-ui/src/app/layoutPersistence.ts @@ -51,6 +51,7 @@ export type AgentConnectionState = { command: string | null; }; +export const UI_LAYOUT_SCHEMA_VERSION = 2; export const LAST_OPENED_PROJECT_KEY = "codestory:last-opened-project"; export const DEFAULT_AGENT_CONNECTION: AgentConnectionState = { diff --git a/codestory-ui/src/app/uiContract.ts b/codestory-ui/src/app/uiContract.ts new file mode 100644 index 0000000..3425f98 --- /dev/null +++ b/codestory-ui/src/app/uiContract.ts @@ -0,0 +1,25 @@ +export const UI_CONTRACT = { + schemaVersion: 2, + shellMaxWidth: 1920, + appNavWidth: { + min: 220, + max: 260, + }, + paneMinHeight: { + desktop: 440, + laptop: 360, + }, + breakpoints: { + mobile: 700, + tablet: 1024, + desktop: 1280, + }, + spaceScale: { + xs: 0.375, + sm: 0.625, + md: 0.875, + lg: 1.25, + }, +} as const; + +export const UI_LAYOUT_SCHEMA_STORAGE_KEY = "codestory:ui-layout-schema-version"; diff --git a/codestory-ui/src/app/useProjectLifecycle.ts b/codestory-ui/src/app/useProjectLifecycle.ts index e427ea2..a9f0500 100644 --- a/codestory-ui/src/app/useProjectLifecycle.ts +++ b/codestory-ui/src/app/useProjectLifecycle.ts @@ -20,6 +20,7 @@ import { normalizeRetrievalProfile, type AgentConnectionState, } from "./layoutPersistence"; +import { trackAnalyticsEvent } from "./analytics"; type IndexProgressState = { current: number; total: number } | null; @@ -181,6 +182,15 @@ export function useProjectLifecycle({ setStatus( `Indexing complete in ${event.data.duration_ms} ms (parse ${phases.parse_index_ms} ms, flush ${phases.projection_flush_ms} ms, resolve ${phases.edge_resolution_ms} ms, cache ${phases.cache_refresh_ms ?? 0} ms).`, ); + trackAnalyticsEvent( + "index_completed", + { + duration_ms: event.data.duration_ms, + }, + { + projectPath, + }, + ); void loadRootSymbols(); break; } @@ -193,7 +203,7 @@ export function useProjectLifecycle({ break; } }); - }, [loadRootSymbols, setIndexProgress, setStatus]); + }, [loadRootSymbols, projectPath, setIndexProgress, setStatus]); const handleOpenProject = useCallback( async (pathOverride?: string, restored = false) => { @@ -211,6 +221,15 @@ export function useProjectLifecycle({ setTrailConfig(defaultTrailUiConfig()); setAgentConnection(DEFAULT_AGENT_CONNECTION); setRetrievalProfile(DEFAULT_RETRIEVAL_PROFILE); + trackAnalyticsEvent( + "project_opened", + { + source: restored ? "restore" : "manual", + }, + { + projectPath: path, + }, + ); await loadRootSymbols(); const saved = await api.getUiLayout(); @@ -285,13 +304,22 @@ export function useProjectLifecycle({ setIsBusy(true); try { await api.startIndexing({ mode }); + trackAnalyticsEvent( + "index_started", + { + mode, + }, + { + projectPath, + }, + ); } catch (error) { setStatus(error instanceof Error ? error.message : "Failed to start indexing."); } finally { setIsBusy(false); } }, - [setIsBusy, setStatus], + [projectPath, setIsBusy, setStatus], ); return { diff --git a/codestory-ui/src/components/AdvancedSettingsDrawer.tsx b/codestory-ui/src/components/AdvancedSettingsDrawer.tsx new file mode 100644 index 0000000..b394e4a --- /dev/null +++ b/codestory-ui/src/components/AdvancedSettingsDrawer.tsx @@ -0,0 +1,274 @@ +import type { ChangeEvent } from "react"; + +import type { + AgentConnectionSettingsDto, + AgentRetrievalPresetDto, + AgentRetrievalProfileSelectionDto, + AgentRetrievalTraceDto, + EdgeKind, + NodeKind, +} from "../generated/api"; + +export type ResolvedCustomConfig = { + depth: number; + direction: "Incoming" | "Outgoing" | "Both"; + edge_filter: EdgeKind[]; + node_filter: NodeKind[]; + max_nodes: number; + include_edge_occurrences: boolean; + enable_source_reads: boolean; +}; + +const PRESET_LABELS: Record = { + architecture: "Architecture", + callflow: "Call Flow", + inheritance: "Inheritance", + impact: "Impact", +}; + +type AdvancedSettingsDrawerProps = { + isOpen: boolean; + onToggle: () => void; + retrievalProfile: AgentRetrievalProfileSelectionDto; + onRetrievalProfileChange: (next: AgentRetrievalProfileSelectionDto) => void; + activeCustomConfig: ResolvedCustomConfig; + onCustomConfigChange: (patch: Partial) => void; + agentBackend: NonNullable; + onAgentBackendChange: (backend: NonNullable) => void; + agentCommand: string; + onAgentCommandChange: (command: string) => void; + retrievalTrace: AgentRetrievalTraceDto | null; +}; + +function parseCsvList(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function formatCsvList(values: string[]): string { + return values.join(", "); +} + +export function AdvancedSettingsDrawer({ + isOpen, + onToggle, + retrievalProfile, + onRetrievalProfileChange, + activeCustomConfig, + onCustomConfigChange, + agentBackend, + onAgentBackendChange, + agentCommand, + onAgentCommandChange, + retrievalTrace, +}: AdvancedSettingsDrawerProps) { + const handleBackendChange = (event: ChangeEvent) => { + const nextBackend = event.target.value; + if (nextBackend === "codex" || nextBackend === "claude_code") { + onAgentBackendChange(nextBackend); + } + }; + + const handleCommandChange = (event: ChangeEvent) => { + onAgentCommandChange(event.target.value); + }; + + const handleProfileModeChange = (event: ChangeEvent) => { + const nextMode = event.target.value; + if (nextMode === "auto") { + onRetrievalProfileChange({ kind: "auto" }); + return; + } + if (nextMode === "preset") { + const preset = retrievalProfile.kind === "preset" ? retrievalProfile.preset : "architecture"; + onRetrievalProfileChange({ kind: "preset", preset }); + return; + } + if (nextMode === "custom") { + onRetrievalProfileChange({ + kind: "custom", + config: activeCustomConfig, + }); + } + }; + + return ( +
+ + + {isOpen ? ( +
+
+ + +
+ +
+ + + {retrievalProfile.kind === "preset" ? ( + + ) : null} + + {retrievalProfile.kind === "custom" ? ( +
+ + + + + + + + + + + + + +
+ ) : null} +
+ +
+ Raw Retrieval Trace + {retrievalTrace ? ( +
{JSON.stringify(retrievalTrace, null, 2)}
+ ) : ( +
Ask a question to view trace data.
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/codestory-ui/src/components/BookmarkManager.tsx b/codestory-ui/src/components/BookmarkManager.tsx index e35e920..b790ca5 100644 --- a/codestory-ui/src/components/BookmarkManager.tsx +++ b/codestory-ui/src/components/BookmarkManager.tsx @@ -1,7 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { api } from "../api/client"; import type { BookmarkCategoryDto, BookmarkDto } from "../generated/api"; +import { + bookmarkTagsToDraft, + filterBookmarksByQuery, + loadBookmarkMetadataMap, + removeBookmarkMetadata, + saveBookmarkMetadataMap, + type BookmarkLocalMetadataMap, + upsertBookmarkMetadata, +} from "./bookmarkManagerUtils"; type BookmarkSeed = { nodeId: string; @@ -14,6 +23,7 @@ type BookmarkManagerProps = { onClose: () => void; onFocusSymbol: (nodeId: string, label: string) => void; onStatus: (message: string) => void; + onPromoteBookmarkToSpace?: (bookmark: BookmarkDto) => void; }; const DEFAULT_CATEGORY_NAME = "General"; @@ -24,10 +34,12 @@ export function BookmarkManager({ onClose, onFocusSymbol, onStatus, + onPromoteBookmarkToSpace, }: BookmarkManagerProps) { const [categories, setCategories] = useState([]); const [bookmarks, setBookmarks] = useState([]); const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [bookmarkSearchQuery, setBookmarkSearchQuery] = useState(""); const [newCategoryName, setNewCategoryName] = useState(""); const [newBookmarkComment, setNewBookmarkComment] = useState(""); const [bookmarkCategoryId, setBookmarkCategoryId] = useState(""); @@ -35,15 +47,49 @@ export function BookmarkManager({ const [categoryDrafts, setCategoryDrafts] = useState>({}); const [bookmarkCommentDrafts, setBookmarkCommentDrafts] = useState>({}); const [bookmarkCategoryDrafts, setBookmarkCategoryDrafts] = useState>({}); + const [bookmarkTagDrafts, setBookmarkTagDrafts] = useState>({}); + const [bookmarkNotesTemplateDrafts, setBookmarkNotesTemplateDrafts] = useState< + Record + >({}); + const [bookmarkMetadataMap, setBookmarkMetadataMap] = useState(() => + loadBookmarkMetadataMap(), + ); const [loading, setLoading] = useState(false); + const deferredBookmarkSearchQuery = useDeferredValue(bookmarkSearchQuery); - const visibleBookmarks = useMemo(() => { + const categoryFilteredBookmarks = useMemo(() => { if (!selectedCategoryId) { return bookmarks; } return bookmarks.filter((bookmark) => bookmark.category_id === selectedCategoryId); }, [bookmarks, selectedCategoryId]); + const visibleBookmarks = useMemo( + () => + filterBookmarksByQuery( + categoryFilteredBookmarks, + deferredBookmarkSearchQuery, + bookmarkMetadataMap, + ), + [bookmarkMetadataMap, categoryFilteredBookmarks, deferredBookmarkSearchQuery], + ); + + const persistBookmarkMetadataDraft = useCallback( + (bookmarkId: string) => { + setBookmarkMetadataMap((previous) => { + const next = upsertBookmarkMetadata( + previous, + bookmarkId, + bookmarkTagDrafts[bookmarkId] ?? "", + bookmarkNotesTemplateDrafts[bookmarkId] ?? "", + ); + saveBookmarkMetadataMap(next); + return next; + }); + }, + [bookmarkNotesTemplateDrafts, bookmarkTagDrafts], + ); + const refreshData = useCallback( async (categoryFilter: string | null = selectedCategoryId): Promise => { setLoading(true); @@ -52,8 +98,10 @@ export function BookmarkManager({ api.getBookmarkCategories(), api.getBookmarks(categoryFilter), ]); + const loadedMetadata = loadBookmarkMetadataMap(); setCategories(loadedCategories); setBookmarks(loadedBookmarks); + setBookmarkMetadataMap(loadedMetadata); setCategoryDrafts( Object.fromEntries(loadedCategories.map((category) => [category.id, category.name])), ); @@ -67,6 +115,22 @@ export function BookmarkManager({ loadedBookmarks.map((bookmark) => [bookmark.id, bookmark.category_id]), ), ); + setBookmarkTagDrafts( + Object.fromEntries( + loadedBookmarks.map((bookmark) => [ + bookmark.id, + bookmarkTagsToDraft(loadedMetadata[bookmark.id]?.tags ?? []), + ]), + ), + ); + setBookmarkNotesTemplateDrafts( + Object.fromEntries( + loadedBookmarks.map((bookmark) => [ + bookmark.id, + loadedMetadata[bookmark.id]?.notesTemplate ?? "", + ]), + ), + ); if (!categoryFilter && loadedCategories[0] && bookmarkCategoryId.length === 0) { setBookmarkCategoryId(loadedCategories[0].id); @@ -194,6 +258,7 @@ export function BookmarkManager({ }; const saveBookmark = async (bookmark: BookmarkDto) => { + persistBookmarkMetadataDraft(bookmark.id); const nextComment = (bookmarkCommentDrafts[bookmark.id] ?? "").trim(); const nextCategory = bookmarkCategoryDrafts[bookmark.id] ?? bookmark.category_id; try { @@ -214,6 +279,21 @@ export function BookmarkManager({ try { await api.deleteBookmark(bookmarkId); setBookmarks((previous) => previous.filter((bookmark) => bookmark.id !== bookmarkId)); + setBookmarkTagDrafts((previous) => { + const next = { ...previous }; + delete next[bookmarkId]; + return next; + }); + setBookmarkNotesTemplateDrafts((previous) => { + const next = { ...previous }; + delete next[bookmarkId]; + return next; + }); + setBookmarkMetadataMap((previous) => { + const next = removeBookmarkMetadata(previous, bookmarkId); + saveBookmarkMetadataMap(next); + return next; + }); onStatus("Bookmark deleted."); } catch (error) { onStatus(error instanceof Error ? error.message : "Failed to delete bookmark."); @@ -241,7 +321,7 @@ export function BookmarkManager({ setNewCategoryName(event.target.value)} - placeholder="New category name" + placeholder="Category name" />