Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,23 @@ function registerIpc() {
}
})

ipcMain.handle('summary:read', (_e, payload) => {
const nodeId = payload?.nodeId
if (!currentFolder || typeof nodeId !== 'string') return null
return projectState.readSummary(currentFolder, nodeId)
})

ipcMain.handle('summary:write', (_e, payload) => {
if (!currentFolder || !payload || typeof payload.nodeId !== 'string') return
projectState.writeSummary(currentFolder, payload)
})

ipcMain.handle('summary:delete', (_e, payload) => {
const nodeId = payload?.nodeId
if (!currentFolder || typeof nodeId !== 'string') return
projectState.deleteSummary(currentFolder, nodeId)
})

ipcMain.handle('graphy:file-tree', async () => {
if (!currentFolder) return null
const root = currentFolder
Expand Down
4 changes: 4 additions & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ contextBridge.exposeInMainWorld('graphyDesktop', {
reloadGraph: () => ipcRenderer.invoke('graphy:reload'),
clearRecents: () => ipcRenderer.invoke('graphy:clear-recents'),
cacheLayout: (layout) => ipcRenderer.invoke('graphy:cache-layout', layout),
readNodeSummary: (nodeId) => ipcRenderer.invoke('summary:read', { nodeId }),
writeNodeSummary: (payload) => ipcRenderer.invoke('summary:write', payload),
deleteNodeSummary: (nodeId) =>
ipcRenderer.invoke('summary:delete', { nodeId }),
onProject: (handler) => subscribe('project:set', handler),
onGraph: (handler) => subscribe('graph:set', handler),
readFunctionSource: (payload) => ipcRenderer.invoke('function:read', payload),
Expand Down
48 changes: 48 additions & 0 deletions electron/project-state.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const path = require('node:path')
const STATE_FILE = 'graphy-state.json'
const CACHE_DIRNAME = '.graphy'
const CACHE_FILENAME = 'cache.json'
const SUMMARIES_FILENAME = 'summaries.json'
const MAX_RECENTS = 10

function statePath(app) {
Expand Down Expand Up @@ -87,11 +88,58 @@ function writeCache(folder, graph, layout) {
fs.writeFileSync(cacheFile(folder), JSON.stringify({ graph, layout }))
}

function summariesFile(folder) {
return path.join(folder, CACHE_DIRNAME, SUMMARIES_FILENAME)
}

function readSummariesFile(folder) {
try {
const raw = fs.readFileSync(summariesFile(folder), 'utf8')
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return { summaries: {} }
const summaries =
parsed.summaries && typeof parsed.summaries === 'object'
? parsed.summaries
: {}
return { summaries }
} catch {
return { summaries: {} }
}
}

function writeSummariesFile(folder, file) {
const dir = path.join(folder, CACHE_DIRNAME)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(summariesFile(folder), JSON.stringify(file))
}

function readSummary(folder, nodeId) {
const { summaries } = readSummariesFile(folder)
return summaries[nodeId] ?? null
}

function writeSummary(folder, payload) {
const file = readSummariesFile(folder)
file.summaries[payload.nodeId] = payload
writeSummariesFile(folder, file)
}

function deleteSummary(folder, nodeId) {
const file = readSummariesFile(folder)
if (file.summaries[nodeId]) {
delete file.summaries[nodeId]
writeSummariesFile(folder, file)
}
}

module.exports = {
read,
recordFolder,
clearRecents,
pruneMissing,
readCache,
writeCache,
readSummary,
writeSummary,
deleteSummary,
}
3 changes: 3 additions & 0 deletions src/modules/ai-chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { AiChatProvider } from './context/ai-chat-provider'
export { AiChatPanel } from './components/ai-chat-panel'
export { AiChatToggle } from './components/ai-chat-toggle'
export { readAiConfig } from './lib/storage'
export { CHAT_MODEL_API_ID, CHAT_MODELS, DEFAULT_CHAT_MODEL } from './types'
export type { AiProvider, ChatModel } from './types'
28 changes: 5 additions & 23 deletions src/modules/graph/components/graph-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import { useProject } from '@/modules/graph/hooks/use-project'
import { toXYFlow } from '@/modules/graph/lib/to-xyflow'
import type { XYFlowGraph } from '@/modules/graph/lib/to-xyflow'
import type { GraphNodeData } from '@/modules/graph/types'
import { MOCK_SUMMARY_TARGET, NodeSummarySheet } from '@/modules/node-summary'
import type { NodeSummaryTarget } from '@/modules/node-summary'
import { NodeSummarySheet } from '@/modules/node-summary'
import { getDesktop } from '@/shared/lib/desktop'
import { clearGraphFocus, useGraphFocusRequest } from '@/shared/lib/graph-focus'
import { DiffBootstrap } from '@/modules/diff-viewer'
Expand All @@ -40,9 +39,7 @@ export function GraphCanvas() {
const [sheetTarget, setSheetTarget] = useState<FunctionSheetTarget | null>(
null,
)
const [summaryTarget, setSummaryTarget] = useState<NodeSummaryTarget | null>(
null,
)
const [summaryNodeId, setSummaryNodeId] = useState<string | null>(null)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(),
)
Expand All @@ -67,22 +64,7 @@ export function GraphCanvas() {
}
pendingClickTimer.current = window.setTimeout(() => {
pendingClickTimer.current = null
// TODO: replace mock body with a real summary fetched for this node.
setSummaryTarget({
...MOCK_SUMMARY_TARGET,
displayName: node.data.displayName,
type: node.data.type,
signature: node.data.signature,
file: node.data.file,
line: node.data.line,
facts: {
isAsync: node.data.isAsync,
isExported: node.data.isExported,
bodyLines: node.data.bodyLines,
inDegree: node.data.inDegree,
outDegree: node.data.outDegree,
},
})
setSummaryNodeId(node.id)
}, 220)
},
[],
Expand Down Expand Up @@ -260,9 +242,9 @@ export function GraphCanvas() {
onOpenChange={handleSheetOpenChange}
/>
<NodeSummarySheet
target={summaryTarget}
nodeId={summaryNodeId}
onOpenChange={(open) => {
if (!open) setSummaryTarget(null)
if (!open) setSummaryNodeId(null)
}}
/>
</>
Expand Down
68 changes: 41 additions & 27 deletions src/modules/node-summary/components/node-summary-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FileCode, Sparkles } from 'lucide-react'
import { FileCode, RefreshCw, Sparkles } from 'lucide-react'

import { Button } from '@/shared/ui/button'
import {
Expand All @@ -10,20 +10,23 @@ import {
SheetTitle,
} from '@/shared/ui/sheet'

import type { NodeSummaryTarget } from '../types'
import { useNodeSummary } from '../hooks/use-node-summary'
import { SummaryFacts } from './summary-facts'
import { SummaryOverview } from './summary-overview'
import { SummaryRelationsList } from './summary-relations-list'

type NodeSummarySheetProps = {
target: NodeSummaryTarget | null
nodeId: string | null
onOpenChange: (open: boolean) => void
}

export function NodeSummarySheet({
target,
nodeId,
onOpenChange,
}: NodeSummarySheetProps) {
const open = target !== null
const { header, callers, callees, overview, status, error, refresh } =
useNodeSummary(nodeId)
const open = nodeId !== null

return (
<Sheet open={open} onOpenChange={onOpenChange}>
Expand All @@ -32,27 +35,42 @@ export function NodeSummarySheet({
className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-[640px]"
>
<SheetHeader className="border-border/60 border-b">
<div className="text-muted-foreground flex items-center gap-1.5 text-[10px] font-semibold tracking-wider uppercase">
<Sparkles size={11} strokeWidth={1.8} />
<span>Node summary</span>
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-[10px] font-semibold tracking-wider uppercase">
<Sparkles size={11} strokeWidth={1.8} />
<span>Node summary</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={refresh}
disabled={
!header || status === 'streaming' || status === 'validating'
}
title="Regenerate summary"
className="-mr-1"
>
<RefreshCw size={13} strokeWidth={1.7} />
<span className="sr-only">Regenerate summary</span>
</Button>
</div>
<SheetTitle className="truncate font-mono text-[13px]">
{target?.displayName ?? 'Node'}
{header?.displayName ?? 'Node'}
</SheetTitle>
<SheetDescription className="truncate font-mono text-[11px]">
{target ? `${target.file}:${target.line}` : ''}
{header ? `${header.file}:${header.line}` : ''}
</SheetDescription>
</SheetHeader>

{target && (
{header && (
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-5">
<SummaryFacts facts={target.facts} />
<SummaryFacts facts={header.facts} />

{target.signature && (
{header.signature && (
<div className="bg-muted/30 border-border/60 rounded-md border px-3 py-2 font-mono text-[11.5px]">
<span className="text-foreground">{target.displayName}</span>
<span className="text-foreground">{header.displayName}</span>
<span className="text-muted-foreground">
{target.signature}
{header.signature}
</span>
</div>
)}
Expand All @@ -61,28 +79,24 @@ export function NodeSummarySheet({
<h3 className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
Overview
</h3>
<div className="flex flex-col gap-3">
{target.overview.map((paragraph, index) => (
<p
key={index}
className="text-foreground/90 text-sm leading-relaxed"
>
{paragraph}
</p>
))}
</div>
<SummaryOverview
paragraphs={overview}
status={status}
error={error}
onRetry={refresh}
/>
</section>

<SummaryRelationsList
title="Used by"
emptyLabel="No callers found."
items={target.callers}
items={callers}
/>

<SummaryRelationsList
title="Uses"
emptyLabel="No dependencies found."
items={target.callees}
items={callees}
/>
</div>
)}
Expand Down
95 changes: 95 additions & 0 deletions src/modules/node-summary/components/summary-overview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { openSettings } from '@/shared/lib/settings-open'
import { Button } from '@/shared/ui/button'
import { Skeleton } from '@/shared/ui/skeleton'

import type { SummaryStatus } from '../types'

type SummaryOverviewProps = {
paragraphs: string[]
status: SummaryStatus
error: string | null
onRetry: () => void
}

export function SummaryOverview({
paragraphs,
status,
error,
onRetry,
}: SummaryOverviewProps) {
if (status === 'no-key') {
return <NoKeyState />
}

if (status === 'error') {
return (
<div className="border-destructive/40 bg-destructive/5 text-destructive flex flex-col gap-2 rounded-md border px-3 py-3 text-xs">
<span>{error ?? 'Failed to generate summary.'}</span>
<div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
</div>
)
}

if (status === 'loading' || status === 'validating') {
return <LoadingState label={statusLabel(status)} />
}

const showCursor = status === 'streaming'
return (
<div className="flex flex-col gap-3">
{paragraphs.length === 0 && showCursor && (
<Skeleton className="h-3 w-2/3" />
)}
{paragraphs.map((paragraph, index) => (
<p
key={index}
className="text-foreground/90 text-sm leading-relaxed whitespace-pre-wrap"
>
{paragraph}
{showCursor && index === paragraphs.length - 1 && (
<span className="text-muted-foreground ml-0.5 inline-block animate-pulse">
▍
</span>
)}
</p>
))}
</div>
)
}

function LoadingState({ label }: { label: string }) {
return (
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-[11px] tracking-wide uppercase">
{label}
</div>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-11/12" />
<Skeleton className="h-3 w-3/4" />
</div>
)
}

function NoKeyState() {
return (
<div className="border-border/60 bg-muted/30 flex flex-col gap-2 rounded-md border px-3 py-3 text-xs">
<span className="text-muted-foreground">
Add a Claude API key in settings to generate node summaries.
</span>
<div>
<Button variant="outline" size="sm" onClick={openSettings}>
Open settings
</Button>
</div>
</div>
)
}

function statusLabel(status: SummaryStatus): string {
if (status === 'validating') return 'Checking cached summary…'
return 'Generating summary…'
}
Loading
Loading