feat: configure prettier with pre-commit hook (#563)

* feat: configure prettier with pre-commit hook integration

Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo
root with husky pre-commit hook integration. Moves husky from
gitnexus/ to root package.json for reliable hook installation.

- Root package.json with prepare/format/format:check scripts
- .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4
- .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md
- .gitattributes enforcing LF line endings for Windows consistency
- Pre-commit hook uses direct node_modules/.bin/ paths (no npx)

* style: apply prettier formatting to entire codebase

One-time bulk format. No logic changes.
Use .git-blame-ignore-revs to skip this commit in git blame.

* chore: add .git-blame-ignore-revs for prettier format commit

* perf: pre-commit hook runs only tests related to staged files

Use vitest --related to scope test execution to tests that import
the changed files, instead of running the full suite on every commit.

* perf: remove vitest from pre-commit hook, keep in CI only

Pre-commit now runs lint-staged + tsc only. Tests run in CI
(ci-tests.yml) where they belong — keeps commits fast.

* ci: add prettier format check to quality workflow

PRs will now fail if code isn't formatted with prettier.
This commit is contained in:
Gergő Magyar 2026-03-28 14:58:04 +00:00 committed by GitHub
parent fd7fb5bf1f
commit bf09eab95b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
353 changed files with 26992 additions and 17009 deletions

2
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,2 @@
# Prettier initial formatting (2026-03-28)
afcc3d1523f99c77ff67c4fd1af12334660113f6

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
.husky/* text eol=lf

2
.github/release.yml vendored
View file

@ -35,7 +35,7 @@ changelog:
- dependencies
- title: "\U0001F4DD Other Changes"
labels:
- "*"
- '*'
exclude:
labels:
- dependencies

View file

@ -4,6 +4,19 @@ on:
workflow_call:
jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npx prettier --check .
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10

View file

@ -6,12 +6,12 @@ name: CI Report
on:
workflow_run:
workflows: ["CI"]
workflows: ['CI']
types: [completed]
permissions:
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
pull-requests: write # needed to post sticky PR comment
jobs:

View file

@ -53,7 +53,7 @@ jobs:
pull-requests: write
issues: write
id-token: write
actions: read # required for Claude to read CI results on PRs
actions: read # required for Claude to read CI results on PRs
steps:
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
- name: Resolve PR context

View file

@ -1,33 +1,26 @@
#!/usr/bin/env bash
# Pre-commit hook (husky): typecheck + unit tests for both packages.
# Mirrors CI checks from ci-quality.yml and ci-tests.yml.
# Pre-commit hook: format staged files + typecheck.
# Tests run in CI (ci-tests.yml), not here.
# Skip with: git commit --no-verify
#
# CI coverage:
# quality / typecheck → tsc --noEmit in gitnexus/
# quality / typecheck-web → tsc -b --noEmit in gitnexus-web/
# tests / ubuntu+coverage → vitest run in gitnexus/ (all projects)
# e2e / chromium → playwright (requires servers — skipped)
ROOT="$(git rev-parse --show-toplevel)"
# 1. Format staged files with prettier via lint-staged
echo "pre-commit: formatting staged files..."
"$ROOT/node_modules/.bin/lint-staged" || exit 1
# 2. Typecheck changed packages
WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
if [ -n "$WEB_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
cd "$ROOT/gitnexus-web" && npx tsc -b --noEmit
echo "pre-commit: running gitnexus-web unit tests..."
npx vitest run --reporter=dot
cd "$ROOT/gitnexus-web" && ./node_modules/.bin/tsc -b --noEmit || exit 1
fi
if [ -n "$CLI_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus..."
cd "$ROOT/gitnexus" && npx tsc --noEmit
echo "pre-commit: running gitnexus unit tests (default project)..."
npx vitest run --project default --reporter=dot
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
fi
echo "pre-commit: all checks passed"

16
.prettierignore Normal file
View file

@ -0,0 +1,16 @@
dist/
coverage/
gitnexus/vendor/
gitnexus/test/fixtures/
gitnexus-web/playwright-report/
gitnexus-web/test-results/
*.d.ts
*.snap
*.wasm
*.md
.gitnexus/
.vercel/
.claude-flow/
.swarm/
assets/
repomix-output*

10
.prettierrc Normal file
View file

@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./gitnexus-web/src/index.css"
}

View file

@ -1,8 +1,8 @@
# Claude Haiku 4.5 — fast, cheap, good baseline
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/anthropic/claude-haiku-4.5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-haiku-4.5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0

View file

@ -2,8 +2,8 @@
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
# To use Anthropic directly, change to: anthropic/claude-opus-4-20250514
model:
model_name: "openrouter/anthropic/claude-opus-4"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-opus-4'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 16384
temperature: 0

View file

@ -2,8 +2,8 @@
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
# To use Anthropic directly, change to: anthropic/claude-sonnet-4-20250514
model:
model_name: "openrouter/anthropic/claude-sonnet-4"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-sonnet-4'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 16384
temperature: 0

View file

@ -1,9 +1,9 @@
model: deepseek-ai/deepseek-chat
provider: openrouter
cost:
input: 0.14 # per 1M tokens
output: 0.28 # per 1M tokens
input: 0.14 # per 1M tokens
output: 0.28 # per 1M tokens
# Native DeepSeek API (direct)
api_key: null
base_url: null

View file

@ -1,9 +1,9 @@
model: deepseek-ai/DeepSeek-V3
provider: openrouter
cost:
input: 0.27 # per 1M tokens
output: 1.10 # per 1M tokens
input: 0.27 # per 1M tokens
output: 1.10 # per 1M tokens
# Native DeepSeek API (direct)
# Get your API key at: https://platform.deepseek.com/
# Or use OpenRouter with: OPENROUTER_API_KEY

View file

@ -1,7 +1,7 @@
# GLM 4.7 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/zhipuai/glm-4.7"
cost_tracking: "ignore_errors"
model_name: 'openrouter/zhipuai/glm-4.7'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0

View file

@ -1,7 +1,7 @@
# GLM 5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/zhipuai/glm-5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/zhipuai/glm-5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0

View file

@ -1,7 +1,7 @@
# MiniMax M1 2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/minimax/minimax-m1-2.5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/minimax/minimax-m1-2.5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0

View file

@ -3,9 +3,9 @@
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
model:
model_class: litellm_textbased
model_name: "openrouter/minimax/minimax-m2.5"
model_name: 'openrouter/minimax/minimax-m2.5'
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
cost_tracking: "ignore_errors"
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0

View file

@ -1,9 +1,9 @@
# Baseline mode — no GitNexus, pure mini-swe-agent (control group)
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "baseline"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'baseline'
step_limit: 30
cost_limit: 3.0
environment:
environment_class: "docker"
environment_class: 'docker'

View file

@ -5,14 +5,14 @@
#
# Use this mode to isolate the value of explicit tools without grep augmentation.
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "native"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'native'
step_limit: 30
cost_limit: 3.0
track_gitnexus_usage: true
environment:
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
enable_gitnexus: true
skip_embeddings: true
gitnexus_timeout: 120

View file

@ -8,8 +8,8 @@
#
# The agent decides when to use explicit tools vs rely on enriched grep results.
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "native_augment"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'native_augment'
step_limit: 30
cost_limit: 3.0
augment_timeout: 5.0
@ -17,7 +17,7 @@ agent:
track_gitnexus_usage: true
environment:
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
enable_gitnexus: true
skip_embeddings: true
gitnexus_timeout: 120

View file

@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
const tokens = cmd.split(/\s+/);
let foundCmd = false;
let skipNext = false;
const flagsWithValues = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '--glob', '-t', '--type', '--include', '--exclude']);
const flagsWithValues = new Set([
'-e',
'-f',
'-m',
'-A',
'-B',
'-C',
'-g',
'--glob',
'-t',
'--type',
'--include',
'--exclude',
]);
for (const token of tokens) {
if (skipNext) { skipNext = false; continue; }
if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue;
@ -98,33 +114,42 @@ function runGitNexusCli(args, cwd, timeout) {
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false;
try {
const which = spawnSync(
isWin ? 'where' : 'which', ['gitnexus'],
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
);
const which = spawnSync(isWin ? 'where' : 'which', ['gitnexus'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
});
useDirectBinary = which.status === 0;
} catch { /* not on PATH */ }
} catch {
/* not on PATH */
}
if (useDirectBinary) {
return spawnSync(
isWin ? 'gitnexus.cmd' : 'gitnexus', args,
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
// npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync(
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args],
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
/**
* Emit a hook response with additional context for the agent.
*/
function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message }
}));
console.log(
JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message },
}),
);
}
/**
@ -149,7 +174,9 @@ function handlePreToolUse(input) {
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch { /* graceful failure */ }
} catch {
/* graceful failure */
}
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
@ -185,10 +212,15 @@ function handlePostToolUse(input) {
let currentHead = '';
try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
timeout: 3000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
currentHead = (headResult.stdout || '').trim();
} catch { return; }
} catch {
return;
}
if (!currentHead) return;
@ -197,16 +229,19 @@ function handlePostToolUse(input) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
} catch { /* no meta — treat as stale */ }
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse',
sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to update the knowledge graph.`
`Run \`${analyzeCmd}\` to update the knowledge graph.`,
);
}

View file

@ -14,17 +14,11 @@ export {
REL_TYPES,
EMBEDDING_TABLE_NAME,
} from './lbug/schema-constants.js';
export type {
NodeTableName,
RelType,
} from './lbug/schema-constants.js';
export type { NodeTableName, RelType } from './lbug/schema-constants.js';
// Language support
export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
// Pipeline progress
export type {
PipelinePhase,
PipelineProgress,
} from './pipeline.js';
export type { PipelinePhase, PipelineProgress } from './pipeline.js';

View file

@ -12,7 +12,13 @@
import { SupportedLanguages } from './languages.js';
/** Ruby extensionless filenames recognised as Ruby source */
const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']);
const RUBY_EXTENSIONLESS_FILES = new Set([
'Rakefile',
'Gemfile',
'Guardfile',
'Vagrantfile',
'Brewfile',
]);
/**
* Exhaustive map: every SupportedLanguages member its file extensions.
@ -21,26 +27,29 @@ const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'V
* TypeScript emits a compile error: "Property 'NewLang' is missing in type..."
*/
const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'],
[SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'],
[SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'],
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
[SupportedLanguages.CSharp]: ['.cs'],
[SupportedLanguages.Go]: ['.go'],
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
[SupportedLanguages.Rust]: ['.rs'],
[SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'],
[SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'],
[SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'],
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
[SupportedLanguages.CSharp]: ['.cs'],
[SupportedLanguages.Go]: ['.go'],
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
[SupportedLanguages.Rust]: ['.rs'],
[SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
const extToLang = new Map<string, SupportedLanguages>();
for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [SupportedLanguages, readonly string[]][]) {
for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [
SupportedLanguages,
readonly string[],
][]) {
for (const ext of exts) {
extToLang.set(ext, lang);
}
@ -75,37 +84,50 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
* TypeScript emits a compile error.
*/
const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.JavaScript]: 'javascript',
[SupportedLanguages.TypeScript]: 'typescript',
[SupportedLanguages.Python]: 'python',
[SupportedLanguages.Java]: 'java',
[SupportedLanguages.C]: 'c',
[SupportedLanguages.CPlusPlus]: 'cpp',
[SupportedLanguages.CSharp]: 'csharp',
[SupportedLanguages.Go]: 'go',
[SupportedLanguages.Ruby]: 'ruby',
[SupportedLanguages.Rust]: 'rust',
[SupportedLanguages.PHP]: 'php',
[SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.JavaScript]: 'javascript',
[SupportedLanguages.TypeScript]: 'typescript',
[SupportedLanguages.Python]: 'python',
[SupportedLanguages.Java]: 'java',
[SupportedLanguages.C]: 'c',
[SupportedLanguages.CPlusPlus]: 'cpp',
[SupportedLanguages.CSharp]: 'csharp',
[SupportedLanguages.Go]: 'go',
[SupportedLanguages.Ruby]: 'ruby',
[SupportedLanguages.Rust]: 'rust',
[SupportedLanguages.PHP]: 'php',
[SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */
const AUXILIARY_SYNTAX_MAP: Record<string, string> = {
json: 'json', yaml: 'yaml', yml: 'yaml',
md: 'markdown', mdx: 'markdown',
html: 'markup', htm: 'markup', erb: 'markup', xml: 'markup',
css: 'css', scss: 'css', sass: 'css',
sh: 'bash', bash: 'bash', zsh: 'bash',
sql: 'sql', toml: 'toml', ini: 'ini',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
md: 'markdown',
mdx: 'markdown',
html: 'markup',
htm: 'markup',
erb: 'markup',
xml: 'markup',
css: 'css',
scss: 'css',
sass: 'css',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
sql: 'sql',
toml: 'toml',
ini: 'ini',
dockerfile: 'docker',
};
/** Extensionless filenames → Prism-compatible syntax identifiers */
const AUXILIARY_BASENAME_MAP: Record<string, string> = {
Makefile: 'makefile', Dockerfile: 'docker',
Makefile: 'makefile',
Dockerfile: 'docker',
};
/**

View file

@ -9,25 +9,63 @@
*/
export const NODE_TABLES = [
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', 'Section',
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
'File',
'Folder',
'Function',
'Class',
'Interface',
'Method',
'CodeElement',
'Community',
'Process',
'Section',
'Struct',
'Enum',
'Macro',
'Typedef',
'Union',
'Namespace',
'Trait',
'Impl',
'TypeAlias',
'Const',
'Static',
'Property',
'Record',
'Delegate',
'Annotation',
'Constructor',
'Template',
'Module',
'Route',
'Tool',
] as const;
export type NodeTableName = typeof NODE_TABLES[number];
export type NodeTableName = (typeof NODE_TABLES)[number];
export const REL_TABLE_NAME = 'CodeRelation';
export const REL_TYPES = [
'CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS',
'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES',
'MEMBER_OF', 'STEP_IN_PROCESS',
'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF',
'WRAPS', 'QUERIES',
'CONTAINS',
'DEFINES',
'IMPORTS',
'CALLS',
'EXTENDS',
'IMPLEMENTS',
'HAS_METHOD',
'HAS_PROPERTY',
'ACCESSES',
'OVERRIDES',
'MEMBER_OF',
'STEP_IN_PROCESS',
'HANDLES_ROUTE',
'FETCHES',
'HANDLES_TOOL',
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
] as const;
export type RelType = typeof REL_TYPES[number];
export type RelType = (typeof REL_TYPES)[number];
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding';

View file

@ -9,7 +9,7 @@ const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const debugTest = process.env.DEBUG_E2E ? test : test.skip;
async function connectToServer(page: import('@playwright/test').Page) {
page.on('console', msg => {
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(`[error] ${msg.text()}`);
});
@ -86,7 +86,10 @@ debugTest('debug: process view Reset View button', async ({ page }, testInfo) =>
const transformAfterReset = await diagramDiv.getAttribute('style');
console.log('Transform AFTER reset:', transformAfterReset);
await page.screenshot({ path: testInfo.outputPath('debug-modal-after-reset.png'), fullPage: true });
await page.screenshot({
path: testInfo.outputPath('debug-modal-after-reset.png'),
fullPage: true,
});
// Verify transform actually changed back
expect(transformAfterZoom).not.toBe(transformBefore);
@ -123,5 +126,8 @@ debugTest('debug: lightbulb clears node selection dimming', async ({ page }, tes
// Click it again to toggle back on
await lightbulbBtn.click();
await page.waitForTimeout(500);
await page.screenshot({ path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'), fullPage: true });
await page.screenshot({
path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'),
fullPage: true,
});
});

View file

@ -12,16 +12,16 @@ import { test } from '@playwright/test';
*/
test.skip(
!!process.env.CI || process.env.PWDEBUG !== '1',
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0'
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0',
);
test('manual recording session', async ({ page }) => {
page.on('console', msg => {
page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') {
console.log(`[${msg.type()}] ${msg.text()}`);
}
});
page.on('pageerror', err => console.log(`[crash] ${err.message}`));
page.on('pageerror', (err) => console.log(`[crash] ${err.message}`));
await page.goto('http://localhost:5173');
await page.pause();

View file

@ -38,7 +38,9 @@ test.describe('Flow 1: Onboarding — no server', () => {
// Step 2 title changes to "Waiting for server to start" once polling begins
await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 });
// Step 3 is always rendered
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({ timeout: 5_000 });
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({
timeout: 5_000,
});
});
test('shows terminal window with command', async ({ page }) => {
@ -98,7 +100,9 @@ test.describe('Flow 2: Server detected — auto-connect', () => {
});
await page.route(`${BACKEND_URL}/api/repo`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({ json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' } });
await route.fulfill({
json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' },
});
});
await page.route(`${BACKEND_URL}/api/graph**`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
@ -135,7 +139,11 @@ test.describe('Flow 2: Server detected — auto-connect', () => {
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: ':ok\n\n' }),
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
await page.goto('/');
@ -158,7 +166,11 @@ test.describe('Flow 3: Analyze form', () => {
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: ':ok\n\n' }),
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
});
@ -222,9 +234,15 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
if (process.env.E2E) return;
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
if (!res.ok) { test.skip(true, SKIP_MSG); return; }
if (!res.ok) {
test.skip(true, SKIP_MSG);
return;
}
const repos = await res.json();
if (!repos.length) { test.skip(true, 'Server has no indexed repos'); return; }
if (!repos.length) {
test.skip(true, 'Server has no indexed repos');
return;
}
} catch {
test.skip(true, SKIP_MSG);
}
@ -238,7 +256,10 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
// Click the project badge (has a chevron)
const badge = page.locator('header button').filter({ has: page.locator('svg') }).first();
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Repo dropdown should be visible
@ -252,7 +273,10 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Open repo dropdown
const badge = page.locator('header button').filter({ has: page.locator('svg') }).first();
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Click "Analyze a new repository..."

View file

@ -21,11 +21,17 @@ test.beforeAll(async () => {
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (backendRes.status === 'rejected' || (backendRes.status === 'fulfilled' && !backendRes.value.ok)) {
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available on :4747');
return;
}
if (frontendRes.status === 'rejected' || (frontendRes.status === 'fulfilled' && !frontendRes.value.ok)) {
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available on :5173');
return;
}
@ -85,7 +91,9 @@ test.describe('Processes Panel', () => {
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
const processRow = page.locator('[data-testid="process-row"]').first();
@ -96,7 +104,10 @@ test.describe('Processes Panel', () => {
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click();
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('process-view-clicked.png'), fullPage: true });
await page.screenshot({
path: testInfo.outputPath('process-view-clicked.png'),
fullPage: true,
});
});
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
@ -104,7 +115,9 @@ test.describe('Processes Panel', () => {
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
@ -129,10 +142,14 @@ test.describe('Turn Off All Highlights', () => {
await fileItem.click();
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]');
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', { timeout: 5_000 });
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', {
timeout: 5_000,
});
await highlightToggle.click();
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000 });
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
timeout: 5_000,
});
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
});
});

View file

@ -4,9 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitNexus</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View file

@ -40,9 +40,6 @@ export default defineConfig({
use: { browserName: 'chromium' },
},
],
reporter: [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
],
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
outputDir: 'test-results',
});

View file

@ -11,7 +11,15 @@ import { FileTreePanel } from './components/FileTreePanel';
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { createKnowledgeGraph } from './core/graph/graph';
import { connectToServer, fetchRepos, normalizeServerUrl, connectHeartbeat, BackendError, type ConnectResult, type BackendRepo } from './services/backend-client';
import {
connectToServer,
fetchRepos,
normalizeServerUrl,
connectHeartbeat,
BackendError,
type ConnectResult,
type BackendRepo,
} from './services/backend-client';
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
const AppContent = () => {
@ -41,36 +49,39 @@ const AppContent = () => {
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const handleServerConnect = useCallback(async (result: ConnectResult): Promise<void> => {
// Extract project name from repoPath
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
const parts = (repoPath || '').split('/').filter(p => p && !p.startsWith('.'));
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
setProjectName(projectName);
const handleServerConnect = useCallback(
async (result: ConnectResult): Promise<void> => {
// Extract project name from repoPath
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
const parts = (repoPath || '').split('/').filter((p) => p && !p.startsWith('.'));
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
setProjectName(projectName);
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
// Transition directly to exploring view
setViewMode('exploring');
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
await initializeAgent(projectName);
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
startEmbeddingsWithFallback();
} catch (err) {
console.warn('Failed to initialize agent:', err);
}
}, [setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback]);
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
// Transition directly to exploring view
setViewMode('exploring');
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
await initializeAgent(projectName);
}
startEmbeddingsWithFallback();
} catch (err) {
console.warn('Failed to initialize agent:', err);
}
},
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
@ -84,7 +95,12 @@ const AppContent = () => {
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
setProgress({
phase: 'extracting',
percent: 0,
message: 'Connecting to server...',
detail: 'Validating server',
});
setViewMode('loading');
const serverUrl = params.get('server') || window.location.origin;
@ -93,34 +109,51 @@ const AppContent = () => {
connectToServer(serverUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
}).then(async (result) => {
await handleServerConnect(result);
setProgress(null);
setServerBaseUrl(baseUrl);
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
}).catch((err) => {
console.error('Auto-connect failed:', err);
setProgress({
phase: 'error',
percent: 0,
message: 'Failed to connect to server',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
})
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);
}, ERROR_RESET_DELAY_MS);
});
setServerBaseUrl(baseUrl);
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
})
.catch((err) => {
console.error('Auto-connect failed:', err);
setProgress({
phase: 'error',
percent: 0,
message: 'Failed to connect to server',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
});
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
const handleFocusNode = useCallback((nodeId: string) => {
@ -176,7 +209,7 @@ const AppContent = () => {
// Exploring view
return (
<div className="flex flex-col h-screen bg-void overflow-hidden">
<div className="flex h-screen flex-col overflow-hidden bg-void">
<Header
onFocusNode={handleFocusNode}
availableRepos={availableRepos}
@ -200,28 +233,30 @@ const AppContent = () => {
} catch (err: unknown) {
if (attempt === 0 && err instanceof BackendError && err.status === 404) {
// Server may still be reinitializing — wait and retry
await new Promise(r => setTimeout(r, 1500));
await new Promise((r) => setTimeout(r, 1500));
continue;
}
console.error('Failed to connect after analyze:', err);
fetchRepos().then(repos => setAvailableRepos(repos)).catch(() => {});
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch(() => {});
return;
}
}
}}
/>
<main className="flex-1 flex min-h-0">
<main className="flex min-h-0 flex-1">
{/* Left Panel - File Tree */}
<FileTreePanel onFocusNode={handleFocusNode} />
{/* Graph area - takes remaining space */}
<div className="flex-1 relative min-w-0">
<div className="relative min-w-0 flex-1">
<GraphCanvas ref={graphCanvasRef} />
{/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */}
{isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && (
<div className="absolute inset-y-0 left-0 z-30 pointer-events-auto">
<div className="pointer-events-auto absolute inset-y-0 left-0 z-30">
<CodeReferencesPanel onFocusNode={handleFocusNode} />
</div>
)}
@ -239,7 +274,6 @@ const AppContent = () => {
onClose={() => setSettingsPanelOpen(false)}
onSettingsSaved={handleSettingsSaved}
/>
</div>
);
};

View file

@ -25,49 +25,44 @@ interface AnalyzeOnboardingProps {
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
return (
<div className="p-7 bg-surface border border-border-default rounded-3xl animate-fade-in relative overflow-hidden">
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
<div className="absolute -top-28 -right-28 w-72 h-72 bg-accent/6 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-56 h-56 bg-node-function/6 rounded-full blur-3xl pointer-events-none" />
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */}
<div className="relative mb-6">
<div className="text-center">
{/* Eyebrow */}
<div className="inline-flex items-center gap-1.5 mb-2">
<Sparkles className="w-3.5 h-3.5 text-accent/70" />
<span className="text-[11px] text-accent/80 font-medium uppercase tracking-widest">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
{/* Icon */}
<div className="mx-auto w-14 h-14 mb-4 flex items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent-dim/10 border border-accent/30 shadow-glow-soft">
<Github className="w-7 h-7 text-accent" />
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Github className="h-7 w-7 text-accent" />
</div>
<h2 className="text-lg font-semibold text-text-primary leading-snug">
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Analyze your first repository
</h2>
<p className="text-sm text-text-secondary mt-1.5 leading-relaxed max-w-xs mx-auto">
Paste a GitHub URL and GitNexus will clone it, parse the code, and
build a live knowledge graph right in your browser.
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live
knowledge graph right in your browser.
</p>
</div>
</div>
{/* Analyzer form */}
<div className="relative">
<RepoAnalyzer
variant="onboarding"
onComplete={onComplete}
/>
<RepoAnalyzer variant="onboarding" onComplete={onComplete} />
</div>
{/* Footer hint */}
<p className="mt-5 text-[11px] text-text-muted text-center leading-relaxed">
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
Public repos only &middot; Cloned locally by the server &middot; No data leaves your machine
</p>
</div>

View file

@ -49,33 +49,26 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
<div className="space-y-4">
{/* Phase label + elapsed */}
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary font-medium">{label}</span>
<span className="text-text-muted font-mono text-xs">{formatElapsed(elapsed)}</span>
<span className="font-medium text-text-secondary">{label}</span>
<span className="font-mono text-xs text-text-muted">{formatElapsed(elapsed)}</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-elevated rounded-full overflow-hidden">
<div className="h-2 overflow-hidden rounded-full bg-elevated">
<div
className="h-full bg-accent rounded-full transition-all duration-300 ease-out"
className="h-full rounded-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
{/* Percent + cancel */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted font-mono">{pct}%</span>
<span className="font-mono text-xs text-text-muted">{pct}%</span>
<button
onClick={onCancel}
className="
flex items-center gap-1.5
px-3 py-1.5
text-xs text-red-400
bg-red-500/10 hover:bg-red-500/20
rounded-lg
transition-all duration-200
"
className="flex items-center gap-1.5 rounded-lg bg-red-500/10 px-3 py-1.5 text-xs text-red-400 transition-all duration-200 hover:bg-red-500/20"
>
<X className="w-3.5 h-3.5" />
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>

View file

@ -1,5 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick, Loader2 } from '@/lib/lucide-icons';
import {
Code,
PanelLeftClose,
PanelLeft,
Trash2,
X,
Target,
FileCode,
Sparkles,
MousePointerClick,
Loader2,
} from '@/lib/lucide-icons';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
@ -47,7 +58,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
return new Map(graph.nodes.map((n) => [n.id, n]));
}, [graph]);
const [isCollapsed, setIsCollapsed] = useState(false);
@ -85,34 +96,40 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
}
}, [panelWidth]);
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startWidth: panelWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startWidth: panelWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const state = resizeRef.current;
if (!state) return;
const delta = ev.clientX - state.startX;
const next = Math.max(420, Math.min(state.startWidth + delta, 900));
setPanelWidth(next);
};
const onMove = (ev: MouseEvent) => {
const state = resizeRef.current;
if (!state) return;
const delta = ev.clientX - state.startX;
const next = Math.max(420, Math.min(state.startWidth + delta, 900));
setPanelWidth(next);
};
const onUp = () => {
resizeRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
const onUp = () => {
resizeRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [panelWidth]);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[panelWidth],
);
const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]);
const aiReferences = useMemo(
() => codeReferences.filter((r) => r.source === 'ai'),
[codeReferences],
);
// When the user clicks a citation badge in chat, focus the corresponding snippet card:
// - expand the panel if collapsed
@ -126,12 +143,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const { filePath, startLine, endLine } = codeReferenceFocus;
const target =
aiReferences.find(r =>
r.filePath === filePath &&
r.startLine === startLine &&
r.endLine === endLine
) ??
aiReferences.find(r => r.filePath === filePath);
aiReferences.find(
(r) => r.filePath === filePath && r.startLine === startLine && r.endLine === endLine,
) ?? aiReferences.find((r) => r.filePath === filePath);
if (!target) return;
@ -158,13 +172,21 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
rafIds.push(outerRafId);
return () => {
rafIds.forEach(id => cancelAnimationFrame(id));
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [codeReferenceFocus?.ts, aiReferences]);
const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => {
return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0 };
return {
ref,
content: null as string | null,
start: 0,
end: 0,
highlightStart: 0,
highlightEnd: 0,
totalLines: 0,
};
});
}, [aiReferences]);
@ -207,20 +229,29 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
endLine: (endLine ?? startLine) + CONTEXT_LINES,
};
readFile(selectedFilePath, options).then(result => {
if (!cancelled) {
setFileResult(result);
setIsLoadingFile(false);
}
}).catch(() => {
if (!cancelled) {
setFileResult(null);
setIsLoadingFile(false);
}
});
readFile(selectedFilePath, options)
.then((result) => {
if (!cancelled) {
setFileResult(result);
setIsLoadingFile(false);
}
})
.catch(() => {
if (!cancelled) {
setFileResult(null);
setIsLoadingFile(false);
}
});
return () => { cancelled = true; };
}, [selectedFilePath, selectedNode?.properties?.startLine, selectedNode?.properties?.endLine, selectedIsFile]);
return () => {
cancelled = true;
};
}, [
selectedFilePath,
selectedNode?.properties?.startLine,
selectedNode?.properties?.endLine,
selectedIsFile,
]);
// Scroll to the selected node's startLine after content loads
useEffect(() => {
@ -234,8 +265,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
if (cancelled) return;
const container = selectedViewerRef.current;
if (!container) return;
const lineEl = container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement
?? container.querySelectorAll('.linenumber')[startLine] as HTMLElement;
const lineEl =
(container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
if (lineEl) {
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
@ -247,27 +279,30 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
rafIds.push(innerRaf);
});
const rafIds = [outerRaf];
return () => { cancelled = true; rafIds.forEach(id => cancelAnimationFrame(id)); };
return () => {
cancelled = true;
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [selectedFileContent, selectedNode?.properties?.startLine]);
if (isCollapsed) {
return (
<aside className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2 flex-shrink-0">
<aside className="flex h-full w-12 flex-shrink-0 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
<button
onClick={() => setIsCollapsed(false)}
className="p-2 text-text-secondary hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-colors"
className="rounded p-2 text-text-secondary transition-colors hover:bg-cyan-500/10 hover:text-cyan-400"
title="Expand Code Panel"
>
<PanelLeft className="w-5 h-5" />
<PanelLeft className="h-5 w-5" />
</button>
<div className="w-6 h-px bg-border-subtle my-1" />
<div className="my-1 h-px w-6 bg-border-subtle" />
{showSelectedViewer && (
<div className="text-[9px] text-amber-400 rotate-90 whitespace-nowrap font-medium tracking-wide">
<div className="rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-amber-400">
SELECTED
</div>
)}
{showCitations && (
<div className="text-[9px] text-cyan-400 rotate-90 whitespace-nowrap font-medium tracking-wide mt-4">
<div className="mt-4 rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-cyan-400">
AI {aiReferences.length}
</div>
)}
@ -277,67 +312,72 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
return (
<aside
ref={(el) => { panelRef.current = el; }}
className="h-full bg-surface/95 backdrop-blur-md border-r border-border-subtle flex flex-col animate-slide-in relative shadow-2xl"
ref={(el) => {
panelRef.current = el;
}}
className="relative flex h-full animate-slide-in flex-col border-r border-border-subtle bg-surface/95 shadow-2xl backdrop-blur-md"
style={{ width: panelWidth }}
>
{/* Resize handle */}
<div
onMouseDown={startResize}
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent hover:bg-cyan-500/25 transition-colors"
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent transition-colors hover:bg-cyan-500/25"
title="Drag to resize"
/>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60">
<div className="flex items-center justify-between border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<Code className="w-4 h-4 text-cyan-400" />
<Code className="h-4 w-4 text-cyan-400" />
<span className="text-sm font-semibold text-text-primary">Code Inspector</span>
</div>
<div className="flex items-center gap-1.5">
{showCitations && (
<button
onClick={() => clearCodeReferences()}
className="p-1.5 text-text-muted hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400"
title="Clear AI citations"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="h-4 w-4" />
</button>
)}
<button
onClick={() => setIsCollapsed(true)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Collapse Panel"
>
<PanelLeftClose className="w-4 h-4" />
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{/* Top: Selected file viewer (when a node is selected) */}
{showSelectedViewer && (
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} min-h-0 flex flex-col`}>
<div className="px-3 py-2 bg-gradient-to-r from-amber-500/8 to-orange-500/5 border-b border-amber-500/20 flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-amber-500/15 rounded-md border border-amber-500/25">
<MousePointerClick className="w-3 h-3 text-amber-400" />
<span className="text-[10px] text-amber-300 font-semibold uppercase tracking-wide">Selected</span>
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} flex min-h-0 flex-col`}>
<div className="flex items-center gap-2 border-b border-amber-500/20 bg-gradient-to-r from-amber-500/8 to-orange-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/15 px-2 py-0.5">
<MousePointerClick className="h-3 w-3 text-amber-400" />
<span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
Selected
</span>
</div>
<FileCode className="w-3.5 h-3.5 text-amber-400/70 ml-1" />
<span className="text-xs text-text-primary font-mono truncate flex-1">
{selectedNode?.properties?.filePath?.split('/').pop() ?? selectedNode?.properties?.name}
<FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
<span className="flex-1 truncate font-mono text-xs text-text-primary">
{selectedNode?.properties?.filePath?.split('/').pop() ??
selectedNode?.properties?.name}
</span>
<button
onClick={() => setSelectedNode(null)}
className="p-1 text-text-muted hover:text-amber-400 hover:bg-amber-500/10 rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-amber-500/10 hover:text-amber-400"
title="Clear selection"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
<div ref={selectedViewerRef} className="flex-1 min-h-0 overflow-auto scrollbar-thin">
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
{isLoadingFile ? (
<div className="flex items-center justify-center py-8 gap-2 text-text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading source...</span>
</div>
) : selectedFileContent ? (
@ -377,7 +417,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
) : (
<div className="px-3 py-3 text-sm text-text-muted">
{selectedIsFile ? (
<>Code not available in memory for <span className="font-mono">{selectedFilePath}</span></>
<>
Code not available in memory for{' '}
<span className="font-mono">{selectedFilePath}</span>
</>
) : (
<>Select a file node to preview its contents.</>
)}
@ -394,128 +437,147 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{/* Bottom: AI citations list */}
{showCitations && (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{/* AI Citations Section Header */}
<div className="px-3 py-2 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 border-b border-cyan-500/20 flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-cyan-500/15 rounded-md border border-cyan-500/25">
<Sparkles className="w-3 h-3 text-cyan-400" />
<span className="text-[10px] text-cyan-300 font-semibold uppercase tracking-wide">AI Citations</span>
</div>
<span className="text-xs text-text-muted ml-1">{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin p-3 space-y-3">
{refsWithSnippets.map(({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label ? (NODE_COLORS as any)[ref.label] || '#6b7280' : '#6b7280';
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
return (
<div
key={ref.id}
ref={(el) => { refCardEls.current.set(ref.id, el); }}
className={[
'bg-elevated border border-border-subtle rounded-xl overflow-hidden transition-all',
isGlowing ? 'ring-2 ring-cyan-300/70 shadow-[0_0_0_6px_rgba(34,211,238,0.14)] animate-pulse' : '',
].join(' ')}
>
<div className="px-3 py-2 border-b border-border-subtle bg-surface/40 flex items-start gap-2">
<span
className="mt-0.5 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide flex-shrink-0"
style={{ backgroundColor: nodeColor, color: '#06060a' }}
title={ref.label ?? 'Code'}
>
{ref.label ?? 'Code'}
<div className="flex items-center gap-2 border-b border-cyan-500/20 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-cyan-500/25 bg-cyan-500/15 px-2 py-0.5">
<Sparkles className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
AI Citations
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-text-primary font-medium truncate">
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
</div>
<div className="text-[11px] text-text-muted font-mono truncate">
{ref.filePath}
{startDisplay !== undefined && (
<span className="text-text-secondary">
{' '}
L{startDisplay}
{endDisplay !== startDisplay ? `${endDisplay}` : ''}
</span>
)}
{totalLines > 0 && <span className="text-text-muted"> {totalLines} lines</span>}
</div>
</div>
<div className="flex items-center gap-1">
{ref.nodeId && (
<button
onClick={() => {
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);
}}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Focus in graph"
>
<Target className="w-4 h-4" />
</button>
)}
<button
onClick={() => removeCodeReference(ref.id)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Remove"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
{content ? (
<SyntaxHighlighter
language={language}
style={customTheme as any}
showLineNumbers
startingLineNumber={start + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const isHighlighted =
hasRange &&
lineNumber >= start + highlightStart + 1 &&
lineNumber <= start + highlightEnd + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted ? 'rgba(6, 182, 212, 0.14)' : 'transparent',
borderLeft: isHighlighted ? '3px solid #06b6d4' : '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{content}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
Code not available in memory for <span className="font-mono">{ref.filePath}</span>
</div>
)}
</div>
<span className="ml-1 text-xs text-text-muted">
{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}
</span>
</div>
);
})}
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{refsWithSnippets.map(
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label
? (NODE_COLORS as any)[ref.label] || '#6b7280'
: '#6b7280';
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
return (
<div
key={ref.id}
ref={(el) => {
refCardEls.current.set(ref.id, el);
}}
className={[
'overflow-hidden rounded-xl border border-border-subtle bg-elevated transition-all',
isGlowing
? 'animate-pulse shadow-[0_0_0_6px_rgba(34,211,238,0.14)] ring-2 ring-cyan-300/70'
: '',
].join(' ')}
>
<div className="flex items-start gap-2 border-b border-border-subtle bg-surface/40 px-3 py-2">
<span
className="mt-0.5 flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase"
style={{ backgroundColor: nodeColor, color: '#06060a' }}
title={ref.label ?? 'Code'}
>
{ref.label ?? 'Code'}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-text-primary">
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
</div>
<div className="truncate font-mono text-[11px] text-text-muted">
{ref.filePath}
{startDisplay !== undefined && (
<span className="text-text-secondary">
{' '}
L{startDisplay}
{endDisplay !== startDisplay ? `${endDisplay}` : ''}
</span>
)}
{totalLines > 0 && (
<span className="text-text-muted"> {totalLines} lines</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{ref.nodeId && (
<button
onClick={() => {
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);
}}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Focus in graph"
>
<Target className="h-4 w-4" />
</button>
)}
<button
onClick={() => removeCodeReference(ref.id)}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Remove"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
{content ? (
<SyntaxHighlighter
language={language}
style={customTheme as any}
showLineNumbers
startingLineNumber={start + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const isHighlighted =
hasRange &&
lineNumber >= start + highlightStart + 1 &&
lineNumber <= start + highlightEnd + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted
? 'rgba(6, 182, 212, 0.14)'
: 'transparent',
borderLeft: isHighlighted
? '3px solid #06b6d4'
: '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{content}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
Code not available in memory for{' '}
<span className="font-mono">{ref.filePath}</span>
</div>
)}
</div>
</div>
);
},
)}
</div>
</div>
)}

View file

@ -12,10 +12,7 @@ interface DropZoneProps {
// ── Crossfade wrapper ───────────────────────────────────────────────────────
// Captures the outgoing children during fade-out, then swaps to the new children on fade-in.
function Crossfade({ activeKey, children }: {
activeKey: string;
children: React.ReactNode;
}) {
function Crossfade({ activeKey, children }: { activeKey: string; children: React.ReactNode }) {
const [displayedKey, setDisplayedKey] = useState(activeKey);
const [isTransitioning, setIsTransitioning] = useState(false);
const snapshotRef = useRef<React.ReactNode>(children);
@ -36,7 +33,9 @@ function Crossfade({ activeKey, children }: {
setIsTransitioning(false);
}, 300);
}
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [activeKey, displayedKey]);
return (
@ -56,30 +55,34 @@ function Crossfade({ activeKey, children }: {
function SuccessCard() {
return (
<div className="p-7 bg-surface border border-emerald-500/20 rounded-3xl relative overflow-hidden" role="status" aria-live="polite">
<div
className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-surface p-7"
role="status"
aria-live="polite"
>
{/* Success glow */}
<div className="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-emerald-500/8 rounded-full blur-3xl pointer-events-none" />
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-emerald-500/8 blur-3xl" />
<div className="relative">
{/* Animated check icon */}
<div className="mx-auto w-16 h-16 mb-5 flex items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/30 shadow-[0_0_30px_rgba(16,185,129,0.15)]">
<Check className="w-8 h-8 text-emerald-400" />
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 shadow-[0_0_30px_rgba(16,185,129,0.15)]">
<Check className="h-8 w-8 text-emerald-400" />
</div>
<h2 className="text-lg font-semibold text-emerald-400 text-center mb-2">
<h2 className="mb-2 text-center text-lg font-semibold text-emerald-400">
Server Connected
</h2>
<p className="text-sm text-text-secondary text-center leading-relaxed">
<p className="text-center text-sm leading-relaxed text-text-secondary">
Preparing your code knowledge graph...
</p>
{/* Subtle progress hint */}
<div className="mt-6 flex items-center justify-center gap-2">
<div className="flex gap-1">
{[0, 1, 2].map(i => (
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-emerald-400/60 animate-pulse"
className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400/60"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
@ -92,26 +95,30 @@ function SuccessCard() {
function LoadingCard({ message }: { message: string }) {
return (
<div className="p-7 bg-surface border border-accent/20 rounded-3xl relative overflow-hidden" role="status" aria-live="polite">
<div
className="relative overflow-hidden rounded-3xl border border-accent/20 bg-surface p-7"
role="status"
aria-live="polite"
>
{/* Loading glow */}
<div className="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-accent/8 rounded-full blur-3xl pointer-events-none" />
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-accent/8 blur-3xl" />
<div className="relative">
{/* Spinner */}
<div className="mx-auto w-16 h-16 mb-5 flex items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent-dim/10 border border-accent/30 shadow-glow-soft">
<Loader2 className="w-8 h-8 text-accent animate-spin" />
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
</div>
<h2 className="text-lg font-semibold text-text-primary text-center mb-2">
<h2 className="mb-2 text-center text-lg font-semibold text-text-primary">
{message || 'Connecting...'}
</h2>
<p className="text-sm text-text-secondary text-center leading-relaxed">
<p className="text-center text-sm leading-relaxed text-text-secondary">
This may take a moment for large repositories
</p>
{/* Decorative sparkle */}
<div className="mt-5 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-accent/30" />
<Sparkles className="h-4 w-4 text-accent/30" />
</div>
</div>
</div>
@ -124,14 +131,23 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const [error, setError] = useState<string | null>(null);
// Backend polling for server detection
const { isConnected, isProbing, startPolling, stopPolling, isPolling, backendUrl: detectedBackendUrl } = useBackend();
const {
isConnected,
isProbing,
startPolling,
stopPolling,
isPolling,
backendUrl: detectedBackendUrl,
} = useBackend();
const [initialProbeComplete, setInitialProbeComplete] = useState(false);
const autoConnectRan = useRef(false);
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Connection state
// 'analyze' = server up but zero repos indexed — show URL input
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>('onboarding');
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>(
'onboarding',
);
const [loadingMessage, setLoadingMessage] = useState('');
const abortControllerRef = useRef<AbortController | null>(null);
@ -279,17 +295,17 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const displayPhase = !initialProbeComplete ? null : phase;
return (
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
<div className="flex min-h-screen items-center justify-center bg-void p-8">
{/* Background gradient effects */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl" />
<div className="pointer-events-none fixed inset-0">
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-accent/10 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-96 w-96 rounded-full bg-node-interface/10 blur-3xl" />
</div>
<div className="relative w-full max-w-lg">
{/* Error — floats above the card */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm text-center animate-fade-in">
<div className="mb-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-center text-sm text-red-400">
{error}
</div>
)}
@ -297,12 +313,8 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
{/* Crossfade between phases */}
{displayPhase && (
<Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && (
<OnboardingGuide isPolling={isPolling} />
)}
{displayPhase === 'analyze' && (
<AnalyzeOnboarding onComplete={handleAnalyzeComplete} />
)}
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={handleAnalyzeComplete} />}
{displayPhase === 'success' && <SuccessCard />}
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
</Crossfade>

View file

@ -8,14 +8,8 @@ import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
* Shows in header when graph is loaded
*/
export const EmbeddingStatus = () => {
const {
embeddingStatus,
embeddingProgress,
startEmbeddings,
graph,
viewMode,
serverBaseUrl,
} = useAppState();
const { embeddingStatus, embeddingProgress, startEmbeddings, graph, viewMode, serverBaseUrl } =
useAppState();
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
@ -29,8 +23,10 @@ export const EmbeddingStatus = () => {
await startEmbeddings();
} catch (error: any) {
// Check if it's a WebGPU not available error
if (error?.name === 'WebGPUNotAvailableError' ||
error?.message?.includes('WebGPU not available')) {
if (
error?.name === 'WebGPUNotAvailableError' ||
error?.message?.includes('WebGPU not available')
) {
setShowFallbackDialog(true);
} else {
console.error('Embedding failed:', error);
@ -47,7 +43,7 @@ export const EmbeddingStatus = () => {
setShowFallbackDialog(false);
// Just close - user can try again later if they want
};
// WebGPU fallback dialog - rendered independently of state
const fallbackDialog = (
<WebGPUFallbackDialog
@ -66,12 +62,12 @@ export const EmbeddingStatus = () => {
<div className="flex items-center gap-2">
<button
onClick={() => handleStartEmbeddings()}
className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary hover:bg-hover hover:text-text-primary hover:border-accent/50 transition-all group"
className="group flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-sm text-text-secondary transition-all hover:border-accent/50 hover:bg-hover hover:text-text-primary"
title="Generate embeddings for semantic search"
>
<Brain className="w-4 h-4 text-node-interface group-hover:text-accent transition-colors" />
<Brain className="h-4 w-4 text-node-interface transition-colors group-hover:text-accent" />
<span className="hidden sm:inline">Enable Semantic Search</span>
<Zap className="w-3 h-3 text-text-muted" />
<Zap className="h-3 w-3 text-text-muted" />
</button>
</div>
{fallbackDialog}
@ -84,13 +80,13 @@ export const EmbeddingStatus = () => {
const downloadPercent = embeddingProgress?.percent ?? 0;
return (
<>
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-accent/30 rounded-lg text-sm">
<Loader2 className="w-4 h-4 text-accent animate-spin" />
<div className="flex items-center gap-2.5 rounded-lg border border-accent/30 bg-surface px-3 py-1.5 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-accent" />
<div className="flex flex-col gap-0.5">
<span className="text-text-secondary text-xs">Loading AI model...</span>
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
<span className="text-xs text-text-secondary">Loading AI model...</span>
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
style={{ width: `${downloadPercent}%` }}
/>
</div>
@ -106,17 +102,17 @@ export const EmbeddingStatus = () => {
const processed = 0;
const total = 0;
const percent = embeddingProgress?.percent ?? 0;
return (
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-node-function/30 rounded-lg text-sm">
<Loader2 className="w-4 h-4 text-node-function animate-spin" />
<div className="flex items-center gap-2.5 rounded-lg border border-node-function/30 bg-surface px-3 py-1.5 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-node-function" />
<div className="flex flex-col gap-0.5">
<span className="text-text-secondary text-xs">
<span className="text-xs text-text-secondary">
Embedding {processed}/{total} nodes
</span>
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-node-function to-accent rounded-full transition-all duration-300"
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-node-function to-accent transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
@ -128,8 +124,8 @@ export const EmbeddingStatus = () => {
// Indexing
if (embeddingStatus === 'indexing') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-node-interface/30 rounded-lg text-sm text-text-secondary">
<Loader2 className="w-4 h-4 text-node-interface animate-spin" />
<div className="flex items-center gap-2 rounded-lg border border-node-interface/30 bg-surface px-3 py-1.5 text-sm text-text-secondary">
<Loader2 className="h-4 w-4 animate-spin text-node-interface" />
<span className="text-xs">Creating vector index...</span>
</div>
);
@ -138,11 +134,11 @@ export const EmbeddingStatus = () => {
// Ready
if (embeddingStatus === 'ready') {
return (
<div
className="flex items-center gap-2 px-3 py-1.5 bg-node-function/10 border border-node-function/30 rounded-lg text-sm text-node-function"
<div
className="flex items-center gap-2 rounded-lg border border-node-function/30 bg-node-function/10 px-3 py-1.5 text-sm text-node-function"
title="Semantic search is ready! Use natural language in the AI chat."
>
<Check className="w-4 h-4" />
<Check className="h-4 w-4" />
<span className="text-xs font-medium">Semantic Ready</span>
</div>
);
@ -154,10 +150,10 @@ export const EmbeddingStatus = () => {
<>
<button
onClick={() => handleStartEmbeddings()}
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/20"
title="Embedding failed. Click to retry."
>
<AlertCircle className="w-4 h-4" />
<AlertCircle className="h-4 w-4" />
<span className="text-xs">Failed - Retry</span>
</button>
{fallbackDialog}
@ -167,4 +163,3 @@ export const EmbeddingStatus = () => {
return null;
};

View file

@ -19,7 +19,13 @@ import {
Type,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
import {
FILTERABLE_LABELS,
NODE_COLORS,
ALL_EDGE_TYPES,
EDGE_INFO,
type EdgeType,
} from '../lib/constants';
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
// Tree node structure
@ -38,12 +44,12 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
const pathMap = new Map<string, TreeNode>();
// Filter to only folders and files
const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File');
const fileNodes = nodes.filter((n) => n.label === 'Folder' || n.label === 'File');
// Sort by path to ensure parents come before children
fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath));
fileNodes.forEach(node => {
fileNodes.forEach((node) => {
const parts = node.properties.filePath.split('/').filter(Boolean);
let currentPath = '';
let currentLevel = root;
@ -107,9 +113,9 @@ const TreeItem = ({
const searchLower = searchQuery.toLowerCase();
const matchesSearch = (node: TreeNode, query: string): boolean => {
if (node.name.toLowerCase().includes(query)) return true;
return node.children?.some(child => matchesSearch(child, query)) ?? false;
return node.children?.some((child) => matchesSearch(child, query)) ?? false;
};
return node.children.filter(child => matchesSearch(child, searchLower));
return node.children.filter((child) => matchesSearch(child, searchLower));
}, [node.children, searchQuery]);
// Check if this node matches search
@ -126,20 +132,15 @@ const TreeItem = ({
<div>
<button
onClick={handleClick}
className={`
w-full flex items-center gap-1.5 px-2 py-1 text-left text-sm
hover:bg-hover transition-colors rounded relative
${isSelected ? 'bg-amber-500/15 text-amber-300 border-l-2 border-amber-400' : 'text-text-secondary hover:text-text-primary border-l-2 border-transparent'}
${matchesSearch ? 'bg-accent/10' : ''}
`}
className={`relative flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-hover ${isSelected ? 'border-l-2 border-amber-400 bg-amber-500/15 text-amber-300' : 'border-l-2 border-transparent text-text-secondary hover:text-text-primary'} ${matchesSearch ? 'bg-accent/10' : ''} `}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{/* Expand/collapse icon */}
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-text-muted" />
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-text-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-text-muted" />
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-muted" />
)
) : (
<span className="w-3.5" />
@ -148,12 +149,12 @@ const TreeItem = ({
{/* Node icon */}
{node.type === 'folder' ? (
isExpanded ? (
<FolderOpen className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
<FolderOpen className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
) : (
<Folder className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
<Folder className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
)
) : (
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
<FileCode className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.File }} />
)}
{/* Name */}
@ -163,7 +164,7 @@ const TreeItem = ({
{/* Children */}
{isExpanded && filteredChildren.length > 0 && (
<div>
{filteredChildren.map(child => (
{filteredChildren.map((child) => (
<TreeItem
key={child.id}
node={child}
@ -184,18 +185,30 @@ const TreeItem = ({
// Icon for node types
const getNodeTypeIcon = (label: NodeLabel) => {
switch (label) {
case 'Folder': return Folder;
case 'File': return FileCode;
case 'Class': return Box;
case 'Function': return Braces;
case 'Method': return Braces;
case 'Interface': return Hash;
case 'Enum': return List;
case 'Type': return Type;
case 'Decorator': return AtSign;
case 'Import': return FileCode;
case 'Variable': return Variable;
default: return Variable;
case 'Folder':
return Folder;
case 'File':
return FileCode;
case 'Class':
return Box;
case 'Function':
return Braces;
case 'Method':
return Braces;
case 'Interface':
return Hash;
case 'Enum':
return List;
case 'Type':
return Type;
case 'Decorator':
return AtSign;
case 'Import':
return FileCode;
case 'Variable':
return Variable;
default:
return Variable;
}
};
@ -204,7 +217,18 @@ interface FileTreePanelProps {
}
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
const {
graph,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
selectedNode,
setSelectedNode,
openCodePanel,
depthFilter,
setDepthFilter,
} = useAppState();
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@ -220,7 +244,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
// Auto-expand first level on initial load
useEffect(() => {
if (fileTree.length > 0 && expandedPaths.size === 0) {
const firstLevel = new Set(fileTree.map(n => n.path));
const firstLevel = new Set(fileTree.map((n) => n.path));
setExpandedPaths(firstLevel);
}
}, [fileTree.length]); // Only run when tree first loads
@ -242,16 +266,16 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
}
if (pathsToExpand.length > 0) {
setExpandedPaths(prev => {
setExpandedPaths((prev) => {
const next = new Set(prev);
pathsToExpand.forEach(p => next.add(p));
pathsToExpand.forEach((p) => next.add(p));
return next;
});
}
}, [selectedNode?.id]); // Trigger when selected node changes
const toggleExpanded = useCallback((path: string) => {
setExpandedPaths(prev => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
@ -262,106 +286,115 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
});
}, []);
const handleNodeClick = useCallback((treeNode: TreeNode) => {
if (treeNode.graphNode) {
// Only focus if selecting a different node
const isSameNode = selectedNode?.id === treeNode.graphNode.id;
setSelectedNode(treeNode.graphNode);
openCodePanel();
if (!isSameNode) {
onFocusNode(treeNode.graphNode.id);
const handleNodeClick = useCallback(
(treeNode: TreeNode) => {
if (treeNode.graphNode) {
// Only focus if selecting a different node
const isSameNode = selectedNode?.id === treeNode.graphNode.id;
setSelectedNode(treeNode.graphNode);
openCodePanel();
if (!isSameNode) {
onFocusNode(treeNode.graphNode.id);
}
}
}
}, [setSelectedNode, openCodePanel, onFocusNode, selectedNode]);
},
[setSelectedNode, openCodePanel, onFocusNode, selectedNode],
);
const selectedPath = selectedNode?.properties.filePath || null;
if (isCollapsed) {
return (
<div className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2">
<div className="flex h-full w-12 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
<button
onClick={() => setIsCollapsed(false)}
className="p-2 text-text-secondary hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-2 text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Expand Panel"
>
<PanelLeft className="w-5 h-5" />
<PanelLeft className="h-5 w-5" />
</button>
<div className="w-6 h-px bg-border-subtle my-1" />
<div className="my-1 h-px w-6 bg-border-subtle" />
<button
onClick={() => { setIsCollapsed(false); setActiveTab('files'); }}
className={`p-2 rounded transition-colors ${activeTab === 'files' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
onClick={() => {
setIsCollapsed(false);
setActiveTab('files');
}}
className={`rounded p-2 transition-colors ${activeTab === 'files' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
title="File Explorer"
>
<Folder className="w-5 h-5" />
<Folder className="h-5 w-5" />
</button>
<button
onClick={() => { setIsCollapsed(false); setActiveTab('filters'); }}
className={`p-2 rounded transition-colors ${activeTab === 'filters' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
onClick={() => {
setIsCollapsed(false);
setActiveTab('filters');
}}
className={`rounded p-2 transition-colors ${activeTab === 'filters' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
title="Filters"
>
<Filter className="w-5 h-5" />
<Filter className="h-5 w-5" />
</button>
</div>
);
}
return (
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
<div className="flex h-full w-64 animate-slide-in flex-col border-r border-border-subtle bg-surface">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2">
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('files')}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`rounded px-2 py-1 text-xs transition-colors ${
activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
Explorer
</button>
<button
onClick={() => setActiveTab('filters')}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`rounded px-2 py-1 text-xs transition-colors ${
activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
Filters
</button>
</div>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Collapse Panel"
>
<PanelLeftClose className="w-4 h-4" />
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
{activeTab === 'files' && (
<>
{/* Search */}
<div className="px-3 py-2 border-b border-border-subtle">
<div className="border-b border-border-subtle px-3 py-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
<Search className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 bg-elevated border border-border-subtle rounded text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
className="w-full rounded border border-border-subtle bg-elevated py-1.5 pr-3 pl-8 text-xs text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
/>
</div>
</div>
{/* File tree */}
<div className="flex-1 overflow-y-auto scrollbar-thin py-2">
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
{fileTree.length === 0 ? (
<div className="px-3 py-4 text-center text-text-muted text-xs">
No files loaded
</div>
<div className="px-3 py-4 text-center text-xs text-text-muted">No files loaded</div>
) : (
fileTree.map(node => (
fileTree.map((node) => (
<TreeItem
key={node.id}
node={node}
@ -379,12 +412,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
)}
{activeTab === 'filters' && (
<div className="flex-1 overflow-y-auto scrollbar-thin p-3">
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
<div className="mb-3">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
Node Types
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Toggle visibility of node types in the graph
</p>
</div>
@ -398,23 +431,21 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={label}
onClick={() => toggleLabelVisibility(label)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
} `}
>
<div
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
className={`flex h-5 w-5 items-center justify-center rounded ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
>
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
<Icon className="h-3 w-3" style={{ color: NODE_COLORS[label] }} />
</div>
<span className="text-xs flex-1">{label}</span>
<span className="flex-1 text-xs">{label}</span>
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
);
@ -422,11 +453,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{/* Edge Type Toggles */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
Edge Types
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Toggle visibility of relationship types
</p>
@ -439,21 +470,19 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={edgeType}
onClick={() => toggleEdgeVisibility(edgeType)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
} `}
>
<div
className={`w-6 h-1.5 rounded-full ${isVisible ? '' : 'opacity-40'}`}
className={`h-1.5 w-6 rounded-full ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: info.color }}
/>
<span className="text-xs flex-1">{info.label}</span>
<span className="flex-1 text-xs">{info.label}</span>
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
);
@ -462,12 +491,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{/* Depth Filter */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<Target className="w-3 h-3 inline mr-1.5" />
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
<Target className="mr-1.5 inline h-3 w-3" />
Focus Depth
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Show nodes within N hops of selection
</p>
@ -482,13 +511,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={label}
onClick={() => setDepthFilter(value)}
className={`
px-2 py-1 text-xs rounded transition-colors
${depthFilter === value
className={`rounded px-2 py-1 text-xs transition-colors ${
depthFilter === value
? 'bg-accent text-white'
: 'bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
}
`}
} `}
>
{label}
</button>
@ -496,22 +523,33 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{depthFilter !== null && !selectedNode && (
<p className="mt-2 text-[10px] text-amber-400">
Select a node to apply depth filter
</p>
<p className="mt-2 text-[10px] text-amber-400">Select a node to apply depth filter</p>
)}
</div>
{/* Legend */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-3">
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-3 text-xs font-medium tracking-wide text-text-secondary uppercase">
Color Legend
</h3>
<div className="grid grid-cols-2 gap-2">
{(['Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Function', 'Method', 'Variable', 'Decorator'] as NodeLabel[]).map(label => (
{(
[
'Folder',
'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
] as NodeLabel[]
).map((label) => (
<div key={label} className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: NODE_COLORS[label] }}
/>
<span className="text-[10px] text-text-muted">{label}</span>
@ -524,7 +562,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
{/* Stats footer */}
{graph && (
<div className="px-3 py-2 border-t border-border-subtle bg-elevated/50">
<div className="border-t border-border-subtle bg-elevated/50 px-3 py-2">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span>{graph.nodes.length} nodes</span>
<span>{graph.relationships.length} edges</span>
@ -534,4 +572,3 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
);
};

View file

@ -1,8 +1,23 @@
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from '@/lib/lucide-icons';
import {
ZoomIn,
ZoomOut,
Maximize2,
Focus,
RotateCcw,
Play,
Pause,
Lightbulb,
LightbulbOff,
} from '@/lib/lucide-icons';
import { useSigma } from '../hooks/useSigma';
import { useAppState } from '../hooks/useAppState';
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
import {
knowledgeGraphToGraphology,
filterGraphByDepth,
SigmaNodeAttributes,
SigmaEdgeAttributes,
} from '../lib/graph-adapter';
import type { GraphNode } from 'gitnexus-shared';
import { QueryFAB } from './QueryFAB';
import Graph from 'graphology';
@ -41,7 +56,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
for (const id of aiToolHighlightedNodeIds) next.add(id);
// Note: blast radius nodes are handled separately with red color
return next;
}, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]);
}, [
highlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
isAIHighlightsEnabled,
]);
// Blast radius nodes (only when AI highlights enabled)
const effectiveBlastRadiusNodeIds = useMemo(() => {
@ -57,26 +77,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
return new Map(graph.nodes.map((n) => [n.id, n]));
}, [graph]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graph) return;
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
}, [graph, nodeById, setSelectedNode, openCodePanel]);
const handleNodeClick = useCallback(
(nodeId: string) => {
if (!graph) return;
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
},
[graph, nodeById, setSelectedNode, openCodePanel],
);
const handleNodeHover = useCallback((nodeId: string | null) => {
if (!nodeId || !graph) {
setHoveredNodeName(null);
return;
}
const node = nodeById.get(nodeId);
setHoveredNodeName(node ? node.properties.name : null);
}, [graph, nodeById]);
const handleNodeHover = useCallback(
(nodeId: string | null) => {
if (!nodeId || !graph) {
setHoveredNodeName(null);
return;
}
const node = nodeById.get(nodeId);
setHoveredNodeName(node ? node.properties.name : null);
},
[graph, nodeById],
);
const handleStageClick = useCallback(() => {
setSelectedNode(null);
@ -91,7 +117,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
setSigmaSelectedNode(null);
}
toggleAIHighlights();
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]);
}, [
isAIHighlightsEnabled,
clearAIToolHighlights,
clearAICitationHighlights,
clearBlastRadius,
setSelectedNode,
toggleAIHighlights,
]);
const {
containerRef,
@ -117,19 +150,23 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
});
// Expose focusNode to parent via ref
useImperativeHandle(ref, () => ({
focusNode: (nodeId: string) => {
// Also update app state so the selection syncs properly
if (graph) {
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
useImperativeHandle(
ref,
() => ({
focusNode: (nodeId: string) => {
// Also update app state so the selection syncs properly
if (graph) {
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
}
}
focusNode(nodeId);
}
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]);
focusNode(nodeId);
},
}),
[focusNode, graph, nodeById, setSelectedNode, openCodePanel],
);
// Update Sigma graph when KnowledgeGraph changes
useEffect(() => {
@ -138,7 +175,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
// Build communityMemberships map from MEMBER_OF relationships
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
const communityMemberships = new Map<string, number>();
graph.relationships.forEach(rel => {
graph.relationships.forEach((rel) => {
if (rel.type === 'MEMBER_OF') {
// Find the community node to get its index
const communityNode = nodeById.get(rel.targetId);
@ -165,7 +202,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
sigma.refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
}, [visibleLabels, depthFilter, appSelectedNode]);
// Sync app selected node with sigma
@ -192,16 +229,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
return (
<div className="relative w-full h-full bg-void">
<div className="relative h-full w-full bg-void">
{/* Background gradient */}
<div className="absolute inset-0 pointer-events-none">
<div className="pointer-events-none absolute inset-0">
<div
className="absolute inset-0"
style={{
background: `
radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%),
linear-gradient(to bottom, #06060a, #0a0a10)
`
`,
}}
/>
</div>
@ -209,29 +246,27 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{/* Sigma container */}
<div
ref={containerRef}
className="sigma-container w-full h-full cursor-grab active:cursor-grabbing"
className="sigma-container h-full w-full cursor-grab active:cursor-grabbing"
/>
{/* Hovered node tooltip - only show when NOT selected */}
{hoveredNodeName && !sigmaSelectedNode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-elevated/95 border border-border-subtle rounded-lg backdrop-blur-sm z-20 pointer-events-none animate-fade-in">
<div className="pointer-events-none absolute top-4 left-1/2 z-20 -translate-x-1/2 animate-fade-in rounded-lg border border-border-subtle bg-elevated/95 px-3 py-1.5 backdrop-blur-sm">
<span className="font-mono text-sm text-text-primary">{hoveredNodeName}</span>
</div>
)}
{/* Selection info bar */}
{sigmaSelectedNode && appSelectedNode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-accent/20 border border-accent/30 rounded-xl backdrop-blur-sm z-20 animate-slide-up">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
<div className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 animate-slide-up items-center gap-2 rounded-xl border border-accent/30 bg-accent/20 px-4 py-2 backdrop-blur-sm">
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
<span className="font-mono text-sm text-text-primary">
{appSelectedNode.properties.name}
</span>
<span className="text-xs text-text-muted">
({appSelectedNode.label})
</span>
<span className="text-xs text-text-muted">({appSelectedNode.label})</span>
<button
onClick={handleClearSelection}
className="ml-2 px-2 py-0.5 text-xs text-text-secondary hover:text-text-primary hover:bg-white/10 rounded transition-colors"
className="ml-2 rounded px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-white/10 hover:text-text-primary"
>
Clear
</button>
@ -239,40 +274,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
)}
{/* Graph Controls - Bottom Right */}
<div className="absolute bottom-4 right-4 flex flex-col gap-1 z-10">
<div className="absolute right-4 bottom-4 z-10 flex flex-col gap-1">
<button
onClick={zoomIn}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Zoom In"
>
<ZoomIn className="w-4 h-4" />
<ZoomIn className="h-4 w-4" />
</button>
<button
onClick={zoomOut}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Zoom Out"
>
<ZoomOut className="w-4 h-4" />
<ZoomOut className="h-4 w-4" />
</button>
<button
onClick={resetZoom}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Fit to Screen"
>
<Maximize2 className="w-4 h-4" />
<Maximize2 className="h-4 w-4" />
</button>
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
<div className="my-1 h-px bg-border-subtle" />
{/* Focus on selected */}
{appSelectedNode && (
<button
onClick={handleFocusSelected}
className="w-9 h-9 flex items-center justify-center bg-accent/20 border border-accent/30 rounded-md text-accent hover:bg-accent/30 transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-accent/30 bg-accent/20 text-accent transition-colors hover:bg-accent/30"
title="Focus on Selected Node"
>
<Focus className="w-4 h-4" />
<Focus className="h-4 w-4" />
</button>
)}
@ -280,41 +315,35 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{sigmaSelectedNode && (
<button
onClick={handleClearSelection}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Clear Selection"
>
<RotateCcw className="w-4 h-4" />
<RotateCcw className="h-4 w-4" />
</button>
)}
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
<div className="my-1 h-px bg-border-subtle" />
{/* Layout control */}
<button
onClick={isLayoutRunning ? stopLayout : startLayout}
className={`
w-9 h-9 flex items-center justify-center border rounded-md transition-all
${isLayoutRunning
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary'
}
`}
className={`flex h-9 w-9 items-center justify-center rounded-md border transition-all ${
isLayoutRunning
? 'animate-pulse border-accent bg-accent text-white shadow-glow'
: 'border-border-subtle bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
} `}
title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'}
>
{isLayoutRunning ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
{isLayoutRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
</div>
{/* Layout running indicator */}
{isLayoutRunning && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-full backdrop-blur-sm z-10 animate-fade-in">
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-ping" />
<span className="text-xs text-emerald-400 font-medium">Layout optimizing...</span>
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/20 px-3 py-1.5 backdrop-blur-sm">
<div className="h-2 w-2 animate-ping rounded-full bg-emerald-400" />
<span className="text-xs font-medium text-emerald-400">Layout optimizing...</span>
</div>
)}
@ -327,13 +356,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
onClick={handleToggleAIHighlights}
className={
isAIHighlightsEnabled
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'
: 'w-10 h-10 flex items-center justify-center bg-elevated border border-border-subtle rounded-lg text-text-muted hover:bg-hover hover:text-text-primary transition-colors'
? 'flex h-10 w-10 items-center justify-center rounded-lg border border-cyan-400/40 bg-cyan-500/15 text-cyan-200 transition-colors hover:border-cyan-300/60 hover:bg-cyan-500/20'
: 'flex h-10 w-10 items-center justify-center rounded-lg border border-border-subtle bg-elevated text-text-muted transition-colors hover:bg-hover hover:text-text-primary'
}
title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
data-testid="ai-highlights-toggle"
>
{isAIHighlightsEnabled ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
{isAIHighlightsEnabled ? (
<Lightbulb className="h-4 w-4" />
) : (
<LightbulbOff className="h-4 w-4" />
)}
</button>
</div>
</div>

View file

@ -1,6 +1,25 @@
import { Search, Settings, HelpCircle, Sparkles, Github, Star, FolderOpen, ChevronDown, Trash2, RefreshCw, Loader2 } from '@/lib/lucide-icons';
import {
Search,
Settings,
HelpCircle,
Sparkles,
Github,
Star,
FolderOpen,
ChevronDown,
Trash2,
RefreshCw,
Loader2,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { deleteRepo, fetchRepos, startAnalyze, streamAnalyzeProgress, type BackendRepo, type JobProgress } from '../services/backend-client';
import {
deleteRepo,
fetchRepos,
startAnalyze,
streamAnalyzeProgress,
type BackendRepo,
type JobProgress,
} from '../services/backend-client';
import { useState, useMemo, useRef, useEffect } from 'react';
import { GraphNode } from 'gitnexus-shared';
import { EmbeddingStatus } from './EmbeddingStatus';
@ -29,7 +48,13 @@ interface HeaderProps {
onReposChanged?: (repos: BackendRepo[]) => void;
}
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnalyzeComplete, onReposChanged }: HeaderProps) => {
export const Header = ({
onFocusNode,
availableRepos = [],
onSwitchRepo,
onAnalyzeComplete,
onReposChanged,
}: HeaderProps) => {
const {
projectName,
graph,
@ -37,7 +62,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
isRightPanelOpen,
rightPanelTab,
setSettingsPanelOpen,
setHelpDialogBoxOpen
setHelpDialogBoxOpen,
} = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
@ -60,7 +85,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
const query = searchQuery.toLowerCase();
return graph.nodes
.filter(node => node.properties.name.toLowerCase().includes(query))
.filter((node) => node.properties.name.toLowerCase().includes(query))
.slice(0, 10); // Limit to 10 results
}, [graph, searchQuery]);
@ -81,7 +106,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
// Cleanup re-analyze SSE on unmount
useEffect(() => {
return () => { reanalyzeSseRef.current?.abort(); };
return () => {
reanalyzeSseRef.current?.abort();
};
}, []);
// Keyboard shortcut (Cmd+K / Ctrl+K)
@ -107,10 +134,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, searchResults.length - 1));
setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const selected = searchResults[selectedIndex];
@ -129,37 +156,40 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
};
return (
<header className="flex items-center justify-between px-5 py-3 bg-deep border-b border-dashed border-border-subtle">
<header className="flex items-center justify-between border-b border-dashed border-border-subtle bg-deep px-5 py-3">
{/* Left section */}
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-md shadow-glow text-white text-sm font-bold">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-accent to-node-interface text-sm font-bold text-white shadow-glow">
</div>
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
<span className="text-[15px] font-semibold tracking-tight">GitNexus</span>
</div>
{/* Project badge + repo dropdown */}
{projectName && (
<div className="relative" ref={repoDropdownRef}>
<button
onClick={() => { setIsRepoDropdownOpen(prev => !prev); setShowAnalyzer(false); }}
className={`
flex items-center gap-2 px-3 py-1.5 border rounded-lg text-sm transition-all cursor-pointer
${isRepoDropdownOpen
? 'bg-accent/10 border-accent/40 text-text-primary'
: 'bg-surface border-border-subtle text-text-secondary hover:bg-hover hover:border-border-default'
}
`}
onClick={() => {
setIsRepoDropdownOpen((prev) => !prev);
setShowAnalyzer(false);
}}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all ${
isRepoDropdownOpen
? 'border-accent/40 bg-accent/10 text-text-primary'
: 'border-border-subtle bg-surface text-text-secondary hover:border-border-default hover:bg-hover'
} `}
>
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[160px]">{projectName}</span>
<ChevronDown className={`w-3 h-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-node-function" />
<span className="max-w-[160px] truncate">{projectName}</span>
<ChevronDown
className={`h-3 w-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{isRepoDropdownOpen && (
<div className="absolute top-full left-0 mt-1.5 w-80 bg-surface border border-border-subtle rounded-xl shadow-xl overflow-hidden z-50 animate-slide-up">
<div className="absolute top-full left-0 z-50 mt-1.5 w-80 animate-slide-up overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
{showAnalyzer ? (
<div className="p-4">
<RepoAnalyzer
@ -177,15 +207,15 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Repo list */}
{availableRepos.length > 0 && (
<div>
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium text-text-muted uppercase tracking-wider">
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
Repositories
</div>
{availableRepos.map(repo => (
{availableRepos.map((repo) => (
<div
key={repo.name}
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
repo.name === projectName
? 'bg-accent/10 border-l-2 border-accent'
? 'border-l-2 border-accent bg-accent/10'
: 'hover:bg-hover'
}`}
>
@ -194,12 +224,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
if (repo.name !== projectName) onSwitchRepo?.(repo.name);
setIsRepoDropdownOpen(false);
}}
className="flex-1 flex items-center gap-3 text-left cursor-pointer min-w-0"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
>
<FolderOpen className="w-3.5 h-3.5 text-node-folder shrink-0" />
<span className="flex-1 truncate text-sm text-text-primary font-mono">{repo.name}</span>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
<span className="flex-1 truncate font-mono text-sm text-text-primary">
{repo.name}
</span>
{repo.name === projectName && (
<span className="text-[10px] text-accent font-mono shrink-0">active</span>
<span className="shrink-0 font-mono text-[10px] text-accent">
active
</span>
)}
</button>
{/* Re-analyze */}
@ -208,9 +242,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
e.stopPropagation();
if (reanalyzing) return; // already running
setReanalyzing(repo.name);
setReanalyzeProgress({ phase: 'queued', percent: 0, message: 'Starting...' });
setReanalyzeProgress({
phase: 'queued',
percent: 0,
message: 'Starting...',
});
try {
const { jobId } = await startAnalyze({ path: repo.path, force: true });
const { jobId } = await startAnalyze({
path: repo.path,
force: true,
});
reanalyzeSseRef.current = streamAnalyzeProgress(
jobId,
(p) => setReanalyzeProgress(p),
@ -234,14 +275,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
}
}}
disabled={!!reanalyzing}
className={`p-1 rounded transition-all cursor-pointer ${
className={`cursor-pointer rounded p-1 transition-all ${
reanalyzing === repo.name
? 'text-accent'
: 'text-text-muted/0 group-hover:text-text-muted hover:!text-accent'
}`}
title={reanalyzing === repo.name ? 'Re-analyzing...' : `Re-analyze ${repo.name}`}
title={
reanalyzing === repo.name
? 'Re-analyzing...'
: `Re-analyze ${repo.name}`
}
>
<RefreshCw className={`w-3.5 h-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3.5 w-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`}
/>
</button>
{/* Delete */}
<button
@ -269,10 +316,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
console.error('Failed to delete repo:', err);
}
}}
className="p-1 text-text-muted/0 group-hover:text-text-muted hover:!text-red-400 rounded transition-all cursor-pointer"
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
title={`Delete ${repo.name}`}
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
@ -281,16 +328,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && (
<div className="px-4 py-2.5 border-t border-border-subtle bg-accent/5">
<div className="flex items-center gap-2 mb-1.5">
<Loader2 className="w-3 h-3 text-accent animate-spin shrink-0" />
<span className="text-xs text-text-secondary truncate">
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
<div className="mb-1.5 flex items-center gap-2">
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
<span className="truncate text-xs text-text-secondary">
Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
</span>
</div>
<div className="h-1 bg-elevated rounded-full overflow-hidden">
<div className="h-1 overflow-hidden rounded-full bg-elevated">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
className="h-full rounded-full bg-accent transition-all duration-300"
style={{ width: `${Math.max(2, reanalyzeProgress.percent)}%` }}
/>
</div>
@ -298,14 +345,22 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
)}
{/* Analyze new */}
<div className={availableRepos.length > 0 || reanalyzing ? 'border-t border-border-subtle' : ''}>
<div
className={
availableRepos.length > 0 || reanalyzing
? 'border-t border-border-subtle'
: ''
}
>
<button
onClick={() => setShowAnalyzer(true)}
disabled={!!reanalyzing}
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-hover transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Sparkles className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="text-sm text-text-secondary">Analyze a new repository...</span>
<Sparkles className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="text-sm text-text-secondary">
Analyze a new repository...
</span>
</button>
</div>
</>
@ -317,9 +372,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
</div>
{/* Center - Search */}
<div className="flex-1 max-w-md mx-6 relative" ref={searchRef}>
<div className="flex items-center gap-2.5 px-3.5 py-2 bg-surface border border-border-subtle rounded-lg transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="w-4 h-4 text-text-muted flex-shrink-0" />
<div className="relative mx-6 max-w-md flex-1" ref={searchRef}>
<div className="flex items-center gap-2.5 rounded-lg border border-border-subtle bg-surface px-3.5 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="h-4 w-4 flex-shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
@ -332,16 +387,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
}}
onFocus={() => setIsSearchOpen(true)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
/>
<kbd className="px-1.5 py-0.5 bg-elevated border border-border-subtle rounded text-[10px] text-text-muted font-mono">
<kbd className="rounded border border-border-subtle bg-elevated px-1.5 py-0.5 font-mono text-[10px] text-text-muted">
K
</kbd>
</div>
{/* Search Results Dropdown */}
{isSearchOpen && searchQuery.trim() && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border-subtle rounded-xl shadow-xl overflow-hidden z-50">
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
{searchResults.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
No nodes found for &ldquo;{searchQuery}&rdquo;
@ -352,19 +407,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
<button
key={node.id}
onClick={() => handleSelectNode(node)}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors cursor-pointer ${index === selectedIndex
? 'bg-accent/20 text-text-primary'
: 'hover:bg-hover text-text-secondary'
}`}
className={`flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === selectedIndex
? 'bg-accent/20 text-text-primary'
: 'text-text-secondary hover:bg-hover'
}`}
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: NODE_TYPE_COLORS[node.label] || '#6b7280' }}
/>
<span className="flex-1 truncate text-sm font-medium">
{node.properties.name}
</span>
<span className="text-xs text-text-muted px-2 py-0.5 bg-elevated rounded">
<span className="rounded bg-elevated px-2 py-0.5 text-xs text-text-muted">
{node.label}
</span>
</button>
@ -382,17 +438,17 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
href="https://github.com/abhigyanpatwari/GitNexus"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3.5 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 group"
className="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-pink-600 px-3.5 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:from-purple-500 hover:to-pink-500 hover:shadow-xl"
>
<Github className="w-4 h-4" />
<Github className="h-4 w-4" />
<span className="hidden sm:inline">Star if cool</span>
<Star className="w-3.5 h-3.5 group-hover:fill-yellow-300 group-hover:text-yellow-300 transition-all" />
<Star className="h-3.5 w-3.5 transition-all group-hover:fill-yellow-300 group-hover:text-yellow-300" />
<span className="hidden sm:inline"></span>
</a>
{/* Stats */}
{graph && (
<div className="flex items-center gap-4 mr-2 text-xs text-text-muted">
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
<span>{nodeCount} nodes</span>
<span>{edgeCount} edges</span>
</div>
@ -404,34 +460,32 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Icon buttons */}
<button
onClick={() => setSettingsPanelOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors cursor-pointer"
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="AI Settings"
>
<Settings className="w-4.5 h-4.5" />
<Settings className="h-4.5 w-4.5" />
</button>
<button
title="Help"
onClick={() => setHelpDialogBoxOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors cursor-pointer">
<HelpCircle className="w-4.5 h-4.5" />
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
<HelpCircle className="h-4.5 w-4.5" />
</button>
{/* AI Button */}
<button
onClick={openChatPanel}
className={`
flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-all
${isRightPanelOpen && rightPanelTab === 'chat'
className={`flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium transition-all ${
isRightPanelOpen && rightPanelTab === 'chat'
? 'bg-accent text-white shadow-glow'
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:shadow-lg hover:-translate-y-0.5'
}
`}
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:-translate-y-0.5 hover:shadow-lg'
} `}
>
<Sparkles className="w-4 h-4" />
<Sparkles className="h-4 w-4" />
<span>Nexus AI</span>
</button>
</div>
</header>
);
};

File diff suppressed because it is too large Load diff

View file

@ -6,24 +6,24 @@ interface LoadingOverlayProps {
export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
return (
<div className="fixed inset-0 flex flex-col items-center justify-center bg-void z-50">
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-void">
{/* Background gradient effects */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-accent/10 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl animate-pulse" />
<div className="pointer-events-none absolute inset-0">
<div className="absolute top-1/3 left-1/3 h-96 w-96 animate-pulse rounded-full bg-accent/10 blur-3xl" />
<div className="absolute right-1/3 bottom-1/3 h-96 w-96 animate-pulse rounded-full bg-node-interface/10 blur-3xl" />
</div>
{/* Pulsing orb */}
<div className="relative mb-10">
<div className="w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full animate-pulse-glow" />
<div className="absolute inset-0 w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full blur-xl opacity-50" />
<div className="h-28 w-28 animate-pulse-glow rounded-full bg-gradient-to-br from-accent to-node-interface" />
<div className="absolute inset-0 h-28 w-28 rounded-full bg-gradient-to-br from-accent to-node-interface opacity-50 blur-xl" />
</div>
{/* Progress bar */}
<div className="w-80 mb-4">
<div className="h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300 ease-out"
<div className="mb-4 w-80">
<div className="h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300 ease-out"
style={{ width: `${progress.percent}%` }}
/>
</div>
@ -31,14 +31,12 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
{/* Status text */}
<div className="text-center">
<p className="font-mono text-sm text-text-secondary mb-1">
<p className="mb-1 font-mono text-sm text-text-secondary">
{progress.message}
<span className="animate-pulse">|</span>
</p>
{progress.detail && (
<p className="font-mono text-xs text-text-muted truncate max-w-md">
{progress.detail}
</p>
<p className="max-w-md truncate font-mono text-xs text-text-muted">{progress.detail}</p>
)}
</div>
@ -46,21 +44,20 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
{progress.stats && (
<div className="mt-8 flex items-center gap-6 text-xs text-text-muted">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-node-file rounded-full" />
<span>{progress.stats.filesProcessed} / {progress.stats.totalFiles} files</span>
<span className="h-2 w-2 rounded-full bg-node-file" />
<span>
{progress.stats.filesProcessed} / {progress.stats.totalFiles} files
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-node-function rounded-full" />
<span className="h-2 w-2 rounded-full bg-node-function" />
<span>{progress.stats.nodesCreated} nodes</span>
</div>
</div>
)}
{/* Percent */}
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">
{progress.percent}%
</p>
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">{progress.percent}%</p>
</div>
);
};

View file

@ -9,213 +9,222 @@ import { Copy, Check } from '@/lib/lucide-icons';
// Custom syntax theme
const customTheme = {
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
};
interface MarkdownRendererProps {
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean;
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean;
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onLinkClick,
toolCalls,
showCopyButton = false
content,
onLinkClick,
toolCalls,
showCopyButton = false,
}) => {
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
useEffect(() => {
return () => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]]
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Pattern 2: Node grounding - [[Type:Name]]
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
}
return parts.join('```');
};
const handleLinkClick = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
}, [onLinkClick]);
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
const markdownComponents = React.useMemo(() => ({
a: ({ href, children, ...props }: any) => {
const hrefStr = href || '';
// Grounding links (Code refs & Node refs)
if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) {
const isNodeRef = hrefStr.startsWith('node-ref:');
const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes.
// Styles
const baseParams = "code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors";
const colorParams = isNodeRef
? "border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70"
: "border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70";
return (
<a
href={hrefStr}
onClick={(e) => handleLinkClick(e, hrefStr)}
className={`${baseParams} ${colorParams}`}
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
// External links
return (
<a
href={hrefStr}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]]
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
},
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
);
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
// Render Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram code={codeContent} />;
}
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
// Pattern 2: Node grounding - [[Type:Name]]
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
},
pre: ({ children }: any) => <>{children}</>,
}), [handleLinkClick]);
);
}
return parts.join('```');
};
return (
<div className="text-text-primary text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={(url) => {
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
// Default behavior for http/https/etc
return url;
}}
components={markdownComponents}
const handleLinkClick = React.useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
},
[onLinkClick],
);
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
const markdownComponents = React.useMemo(
() => ({
a: ({ href, children, ...props }: any) => {
const hrefStr = href || '';
// Grounding links (Code refs & Node refs)
if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) {
const isNodeRef = hrefStr.startsWith('node-ref:');
const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes.
// Styles
const baseParams =
'code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors';
const colorParams = isNodeRef
? 'border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70'
: 'border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70';
return (
<a
href={hrefStr}
onClick={(e) => handleLinkClick(e, hrefStr)}
className={`${baseParams} ${colorParams}`}
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
{...props}
>
{formattedContent}
</ReactMarkdown>
<span className="text-inherit">{children}</span>
</a>
);
}
{/* Copy Button */}
{showCopyButton && (
<div className="mt-2 flex justify-end">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-muted hover:text-text-primary hover:bg-surface border border-transparent hover:border-border-subtle rounded transition-all"
title="Copy to clipboard"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
)}
// External links
return (
<a
href={hrefStr}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{toolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
// Render Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram code={codeContent} />;
}
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }: any) => <>{children}</>,
}),
[handleLinkClick],
);
return (
<div className="text-sm text-text-primary">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={(url) => {
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
// Default behavior for http/https/etc
return url;
}}
components={markdownComponents}
>
{formattedContent}
</ReactMarkdown>
{/* Copy Button */}
{showCopyButton && (
<div className="mt-2 flex justify-end">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 rounded border border-transparent px-2 py-1 text-xs text-text-muted transition-all hover:border-border-subtle hover:bg-surface hover:text-text-primary"
title="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
);
)}
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{toolCalls.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
)}
</div>
);
};

View file

@ -71,11 +71,14 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
// Render the diagram
const { svg: renderedSvg } = await mermaid.render(id, code.trim());
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
setSvg(sanitizedSvg);
setError(null);
} catch (err) {
// Silent catch for streaming:
// Silent catch for streaming:
// If render fails (common during partial streaming), we:
// 1. Log to console for debugging
// 2. Do NOT set error state (avoids flashing red box)
@ -93,29 +96,31 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
}, [code]);
// Create a pseudo ProcessData for the modal (with custom rawMermaid property)
const processData: any = showModal ? {
id: 'ai-generated',
label: 'AI Generated Diagram',
processType: 'intra_community',
steps: [], // Empty - we'll render raw mermaid
edges: [],
clusters: [],
rawMermaid: code, // Pass raw mermaid code
} : null;
const processData: any = showModal
? {
id: 'ai-generated',
label: 'AI Generated Diagram',
processType: 'intra_community',
steps: [], // Empty - we'll render raw mermaid
edges: [],
clusters: [],
rawMermaid: code, // Pass raw mermaid code
}
: null;
if (error) {
return (
<div className="my-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg">
<div className="flex items-center gap-2 text-rose-300 text-sm mb-2">
<AlertTriangle className="w-4 h-4" />
<div className="my-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-4">
<div className="mb-2 flex items-center gap-2 text-sm text-rose-300">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">Diagram Error</span>
</div>
<pre className="text-xs text-rose-200/70 font-mono whitespace-pre-wrap">{error}</pre>
<pre className="font-mono text-xs whitespace-pre-wrap text-rose-200/70">{error}</pre>
<details className="mt-2">
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary">
<summary className="cursor-pointer text-xs text-text-muted hover:text-text-secondary">
Show source
</summary>
<pre className="mt-2 p-2 bg-surface rounded text-xs text-text-muted overflow-x-auto">
<pre className="mt-2 overflow-x-auto rounded bg-surface p-2 text-xs text-text-muted">
{code}
</pre>
</details>
@ -125,42 +130,40 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
return (
<>
<div className="my-3 relative group">
<div className="relative bg-gradient-to-b from-surface to-elevated border border-border-subtle rounded-xl overflow-hidden">
<div className="group relative my-3">
<div className="relative overflow-hidden rounded-xl border border-border-subtle bg-gradient-to-b from-surface to-elevated">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle">
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium">
<div className="flex items-center justify-between border-b border-border-subtle bg-surface/60 px-3 py-2">
<span className="text-[10px] font-medium tracking-wider text-text-muted uppercase">
Diagram
</span>
<button
onClick={() => setShowModal(true)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Expand"
>
<Maximize2 className="w-3.5 h-3.5" />
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Diagram container */}
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }) }}
className="flex max-h-[400px] items-center justify-center overflow-auto p-4"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
}),
}}
/>
</div>
</div>
{/* Use ProcessFlowModal for expansion */}
{showModal && processData && (
<Suspense
fallback={
<div className="p-4 text-sm text-text-muted">Loading diagram</div>
}
>
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
<Suspense fallback={<div className="p-4 text-sm text-text-muted">Loading diagram</div>}>
<ProcessFlowModal process={processData} onClose={() => setShowModal(false)} />
</Suspense>
)}
</>

View file

@ -13,7 +13,9 @@ function CopyButton({ text }: { text: string }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async () => {
@ -31,20 +33,13 @@ function CopyButton({ text }: { text: string }) {
<button
onClick={handleCopy}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
className={`
shrink-0 px-2 py-1 rounded-md cursor-pointer
transition-all duration-200
focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none
${copied
? 'text-emerald-400 bg-emerald-400/10'
: 'text-text-muted hover:text-text-primary hover:bg-white/5'
}
`}
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 transition-all duration-200 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none ${
copied
? 'bg-emerald-400/10 text-emerald-400'
: 'text-text-muted hover:bg-white/5 hover:text-text-primary'
} `}
>
{copied
? <Check className="w-3.5 h-3.5" />
: <Copy className="w-3.5 h-3.5" />
}
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
@ -62,28 +57,28 @@ function TerminalWindow({
}) {
return (
<div
className={`
rounded-xl overflow-hidden border transition-all duration-300
${isActive
className={`overflow-hidden rounded-xl border transition-all duration-300 ${
isActive
? 'border-accent/40 shadow-glow-soft'
: 'border-border-default hover:border-accent/20 hover:shadow-glow-soft'
}
`}
} `}
>
{/* Title bar */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-deep border-b border-border-subtle">
<div className="flex items-center gap-2 border-b border-border-subtle bg-deep px-4 py-2.5">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/60" />
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/60" />
</div>
<span className="flex-1 text-[11px] text-text-muted text-center font-mono">{label}</span>
<span className="flex-1 text-center font-mono text-[11px] text-text-muted">{label}</span>
<CopyButton text={command} />
</div>
{/* Command body */}
<div className="px-4 py-3.5 bg-void font-mono text-sm flex items-center gap-3">
<span className="text-accent/60 select-none" aria-hidden="true">$</span>
<code className="flex-1 overflow-x-auto whitespace-nowrap text-text-primary tracking-wide">
<div className="flex items-center gap-3 bg-void px-4 py-3.5 font-mono text-sm">
<span className="text-accent/60 select-none" aria-hidden="true">
$
</span>
<code className="flex-1 overflow-x-auto tracking-wide whitespace-nowrap text-text-primary">
{command}
</code>
</div>
@ -98,24 +93,24 @@ type StepState = 'waiting' | 'active' | 'done';
function StepDot({ state, number }: { state: StepState; number: number }) {
if (state === 'done') {
return (
<div className="w-6 h-6 rounded-full bg-emerald-500/20 border border-emerald-500/50 flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-emerald-400" />
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-500/50 bg-emerald-500/20">
<Check className="h-3 w-3 text-emerald-400" />
</div>
);
}
if (state === 'active') {
return (
<div className="relative w-6 h-6 shrink-0 flex items-center justify-center">
<div className="absolute inset-0 rounded-full border border-accent/30 animate-ping" />
<div className="w-6 h-6 rounded-full bg-accent/20 border border-accent/60 flex items-center justify-center">
<span className="text-[10px] font-semibold text-accent leading-none">{number}</span>
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center">
<div className="absolute inset-0 animate-ping rounded-full border border-accent/30" />
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-accent/60 bg-accent/20">
<span className="text-[10px] leading-none font-semibold text-accent">{number}</span>
</div>
</div>
);
}
return (
<div className="w-6 h-6 rounded-full bg-elevated border border-border-subtle flex items-center justify-center shrink-0">
<span className="text-[10px] font-semibold text-text-muted leading-none">{number}</span>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border-subtle bg-elevated">
<span className="text-[10px] leading-none font-semibold text-text-muted">{number}</span>
</div>
);
}
@ -137,38 +132,33 @@ function StepRow({
return (
<div
className={`
transition-all duration-300
${state === 'waiting' ? 'opacity-40' : 'opacity-100'}
`}
className={`transition-all duration-300 ${state === 'waiting' ? 'opacity-40' : 'opacity-100'} `}
>
<div className="flex items-start gap-3">
<StepDot state={state} number={number} />
<div className="flex-1 min-w-0 pt-0.5">
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium transition-colors duration-200 ${
state === 'done'
? 'text-emerald-400'
: state === 'active'
? 'text-text-primary'
: 'text-text-muted'
? 'text-text-primary'
: 'text-text-muted'
}`}
>
{title}
</span>
{state === 'done' && (
<span className="text-[10px] text-emerald-400/60 font-mono uppercase tracking-wider animate-fade-in">
<span className="animate-fade-in font-mono text-[10px] tracking-wider text-emerald-400/60 uppercase">
done
</span>
)}
</div>
{description && (
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{description}</p>
)}
{isVisible && children && (
<div className="mt-3 animate-slide-up">{children}</div>
<p className="mt-0.5 text-xs leading-relaxed text-text-muted">{description}</p>
)}
{isVisible && children && <div className="mt-3 animate-slide-up">{children}</div>}
</div>
</div>
</div>
@ -180,27 +170,25 @@ function StepRow({
function PollingBar() {
return (
<div
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-accent/5 border border-accent/15 animate-fade-in"
className="flex animate-fade-in items-center gap-3 rounded-xl border border-accent/15 bg-accent/5 px-4 py-3"
aria-live="polite"
role="status"
>
<div className="relative shrink-0">
<Zap className="w-4 h-4 text-accent/70" />
<Zap className="h-4 w-4 text-accent/70" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-5 h-5 rounded-full border border-accent/25 animate-pulse" />
<div className="h-5 w-5 animate-pulse rounded-full border border-accent/25" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-secondary">
Listening for server
<span className="inline-flex ml-0.5 text-text-muted">
<span className="ml-0.5 inline-flex text-text-muted">
<span className="animate-pulse">...</span>
</span>
</p>
<p className="text-[11px] text-text-muted mt-0.5">
Will auto-connect when detected
</p>
<p className="mt-0.5 text-[11px] text-text-muted">Will auto-connect when detected</p>
</div>
</div>
);
@ -213,8 +201,8 @@ interface OnboardingGuideProps {
}
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
const primary = isDev ? 'cd gitnexus && npm run serve' : 'npx gitnexus@latest serve';
const termLabel = isDev ? 'Start backend' : 'Terminal';
const primary = isDev ? 'cd gitnexus && npm run serve' : 'npx gitnexus@latest serve';
const termLabel = isDev ? 'Start backend' : 'Terminal';
// Step states: step 1 = copy command, step 2 = run/wait, step 3 = auto-connect
// Once polling starts the user has presumably run the command — mark step 1 done.
@ -223,25 +211,24 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
const step3State: StepState = 'waiting';
return (
<div className="p-7 bg-surface border border-border-default rounded-3xl animate-fade-in relative overflow-hidden">
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient background glows */}
<div className="absolute -top-28 -right-28 w-72 h-72 bg-accent/6 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-56 h-56 bg-node-function/6 rounded-full blur-3xl pointer-events-none" />
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* ── Headline ─────────────────────────────────────────────── */}
<div className="relative mb-6">
<div className="text-center">
<div className="inline-flex items-center gap-1.5 mb-2">
<Sparkles className="w-3.5 h-3.5 text-accent/70" />
<span className="text-[11px] text-accent/80 font-medium uppercase tracking-widest">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
<h2 className="text-lg font-semibold text-text-primary leading-snug">
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Start your local server
</h2>
<p className="text-sm text-text-secondary mt-1 leading-relaxed max-w-xs mx-auto">
<p className="mx-auto mt-1 max-w-xs text-sm leading-relaxed text-text-secondary">
{isDev
? 'Fire up the Express backend in a separate terminal to unlock the full graph.'
: 'One command is all it takes. The browser connects automatically.'}
@ -251,10 +238,9 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
{/* ── Step-by-step flow ───────────────────────────────────────── */}
<div className="relative space-y-5">
{/* Vertical connector line behind the dots */}
<div
className="absolute left-[11px] top-6 bottom-6 w-px bg-border-subtle pointer-events-none"
className="pointer-events-none absolute top-6 bottom-6 left-[11px] w-px bg-border-subtle"
aria-hidden="true"
/>
@ -265,19 +251,17 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
title="Copy the command"
description={isPolling ? undefined : 'Click the icon in the terminal to copy.'}
>
<TerminalWindow
command={primary}
label={termLabel}
isActive={step1State === 'active'}
/>
<TerminalWindow command={primary} label={termLabel} isActive={step1State === 'active'} />
{/* Secondary global-install option — production only */}
{!isDev && (
<>
<div className="flex items-center gap-3 my-3">
<div className="flex-1 h-px bg-border-subtle" />
<span className="text-[11px] text-text-muted uppercase tracking-widest">or install globally</span>
<div className="flex-1 h-px bg-border-subtle" />
<div className="my-3 flex items-center gap-3">
<div className="h-px flex-1 bg-border-subtle" />
<span className="text-[11px] tracking-widest text-text-muted uppercase">
or install globally
</span>
<div className="h-px flex-1 bg-border-subtle" />
</div>
<TerminalWindow
command="npm install -g gitnexus && gitnexus serve"
@ -293,11 +277,7 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
state={step2State}
number={2}
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
description={
isPolling
? undefined
: 'Open a new terminal window, paste, and hit Enter.'
}
description={isPolling ? undefined : 'Open a new terminal window, paste, and hit Enter.'}
>
{isPolling && <PollingBar />}
</StepRow>
@ -312,21 +292,21 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
</div>
{/* ── Prerequisite footnote ────────────────────────────────────── */}
<div className="mt-6 pt-5 border-t border-border-subtle flex items-center justify-center gap-1.5 text-xs text-text-muted">
<Server className="w-3 h-3 shrink-0" />
<div className="mt-6 flex items-center justify-center gap-1.5 border-t border-border-subtle pt-5 text-xs text-text-muted">
<Server className="h-3 w-3 shrink-0" />
<span>
Requires{' '}
<a
href="https://nodejs.org"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent/80 hover:underline transition-colors"
className="text-accent transition-colors hover:text-accent/80 hover:underline"
>
Node.js {REQUIRED_NODE_VERSION}+
</a>
</span>
<span className="text-border-default mx-1">·</span>
<Terminal className="w-3 h-3 shrink-0" />
<span className="mx-1 text-border-default">·</span>
<Terminal className="h-3 w-3 shrink-0" />
<span>Port 4747</span>
</div>
</div>

View file

@ -1,6 +1,6 @@
/**
* Process Flow Modal
*
*
* Displays a Mermaid flowchart for a process in a centered modal popup.
*/
@ -11,295 +11,312 @@ import DOMPurify from 'dompurify';
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
interface ProcessFlowModalProps {
process: ProcessData | null;
onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean;
process: ProcessData | null;
onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean;
}
// Initialize mermaid with cyan/purple theme matching GitNexus
// Initialize mermaid with cyan/purple theme matching GitNexus
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base',
themeVariables: {
primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee',
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 50,
nodeSpacing: 120,
rankSpacing: 140,
htmlLabels: true,
},
startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base',
themeVariables: {
primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee',
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 50,
nodeSpacing: 120,
rankSpacing: 140,
htmlLabels: true,
},
});
// Suppress distinct syntax error overlay
mermaid.parseError = (err) => {
// Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err);
// Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err);
};
export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false }: ProcessFlowModalProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Full process map gets higher default zoom (667%) and max zoom (3000%)
const defaultZoom = isFullScreen ? 6.67 : 1;
const maxZoom = isFullScreen ? 30 : 10;
const [zoom, setZoom] = useState(defaultZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Reset zoom when switching between full screen and regular mode
useEffect(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [isFullScreen, defaultZoom]);
export const ProcessFlowModal = ({
process,
onClose,
onFocusInGraph,
isFullScreen = false,
}: ProcessFlowModalProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Handle zoom with scroll wheel
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom(prev => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
// Full process map gets higher default zoom (667%) and max zoom (3000%)
const defaultZoom = isFullScreen ? 6.67 : 1;
const maxZoom = isFullScreen ? 30 : 10;
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
const [zoom, setZoom] = useState(defaultZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '+' || e.key === '=') {
setZoom(prev => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
setZoom(prev => Math.max(prev - 0.2, 0.1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [maxZoom]);
// Reset zoom when switching between full screen and regular mode
useEffect(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [isFullScreen, defaultZoom]);
// Zoom in/out handlers
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev + 0.25, maxZoom));
}, [maxZoom]);
// Handle zoom with scroll wheel
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom((prev) => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev - 0.25, 0.1));
}, []);
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
// Handle pan with mouse drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}, [pan]);
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '+' || e.key === '=') {
setZoom((prev) => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
setZoom((prev) => Math.max(prev - 0.2, 0.1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [maxZoom]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
}, [isPanning, panStart]);
// Zoom in/out handlers
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + 0.25, maxZoom));
}, [maxZoom]);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - 0.25, 0.1));
}, []);
const resetView = useCallback(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
// Handle pan with mouse drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
},
[pan],
);
// Render mermaid diagram
useEffect(() => {
if (!process || !diagramRef.current) return;
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
},
[isPanning, panStart],
);
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// Clear previous content
diagramRef.current!.innerHTML = '';
const resetView = useCallback(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
const { svg } = await mermaid.render(id, mermaidCode);
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
// Render mermaid diagram
useEffect(() => {
if (!process || !diagramRef.current) return;
diagramRef.current!.innerHTML = `
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
// Clear previous content
diagramRef.current!.innerHTML = '';
const { svg } = await mermaid.render(id, mermaidCode);
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
diagramRef.current!.innerHTML = `
<div class="text-center p-8">
<div class="text-red-400 text-sm font-medium mb-2">
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
</div>
<div class="text-slate-400 text-xs max-w-md">
${isSizeError
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
}
${
isSizeError
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
}
</div>
</div>
`;
}
};
}
};
renderDiagram();
}, [process]);
renderDiagram();
}, [process]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose();
}
}, [onClose]);
// Copy mermaid code to clipboard
const handleCopyMermaid = useCallback(async () => {
if (!process) return;
const mermaidCode = generateProcessMermaid(process);
await navigator.clipboard.writeText(mermaidCode);
}, [process]);
// Focus in graph
const handleFocusInGraph = useCallback(() => {
if (!process || !onFocusInGraph) return;
const nodeIds = process.steps.map(s => s.id);
onFocusInGraph(nodeIds, process.id);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose();
}, [process, onFocusInGraph, onClose]);
}
},
[onClose],
);
if (!process) return null;
// Copy mermaid code to clipboard
const handleCopyMermaid = useCallback(async () => {
if (!process) return;
const mermaidCode = generateProcessMermaid(process);
await navigator.clipboard.writeText(mermaidCode);
}, [process]);
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in"
onClick={handleBackdropClick}
data-testid="process-modal"
>
{/* Glassmorphism Modal */}
<div className={`bg-slate-900/60 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl shadow-cyan-500/10 flex flex-col animate-scale-in overflow-hidden relative ${isFullScreen
? 'w-[98%] h-[95vh] max-w-none'
: 'w-[95%] max-w-5xl max-h-[90vh]'
}`}>
{/* Subtle gradient overlay for extra glass feel */}
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
// Focus in graph
const handleFocusInGraph = useCallback(() => {
if (!process || !onFocusInGraph) return;
const nodeIds = process.steps.map((s) => s.id);
onFocusInGraph(nodeIds, process.id);
onClose();
}, [process, onFocusInGraph, onClose]);
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 relative z-10">
<h2 className="text-lg font-semibold text-white">
Process: {process.label}
</h2>
</div>
if (!process) return null;
{/* Diagram */}
<div
ref={scrollContainerRef}
className={`flex-1 p-8 flex items-center justify-center relative z-10 overflow-hidden ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
>
<div
ref={diagramRef}
className="[&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400 transition-transform origin-center w-fit h-fit"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
/>
</div>
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-black/20"
onClick={handleBackdropClick}
data-testid="process-modal"
>
{/* Glassmorphism Modal */}
<div
className={`animate-scale-in relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-slate-900/60 shadow-2xl shadow-cyan-500/10 backdrop-blur-2xl ${
isFullScreen ? 'h-[95vh] w-[98%] max-w-none' : 'max-h-[90vh] w-[95%] max-w-5xl'
}`}
>
{/* Subtle gradient overlay for extra glass feel */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
{/* Footer Actions */}
<div className="flex items-center justify-center gap-3 px-6 py-4 border-t border-white/10 bg-slate-900/50 relative z-10">
{/* Zoom controls */}
<div className="flex items-center gap-1 bg-white/5 border border-white/10 rounded-lg p-1">
<button
onClick={handleZoomOut}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom out (-)"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-slate-400 font-mono min-w-[3rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom in (+)"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
<button
onClick={resetView}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
title="Reset zoom and pan"
>
Reset View
</button>
{onFocusInGraph && (
<button
onClick={handleFocusInGraph}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-slate-900 bg-cyan-400 hover:bg-cyan-300 rounded-lg transition-all shadow-lg shadow-cyan-500/20"
>
<Focus className="w-4 h-4" />
Toggle Focus
</button>
)}
<button
onClick={handleCopyMermaid}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-all shadow-lg shadow-purple-500/20"
>
<Copy className="w-4 h-4" />
Copy Mermaid
</button>
<button
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
>
Close
</button>
</div>
</div>
{/* Header */}
<div className="relative z-10 border-b border-white/10 px-6 py-5">
<h2 className="text-lg font-semibold text-white">Process: {process.label}</h2>
</div>
);
{/* Diagram */}
<div
ref={scrollContainerRef}
className={`relative z-10 flex flex-1 items-center justify-center overflow-hidden p-8 ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
>
<div
ref={diagramRef}
className="h-fit w-fit origin-center transition-transform [&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
/>
</div>
{/* Footer Actions */}
<div className="relative z-10 flex items-center justify-center gap-3 border-t border-white/10 bg-slate-900/50 px-6 py-4">
{/* Zoom controls */}
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 p-1">
<button
onClick={handleZoomOut}
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Zoom out (-)"
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] px-2 text-center font-mono text-xs text-slate-400">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Zoom in (+)"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<button
onClick={resetView}
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Reset zoom and pan"
>
Reset View
</button>
{onFocusInGraph && (
<button
onClick={handleFocusInGraph}
className="flex items-center gap-2 rounded-lg bg-cyan-400 px-5 py-2.5 text-sm font-medium text-slate-900 shadow-lg shadow-cyan-500/20 transition-all hover:bg-cyan-300"
>
<Focus className="h-4 w-4" />
Toggle Focus
</button>
)}
<button
onClick={handleCopyMermaid}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-purple-500/20 transition-all hover:bg-purple-500"
>
<Copy className="h-4 w-4" />
Copy Mermaid
</button>
<button
onClick={onClose}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
>
Close
</button>
</div>
</div>
</div>
);
};

View file

@ -1,12 +1,23 @@
/**
* Processes Panel
*
*
* Lists all detected processes grouped by type (cross-community / intra-community).
* Clicking a process opens the ProcessFlowModal with a flowchart.
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { GitBranch, Search, Eye, Zap, Home, ChevronDown, ChevronRight, Sparkles, Lightbulb, Layers } from 'lucide-react';
import {
GitBranch,
Search,
Eye,
Zap,
Home,
ChevronDown,
ChevronRight,
Sparkles,
Lightbulb,
Layers,
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
@ -15,502 +26,534 @@ import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['cross', 'intra']));
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['cross', 'intra']),
);
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
// Extract processes from graph
const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] };
// Extract processes from graph
const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] };
const processNodes = graph.nodes.filter(n => n.label === 'Process');
const processNodes = graph.nodes.filter((n) => n.label === 'Process');
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
for (const node of processNodes) {
const item = {
id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [],
};
for (const node of processNodes) {
const item = {
id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [],
};
if (node.properties.processType === 'cross_community') {
cross.push(item);
} else {
intra.push(item);
}
}
if (node.properties.processType === 'cross_community') {
cross.push(item);
} else {
intra.push(item);
}
}
// Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount);
// Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount);
return { cross, intra };
}, [graph]);
return { cross, intra };
}, [graph]);
// Filter by search
const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes;
// Filter by search
const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes;
const query = searchQuery.toLowerCase();
return {
cross: processes.cross.filter(p => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter(p => p.label.toLowerCase().includes(query)),
};
}, [processes, searchQuery]);
const query = searchQuery.toLowerCase();
return {
cross: processes.cross.filter((p) => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter((p) => p.label.toLowerCase().includes(query)),
};
}, [processes, searchQuery]);
// Toggle section expansion
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// Toggle section expansion
const toggleSection = useCallback((section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all');
// Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all');
try {
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
try {
const allProcessIds = [...processes.cross, ...processes.intra]
.map((p) => p.id)
.filter(isSafeId);
if (allProcessIds.length === 0) return;
if (allProcessIds.length === 0) return;
// Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = [];
// Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = [];
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
// Optimization: Fetch all steps in one query if possible
const allStepsQuery = `
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
// Optimization: Fetch all steps in one query if possible
const allStepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE p.id IN [${allProcessIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE p.id IN [${allProcessIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
`;
const stepsResult = await runQuery(allStepsQuery);
const stepsResult = await runQuery(allStepsQuery);
for (const row of stepsResult) {
const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, {
id: stepId,
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
});
}
}
for (const row of stepsResult) {
const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, {
id: stepId,
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
});
}
}
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map((s) => s.id).filter(isSafeId);
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
// Batch query if too many steps
const edgesQuery = `
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
// Batch query if too many steps
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
allEdges.push(...edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to));
} catch (err) {
console.warn('Could not fetch combined edges:', err);
}
}
const combinedProcessData: ProcessData = {
id: 'combined-all',
label: `All Processes (${allProcessIds.length} combined)`,
processType: 'cross_community', // Treat as cross-community for styling
steps: allSteps,
edges: allEdges,
clusters: [],
};
setSelectedProcess(combinedProcessData);
} catch (error) {
console.error('Failed to load combined processes:', error);
} finally {
setLoadingProcess(null);
}
}, [processes, runQuery]);
// Load process steps and open modal
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
if (!isSafeId(processId)) return;
setLoadingProcess(processId);
try {
// Query for process steps
const stepsQuery = `
const edgesResult = await runQuery(edgesQuery);
allEdges.push(
...edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter((edge) => edge.from !== edge.to),
);
} catch (err) {
console.warn('Could not fetch combined edges:', err);
}
}
const combinedProcessData: ProcessData = {
id: 'combined-all',
label: `All Processes (${allProcessIds.length} combined)`,
processType: 'cross_community', // Treat as cross-community for styling
steps: allSteps,
edges: allEdges,
clusters: [],
};
setSelectedProcess(combinedProcessData);
} catch (error) {
console.error('Failed to load combined processes:', error);
} finally {
setLoadingProcess(null);
}
}, [processes, runQuery]);
// Load process steps and open modal
const handleViewProcess = useCallback(
async (processId: string, label: string, processType: string) => {
if (!isSafeId(processId)) return;
setLoadingProcess(processId);
try {
// Query for process steps
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
ORDER BY r.step
`;
const stepsResult = await runQuery(stepsQuery);
const stepsResult = await runQuery(stepsQuery);
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0],
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
}));
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0],
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
}));
// Get step IDs for edge query
const stepIds = steps.map(s => s.id).filter(isSafeId);
// Get step IDs for edge query
const stepIds = steps.map((s) => s.id).filter(isSafeId);
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) {
const edgesQuery = `
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) {
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
edges = edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to); // Remove self-loops
} catch (err) {
console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear
}
}
// Get clusters for this process
const processNode = graph?.nodes.find(n => n.id === processId);
const clusters = processNode?.properties.communities || [];
const processData: ProcessData = {
id: processId,
label,
processType: processType as 'cross_community' | 'intra_community',
steps,
edges,
clusters,
};
setSelectedProcess(processData);
} catch (error) {
console.error('Failed to load process steps:', error);
} finally {
setLoadingProcess(null);
}
}, [runQuery, graph]);
// Cache for process steps (so we don't re-query when toggling focus)
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
if (!isSafeId(processId)) return;
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
return;
try {
const edgesResult = await runQuery(edgesQuery);
edges = edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter((edge) => edge.from !== edge.to); // Remove self-loops
} catch (err) {
console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear
}
}
// Check if we have cached steps
if (processStepsCache.has(processId)) {
const stepIds = processStepsCache.get(processId)!;
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Get clusters for this process
const processNode = graph?.nodes.find((n) => n.id === processId);
const clusters = processNode?.properties.communities || [];
// Load steps for this process
setLoadingProcess(processId);
try {
const stepsQuery = `
const processData: ProcessData = {
id: processId,
label,
processType: processType as 'cross_community' | 'intra_community',
steps,
edges,
clusters,
};
setSelectedProcess(processData);
} catch (error) {
console.error('Failed to load process steps:', error);
} finally {
setLoadingProcess(null);
}
},
[runQuery, graph],
);
// Cache for process steps (so we don't re-query when toggling focus)
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(
async (processId: string) => {
if (!isSafeId(processId)) return;
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
return;
}
// Check if we have cached steps
if (processStepsCache.has(processId)) {
const stepIds = processStepsCache.get(processId)!;
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Load steps for this process
setLoadingProcess(processId);
try {
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id
`;
const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
// Cache the result
setProcessStepsCache(prev => new Map(prev).set(processId, stepIds));
// Cache the result
setProcessStepsCache((prev) => new Map(prev).set(processId, stepIds));
// Set focus
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
} catch (error) {
console.error('Failed to load process steps for focus:', error);
} finally {
setLoadingProcess(null);
}
}, [focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds]);
// Set focus
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
} catch (error) {
console.error('Failed to load process steps for focus:', error);
} finally {
setLoadingProcess(null);
}
},
[focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds],
);
// Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback((nodeIds: string[], processId: string) => {
// Check if this process is already focused
if (focusedProcessId === processId) {
// Clear focus
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
} else {
// Set focus and cache
setHighlightedNodeIds(new Set(nodeIds));
setFocusedProcessId(processId);
setProcessStepsCache(prev => new Map(prev).set(processId, nodeIds));
}
}, [focusedProcessId, setHighlightedNodeIds]);
// Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback(
(nodeIds: string[], processId: string) => {
// Check if this process is already focused
if (focusedProcessId === processId) {
// Clear focus
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
} else {
// Set focus and cache
setHighlightedNodeIds(new Set(nodeIds));
setFocusedProcessId(processId);
setProcessStepsCache((prev) => new Map(prev).set(processId, nodeIds));
}
},
[focusedProcessId, setHighlightedNodeIds],
);
// Clear focused process when highlights are cleared externally
useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(null);
}
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-surface rounded-xl">
<GitBranch className="w-7 h-7 text-text-muted" />
</div>
<h3 className="text-base font-medium text-text-primary mb-2">No Processes Detected</h3>
<p className="text-sm text-text-secondary max-w-xs">
Processes are execution flows traced from entry points. Load a codebase to see detected processes.
</p>
</div>
);
// Clear focused process when highlights are cleared externally
useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(null);
}
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return (
<div className="flex flex-col h-full">
{/* Header with search */}
<div className="p-3 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-lg focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="w-4 h-4 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter processes..."
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted" data-testid="process-list-loaded">
<span>{totalCount} processes detected</span>
</div>
</div>
{/* Process list */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button
onClick={handleViewAllProcesses}
disabled={loadingProcess !== null}
className="w-full flex items-center gap-3 p-3 bg-elevated/40 hover:bg-elevated/80 border border-border-subtle hover:border-cyan-500/30 rounded-xl transition-all group shadow-sm hover:shadow-cyan-900/10 text-left"
>
<div className="p-2 bg-cyan-500/10 rounded-lg group-hover:bg-cyan-500/20 transition-colors">
<Layers className="w-5 h-5 text-cyan-400" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">Full Process Map</h4>
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
</div>
{loadingProcess === 'all' ? (
<span className="animate-spin mr-1">
<Sparkles className="w-4 h-4 text-cyan-400" />
</span>
) : (
<Eye className="w-4 h-4 text-text-muted group-hover:text-cyan-400" />
)}
</button>
</div>
{/* Cross-Community Section */}
{filteredProcesses.cross.length > 0 && (
<div className="border-b border-border-subtle">
<button
onClick={() => toggleSection('cross')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('cross') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.cross.length}
</span>
</button>
{expandedSections.has('cross') && (
<div className="pb-2">
{filteredProcesses.cross.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
{/* Intra-Community Section */}
{filteredProcesses.intra.length > 0 && (
<div>
<button
onClick={() => toggleSection('intra')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('intra') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Home className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.intra.length}
</span>
</button>
{expandedSections.has('intra') && (
<div className="pb-2">
{filteredProcesses.intra.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modal */}
<ProcessFlowModal
process={selectedProcess}
onClose={() => setSelectedProcess(null)}
onFocusInGraph={handleFocusInGraph}
isFullScreen={selectedProcess?.id === 'combined-all'}
/>
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-surface">
<GitBranch className="h-7 w-7 text-text-muted" />
</div>
<h3 className="mb-2 text-base font-medium text-text-primary">No Processes Detected</h3>
<p className="max-w-xs text-sm text-text-secondary">
Processes are execution flows traced from entry points. Load a codebase to see detected
processes.
</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Header with search */}
<div className="border-b border-border-subtle p-3">
<div className="mb-2 flex items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="h-4 w-4 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter processes..."
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
/>
</div>
</div>
<div
className="flex items-center gap-2 text-xs text-text-muted"
data-testid="process-list-loaded"
>
<span>{totalCount} processes detected</span>
</div>
</div>
{/* Process list */}
<div className="scrollbar-thin flex-1 overflow-y-auto">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button
onClick={handleViewAllProcesses}
disabled={loadingProcess !== null}
className="group flex w-full items-center gap-3 rounded-xl border border-border-subtle bg-elevated/40 p-3 text-left shadow-sm transition-all hover:border-cyan-500/30 hover:bg-elevated/80 hover:shadow-cyan-900/10"
>
<div className="rounded-lg bg-cyan-500/10 p-2 transition-colors group-hover:bg-cyan-500/20">
<Layers className="h-5 w-5 text-cyan-400" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">
Full Process Map
</h4>
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
</div>
{loadingProcess === 'all' ? (
<span className="mr-1 animate-spin">
<Sparkles className="h-4 w-4 text-cyan-400" />
</span>
) : (
<Eye className="h-4 w-4 text-text-muted group-hover:text-cyan-400" />
)}
</button>
</div>
{/* Cross-Community Section */}
{filteredProcesses.cross.length > 0 && (
<div className="border-b border-border-subtle">
<button
onClick={() => toggleSection('cross')}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
>
{expandedSections.has('cross') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
<Zap className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
{filteredProcesses.cross.length}
</span>
</button>
{expandedSections.has('cross') && (
<div className="pb-2">
{filteredProcesses.cross.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
{/* Intra-Community Section */}
{filteredProcesses.intra.length > 0 && (
<div>
<button
onClick={() => toggleSection('intra')}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
>
{expandedSections.has('intra') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
<Home className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
{filteredProcesses.intra.length}
</span>
</button>
{expandedSections.has('intra') && (
<div className="pb-2">
{filteredProcesses.intra.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modal */}
<ProcessFlowModal
process={selectedProcess}
onClose={() => setSelectedProcess(null)}
onFocusInGraph={handleFocusInGraph}
isFullScreen={selectedProcess?.id === 'combined-all'}
/>
</div>
);
};
// Individual process item
interface ProcessItemProps {
process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean;
isSelected: boolean;
isFocused: boolean;
onView: () => void;
onToggleFocus: () => void;
process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean;
isSelected: boolean;
isFocused: boolean;
onView: () => void;
onToggleFocus: () => void;
}
const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onToggleFocus }: ProcessItemProps) => {
// Determine row styling - focused gets special highlight
const rowClass = isFocused
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
: isSelected
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
: '';
const ProcessItem = ({
process,
isLoading,
isSelected,
isFocused,
onView,
onToggleFocus,
}: ProcessItemProps) => {
// Determine row styling - focused gets special highlight
const rowClass = isFocused
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
: isSelected
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
: '';
return (
<div data-testid="process-row" className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{process.label}</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{process.stepCount} steps</span>
{process.clusters.length > 0 && (
<>
<span></span>
<span>{process.clusters.length} clusters</span>
</>
)}
</div>
</div>
{/* Lightbulb icon - appears on hover, always visible when focused */}
<button
onClick={onToggleFocus}
className={`p-1.5 rounded-md transition-all ${isFocused
? 'text-amber-400 hover:text-amber-300 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-400/40 animate-pulse opacity-100'
: 'text-text-muted hover:text-cyan-400 bg-white/5 hover:bg-cyan-500/20 border border-white/10 hover:border-cyan-400/40 opacity-0 group-hover:opacity-100'
}`}
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
data-testid="process-highlight-button"
>
<Lightbulb className="w-4 h-4" />
</button>
<button
onClick={onView}
disabled={isLoading}
data-testid="process-view-button"
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-all disabled:opacity-50 shadow-sm ${isSelected
? 'text-cyan-300 bg-cyan-900/60 border border-cyan-400/60 opacity-100'
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-900/50 border border-cyan-500/30 hover:border-cyan-400/50 opacity-0 group-hover:opacity-100 shadow-cyan-900/20'
}`}
>
{isLoading ? (
<span className="animate-pulse">Loading...</span>
) : isSelected ? (
<>
<Eye className="w-3.5 h-3.5" />
Viewing
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View
</>
)}
</button>
return (
<div
data-testid="process-row"
className={`group mx-2 flex items-center gap-2 rounded-lg px-4 py-2 transition-all hover:bg-hover ${rowClass}`}
>
<GitBranch className="h-4 w-4 flex-shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-text-primary">{process.label}</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{process.stepCount} steps</span>
{process.clusters.length > 0 && (
<>
<span></span>
<span>{process.clusters.length} clusters</span>
</>
)}
</div>
);
</div>
{/* Lightbulb icon - appears on hover, always visible when focused */}
<button
onClick={onToggleFocus}
className={`rounded-md p-1.5 transition-all ${
isFocused
? 'animate-pulse border border-amber-400/40 bg-amber-500/20 text-amber-400 opacity-100 hover:bg-amber-500/30 hover:text-amber-300'
: 'border border-white/10 bg-white/5 text-text-muted opacity-0 group-hover:opacity-100 hover:border-cyan-400/40 hover:bg-cyan-500/20 hover:text-cyan-400'
}`}
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
data-testid="process-highlight-button"
>
<Lightbulb className="h-4 w-4" />
</button>
<button
onClick={onView}
disabled={isLoading}
data-testid="process-view-button"
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all disabled:opacity-50 ${
isSelected
? 'border border-cyan-400/60 bg-cyan-900/60 text-cyan-300 opacity-100'
: 'border border-cyan-500/30 bg-cyan-950/30 text-cyan-400 opacity-0 shadow-cyan-900/20 group-hover:opacity-100 hover:border-cyan-400/50 hover:bg-cyan-900/50 hover:text-cyan-300'
}`}
>
{isLoading ? (
<span className="animate-pulse">Loading...</span>
) : isSelected ? (
<>
<Eye className="h-3.5 w-3.5" />
Viewing
</>
) : (
<>
<Eye className="h-3.5 w-3.5" />
View
</>
)}
</button>
</div>
);
};

View file

@ -1,5 +1,14 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from '@/lib/lucide-icons';
import {
Terminal,
Play,
X,
ChevronDown,
ChevronUp,
Loader2,
Sparkles,
Table,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
const EXAMPLE_QUERIES = [
@ -26,7 +35,15 @@ const EXAMPLE_QUERIES = [
];
export const QueryFAB = () => {
const { setHighlightedNodeIds, setQueryResult, queryResult, clearQueryHighlights, graph, runQuery, isDatabaseReady } = useAppState();
const {
setHighlightedNodeIds,
setQueryResult,
queryResult,
clearQueryHighlights,
graph,
runQuery,
isDatabaseReady,
} = useAppState();
const [isExpanded, setIsExpanded] = useState(false);
const [query, setQuery] = useState('');
@ -95,12 +112,12 @@ export const QueryFAB = () => {
const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/;
const nodeIds = rows
.flatMap(row => {
.flatMap((row) => {
const ids: string[] = [];
if (Array.isArray(row)) {
// Array format - check all elements for node ID patterns
row.forEach(val => {
row.forEach((val) => {
if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) {
ids.push(val);
}
@ -169,25 +186,12 @@ export const QueryFAB = () => {
return (
<button
onClick={() => setIsExpanded(true)}
className="
group absolute bottom-4 left-4 z-20
flex items-center gap-2 px-4 py-2.5
bg-gradient-to-r from-cyan-500 to-teal-500
rounded-xl text-white font-medium text-sm
shadow-[0_0_20px_rgba(6,182,212,0.4)]
hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]
hover:-translate-y-0.5
transition-all duration-200
"
className="group absolute bottom-4 left-4 z-20 flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-2.5 text-sm font-medium text-white shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]"
>
<Terminal className="w-4 h-4" />
<Terminal className="h-4 w-4" />
<span>Query</span>
{queryResult && queryResult.nodeIds.length > 0 && (
<span className="
px-1.5 py-0.5 ml-1
bg-white/20 rounded-md
text-xs font-semibold
">
<span className="ml-1 rounded-md bg-white/20 px-1.5 py-0.5 text-xs font-semibold">
{queryResult.nodeIds.length}
</span>
)}
@ -198,28 +202,20 @@ export const QueryFAB = () => {
return (
<div
ref={panelRef}
className="
absolute bottom-4 left-4 z-20
w-[480px] max-w-[calc(100%-2rem)]
bg-deep/95 backdrop-blur-md
border border-cyan-500/30
rounded-xl
shadow-[0_0_40px_rgba(6,182,212,0.2)]
animate-fade-in
"
className="absolute bottom-4 left-4 z-20 w-[480px] max-w-[calc(100%-2rem)] animate-fade-in rounded-xl border border-cyan-500/30 bg-deep/95 shadow-[0_0_40px_rgba(6,182,212,0.2)] backdrop-blur-md"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-cyan-500 to-teal-500 rounded-lg">
<Terminal className="w-4 h-4 text-white" />
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-500">
<Terminal className="h-4 w-4 text-white" />
</div>
<span className="font-medium text-sm">Cypher Query</span>
<span className="text-sm font-medium">Cypher Query</span>
</div>
<button
onClick={handleClose}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded-md transition-colors"
className="rounded-md p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
@ -232,52 +228,30 @@ export const QueryFAB = () => {
onKeyDown={handleKeyDown}
placeholder="MATCH (n:Function) RETURN n.name, n.filePath LIMIT 10"
rows={3}
className="
w-full px-3 py-2.5
bg-surface border border-border-subtle rounded-lg
text-sm font-mono text-text-primary
placeholder:text-text-muted
focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20
outline-none resize-none
transition-all
"
className="w-full resize-none rounded-lg border border-border-subtle bg-surface px-3 py-2.5 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20"
/>
</div>
<div className="flex items-center justify-between mt-3">
<div className="mt-3 flex items-center justify-between">
<div className="relative">
<button
onClick={() => setShowExamples(!showExamples)}
className="
flex items-center gap-1.5 px-3 py-1.5
text-xs text-text-secondary
hover:text-text-primary hover:bg-hover
rounded-md transition-colors
"
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
<Sparkles className="w-3.5 h-3.5" />
<Sparkles className="h-3.5 w-3.5" />
<span>Examples</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`} />
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`}
/>
</button>
{showExamples && (
<div className="
absolute bottom-full left-0 mb-2
w-64 py-1
bg-surface border border-border-subtle rounded-lg
shadow-xl
animate-fade-in
">
<div className="absolute bottom-full left-0 mb-2 w-64 animate-fade-in rounded-lg border border-border-subtle bg-surface py-1 shadow-xl">
{EXAMPLE_QUERIES.map((example) => (
<button
key={example.label}
onClick={() => handleSelectExample(example.query)}
className="
w-full px-3 py-2 text-left
text-sm text-text-secondary
hover:bg-hover hover:text-text-primary
transition-colors
"
className="w-full px-3 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
{example.label}
</button>
@ -290,12 +264,7 @@ export const QueryFAB = () => {
{query && (
<button
onClick={handleClear}
className="
px-3 py-1.5
text-xs text-text-secondary
hover:text-text-primary hover:bg-hover
rounded-md transition-colors
"
className="rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
Clear
</button>
@ -303,76 +272,74 @@ export const QueryFAB = () => {
<button
onClick={handleRunQuery}
disabled={!query.trim() || isRunning}
className="
flex items-center gap-1.5 px-4 py-1.5
bg-gradient-to-r from-cyan-500 to-teal-500
rounded-md text-white text-sm font-medium
shadow-[0_0_15px_rgba(6,182,212,0.3)]
hover:shadow-[0_0_20px_rgba(6,182,212,0.5)]
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none
transition-all
"
className="flex items-center gap-1.5 rounded-md bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.5)] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
<Play className="h-3.5 w-3.5" />
)}
<span>Run</span>
<kbd className="ml-1 px-1 py-0.5 bg-white/20 rounded text-[10px]"></kbd>
<kbd className="ml-1 rounded bg-white/20 px-1 py-0.5 text-[10px]"></kbd>
</button>
</div>
</div>
</div>
{error && (
<div className="px-4 py-2 bg-red-500/10 border-t border-red-500/20">
<p className="text-xs text-red-400 font-mono">{error}</p>
<div className="border-t border-red-500/20 bg-red-500/10 px-4 py-2">
<p className="font-mono text-xs text-red-400">{error}</p>
</div>
)}
{queryResult && !error && (
<div className="border-t border-cyan-500/20">
<div className="px-4 py-2.5 bg-cyan-500/5 flex items-center justify-between">
<div className="flex items-center justify-between bg-cyan-500/5 px-4 py-2.5">
<div className="flex items-center gap-3 text-xs">
<span className="text-text-secondary">
<span className="text-cyan-400 font-semibold">{queryResult.rows.length}</span> rows
<span className="font-semibold text-cyan-400">{queryResult.rows.length}</span> rows
</span>
{queryResult.nodeIds.length > 0 && (
<span className="text-text-secondary">
<span className="text-cyan-400 font-semibold">{queryResult.nodeIds.length}</span> highlighted
<span className="font-semibold text-cyan-400">{queryResult.nodeIds.length}</span>{' '}
highlighted
</span>
)}
<span className="text-text-muted">
{queryResult.executionTime.toFixed(1)}ms
</span>
<span className="text-text-muted">{queryResult.executionTime.toFixed(1)}ms</span>
</div>
<div className="flex items-center gap-2">
{queryResult.nodeIds.length > 0 && (
<button
onClick={clearQueryHighlights}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
className="text-xs text-text-muted transition-colors hover:text-text-primary"
>
Clear
</button>
)}
<button
onClick={() => setShowResults(!showResults)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-primary transition-colors"
className="flex items-center gap-1 text-xs text-text-muted transition-colors hover:text-text-primary"
>
<Table className="w-3 h-3" />
{showResults ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
<Table className="h-3 w-3" />
{showResults ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</button>
</div>
</div>
{showResults && queryResult.rows.length > 0 && (
<div className="max-h-48 overflow-auto scrollbar-thin border-t border-border-subtle">
<div className="scrollbar-thin max-h-48 overflow-auto border-t border-border-subtle">
<table className="w-full text-xs">
<thead className="bg-surface sticky top-0">
<thead className="sticky top-0 bg-surface">
<tr>
{Object.keys(queryResult.rows[0]).map((key) => (
<th key={key} className="px-3 py-2 text-left text-text-muted font-medium border-b border-border-subtle">
<th
key={key}
className="border-b border-border-subtle px-3 py-2 text-left font-medium text-text-muted"
>
{key}
</th>
))}
@ -380,9 +347,12 @@ export const QueryFAB = () => {
</thead>
<tbody>
{queryResult.rows.slice(0, 50).map((row, i) => (
<tr key={i} className="hover:bg-hover/50 transition-colors">
<tr key={i} className="transition-colors hover:bg-hover/50">
{Object.values(row).map((val, j) => (
<td key={j} className="px-3 py-1.5 text-text-secondary border-b border-border-subtle/50 font-mono truncate max-w-[200px]">
<td
key={j}
className="max-w-[200px] truncate border-b border-border-subtle/50 px-3 py-1.5 font-mono text-text-secondary"
>
{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '')}
</td>
))}
@ -391,7 +361,7 @@ export const QueryFAB = () => {
</tbody>
</table>
{queryResult.rows.length > 50 && (
<div className="px-3 py-2 text-xs text-text-muted bg-surface border-t border-border-subtle">
<div className="border-t border-border-subtle bg-surface px-3 py-2 text-xs text-text-muted">
Showing 50 of {queryResult.rows.length} rows
</div>
)}
@ -402,4 +372,3 @@ export const QueryFAB = () => {
</div>
);
};

View file

@ -39,39 +39,31 @@ function isValidGithubUrl(value: string): boolean {
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
return (
<div className="flex gap-1 p-1 bg-elevated rounded-lg" role="tablist" aria-label="Input type">
<div className="flex gap-1 rounded-lg bg-elevated p-1" role="tablist" aria-label="Input type">
<button
role="tab"
aria-selected={mode === 'github'}
onClick={() => onChange('github')}
className={`
flex-1 flex items-center justify-center gap-1.5
px-3 py-1.5 text-xs font-medium rounded-md
transition-all duration-150 cursor-pointer
${mode === 'github'
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'github'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}
`}
} `}
>
<Github className="w-3 h-3" />
<Github className="h-3 w-3" />
GitHub URL
</button>
<button
role="tab"
aria-selected={mode === 'local'}
onClick={() => onChange('local')}
className={`
flex-1 flex items-center justify-center gap-1.5
px-3 py-1.5 text-xs font-medium rounded-md
transition-all duration-150 cursor-pointer
${mode === 'local'
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'local'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}
`}
} `}
>
<FolderOpen className="w-3 h-3" />
<FolderOpen className="h-3 w-3" />
Local Folder
</button>
</div>
@ -80,28 +72,32 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
// ── Analyze button ───────────────────────────────────────────────────────────
function AnalyzeButton({ canSubmit, isLoading, onClick, variant }: {
function AnalyzeButton({
canSubmit,
isLoading,
onClick,
variant,
}: {
canSubmit: boolean;
isLoading: boolean;
onClick: () => void;
variant: 'onboarding' | 'sheet';
}) {
const sizeClass = variant === 'onboarding' ? 'w-full px-5 py-3.5 text-sm' : 'w-full px-4 py-3 text-sm';
const sizeClass =
variant === 'onboarding' ? 'w-full px-5 py-3.5 text-sm' : 'w-full px-4 py-3 text-sm';
return (
<button
onClick={onClick}
disabled={!canSubmit || isLoading}
className={`
${sizeClass} flex items-center justify-center gap-2.5 rounded-xl font-medium transition-all duration-200
${canSubmit && !isLoading
? 'bg-accent hover:bg-accent/90 text-white shadow-glow-soft hover:shadow-glow hover:-translate-y-0.5 cursor-pointer'
: 'bg-elevated border border-border-subtle text-text-muted cursor-not-allowed'
}
`}
className={` ${sizeClass} flex items-center justify-center gap-2.5 rounded-xl font-medium transition-all duration-200 ${
canSubmit && !isLoading
? 'cursor-pointer bg-accent text-white shadow-glow-soft hover:-translate-y-0.5 hover:bg-accent/90 hover:shadow-glow'
: 'cursor-not-allowed border border-border-subtle bg-elevated text-text-muted'
} `}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
<span>{isLoading ? 'Starting analysis...' : 'Analyze Repository'}</span>
{canSubmit && !isLoading && <ArrowRight className="w-3.5 h-3.5" />}
{canSubmit && !isLoading && <ArrowRight className="h-3.5 w-3.5" />}
</button>
);
}
@ -110,13 +106,17 @@ function AnalyzeButton({ canSubmit, isLoading, onClick, variant }: {
function DoneState({ repoName }: { repoName: string }) {
return (
<div className="py-4 flex flex-col items-center gap-3 animate-fade-in" role="status" aria-live="polite">
<div className="w-12 h-12 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<Check className="w-6 h-6 text-emerald-400" />
<div
className="flex animate-fade-in flex-col items-center gap-3 py-4"
role="status"
aria-live="polite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<Check className="h-6 w-6 text-emerald-400" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-emerald-400">Analysis complete</p>
<p className="text-xs text-text-muted mt-0.5 font-mono">{repoName}</p>
<p className="mt-0.5 font-mono text-xs text-text-muted">{repoName}</p>
</div>
<p className="text-xs text-text-secondary">Loading graph...</p>
</div>
@ -141,7 +141,11 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('input');
const [validationError, setValidationError] = useState<string | null>(null);
const [progress, setProgress] = useState<JobProgress>({ phase: 'queued', percent: 0, message: 'Queued' });
const [progress, setProgress] = useState<JobProgress>({
phase: 'queued',
percent: 0,
message: 'Queued',
});
const [completedRepoName, setCompletedRepoName] = useState('');
const jobIdRef = useRef<string | null>(null);
@ -168,9 +172,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
// browsers don't expose absolute paths for security reasons).
// For local paths, the user types or pastes the absolute path.
const canSubmit = mode === 'github'
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const canSubmit =
mode === 'github'
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
@ -186,9 +191,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setPhase('starting');
try {
const request = mode === 'github'
? { url: githubUrl.trim() }
: { path: localPath.trim() };
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
jobIdRef.current = jobId;
setPhase('analyzing');
@ -198,9 +201,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
jobId,
(p) => setProgress(p),
(data) => {
const name = data.repoName
?? nameSource.split(/[/\\]/).filter(Boolean).at(-1)
?? 'repository';
const name =
data.repoName ?? nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? 'repository';
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
@ -225,7 +227,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
sseControllerRef.current?.abort();
sseControllerRef.current = null;
if (jobIdRef.current) {
try { await cancelAnalyze(jobIdRef.current); } catch {}
try {
await cancelAnalyze(jobIdRef.current);
} catch {}
jobIdRef.current = null;
}
setPhase('input');
@ -244,37 +248,49 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
{/* GitHub URL input */}
{showInput && mode === 'github' && (
<div className="space-y-2">
<label htmlFor={inputId} className="block text-xs font-medium text-text-secondary uppercase tracking-wider">
<label
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
GitHub Repository URL
</label>
<div className={`
flex items-center gap-3 px-4 py-3.5 bg-void border rounded-xl transition-all duration-200
${validationError && phase === 'error'
? 'border-red-500/50'
: isValidGithubUrl(githubUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
}
`}>
<Github className="w-4 h-4 text-text-muted shrink-0" />
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: isValidGithubUrl(githubUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<Github className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={inputId}
type="url"
value={githubUrl}
onChange={e => { setGithubUrl(e.target.value); if (validationError) setValidationError(null); }}
onKeyDown={e => { if (e.key === 'Enter' && canSubmit && !isLoading) { e.preventDefault(); handleAnalyze(); } }}
onChange={(e) => {
setGithubUrl(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder="https://github.com/owner/repo"
autoComplete="url"
spellCheck={false}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted disabled:opacity-50 font-mono"
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{githubUrl.length > 10 && (
<div className="shrink-0">
{isValidGithubUrl(githubUrl)
? <Check className="w-3.5 h-3.5 text-emerald-400" />
: <AlertCircle className="w-3.5 h-3.5 text-text-muted" />
}
{isValidGithubUrl(githubUrl) ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</div>
)}
</div>
@ -284,33 +300,44 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
{/* Local folder input */}
{showInput && mode === 'local' && (
<div className="space-y-2">
<label htmlFor={`${inputId}-local`} className="block text-xs font-medium text-text-secondary uppercase tracking-wider">
<label
htmlFor={`${inputId}-local`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
Local Folder Path
</label>
<div className={`
flex items-center gap-3 px-4 py-3.5 bg-void border rounded-xl transition-all duration-200
${validationError && phase === 'error'
? 'border-red-500/50'
: localPath.trim().length > 1
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
}
`}>
<FolderOpen className="w-4 h-4 text-text-muted shrink-0" />
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: localPath.trim().length > 1
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<FolderOpen className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={`${inputId}-local`}
type="text"
value={localPath}
onChange={e => { setLocalPath(e.target.value); if (validationError) setValidationError(null); }}
onKeyDown={e => { if (e.key === 'Enter' && canSubmit && !isLoading) { e.preventDefault(); handleAnalyze(); } }}
onChange={(e) => {
setLocalPath(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
autoComplete="off"
spellCheck={false}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted disabled:opacity-50 font-mono"
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{localPath.trim().length > 1 && (
<Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
</div>
{/* Native folder picker + Browse button — below the input */}
@ -320,7 +347,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
webkitdirectory=""
className="hidden"
onChange={e => {
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
const rel = files[0].webkitRelativePath;
@ -337,9 +364,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
type="button"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-text-secondary hover:text-text-primary bg-elevated hover:bg-hover border border-border-subtle rounded-lg transition-all duration-150 cursor-pointer disabled:opacity-50"
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
>
<FolderOpen className="w-3.5 h-3.5" />
<FolderOpen className="h-3.5 w-3.5" />
Browse for folder
</button>
</div>
@ -347,8 +374,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
{/* Error message */}
{(phase === 'error' || (phase === 'input' && validationError)) && validationError && (
<p className="text-xs text-red-400 animate-fade-in flex items-center gap-1.5">
<AlertCircle className="w-3 h-3 shrink-0" />{validationError}
<p className="flex animate-fade-in items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="h-3 w-3 shrink-0" />
{validationError}
</p>
)}
@ -364,20 +392,31 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
{/* CTA button */}
{(phase === 'input' || phase === 'starting') && (
<AnalyzeButton canSubmit={canSubmit} isLoading={isLoading} onClick={handleAnalyze} variant={variant} />
<AnalyzeButton
canSubmit={canSubmit}
isLoading={isLoading}
onClick={handleAnalyze}
variant={variant}
/>
)}
{/* Error retry */}
{phase === 'error' && (
<div className="flex items-center gap-3">
<button
onClick={() => { setValidationError(null); setPhase('input'); }}
className="flex-1 px-4 py-2.5 bg-elevated border border-border-subtle text-sm text-text-secondary hover:text-text-primary hover:bg-hover rounded-xl transition-all duration-200 cursor-pointer"
onClick={() => {
setValidationError(null);
setPhase('input');
}}
className="flex-1 cursor-pointer rounded-xl border border-border-subtle bg-elevated px-4 py-2.5 text-sm text-text-secondary transition-all duration-200 hover:bg-hover hover:text-text-primary"
>
Try again
</button>
{onCancel && (
<button onClick={onCancel} className="px-4 py-2.5 text-sm text-text-muted hover:text-text-secondary transition-colors cursor-pointer">
<button
onClick={onCancel}
className="cursor-pointer px-4 py-2.5 text-sm text-text-muted transition-colors hover:text-text-secondary"
>
Dismiss
</button>
)}
@ -386,7 +425,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
{/* Dismiss for sheet variant while analyzing */}
{phase === 'analyzing' && variant === 'sheet' && onCancel && (
<button onClick={onCancel} className="w-full text-xs text-text-muted hover:text-text-secondary transition-colors py-1 cursor-pointer">
<button
onClick={onCancel}
className="w-full cursor-pointer py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
>
Hide (analysis continues in background)
</button>
)}

View file

@ -1,7 +1,13 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Square, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, GitBranch
Send,
Square,
Sparkles,
User,
PanelRightClose,
Loader2,
AlertTriangle,
GitBranch,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
@ -42,102 +48,119 @@ export const RightPanel = () => {
return null;
}, []);
const findFileNodeIdForUI = useCallback((filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) => n.label === 'File' && n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target
);
return node?.id;
}, [graph]);
const findFileNodeIdForUI = useCallback(
(filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) =>
n.label === 'File' &&
n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target,
);
return node?.id;
},
[graph],
);
const handleGroundingClick = useCallback((inner: string) => {
const raw = inner.trim();
if (!raw) return;
const handleGroundingClick = useCallback(
(inner: string) => {
const raw = inner.trim();
if (!raw) return;
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
// Match line:num or line:num-num (supports both hyphen - and en dash )
const lineMatch = raw.match(/^(.*):(\d+)(?:[-](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
// Match line:num or line:num-num (supports both hyphen - and en dash )
const lineMatch = raw.match(/^(.*):(\d+)(?:[-](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
const nodeId = findFileNodeIdForUI(resolvedPath);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1 ? Math.max(0, endLine1 - 1) : (startLine1 ? Math.max(0, startLine1 - 1) : undefined),
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
}, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1
? Math.max(0, endLine1 - 1)
: startLine1
? Math.max(0, startLine1 - 1)
: undefined,
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
},
[addCodeReference, findFileNodeIdForUI, resolveFilePathForUI],
);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
const handleNodeGroundingClick = useCallback((nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
const handleNodeGroundingClick = useCallback(
(nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
// Parse Type:Name format
const match = raw.match(/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/);
if (!match) return;
// Parse Type:Name format
const match = raw.match(
/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/,
);
if (!match) return;
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
// Find node in graph by type + name
const node = graph.nodes.find(n =>
n.label === nodeType &&
n.properties.name === trimmedName
);
// Find node in graph by type + name
const node = graph.nodes.find(
(n) => n.label === nodeType && n.properties.name === trimmedName,
);
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
}
}, [graph, resolveFilePathForUI, addCodeReference]);
const handleLinkClick = useCallback((href: string) => {
if (href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
handleGroundingClick(inner);
} else if (href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
handleNodeGroundingClick(inner);
}
}, [handleGroundingClick, handleNodeGroundingClick]);
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
}
}
},
[graph, resolveFilePathForUI, addCodeReference],
);
const handleLinkClick = useCallback(
(href: string) => {
if (href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
handleGroundingClick(inner);
} else if (href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
handleNodeGroundingClick(inner);
}
},
[handleGroundingClick, handleNodeGroundingClick],
);
// Auto-resize textarea as user types
const adjustTextareaHeight = useCallback(() => {
@ -189,33 +212,35 @@ export const RightPanel = () => {
if (!isRightPanelOpen) return null;
return (
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
<aside className="relative z-30 flex w-[40%] max-w-[600px] min-w-[400px] flex-shrink-0 animate-slide-in flex-col border-l border-border-subtle bg-deep">
{/* Header with Tabs */}
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle bg-surface px-4 py-2">
<div className="flex items-center gap-1">
{/* Chat Tab */}
<button
onClick={() => setActiveTab('chat')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'chat'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === 'chat'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:bg-hover hover:text-text-primary'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
<Sparkles className="h-3.5 w-3.5" />
<span>Nexus AI</span>
</button>
{/* Processes Tab */}
<button
onClick={() => setActiveTab('processes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'processes'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === 'processes'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:bg-hover hover:text-text-primary'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
<GitBranch className="h-3.5 w-3.5" />
<span>Processes</span>
<span className="text-[10px] px-1.5 py-0.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-full font-semibold">
<span className="rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
NEW
</span>
</button>
@ -224,34 +249,34 @@ export const RightPanel = () => {
{/* Close button */}
<button
onClick={() => setRightPanelOpen(false)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Close Panel"
>
<PanelRightClose className="w-4 h-4" />
<PanelRightClose className="h-4 w-4" />
</button>
</div>
{/* Processes Tab */}
{activeTab === 'processes' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
<ProcessesPanel />
</div>
)}
{/* Chat Content - only show when chat tab is active */}
{activeTab === 'chat' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Status bar */}
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
<div className="flex items-center gap-2.5 border-b border-border-subtle bg-elevated/50 px-4 py-3">
<div className="ml-auto flex items-center gap-2">
{!isAgentReady && (
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-300">
Configure AI
</span>
)}
{isAgentInitializing && (
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
<span className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Connecting
</span>
)}
</div>
@ -259,33 +284,30 @@ export const RightPanel = () => {
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
<AlertTriangle className="h-4 w-4" />
<span>{agentError}</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
🧠
</div>
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
<h3 className="mb-2 text-base font-medium">Ask me anything</h3>
<p className="mb-5 text-sm leading-relaxed text-text-secondary">
I can help you understand the architecture, find functions, or explain
connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="flex flex-wrap justify-center gap-2">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
className="rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent hover:text-text-primary"
>
{suggestion}
</button>
@ -295,41 +317,40 @@ export const RightPanel = () => {
) : (
<div className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div
key={message.id}
className="animate-fade-in"
>
<div key={message.id} className="animate-fade-in">
{/* User message - compact label style */}
{message.role === 'user' && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-text-muted" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
</div>
<div className="pl-6 text-sm text-text-primary">
{message.content}
<div className="mb-2 flex items-center gap-2">
<User className="h-4 w-4 text-text-muted" />
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
You
</span>
</div>
<div className="pl-6 text-sm text-text-primary">{message.content}</div>
</div>
)}
{/* Assistant message - copilot style */}
{message.role === 'assistant' && (
<div>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
Nexus AI
</span>
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
<Loader2 className="w-3 h-3 animate-spin text-accent" />
<Loader2 className="h-3 w-3 animate-spin text-accent" />
)}
</div>
<div className="pl-6 chat-prose">
<div className="chat-prose pl-6">
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step, index) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
<div className="mb-3 border-l-2 border-text-muted/30 pl-3 text-sm text-text-secondary italic">
<MarkdownRenderer
content={step.content}
onLinkClick={handleLinkClick}
@ -338,7 +359,10 @@ export const RightPanel = () => {
)}
{step.type === 'tool_call' && step.toolCall && (
<div className="mb-3">
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
<ToolCallCard
toolCall={step.toolCall}
defaultExpanded={false}
/>
</div>
)}
{step.type === 'content' && step.content && (
@ -365,8 +389,6 @@ export const RightPanel = () => {
)}
</div>
))}
</div>
)}
{/* Scroll anchor for auto-scroll */}
@ -374,8 +396,8 @@ export const RightPanel = () => {
</div>
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<div className="border-t border-border-subtle bg-surface p-3">
<div className="flex items-end gap-2 rounded-xl border border-border-subtle bg-elevated px-3 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
@ -383,12 +405,12 @@ export const RightPanel = () => {
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
className="px-2 py-1 text-xs text-text-muted transition-colors hover:text-text-primary"
title="Clear chat"
>
Clear
@ -396,24 +418,24 @@ export const RightPanel = () => {
{isChatLoading ? (
<button
onClick={stopChatResponse}
className="w-9 h-9 flex items-center justify-center bg-red-500/80 rounded-md text-white transition-all hover:bg-red-500"
className="flex h-9 w-9 items-center justify-center rounded-md bg-red-500/80 text-white transition-all hover:bg-red-500"
title="Stop response"
>
<Square className="w-3.5 h-3.5 fill-current" />
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white transition-all hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<div className="mt-2 flex items-center gap-2 text-xs text-amber-200">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
@ -427,6 +449,3 @@ export const RightPanel = () => {
</aside>
);
};

View file

@ -1,5 +1,18 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons';
import {
X,
Key,
Server,
Brain,
Check,
AlertCircle,
Eye,
EyeOff,
RefreshCw,
ChevronDown,
Loader2,
Search,
} from '@/lib/lucide-icons';
import {
loadSettings,
saveSettings,
@ -31,7 +44,13 @@ interface OpenRouterModelComboboxProps {
onLoadModels: () => void;
}
const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadModels }: OpenRouterModelComboboxProps) => {
const OpenRouterModelCombobox = ({
value,
onChange,
models,
isLoading,
onLoadModels,
}: OpenRouterModelComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
@ -41,16 +60,15 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
const filteredModels = useMemo(() => {
if (!searchTerm.trim()) return models;
const lower = searchTerm.toLowerCase();
return models.filter(m =>
m.id.toLowerCase().includes(lower) ||
m.name.toLowerCase().includes(lower)
return models.filter(
(m) => m.id.toLowerCase().includes(lower) || m.name.toLowerCase().includes(lower),
);
}, [models, searchTerm]);
// Find display name for current value
const displayValue = useMemo(() => {
if (!value) return '';
const found = models.find(m => m.id === value);
const found = models.find((m) => m.id === value);
return found ? found.name : value;
}, [value, models]);
@ -93,7 +111,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchTerm) {
// If exact match in filtered, select it; otherwise use raw input
const exact = filteredModels.find(m => m.id.toLowerCase() === searchTerm.toLowerCase());
const exact = filteredModels.find((m) => m.id.toLowerCase() === searchTerm.toLowerCase());
if (exact) {
handleSelect(exact.id);
} else if (filteredModels.length === 1) {
@ -115,8 +133,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
{/* Main input/button */}
<div
onClick={handleOpen}
className={`w-full px-4 py-3 bg-elevated border rounded-xl cursor-pointer transition-all flex items-center gap-2
${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
className={`flex w-full cursor-pointer items-center gap-2 rounded-xl border bg-elevated px-4 py-3 transition-all ${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
>
{isOpen ? (
<input
@ -126,58 +143,61 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Search or type model ID..."
className="flex-1 bg-transparent text-text-primary placeholder:text-text-muted outline-none font-mono text-sm"
onClick={e => e.stopPropagation()}
className="flex-1 bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={`flex-1 font-mono text-sm truncate ${value ? 'text-text-primary' : 'text-text-muted'}`}>
<span
className={`flex-1 truncate font-mono text-sm ${value ? 'text-text-primary' : 'text-text-muted'}`}
>
{displayValue || 'Select or type a model...'}
</span>
)}
<div className="flex items-center gap-1">
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-text-muted" />}
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-text-muted" />}
<ChevronDown
className={`h-4 w-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-elevated border border-border-subtle rounded-xl shadow-xl overflow-hidden">
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border border-border-subtle bg-elevated shadow-xl">
{isLoading ? (
<div className="px-4 py-6 text-center text-text-muted text-sm flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<div className="flex items-center justify-center gap-2 px-4 py-6 text-center text-sm text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
Loading models...
</div>
) : filteredModels.length === 0 ? (
<div className="px-4 py-4 text-center">
{models.length === 0 ? (
<div className="text-text-muted text-sm">
<Search className="w-5 h-5 mx-auto mb-2 opacity-50" />
<div className="text-sm text-text-muted">
<Search className="mx-auto mb-2 h-5 w-5 opacity-50" />
<p>Type a model ID or press Enter</p>
<p className="text-xs mt-1">e.g. openai/gpt-4o</p>
<p className="mt-1 text-xs">e.g. openai/gpt-4o</p>
</div>
) : (
<div className="text-text-muted text-sm">
<div className="text-sm text-text-muted">
<p>No models match "{searchTerm}"</p>
<p className="text-xs mt-1">Press Enter to use as custom ID</p>
<p className="mt-1 text-xs">Press Enter to use as custom ID</p>
</div>
)}
</div>
) : (
<div className="max-h-64 overflow-y-auto">
{filteredModels.slice(0, 50).map(model => (
{filteredModels.slice(0, 50).map((model) => (
<button
key={model.id}
onClick={() => handleSelect(model.id)}
className={`w-full px-4 py-2.5 text-left hover:bg-hover transition-colors flex flex-col
${model.id === value ? 'bg-accent/10' : ''}`}
className={`flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-hover ${model.id === value ? 'bg-accent/10' : ''}`}
>
<span className="text-text-primary text-sm truncate">{model.name}</span>
<span className="text-text-muted text-xs font-mono truncate">{model.id}</span>
<span className="truncate text-sm text-text-primary">{model.name}</span>
<span className="truncate font-mono text-xs text-text-muted">{model.id}</span>
</button>
))}
{filteredModels.length > 50 && (
<div className="px-4 py-2 text-xs text-text-muted text-center border-t border-border-subtle">
<div className="border-t border-border-subtle px-4 py-2 text-center text-xs text-text-muted">
+{filteredModels.length - 50} more Refine your search
</div>
)}
@ -192,7 +212,9 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
/**
* Check connection to local Ollama instance
*/
const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error: string | null }> => {
const checkOllamaStatus = async (
baseUrl: string,
): Promise<{ ok: boolean; error: string | null }> => {
try {
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
@ -201,7 +223,10 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
if (!response.ok) {
if (response.status === 0 || response.status === 404) {
return { ok: false, error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' };
return {
ok: false,
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
};
}
return { ok: false, error: `Ollama API error: ${response.status}` };
}
@ -210,12 +235,19 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
} catch (error) {
return {
ok: false,
error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`'
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
};
}
};
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, isBackendConnected, onBackendUrlChange }: SettingsPanelProps) => {
export const SettingsPanel = ({
isOpen,
onClose,
onSettingsSaved,
backendUrl,
isBackendConnected,
onBackendUrlChange,
}: SettingsPanelProps) => {
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
@ -274,7 +306,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
}, [settings.ollama?.baseUrl, settings.activeProvider, checkOllamaConnection]);
const handleProviderChange = (provider: LLMProvider) => {
setSettings(prev => ({ ...prev, activeProvider: provider }));
setSettings((prev) => ({ ...prev, activeProvider: provider }));
};
const handleSave = () => {
@ -292,29 +324,34 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
};
const toggleApiKeyVisibility = (key: string) => {
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
setShowApiKey((prev) => ({ ...prev, [key]: !prev[key] }));
};
if (!isOpen) return null;
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax', 'glm'];
const providers: LLMProvider[] = [
'openai',
'gemini',
'anthropic',
'azure-openai',
'ollama',
'openrouter',
'minimax',
'glm',
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Panel */}
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden max-h-[90vh] flex flex-col">
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-elevated/50">
<div className="flex items-center justify-between border-b border-border-subtle bg-elevated/50 px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 flex items-center justify-center bg-accent/20 rounded-xl">
<Brain className="w-5 h-5 text-accent" />
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
<Brain className="h-5 w-5 text-accent" />
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
@ -323,25 +360,25 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
<button
onClick={onClose}
className="p-2 text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
>
<X className="w-5 h-5" />
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* Local Server */}
{backendUrl !== undefined && onBackendUrlChange && (
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
Local Server
</label>
<label className="block text-sm font-medium text-text-secondary">Local Server</label>
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Server className="w-4 h-4 text-text-muted" />
<div className="mb-2 flex items-center gap-2">
<Server className="h-4 w-4 text-text-muted" />
<span className="text-sm text-text-secondary">Backend URL</span>
<span className={`w-2 h-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`} />
<span
className={`h-2 w-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`}
/>
<span className="text-xs text-text-muted">
{isBackendConnected ? 'Connected' : 'Not connected'}
</span>
@ -351,10 +388,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
value={backendUrl}
onChange={(e) => onBackendUrlChange(e.target.value)}
placeholder="http://localhost:4747"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Run <code className="px-1 py-0.5 bg-elevated rounded">gitnexus serve</code> to start the local server
Run <code className="rounded bg-elevated px-1 py-0.5">gitnexus serve</code> to
start the local server
</p>
</div>
</div>
@ -362,27 +400,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* Provider Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
Provider
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{providers.map(provider => (
<label className="block text-sm font-medium text-text-secondary">Provider</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{providers.map((provider) => (
<button
key={provider}
onClick={() => handleProviderChange(provider)}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all
${settings.activeProvider === provider
className={`flex items-center gap-3 rounded-xl border-2 p-4 transition-all ${
settings.activeProvider === provider
? 'border-accent bg-accent/10 text-text-primary'
: 'border-border-subtle bg-elevated hover:border-accent/50 text-text-secondary'
}
`}
: 'border-border-subtle bg-elevated text-text-secondary hover:border-accent/50'
} `}
>
<div className={`
w-8 h-8 rounded-lg flex items-center justify-center text-lg
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
`}>
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : provider === 'glm' ? '🔮' : '☁️'}
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg text-lg ${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'} `}
>
{provider === 'openai'
? '🤖'
: provider === 'gemini'
? '💎'
: provider === 'anthropic'
? '🧠'
: provider === 'ollama'
? '🦙'
: provider === 'openrouter'
? '🌐'
: provider === 'minimax'
? '⚡'
: provider === 'glm'
? '🔮'
: '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@ -390,7 +437,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
</div>
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-200">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
API keys are stored in session storage and will be cleared when you close this tab.
</div>
@ -405,38 +452,43 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://platform.openai.com/api-keys',
helperLinkLabel: 'OpenAI Platform',
isVisible: !!showApiKey['openai'],
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('openai'),
}}
model={{
value: settings.openai?.model ?? 'gpt-5.2-chat',
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, model: value },
})),
}}
>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
Base URL <span className="text-text-muted font-normal">(optional)</span>
<Server className="h-4 w-4" />
Base URL <span className="font-normal text-text-muted">(optional)</span>
</label>
<input
type="url"
value={settings.openai?.baseUrl ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, baseUrl: e.target.value },
}))
}
placeholder="https://api.openai.com/v1 (default)"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
Leave empty to use the default OpenAI API. Set a custom URL for proxies or
compatible APIs.
</p>
</div>
</ProviderConfigCard>
@ -453,19 +505,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://aistudio.google.com/app/apikey',
helperLinkLabel: 'Google AI Studio',
isVisible: !!showApiKey['gemini'],
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
gemini: { ...prev.gemini!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('gemini'),
}}
model={{
value: settings.gemini?.model ?? 'gemini-2.0-flash',
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
gemini: { ...prev.gemini!, model: value },
})),
}}
/>
)}
@ -481,66 +535,76 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://console.anthropic.com/settings/keys',
helperLinkLabel: 'Anthropic Console',
isVisible: !!showApiKey['anthropic'],
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('anthropic'),
}}
model={{
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
anthropic: { ...prev.anthropic!, model: value },
})),
}}
/>
)}
{/* Azure OpenAI Settings */}
{settings.activeProvider === 'azure-openai' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['azure'] ? 'text' : 'password'}
value={settings.azureOpenAI?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value },
}))
}
placeholder="Enter your Azure OpenAI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('azure')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{showApiKey['azure'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showApiKey['azure'] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
<Server className="h-4 w-4" />
Endpoint
</label>
<input
type="url"
value={settings.azureOpenAI?.endpoint ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value },
}))
}
placeholder="https://your-resource.openai.azure.com"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@ -549,12 +613,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.deploymentName ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value },
}))
}
placeholder="e.g., gpt-4o-deployment"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@ -564,12 +630,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value },
}))
}
placeholder="gpt-4o"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@ -578,12 +646,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value },
}))
}
placeholder="2024-08-01-preview"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
</div>
@ -604,10 +674,10 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* Ollama Settings */}
{settings.activeProvider === 'ollama' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
{/* How to run Ollama */}
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl">
<p className="text-xs text-amber-300 leading-relaxed">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-xs leading-relaxed text-amber-300">
<span className="font-medium">📋 Quick Start:</span> Install Ollama from{' '}
<a
href="https://ollama.ai"
@ -616,41 +686,46 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
className="text-accent hover:underline"
>
ollama.ai
</a>, then run:
</a>
, then run:
</p>
<code className="block mt-2 px-3 py-2 bg-black/30 rounded-lg text-amber-200 font-mono text-sm">
<code className="mt-2 block rounded-lg bg-black/30 px-3 py-2 font-mono text-sm text-amber-200">
ollama serve
</code>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
<Server className="h-4 w-4" />
Base URL
</label>
<div className="flex gap-2">
<input
type="url"
value={settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value },
}))
}
placeholder={DEFAULT_OLLAMA_BASE_URL}
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="flex-1 rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)}
onClick={() =>
checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)
}
disabled={isCheckingOllama}
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
className="rounded-xl border border-border-subtle bg-elevated px-3 py-3 text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary disabled:opacity-50"
title="Check connection"
>
<RefreshCw className={`w-4 h-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-4 w-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-text-muted">
Default port is <code className="px-1 py-0.5 bg-elevated rounded">11434</code>.
Default port is <code className="rounded bg-elevated px-1 py-0.5">11434</code>.
</p>
</div>
@ -658,9 +733,9 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
{ollamaError && !isCheckingOllama && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2">
<p className="flex items-center gap-1 text-xs text-red-400">
<AlertCircle className="h-3 w-3" />
{ollamaError}
</p>
</div>
@ -669,15 +744,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.ollama?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, model: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ollama: { ...prev.ollama!, model: e.target.value },
}))
}
placeholder="e.g., llama3.2, mistral, codellama"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Pull a model with <code className="px-1 py-0.5 bg-elevated rounded">ollama pull llama3.2</code>
Pull a model with{' '}
<code className="rounded bg-elevated px-1 py-0.5">ollama pull llama3.2</code>
</p>
</div>
</div>
@ -694,10 +772,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://openrouter.ai/keys',
helperLinkLabel: 'OpenRouter Keys',
isVisible: !!showApiKey['openrouter'],
onChange: (value) => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('openrouter'),
}}
>
@ -705,10 +784,12 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
<OpenRouterModelCombobox
value={settings.openrouter?.model ?? ''}
onChange={(model) => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, model }
}))}
onChange={(model) =>
setSettings((prev) => ({
...prev,
openrouter: { ...prev.openrouter!, model },
}))
}
models={openRouterModels}
isLoading={isLoadingModels}
onLoadModels={loadOpenRouterModels}
@ -739,19 +820,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://platform.minimax.io',
helperLinkLabel: 'MiniMax Platform',
isVisible: !!showApiKey['minimax'],
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
minimax: { ...prev.minimax!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
}}
model={{
value: settings.minimax?.model ?? 'MiniMax-M2.5',
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
minimax: { ...prev.minimax!, model: value },
})),
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
}}
/>
@ -759,29 +842,35 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* GLM Settings */}
{settings.activeProvider === 'glm' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['glm'] ? 'text' : 'password'}
value={settings.glm?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, apiKey: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, apiKey: e.target.value },
}))
}
placeholder="Enter your Z.AI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('glm')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{showApiKey['glm'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showApiKey['glm'] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="text-xs text-text-muted">
@ -801,14 +890,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
<select
value={settings.glm?.model ?? 'GLM-5'}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, model: e.target.value },
}))
}
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
>
{getAvailableModels('glm').map(model => (
<option key={model} value={model}>{model}</option>
{getAvailableModels('glm').map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
@ -818,12 +911,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.glm?.baseUrl ?? 'https://api.z.ai/api/coding/paas/v4'}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, baseUrl: e.target.value },
}))
}
placeholder="https://api.z.ai/api/coding/paas/v4"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API.
@ -833,30 +928,33 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
)}
{/* Privacy Note */}
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
<div className="rounded-xl border border-border-subtle bg-elevated/50 p-4">
<div className="flex gap-3">
<div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-lg text-green-400 flex-shrink-0">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-green-500/20 text-green-400">
🔒
</div>
<div className="text-xs text-text-muted leading-relaxed">
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's session storage and are cleared when the tab closes. They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
<div className="text-xs leading-relaxed text-text-muted">
<span className="font-medium text-text-secondary">Privacy:</span> Your API keys are
stored only in your browser's session storage and are cleared when the tab closes.
They're sent directly to the LLM provider when you chat. Your code never leaves your
machine.
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border-subtle bg-elevated/30">
<div className="flex items-center justify-between border-t border-border-subtle bg-elevated/30 px-6 py-4">
<div className="flex items-center gap-2 text-sm">
{saveStatus === 'saved' && (
<span className="flex items-center gap-1.5 text-green-400 animate-fade-in">
<Check className="w-4 h-4" />
<span className="flex animate-fade-in items-center gap-1.5 text-green-400">
<Check className="h-4 w-4" />
Settings saved
</span>
)}
{saveStatus === 'error' && (
<span className="flex items-center gap-1.5 text-red-400 animate-fade-in">
<AlertCircle className="w-4 h-4" />
<span className="flex animate-fade-in items-center gap-1.5 text-red-400">
<AlertCircle className="h-4 w-4" />
Failed to save
</span>
)}
@ -864,13 +962,13 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
className="px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-5 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-dim"
>
Save Settings
</button>
@ -880,4 +978,3 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
);
};

View file

@ -11,28 +11,29 @@ export const StatusBar = () => {
// Detect primary language
const primaryLanguage = useMemo(() => {
if (!graph) return null;
const languages = graph.nodes
.map(n => n.properties.language)
.filter(Boolean);
const languages = graph.nodes.map((n) => n.properties.language).filter(Boolean);
if (languages.length === 0) return null;
const counts = languages.reduce((acc, lang) => {
acc[lang!] = (acc[lang!] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const counts = languages.reduce(
(acc, lang) => {
acc[lang!] = (acc[lang!] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
}, [graph]);
return (
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
<footer className="flex items-center justify-between border-t border-dashed border-border-subtle bg-deep px-5 py-2 text-[11px] text-text-muted">
{/* Left - Status */}
<div className="flex items-center gap-4">
{progress && progress.phase !== 'complete' ? (
<>
<div className="w-28 h-1 bg-elevated rounded-full overflow-hidden">
<div className="h-1 w-28 overflow-hidden rounded-full bg-elevated">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
@ -40,7 +41,7 @@ export const StatusBar = () => {
</>
) : (
<div className="flex items-center gap-1.5" data-testid="status-ready">
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
<span className="h-1.5 w-1.5 rounded-full bg-node-function" />
<span>Ready</span>
</div>
)}
@ -51,11 +52,13 @@ export const StatusBar = () => {
href="https://github.com/sponsors/abhigyanpatwari"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 hover:bg-pink-500/20 hover:border-pink-500/40 hover:scale-[1.02] transition-all duration-200 cursor-pointer"
className="group flex cursor-pointer items-center gap-2 rounded-full border border-pink-500/20 bg-pink-500/10 px-3 py-1 transition-all duration-200 hover:scale-[1.02] hover:border-pink-500/40 hover:bg-pink-500/20"
>
<Heart className="w-3.5 h-3.5 text-pink-500 fill-pink-500/40 group-hover:fill-pink-500 group-hover:scale-110 transition-all duration-200 animate-pulse" />
<span className="text-[11px] font-medium text-pink-400 group-hover:text-pink-300 transition-colors">Sponsor</span>
<span className="text-[10px] text-pink-300/50 group-hover:text-pink-300/80 italic hidden md:inline transition-colors">
<Heart className="h-3.5 w-3.5 animate-pulse fill-pink-500/40 text-pink-500 transition-all duration-200 group-hover:scale-110 group-hover:fill-pink-500" />
<span className="text-[11px] font-medium text-pink-400 transition-colors group-hover:text-pink-300">
Sponsor
</span>
<span className="hidden text-[10px] text-pink-300/50 italic transition-colors group-hover:text-pink-300/80 md:inline">
need to buy some API credits to run SWE-bench 😅
</span>
</a>

View file

@ -1,12 +1,19 @@
/**
* ToolCallCard Component
*
*
* Displays a tool call with expand/collapse functionality.
* Shows the tool name, status, and when expanded, the query/args and result.
*/
import { useState } from 'react';
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from '@/lib/lucide-icons';
import {
ChevronDown,
ChevronRight,
Sparkles,
Check,
Loader2,
AlertCircle,
} from '@/lib/lucide-icons';
import type { ToolCallInfo } from '../core/llm/types';
interface ToolCallCardProps {
@ -49,28 +56,28 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="w-3.5 h-3.5 animate-spin" />,
icon: <Loader2 className="h-3.5 w-3.5 animate-spin" />,
color: 'text-amber-400',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
};
case 'completed':
return {
icon: <Check className="w-3.5 h-3.5" />,
icon: <Check className="h-3.5 w-3.5" />,
color: 'text-emerald-400',
bgColor: 'bg-emerald-500/10',
borderColor: 'border-emerald-500/30',
};
case 'error':
return {
icon: <AlertCircle className="w-3.5 h-3.5" />,
icon: <AlertCircle className="h-3.5 w-3.5" />,
color: 'text-rose-400',
bgColor: 'bg-rose-500/10',
borderColor: 'border-rose-500/30',
};
default:
return {
icon: <Sparkles className="w-3.5 h-3.5" />,
icon: <Sparkles className="h-3.5 w-3.5" />,
color: 'text-text-muted',
bgColor: 'bg-surface',
borderColor: 'border-border-subtle',
@ -84,13 +91,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
const getToolDisplayName = (name: string): string => {
const names: Record<string, string> = {
// Current 7-tool architecture
'search': '🔍 Search Code',
'cypher': '🔗 Cypher Query',
'grep': '🔎 Pattern Search',
'read': '📄 Read File',
'overview': '🗺️ Codebase Overview',
'explore': '🔬 Deep Dive',
'impact': '💥 Impact Analysis',
search: '🔍 Search Code',
cypher: '🔗 Cypher Query',
grep: '🔎 Pattern Search',
read: '📄 Read File',
overview: '🗺️ Codebase Overview',
explore: '🔬 Deep Dive',
impact: '💥 Impact Analysis',
};
return names[name] || name;
};
@ -101,18 +108,25 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
const formattedArgs = formatArgs(toolCall.args);
return (
<div className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}>
<div
className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}
>
{/* Header - always visible */}
<div
role="button"
tabIndex={0}
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsExpanded(!isExpanded); } }}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors cursor-pointer select-none"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors select-none hover:bg-white/5"
>
{/* Expand/collapse icon */}
<span className="text-text-muted">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
{/* Tool name */}
@ -132,11 +146,11 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
<div className="border-t border-border-subtle/50">
{/* Arguments/Query */}
{formattedArgs && (
<div className="px-3 py-2 border-b border-border-subtle/50">
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
<div className="border-b border-border-subtle/50 px-3 py-2">
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
{toolCall.name === 'cypher' ? 'Query' : 'Input'}
</div>
<pre className="text-xs text-text-secondary bg-surface/50 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
<pre className="overflow-x-auto rounded bg-surface/50 p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
{formattedArgs}
</pre>
</div>
@ -145,15 +159,14 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
{/* Result */}
{toolCall.result && (
<div className="px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
Result
</div>
<div className="max-h-[400px] overflow-y-auto bg-surface/50 rounded">
<pre className="text-xs text-text-secondary p-2 whitespace-pre-wrap font-mono">
<div className="max-h-[400px] overflow-y-auto rounded bg-surface/50">
<pre className="p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
{toolCall.result.length > 3000
? toolCall.result.slice(0, 3000) + '\n\n... (truncated)'
: toolCall.result
}
: toolCall.result}
</pre>
</div>
</div>
@ -161,8 +174,8 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
{/* Loading state for in-progress */}
{toolCall.status === 'running' && !toolCall.result && (
<div className="px-3 py-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
<div className="flex items-center gap-2 px-3 py-3 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Executing...</span>
</div>
)}

View file

@ -41,27 +41,27 @@ export const WebGPUFallbackDialog = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
/>
{/* Dialog */}
<div
className={`relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden transition-all duration-200 ${isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}
<div
className={`relative mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
>
{/* Header with scratching emoji */}
<div className="relative bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5 border-b border-border-subtle">
<div className="relative border-b border-border-subtle bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-4 right-4 p-1 text-text-muted transition-colors hover:text-text-primary"
>
<X className="w-5 h-5" />
<X className="h-5 w-5" />
</button>
<div className="flex items-center gap-4">
{/* Animated emoji */}
<div
<div
className={`text-5xl ${isAnimating ? 'animate-bounce' : ''}`}
onAnimationEnd={() => setIsAnimating(false)}
onClick={() => setIsAnimating(true)}
@ -69,10 +69,8 @@ export const WebGPUFallbackDialog = ({
🤔
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">
WebGPU said "nope"
</h2>
<p className="text-sm text-text-muted mt-0.5">
<h2 className="text-lg font-semibold text-text-primary">WebGPU said "nope"</h2>
<p className="mt-0.5 text-sm text-text-muted">
Your browser doesn't support GPU acceleration
</p>
</div>
@ -80,65 +78,68 @@ export const WebGPUFallbackDialog = ({
</div>
{/* Content */}
<div className="px-6 py-5 space-y-4">
<p className="text-sm text-text-secondary leading-relaxed">
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG)
won't be as smart. The graph still works fine though!
<div className="space-y-4 px-6 py-5">
<p className="text-sm leading-relaxed text-text-secondary">
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as
smart. The graph still works fine though!
</p>
<div className="bg-elevated/50 rounded-lg p-4 border border-border-subtle">
<div className="rounded-lg border border-border-subtle bg-elevated/50 p-4">
<p className="text-sm text-text-secondary">
<span className="font-medium text-text-primary">Your options:</span>
</p>
<ul className="mt-2 space-y-1.5 text-sm text-text-muted">
<li className="flex items-start gap-2">
<Snail className="w-4 h-4 mt-0.5 text-amber-400 flex-shrink-0" />
<Snail className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-400" />
<span>
<strong className="text-text-secondary">Use CPU</strong> Works but {isSmallCodebase ? 'a bit' : 'way'} slower
<strong className="text-text-secondary">Use CPU</strong> Works but{' '}
{isSmallCodebase ? 'a bit' : 'way'} slower
{nodeCount > 0 && (
<span className="text-text-muted"> (~{estimatedMinutes} min for {nodeCount} nodes)</span>
<span className="text-text-muted">
{' '}
(~{estimatedMinutes} min for {nodeCount} nodes)
</span>
)}
</span>
</li>
<li className="flex items-start gap-2">
<SkipForward className="w-4 h-4 mt-0.5 text-blue-400 flex-shrink-0" />
<SkipForward className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
<span>
<strong className="text-text-secondary">Skip it</strong> Graph works, just no AI semantic search
<strong className="text-text-secondary">Skip it</strong> Graph works, just no AI
semantic search
</span>
</li>
</ul>
</div>
{isSmallCodebase && (
<p className="text-xs text-node-function flex items-center gap-1.5 bg-node-function/10 px-3 py-2 rounded-lg">
<Rocket className="w-3.5 h-3.5" />
<p className="flex items-center gap-1.5 rounded-lg bg-node-function/10 px-3 py-2 text-xs text-node-function">
<Rocket className="h-3.5 w-3.5" />
Small codebase detected! CPU should be fine.
</p>
)}
<p className="text-xs text-text-muted">
💡 Tip: Try Chrome or Edge for WebGPU support
</p>
<p className="text-xs text-text-muted">💡 Tip: Try Chrome or Edge for WebGPU support</p>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-elevated/30 border-t border-border-subtle flex gap-3">
<div className="flex gap-3 border-t border-border-subtle bg-elevated/30 px-6 py-4">
<button
onClick={onSkip}
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary bg-surface border border-border-subtle rounded-lg hover:bg-hover hover:text-text-primary transition-all flex items-center justify-center gap-2"
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2.5 text-sm font-medium text-text-secondary transition-all hover:bg-hover hover:text-text-primary"
>
<SkipForward className="w-4 h-4" />
<SkipForward className="h-4 w-4" />
Skip Embeddings
</button>
<button
onClick={onUseCPU}
className={`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all flex items-center justify-center gap-2 ${
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all ${
isSmallCodebase
? 'bg-node-function text-white hover:bg-node-function/90'
: 'bg-amber-500/20 text-amber-300 border border-amber-500/30 hover:bg-amber-500/30'
: 'border border-amber-500/30 bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
}`}
>
<Snail className="w-4 h-4" />
<Snail className="h-4 w-4" />
Use CPU {isSmallCodebase ? '(Recommended)' : '(Slow)'}
</button>
</div>
@ -146,4 +147,3 @@ export const WebGPUFallbackDialog = ({
</div>
);
};

View file

@ -36,36 +36,34 @@ export const ProviderConfigCard = ({
children,
}: ProviderConfigCardProps) => {
return (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
{description ? (
<p className="text-xs text-text-muted">{description}</p>
) : null}
{description ? <p className="text-xs text-text-muted">{description}</p> : null}
</div>
</div>
{apiKey && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={apiKey.isVisible ? 'text' : 'password'}
value={apiKey.value}
onChange={e => apiKey.onChange(e.target.value)}
onChange={(e) => apiKey.onChange(e.target.value)}
placeholder={apiKey.placeholder}
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={apiKey.onToggleVisibility}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{apiKey.isVisible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{apiKey.isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{apiKey.helperText && (
@ -94,13 +92,11 @@ export const ProviderConfigCard = ({
<input
type="text"
value={model.value}
onChange={e => model.onChange(e.target.value)}
onChange={(e) => model.onChange(e.target.value)}
placeholder={model.placeholder}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
{model.helperText ? (
<p className="text-xs text-text-muted">{model.helperText}</p>
) : null}
{model.helperText ? <p className="text-xs text-text-muted">{model.helperText}</p> : null}
</div>
)}

View file

@ -1,191 +1,270 @@
const DEFAULT_IGNORE_LIST = new Set([
// Version Control
'.git',
'.svn',
'.hg',
'.bzr',
// IDEs & Editors
'.idea',
'.vscode',
'.vs',
'.eclipse',
'.settings',
'.DS_Store',
'Thumbs.db',
// Dependencies
'node_modules',
'bower_components',
'jspm_packages',
'vendor', // PHP/Go
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
'venv',
'.venv',
'env',
'.env',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'site-packages',
'.tox',
'eggs',
'.eggs',
'lib64',
'parts',
'sdist',
'wheels',
// Build Outputs
'dist',
'build',
'out',
'output',
'bin',
'obj',
'target', // Java/Rust
'.next',
'.nuxt',
'.output',
'.vercel',
'.netlify',
'.serverless',
'_build',
'public/build',
'.parcel-cache',
'.turbo',
'.svelte-kit',
// Test & Coverage
'coverage',
'.nyc_output',
'htmlcov',
'.coverage',
'__tests__', // Often just test files
'__mocks__',
'.jest',
// Logs & Temp
'logs',
'log',
'tmp',
'temp',
'cache',
'.cache',
'.tmp',
'.temp',
// Generated/Compiled
'.generated',
'generated',
'auto-generated',
'.terraform',
'.serverless',
// Documentation (optional - might want to keep)
// 'docs',
// 'documentation',
// Misc
'.husky',
'.github', // GitHub config, not code
'.circleci',
'.gitlab',
'fixtures', // Test fixtures
'snapshots', // Jest snapshots
'__snapshots__',
// Version Control
'.git',
'.svn',
'.hg',
'.bzr',
// IDEs & Editors
'.idea',
'.vscode',
'.vs',
'.eclipse',
'.settings',
'.DS_Store',
'Thumbs.db',
// Dependencies
'node_modules',
'bower_components',
'jspm_packages',
'vendor', // PHP/Go
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
'venv',
'.venv',
'env',
'.env',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'site-packages',
'.tox',
'eggs',
'.eggs',
'lib64',
'parts',
'sdist',
'wheels',
// Build Outputs
'dist',
'build',
'out',
'output',
'bin',
'obj',
'target', // Java/Rust
'.next',
'.nuxt',
'.output',
'.vercel',
'.netlify',
'.serverless',
'_build',
'public/build',
'.parcel-cache',
'.turbo',
'.svelte-kit',
// Test & Coverage
'coverage',
'.nyc_output',
'htmlcov',
'.coverage',
'__tests__', // Often just test files
'__mocks__',
'.jest',
// Logs & Temp
'logs',
'log',
'tmp',
'temp',
'cache',
'.cache',
'.tmp',
'.temp',
// Generated/Compiled
'.generated',
'generated',
'auto-generated',
'.terraform',
'.serverless',
// Documentation (optional - might want to keep)
// 'docs',
// 'documentation',
// Misc
'.husky',
'.github', // GitHub config, not code
'.circleci',
'.gitlab',
'fixtures', // Test fixtures
'snapshots', // Jest snapshots
'__snapshots__',
]);
const IGNORED_EXTENSIONS = new Set([
// Images
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp', '.tiff', '.tif',
'.psd', '.ai', '.sketch', '.fig', '.xd',
// Archives
'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz', '.tgz',
// Binary/Compiled
'.exe', '.dll', '.so', '.dylib', '.a', '.lib', '.o', '.obj',
'.class', '.jar', '.war', '.ear',
'.pyc', '.pyo', '.pyd',
'.beam', // Erlang
'.wasm', // WebAssembly - important!
'.node', // Native Node addons
// Documents
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.odt', '.ods', '.odp',
// Media
'.mp4', '.mp3', '.wav', '.mov', '.avi', '.mkv', '.flv', '.wmv',
'.ogg', '.webm', '.flac', '.aac', '.m4a',
// Fonts
'.woff', '.woff2', '.ttf', '.eot', '.otf',
// Databases
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb',
// Minified/Bundled files
'.min.js', '.min.css', '.bundle.js', '.chunk.js',
// Source maps (debug files, not source)
'.map',
// Lock files (handled separately, but also here)
'.lock',
// Certificates & Keys (security - don't index!)
'.pem', '.key', '.crt', '.cer', '.p12', '.pfx',
// Data files (often large/binary)
'.csv', '.tsv', '.parquet', '.avro', '.feather',
'.npy', '.npz', '.pkl', '.pickle', '.h5', '.hdf5',
// Misc binary
'.bin', '.dat', '.data', '.raw',
'.iso', '.img', '.dmg',
// Images
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
'.ico',
'.webp',
'.bmp',
'.tiff',
'.tif',
'.psd',
'.ai',
'.sketch',
'.fig',
'.xd',
// Archives
'.zip',
'.tar',
'.gz',
'.rar',
'.7z',
'.bz2',
'.xz',
'.tgz',
// Binary/Compiled
'.exe',
'.dll',
'.so',
'.dylib',
'.a',
'.lib',
'.o',
'.obj',
'.class',
'.jar',
'.war',
'.ear',
'.pyc',
'.pyo',
'.pyd',
'.beam', // Erlang
'.wasm', // WebAssembly - important!
'.node', // Native Node addons
// Documents
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.odt',
'.ods',
'.odp',
// Media
'.mp4',
'.mp3',
'.wav',
'.mov',
'.avi',
'.mkv',
'.flv',
'.wmv',
'.ogg',
'.webm',
'.flac',
'.aac',
'.m4a',
// Fonts
'.woff',
'.woff2',
'.ttf',
'.eot',
'.otf',
// Databases
'.db',
'.sqlite',
'.sqlite3',
'.mdb',
'.accdb',
// Minified/Bundled files
'.min.js',
'.min.css',
'.bundle.js',
'.chunk.js',
// Source maps (debug files, not source)
'.map',
// Lock files (handled separately, but also here)
'.lock',
// Certificates & Keys (security - don't index!)
'.pem',
'.key',
'.crt',
'.cer',
'.p12',
'.pfx',
// Data files (often large/binary)
'.csv',
'.tsv',
'.parquet',
'.avro',
'.feather',
'.npy',
'.npz',
'.pkl',
'.pickle',
'.h5',
'.hdf5',
// Misc binary
'.bin',
'.dat',
'.data',
'.raw',
'.iso',
'.img',
'.dmg',
]);
// Files to ignore by exact name
const IGNORED_FILES = new Set([
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'composer.lock',
'Gemfile.lock',
'poetry.lock',
'Cargo.lock',
'go.sum',
'.gitignore',
'.gitattributes',
'.npmrc',
'.yarnrc',
'.editorconfig',
'.prettierrc',
'.prettierignore',
'.eslintignore',
'.dockerignore',
'Thumbs.db',
'.DS_Store',
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'CHANGELOG.md',
'CHANGELOG',
'CONTRIBUTING.md',
'CODE_OF_CONDUCT.md',
'SECURITY.md',
'.env',
'.env.local',
'.env.development',
'.env.production',
'.env.test',
'.env.example',
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'composer.lock',
'Gemfile.lock',
'poetry.lock',
'Cargo.lock',
'go.sum',
'.gitignore',
'.gitattributes',
'.npmrc',
'.yarnrc',
'.editorconfig',
'.prettierrc',
'.prettierignore',
'.eslintignore',
'.dockerignore',
'Thumbs.db',
'.DS_Store',
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'CHANGELOG.md',
'CHANGELOG',
'CONTRIBUTING.md',
'CODE_OF_CONDUCT.md',
'SECURITY.md',
'.env',
'.env.local',
'.env.development',
'.env.production',
'.env.test',
'.env.example',
]);
export const shouldIgnorePath = (filePath: string): boolean => {
const normalizedPath = filePath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
@ -209,7 +288,7 @@ export const shouldIgnorePath = (filePath: string): boolean => {
if (lastDotIndex !== -1) {
const ext = fileNameLower.substring(lastDotIndex);
if (IGNORED_EXTENSIONS.has(ext)) return true;
// Handle compound extensions like .min.js, .bundle.js
const secondLastDot = fileNameLower.lastIndexOf('.', lastDotIndex - 1);
if (secondLastDot !== -1) {
@ -227,13 +306,15 @@ export const shouldIgnorePath = (filePath: string): boolean => {
}
// Ignore files that look like generated/bundled code
if (fileNameLower.includes('.bundle.') ||
fileNameLower.includes('.chunk.') ||
fileNameLower.includes('.generated.') ||
fileNameLower.endsWith('.d.ts')) { // TypeScript declaration files
if (
fileNameLower.includes('.bundle.') ||
fileNameLower.includes('.chunk.') ||
fileNameLower.includes('.generated.') ||
fileNameLower.endsWith('.d.ts')
) {
// TypeScript declaration files
return true;
}
return false;
}
};

View file

@ -6,7 +6,7 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
const relationshipMap = new Map<string, GraphRelationship>();
const addNode = (node: GraphNode) => {
if(!nodeMap.has(node.id)) {
if (!nodeMap.has(node.id)) {
nodeMap.set(node.id, node);
}
};
@ -17,13 +17,13 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
}
};
return{
get nodes(){
return Array.from(nodeMap.values())
return {
get nodes() {
return Array.from(nodeMap.values());
},
get relationships(){
return Array.from(relationshipMap.values())
get relationships() {
return Array.from(relationshipMap.values());
},
// O(1) count getters - avoid creating arrays just for length
@ -37,6 +37,5 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
addNode,
addRelationship,
};
};
};

View file

@ -1,6 +1,6 @@
/**
* Cluster Enricher
*
*
* LLM-based enrichment for community clusters.
* Generates semantic names, keywords, and descriptions using an LLM.
*/
@ -42,43 +42,35 @@ export interface ClusterMemberInfo {
// PROMPT TEMPLATE
// ============================================================================
const buildEnrichmentPrompt = (
members: ClusterMemberInfo[],
heuristicLabel: string
): string => {
const buildEnrichmentPrompt = (members: ClusterMemberInfo[], heuristicLabel: string): string => {
// Limit to first 20 members to control token usage
const limitedMembers = members.slice(0, 20);
const memberList = limitedMembers
.map(m => `${m.name} (${m.type})`)
.join(', ');
const memberList = limitedMembers.map((m) => `${m.name} (${m.type})`).join(', ');
return `Analyze this code cluster and provide a semantic name and short description.
Heuristic: "${heuristicLabel}"
Members: ${memberList}${members.length > 20 ? ` (+${members.length - 20} more)` : ''}
Reply with JSON only:
{"name": "2-4 word semantic name", "description": "One sentence describing purpose"}`
{"name": "2-4 word semantic name", "description": "One sentence describing purpose"}`;
};
// ============================================================================
// PARSE LLM RESPONSE
// ============================================================================
const parseEnrichmentResponse = (
response: string,
fallbackLabel: string
): ClusterEnrichment => {
const parseEnrichmentResponse = (response: string, fallbackLabel: string): ClusterEnrichment => {
try {
// Extract JSON from response (handles markdown code blocks)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const parsed = JSON.parse(jsonMatch[0]);
return {
name: parsed.name || fallbackLabel,
keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [],
@ -100,7 +92,7 @@ const parseEnrichmentResponse = (
/**
* Enrich clusters with LLM-generated names, keywords, and descriptions
*
*
* @param communities - Community nodes to enrich
* @param memberMap - Map of communityId -> member info
* @param llmClient - LLM client for generation
@ -110,17 +102,17 @@ export const enrichClusters = async (
communities: CommunityNode[],
memberMap: Map<string, ClusterMemberInfo[]>,
llmClient: LLMClient,
onProgress?: (current: number, total: number) => void
onProgress?: (current: number, total: number) => void,
): Promise<EnrichmentResult> => {
const enrichments = new Map<string, ClusterEnrichment>();
let tokensUsed = 0;
for (let i = 0; i < communities.length; i++) {
const community = communities[i];
const members = memberMap.get(community.id) || [];
onProgress?.(i + 1, communities.length);
if (members.length === 0) {
// No members, use heuristic
enrichments.set(community.id, {
@ -130,14 +122,14 @@ export const enrichClusters = async (
});
continue;
}
try {
const prompt = buildEnrichmentPrompt(members, community.heuristicLabel);
const response = await llmClient.generate(prompt);
// Rough token estimate
tokensUsed += prompt.length / 4 + response.length / 4;
const enrichment = parseEnrichmentResponse(response, community.heuristicLabel);
enrichments.set(community.id, enrichment);
} catch (error) {
@ -150,7 +142,7 @@ export const enrichClusters = async (
});
}
}
return { enrichments, tokensUsed };
};
@ -167,30 +159,30 @@ export const enrichClustersBatch = async (
memberMap: Map<string, ClusterMemberInfo[]>,
llmClient: LLMClient,
batchSize: number = 5,
onProgress?: (current: number, total: number) => void
onProgress?: (current: number, total: number) => void,
): Promise<EnrichmentResult> => {
const enrichments = new Map<string, ClusterEnrichment>();
let tokensUsed = 0;
// Process in batches
for (let i = 0; i < communities.length; i += batchSize) {
// Report progress
onProgress?.(Math.min(i + batchSize, communities.length), communities.length);
const batch = communities.slice(i, i + batchSize);
const batchPrompt = batch.map((community, idx) => {
const members = memberMap.get(community.id) || [];
const limitedMembers = members.slice(0, 15);
const memberList = limitedMembers
.map(m => `${m.name} (${m.type})`)
.join(', ');
return `Cluster ${idx + 1} (id: ${community.id}):
const batchPrompt = batch
.map((community, idx) => {
const members = memberMap.get(community.id) || [];
const limitedMembers = members.slice(0, 15);
const memberList = limitedMembers.map((m) => `${m.name} (${m.type})`).join(', ');
return `Cluster ${idx + 1} (id: ${community.id}):
Heuristic: "${community.heuristicLabel}"
Members: ${memberList}`;
}).join('\n\n');
})
.join('\n\n');
const prompt = `Analyze these code clusters and generate semantic names, keywords, and descriptions.
${batchPrompt}
@ -200,11 +192,11 @@ Output JSON array:
{"id": "comm_X", "name": "...", "keywords": [...], "description": "..."},
...
]`;
try {
const response = await llmClient.generate(prompt);
tokensUsed += prompt.length / 4 + response.length / 4;
// Parse batch response
const jsonMatch = response.match(/\[[\s\S]*\]/);
if (jsonMatch) {
@ -214,7 +206,7 @@ Output JSON array:
keywords: string[];
description: string;
}>;
for (const item of parsed) {
enrichments.set(item.id, {
name: item.name,
@ -235,7 +227,7 @@ Output JSON array:
}
}
}
// Fill in any missing communities
for (const community of communities) {
if (!enrichments.has(community.id)) {
@ -246,6 +238,6 @@ Output JSON array:
});
}
}
return { enrichments, tokensUsed };
};

View file

@ -1,6 +1,6 @@
/**
* Graph RAG Agent Factory
*
*
* Creates a LangChain agent configured for code graph analysis.
* Supports Azure OpenAI and Google Gemini providers.
*/
@ -25,15 +25,12 @@ import type {
GLMConfig,
AgentStreamChunk,
} from './types';
import {
type CodebaseContext,
buildDynamicSystemPrompt,
} from './context-builder';
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
/**
* System prompt for the Graph RAG agent
*
*
* Design principles (based on Aider/Cline research):
* - Short, punchy directives > long explanations
* - No template-inducing examples
@ -43,7 +40,7 @@ import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../conf
*/
/**
* Base system prompt - exported so it can be used with dynamic context injection
*
*
* Structure (optimized for instruction following):
* 1. Identity + GROUNDING mandate (most important)
* 2. Core protocol (how to work)
@ -131,11 +128,11 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
switch (config.provider) {
case 'openai': {
const openaiConfig = config as OpenAIConfig;
if (!openaiConfig.apiKey || openaiConfig.apiKey.trim() === '') {
throw new Error('OpenAI API key is required but was not provided');
}
return new ChatOpenAI({
apiKey: openaiConfig.apiKey,
modelName: openaiConfig.model,
@ -148,7 +145,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
streaming: true,
});
}
case 'azure-openai': {
const azureConfig = config as AzureOpenAIConfig;
return new AzureChatOpenAI({
@ -160,7 +157,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
streaming: true,
});
}
case 'gemini': {
const geminiConfig = config as GeminiConfig;
return new ChatGoogleGenerativeAI({
@ -171,7 +168,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
streaming: true,
});
}
case 'anthropic': {
const anthropicConfig = config as AnthropicConfig;
return new ChatAnthropic({
@ -182,7 +179,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
streaming: true,
});
}
case 'ollama': {
const ollamaConfig = config as OllamaConfig;
return new ChatOllama({
@ -197,7 +194,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
numCtx: 32768,
});
}
case 'openrouter': {
const openRouterConfig = config as OpenRouterConfig;
@ -298,27 +295,27 @@ const extractInstanceName = (endpoint: string): string => {
export const createGraphRAGAgent = (
config: ProviderConfig,
backend: GraphRAGBackend,
codebaseContext?: CodebaseContext
codebaseContext?: CodebaseContext,
) => {
const model = createChatModel(config);
const tools = createGraphRAGTools(backend);
// Use dynamic prompt if context is provided, otherwise use base prompt
const systemPrompt = codebaseContext
const systemPrompt = codebaseContext
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
: BASE_SYSTEM_PROMPT;
// Log the full prompt for debugging
if (import.meta.env.DEV) {
console.log('🤖 AGENT SYSTEM PROMPT:\n', systemPrompt);
}
const agent = createReactAgent({
llm: model as any,
tools: tools as any,
messageModifier: new SystemMessage(systemPrompt) as any,
});
return agent;
};
@ -335,29 +332,26 @@ export interface AgentMessage {
* Uses BOTH streamModes for best of both worlds:
* - 'values' for state transitions (tool calls, results) in proper order
* - 'messages' for token-by-token text streaming
*
*
* This preserves the natural progression: reasoning tool reasoning tool answer
*/
export async function* streamAgentResponse(
agent: ReturnType<typeof createReactAgent>,
messages: AgentMessage[]
messages: AgentMessage[],
): AsyncGenerator<AgentStreamChunk> {
try {
const formattedMessages = messages.map(m => ({
const formattedMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
const stream = await agent.stream(
{ messages: formattedMessages },
{
streamMode: ['values', 'messages'] as any,
// Allow longer tool/reasoning loops (more Cursor-like persistence)
recursionLimit: 50,
} as any
);
const stream = await agent.stream({ messages: formattedMessages }, {
streamMode: ['values', 'messages'] as any,
// Allow longer tool/reasoning loops (more Cursor-like persistence)
recursionLimit: 50,
} as any);
// Track what we've yielded to avoid duplicates
const yieldedToolCalls = new Set<string>();
const yieldedToolResults = new Set<string>();
@ -368,13 +362,13 @@ export async function* streamAgentResponse(
// Anything before the first tool call should be treated as "reasoning/narration"
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
let hasSeenToolCallThisTurn = false;
for await (const event of stream) {
// Events come as [streamMode, data] tuples when using multiple modes
// or just data when using single mode
let mode: string;
let data: any;
if (Array.isArray(event) && event.length === 2 && typeof event[0] === 'string') {
[mode, data] = event;
} else if (Array.isArray(event) && event[0]?._getType) {
@ -386,10 +380,10 @@ export async function* streamAgentResponse(
mode = 'values';
data = event;
}
// DEBUG: Enhanced logging
if (import.meta.env.DEV) {
const msgType = mode === 'messages' && data?.[0]?._getType?.() || 'n/a';
const msgType = (mode === 'messages' && data?.[0]?._getType?.()) || 'n/a';
const hasContent = mode === 'messages' && data?.[0]?.content;
const hasToolCalls = mode === 'messages' && data?.[0]?.tool_calls?.length > 0;
console.log(`🔄 [${mode}] type:${msgType} content:${!!hasContent} tools:${hasToolCalls}`);
@ -398,14 +392,14 @@ export async function* streamAgentResponse(
if (mode === 'messages') {
const [msg] = Array.isArray(data) ? data : [data];
if (!msg) continue;
const msgType = msg._getType?.() || msg.type || msg.constructor?.name || 'unknown';
// AIMessageChunk - streaming text tokens
if (msgType === 'ai' || msgType === 'AIMessage' || msgType === 'AIMessageChunk') {
const rawContent = msg.content;
const toolCalls = msg.tool_calls || [];
// Handle content that can be string or array of content blocks
let content: string = '';
if (typeof rawContent === 'string') {
@ -414,10 +408,10 @@ export async function* streamAgentResponse(
// Content blocks format: [{type: 'text', text: '...'}, ...]
content = rawContent
.filter((block: any) => block.type === 'text' || typeof block === 'string')
.map((block: any) => typeof block === 'string' ? block : block.text || '')
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
.join('');
}
// If chunk has content, stream it
if (content && content.length > 0) {
// Determine if this is reasoning/narration vs final answer content.
@ -425,15 +419,13 @@ export async function* streamAgentResponse(
// - Between tool calls/results: treat as reasoning
// - After all tools are done: treat as final content
const isReasoning =
!hasSeenToolCallThisTurn ||
toolCalls.length > 0 ||
pendingToolCalls > 0;
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
yield {
type: isReasoning ? 'reasoning' : 'content',
[isReasoning ? 'reasoning' : 'content']: content,
};
}
// Track tool calls from message chunks
if (toolCalls.length > 0) {
hasSeenToolCallThisTurn = true;
@ -461,13 +453,14 @@ export async function* streamAgentResponse(
}
}
}
// ToolMessage in messages mode
if (msgType === 'tool' || msgType === 'ToolMessage') {
const toolCallId = msg.tool_call_id || '';
if (toolCallId && !yieldedToolResults.has(toolCallId)) {
yieldedToolResults.add(toolCallId);
const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
const result =
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
yield {
type: 'tool_result',
toolCall: {
@ -483,16 +476,16 @@ export async function* streamAgentResponse(
}
}
}
// Handle 'values' mode - state snapshots for structure
if (mode === 'values' && data?.messages) {
const stepMessages = data.messages || [];
// Process new messages for tool calls/results we might have missed
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
const msg = stepMessages[i];
const msgType = msg._getType?.() || msg.type || 'unknown';
// Catch tool calls from values mode (backup)
if ((msgType === 'ai' || msgType === 'AIMessage') && !yieldedToolCalls.size) {
const toolCalls = msg.tool_calls || [];
@ -513,13 +506,14 @@ export async function* streamAgentResponse(
}
}
}
// Catch tool results from values mode (backup)
if (msgType === 'tool' || msgType === 'ToolMessage') {
const toolCallId = msg.tool_call_id || '';
if (toolCallId && !yieldedToolResults.has(toolCallId)) {
yieldedToolResults.add(toolCallId);
const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
const result =
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
yield {
type: 'tool_result',
toolCall: {
@ -534,11 +528,11 @@ export async function* streamAgentResponse(
}
}
}
lastProcessedMsgCount = stepMessages.length;
}
}
// DEBUG: Stream completed normally
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
@ -550,8 +544,8 @@ export async function* streamAgentResponse(
if (import.meta.env.DEV) {
console.error('❌ Stream error:', message, error);
}
yield {
type: 'error',
yield {
type: 'error',
error: message,
};
}
@ -563,17 +557,16 @@ export async function* streamAgentResponse(
*/
export const invokeAgent = async (
agent: ReturnType<typeof createReactAgent>,
messages: AgentMessage[]
messages: AgentMessage[],
): Promise<string> => {
const formattedMessages = messages.map(m => ({
const formattedMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
const result = await agent.invoke({ messages: formattedMessages });
// result.messages is the full conversation state
const lastMessage = result.messages[result.messages.length - 1];
return lastMessage?.content?.toString() ?? 'No response generated.';
};

View file

@ -1,6 +1,6 @@
/**
* Context Builder for Graph RAG Agent
*
*
* Generates dynamic context about the loaded codebase to inject into the system prompt.
* This helps the LLM understand the project structure, scale, and key entry points
* without needing to explore from scratch.
@ -54,7 +54,7 @@ export interface CodebaseContext {
*/
export async function getCodebaseStats(
executeQuery: (cypher: string) => Promise<any[]>,
projectName: string
projectName: string,
): Promise<CodebaseStats> {
try {
// Count each node type
@ -67,7 +67,7 @@ export async function getCodebaseStats(
];
const counts: Record<string, number> = {};
for (const { type, query } of countQueries) {
try {
const result = await executeQuery(query);
@ -100,13 +100,12 @@ export async function getCodebaseStats(
}
}
/**
* Find hotspots - nodes with the most connections
*/
export async function getHotspots(
executeQuery: (cypher: string) => Promise<any[]>,
limit: number = 8
limit: number = 8,
): Promise<Hotspot[]> {
try {
// Find nodes with most edges (both directions)
@ -118,25 +117,27 @@ export async function getHotspots(
LIMIT ${limit}
RETURN n.name AS name, LABEL(n) AS type, n.filePath AS filePath, connections
`;
const results = await executeQuery(query);
return results.map(row => {
if (Array.isArray(row)) {
return results
.map((row) => {
if (Array.isArray(row)) {
return {
name: row[0],
type: row[1],
filePath: row[2],
connections: row[3],
};
}
return {
name: row[0],
type: row[1],
filePath: row[2],
connections: row[3],
name: row.name,
type: row.type,
filePath: row.filePath,
connections: row.connections,
};
}
return {
name: row.name,
type: row.type,
filePath: row.filePath,
connections: row.connections,
};
}).filter(h => h.name && h.type);
})
.filter((h) => h.name && h.type);
} catch (error) {
console.error('Failed to get hotspots:', error);
return [];
@ -149,17 +150,19 @@ export async function getHotspots(
*/
export async function getFolderTree(
executeQuery: (cypher: string) => Promise<any[]>,
maxDepth: number = 10
maxDepth: number = 10,
): Promise<string> {
try {
// Get all file paths
const query = 'MATCH (f:File) RETURN f.filePath AS path ORDER BY path';
const results = await executeQuery(query);
const paths = results.map(row => {
if (Array.isArray(row)) return row[0];
return row.path;
}).filter(Boolean);
const paths = results
.map((row) => {
if (Array.isArray(row)) return row[0];
return row.path;
})
.filter(Boolean);
if (paths.length === 0) return '';
@ -175,7 +178,7 @@ export async function getFolderTree(
* Format paths as indented tree (TOON-style, no ASCII box chars)
* Uses indentation only for hierarchy - more token efficient than ASCII tree
* Shows complete structure with no truncation
*
*
* Example output:
* src/
* components/ (45 files)
@ -192,22 +195,22 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
children: Map<string, TreeNode>;
fileCount: number;
}
const root: TreeNode = { isFile: false, children: new Map(), fileCount: 0 };
for (const path of paths) {
const normalized = path.replace(/\\/g, '/');
const parts = normalized.split('/').filter(Boolean);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (!current.children.has(part)) {
current.children.set(part, { isFile, children: new Map(), fileCount: 0 });
}
current = current.children.get(part)!;
if (isFile) {
// Count files in parent directories
@ -219,10 +222,10 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
}
}
}
// Render tree with indentation only (no ASCII box chars)
const lines: string[] = [];
function renderNode(node: TreeNode, indent: string, depth: number): void {
const entries = [...node.children.entries()];
// Sort: folders first (by file count desc), then files alphabetically
@ -231,7 +234,7 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
if (!aNode.isFile && !bNode.isFile) return bNode.fileCount - aNode.fileCount;
return aName.localeCompare(bName);
});
for (const [name, childNode] of entries) {
if (childNode.isFile) {
// File
@ -240,7 +243,7 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
// Directory
const childCount = childNode.children.size;
const fileCount = childNode.fileCount;
// Only collapse if beyond maxDepth
if (depth >= maxDepth) {
lines.push(`${indent}${name}/ (${fileCount} files)`);
@ -251,9 +254,9 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
}
}
}
renderNode(root, '', 0);
return lines.join('\n');
}
@ -262,23 +265,23 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
*/
function buildTreeFromPaths(paths: string[], maxDepth: number): Map<string, any> {
const root = new Map<string, any>();
for (const fullPath of paths) {
// Normalize path separators
const normalizedPath = fullPath.replace(/\\/g, '/');
const parts = normalizedPath.split('/').filter(Boolean);
let current = root;
const depth = Math.min(parts.length, maxDepth + 1); // +1 to include files at maxDepth
for (let i = 0; i < depth; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (!current.has(part)) {
current.set(part, isFile ? null : new Map<string, any>());
}
const next = current.get(part);
if (next instanceof Map) {
current = next;
@ -287,21 +290,17 @@ function buildTreeFromPaths(paths: string[], maxDepth: number): Map<string, any>
}
}
}
return root;
}
/**
* Format tree as ASCII (like VS Code sidebar)
*/
function formatTreeAsAscii(
tree: Map<string, any>,
prefix: string,
isLast: boolean = true
): string {
function formatTreeAsAscii(tree: Map<string, any>, prefix: string, isLast: boolean = true): string {
const lines: string[] = [];
const entries = Array.from(tree.entries());
// Sort: folders first, then files, alphabetically
entries.sort(([a, aVal], [b, bVal]) => {
const aIsDir = aVal instanceof Map;
@ -309,12 +308,12 @@ function formatTreeAsAscii(
if (aIsDir !== bIsDir) return bIsDir ? 1 : -1;
return a.localeCompare(b);
});
entries.forEach(([name, subtree], index) => {
const isLastItem = index === entries.length - 1;
const connector = isLastItem ? '└── ' : '├── ';
const childPrefix = prefix + (isLastItem ? ' ' : '│ ');
if (subtree instanceof Map && subtree.size > 0) {
// Folder with children
const childCount = countItems(subtree);
@ -329,7 +328,7 @@ function formatTreeAsAscii(
lines.push(`${prefix}${connector}${name}`);
}
});
return lines.filter(Boolean).join('\n');
}
@ -353,7 +352,7 @@ function countItems(tree: Map<string, any>): number {
*/
export async function buildCodebaseContext(
executeQuery: (cypher: string) => Promise<any[]>,
projectName: string
projectName: string,
): Promise<CodebaseContext> {
// Run all queries in parallel for speed
const [stats, hotspots, folderTree] = await Promise.all([
@ -374,12 +373,12 @@ export async function buildCodebaseContext(
*/
export function formatContextForPrompt(context: CodebaseContext): string {
const { stats, hotspots, folderTree } = context;
const lines: string[] = [];
// Project header with stats
lines.push(`### 📊 CODEBASE: ${stats.projectName}`);
const statParts = [
`Files: ${stats.fileCount}`,
`Functions: ${stats.functionCount}`,
@ -388,16 +387,16 @@ export function formatContextForPrompt(context: CodebaseContext): string {
].filter(Boolean);
lines.push(statParts.join(' | '));
lines.push('');
// Hotspots
if (hotspots.length > 0) {
lines.push('**Hotspots** (most connected):');
hotspots.slice(0, 5).forEach(h => {
hotspots.slice(0, 5).forEach((h) => {
lines.push(`- \`${h.name}\` (${h.type}) — ${h.connections} edges`);
});
lines.push('');
}
// Folder tree
if (folderTree) {
lines.push('### 📁 STRUCTURE');
@ -406,7 +405,7 @@ export function formatContextForPrompt(context: CodebaseContext): string {
lines.push(folderTree);
lines.push('```');
}
return lines.join('\n');
}
@ -414,12 +413,9 @@ export function formatContextForPrompt(context: CodebaseContext): string {
* Build the complete dynamic system prompt
* Context is appended at the END so core instructions remain at the top
*/
export function buildDynamicSystemPrompt(
basePrompt: string,
context: CodebaseContext
): string {
export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseContext): string {
const contextSection = formatContextForPrompt(context);
// Append context at the END - keeps core instructions at top for better adherence
return `${basePrompt}

View file

@ -1,6 +1,6 @@
/**
* LLM Module Exports
*
*
* Provides Graph RAG agent capabilities for code analysis.
*/

View file

@ -1,6 +1,6 @@
/**
* Settings Service
*
*
* Handles localStorage persistence for LLM provider settings.
* All API keys are stored locally - never sent to any server except the LLM provider.
*/
@ -127,16 +127,24 @@ export const saveSettings = (settings: LLMSettings): void => {
export const updateProviderSettings = <T extends LLMProvider>(
provider: T,
updates: Partial<
T extends 'openai' ? Partial<Omit<OpenAIConfig, 'provider'>> :
T extends 'azure-openai' ? Partial<Omit<AzureOpenAIConfig, 'provider'>> :
T extends 'gemini' ? Partial<Omit<GeminiConfig, 'provider'>> :
T extends 'anthropic' ? Partial<Omit<AnthropicConfig, 'provider'>> :
T extends 'ollama' ? Partial<Omit<OllamaConfig, 'provider'>> :
T extends 'openrouter' ? Partial<Omit<OpenRouterConfig, 'provider'>> :
T extends 'minimax' ? Partial<Omit<MiniMaxConfig, 'provider'>> :
T extends 'glm' ? Partial<Omit<GLMConfig, 'provider'>> :
never
>
T extends 'openai'
? Partial<Omit<OpenAIConfig, 'provider'>>
: T extends 'azure-openai'
? Partial<Omit<AzureOpenAIConfig, 'provider'>>
: T extends 'gemini'
? Partial<Omit<GeminiConfig, 'provider'>>
: T extends 'anthropic'
? Partial<Omit<AnthropicConfig, 'provider'>>
: T extends 'ollama'
? Partial<Omit<OllamaConfig, 'provider'>>
: T extends 'openrouter'
? Partial<Omit<OpenRouterConfig, 'provider'>>
: T extends 'minimax'
? Partial<Omit<MiniMaxConfig, 'provider'>>
: T extends 'glm'
? Partial<Omit<GLMConfig, 'provider'>>
: never
>,
): LLMSettings => {
const current = loadSettings();
@ -377,7 +385,12 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
case 'gemini':
return ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-1.0-pro'];
case 'anthropic':
return ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'];
return [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
];
case 'ollama':
return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder'];
case 'minimax':
@ -406,4 +419,3 @@ export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name:
return [];
}
};

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
/**
* LLM Provider Types
*
*
* Type definitions for multi-provider LLM support.
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
*/
@ -9,7 +9,15 @@
* Supported LLM providers
*/
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax' | 'glm';
export type LLMProvider =
| 'openai'
| 'azure-openai'
| 'gemini'
| 'anthropic'
| 'ollama'
| 'openrouter'
| 'minimax'
| 'glm';
/**
* Base configuration shared by all providers
@ -27,8 +35,8 @@ export interface BaseProviderConfig {
export interface OpenAIConfig extends BaseProviderConfig {
provider: 'openai';
apiKey: string;
model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'
baseUrl?: string; // optional, for custom endpoints or proxies
model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'
baseUrl?: string; // optional, for custom endpoints or proxies
}
/**
@ -37,9 +45,9 @@ export interface OpenAIConfig extends BaseProviderConfig {
export interface AzureOpenAIConfig extends BaseProviderConfig {
provider: 'azure-openai';
apiKey: string;
endpoint: string; // e.g., https://your-resource.openai.azure.com
endpoint: string; // e.g., https://your-resource.openai.azure.com
deploymentName: string;
apiVersion?: string; // defaults to '2024-08-01-preview'
apiVersion?: string; // defaults to '2024-08-01-preview'
}
/**
@ -48,7 +56,7 @@ export interface AzureOpenAIConfig extends BaseProviderConfig {
export interface GeminiConfig extends BaseProviderConfig {
provider: 'gemini';
apiKey: string;
model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro'
model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro'
}
/**
@ -57,7 +65,7 @@ export interface GeminiConfig extends BaseProviderConfig {
export interface AnthropicConfig extends BaseProviderConfig {
provider: 'anthropic';
apiKey: string;
model: string; // e.g., 'claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022'
model: string; // e.g., 'claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022'
}
/**
@ -65,7 +73,7 @@ export interface AnthropicConfig extends BaseProviderConfig {
*/
export interface OllamaConfig extends BaseProviderConfig {
provider: 'ollama';
baseUrl?: string; // defaults to http://localhost:11434
baseUrl?: string; // defaults to http://localhost:11434
model: string;
}
@ -75,8 +83,8 @@ export interface OllamaConfig extends BaseProviderConfig {
export interface OpenRouterConfig extends BaseProviderConfig {
provider: 'openrouter';
apiKey: string;
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
}
/**
@ -85,7 +93,7 @@ export interface OpenRouterConfig extends BaseProviderConfig {
export interface MiniMaxConfig extends BaseProviderConfig {
provider: 'minimax';
apiKey: string;
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
}
/**
@ -94,14 +102,22 @@ export interface MiniMaxConfig extends BaseProviderConfig {
export interface GLMConfig extends BaseProviderConfig {
provider: 'glm';
apiKey: string;
model: string; // e.g., 'GLM-4.7', 'GLM-4.5', 'GLM-4.5-Air', 'GLM-5'
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
model: string; // e.g., 'GLM-4.7', 'GLM-4.5', 'GLM-4.5-Air', 'GLM-5'
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
}
/**
* Union type for all provider configurations
*/
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig | MiniMaxConfig | GLMConfig;
export type ProviderConfig =
| OpenAIConfig
| AzureOpenAIConfig
| GeminiConfig
| AnthropicConfig
| OllamaConfig
| OpenRouterConfig
| MiniMaxConfig
| GLMConfig;
/**
* Stored settings (what goes to localStorage)
@ -380,4 +396,3 @@ NOTES:
- For vector search, join CodeEmbedding.nodeId to the appropriate table's id
- Use LIMIT to avoid returning too many results
`;

View file

@ -29,37 +29,36 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels(prev =>
prev.includes(label) ? prev.filter(l => l !== label) : [...prev, label]
setVisibleLabels((prev) =>
prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label],
);
}, []);
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
setVisibleEdgeTypes(prev =>
prev.includes(edgeType) ? prev.filter(e => e !== edgeType) : [...prev, edgeType]
setVisibleEdgeTypes((prev) =>
prev.includes(edgeType) ? prev.filter((e) => e !== edgeType) : [...prev, edgeType],
);
}, []);
const value = useMemo<GraphStateContextValue>(() => ({
graph,
setGraph,
selectedNode,
setSelectedNode,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
}), [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]);
return (
<GraphStateContext.Provider value={value}>
{children}
</GraphStateContext.Provider>
const value = useMemo<GraphStateContextValue>(
() => ({
graph,
setGraph,
selectedNode,
setSelectedNode,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
}),
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
);
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
};
export const useGraphState = (): GraphStateContextValue => {

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
probeBackend,
setBackendUrl as setServiceUrl,
} from '../services/backend-client';
import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
// ── localStorage keys ────────────────────────────────────────────────────────
@ -117,7 +114,7 @@ export function useBackend(): UseBackendResult {
pollingTimerRef.current = null;
}
// Probe immediately, then restart the polling chain if still disconnected
void probeRef.current().then(ok => {
void probeRef.current().then((ok) => {
if (!ok && isPolling) {
// Restart the setTimeout chain — schedule is captured in startPolling's closure,
// so we re-call startPolling which clears+restarts cleanly.

View file

@ -2,9 +2,9 @@ import { useAppState } from './useAppState';
export const useSettings = () => {
const { llmSettings, updateLLMSettings } = useAppState();
return {
settings: llmSettings,
updateSettings: updateLLMSettings
updateSettings: updateLLMSettings,
};
};

View file

@ -22,10 +22,15 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
// Helper: RGB to hex
const rgbToHex = (r: number, g: number, b: number): string => {
return '#' + [r, g, b].map(x => {
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('');
return (
'#' +
[r, g, b]
.map((x) => {
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
};
// Dim a color by mixing with dark background (keeps color hint)
@ -35,7 +40,7 @@ const dimColor = (hex: string, amount: number): string => {
return rgbToHex(
darkBg.r + (rgb.r - darkBg.r) * amount,
darkBg.g + (rgb.g - darkBg.g) * amount,
darkBg.b + (rgb.b - darkBg.b) * amount
darkBg.b + (rgb.b - darkBg.b) * amount,
);
};
@ -43,9 +48,9 @@ const dimColor = (hex: string, amount: number): string => {
const brightenColor = (hex: string, factor: number): string => {
const rgb = hexToRgb(hex);
return rgbToHex(
rgb.r + (255 - rgb.r) * (factor - 1) / factor,
rgb.g + (255 - rgb.g) * (factor - 1) / factor,
rgb.b + (255 - rgb.b) * (factor - 1) / factor
rgb.r + ((255 - rgb.r) * (factor - 1)) / factor,
rgb.g + ((255 - rgb.g) * (factor - 1)) / factor,
rgb.b + ((255 - rgb.b) * (factor - 1)) / factor,
);
};
@ -77,7 +82,7 @@ interface UseSigmaReturn {
// Noverlap for final cleanup - minimal since it starts with good positions
const NOVERLAP_SETTINGS = {
maxIterations: 20, // Reduced - less cleanup needed
maxIterations: 20, // Reduced - less cleanup needed
ratio: 1.1,
margin: 10,
expansion: 1.05,
@ -88,21 +93,21 @@ const getFA2Settings = (nodeCount: number) => {
const isSmall = nodeCount < 500;
const isMedium = nodeCount >= 500 && nodeCount < 2000;
const isLarge = nodeCount >= 2000 && nodeCount < 10000;
return {
// Lower gravity allows folders to stay spread out
gravity: isSmall ? 0.8 : isMedium ? 0.5 : isLarge ? 0.3 : 0.15,
// Higher scaling ratio = more spread out overall
scalingRatio: isSmall ? 15 : isMedium ? 30 : isLarge ? 60 : 100,
// LOW slowDown = FASTER movement (converges quicker)
slowDown: isSmall ? 1 : isMedium ? 2 : isLarge ? 3 : 5,
// Barnes-Hut for performance - use it even on smaller graphs
barnesHutOptimize: nodeCount > 200,
barnesHutTheta: isLarge ? 0.8 : 0.6, // Higher = faster but less accurate
barnesHutTheta: isLarge ? 0.8 : 0.6, // Higher = faster but less accurate
// These help with clustering while keeping spread
strongGravityMode: false,
outboundAttractionDistribution: true,
@ -115,12 +120,12 @@ const getFA2Settings = (nodeCount: number) => {
// Layout duration - let it run longer for better results
// Web Worker + WebGL means minimal system impact
const getLayoutDuration = (nodeCount: number): number => {
if (nodeCount > 10000) return 45000; // 45s for huge graphs
if (nodeCount > 5000) return 35000; // 35s
if (nodeCount > 2000) return 30000; // 30s
if (nodeCount > 1000) return 30000; // 30s
if (nodeCount > 500) return 25000; // 25s
return 20000; // 20s for small graphs
if (nodeCount > 10000) return 45000; // 45s for huge graphs
if (nodeCount > 5000) return 35000; // 35s
if (nodeCount > 2000) return 30000; // 30s
if (nodeCount > 1000) return 30000; // 30s
if (nodeCount > 500) return 25000; // 25s
return 20000; // 20s for small graphs
};
export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
@ -144,7 +149,12 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
animatedNodesRef.current = options.animatedNodes || new Map();
visibleEdgeTypesRef.current = options.visibleEdgeTypes || null;
sigmaRef.current?.refresh();
}, [options.highlightedNodeIds, options.blastRadiusNodeIds, options.animatedNodes, options.visibleEdgeTypes]);
}, [
options.highlightedNodeIds,
options.blastRadiusNodeIds,
options.animatedNodes,
options.visibleEdgeTypes,
]);
// Animation loop for node effects
useEffect(() => {
@ -174,19 +184,16 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const setSelectedNode = useCallback((nodeId: string | null) => {
selectedNodeRef.current = nodeId;
setSelectedNodeState(nodeId);
const sigma = sigmaRef.current;
if (!sigma) return;
// Tiny camera nudge to force edge refresh (workaround for Sigma edge caching)
const camera = sigma.getCamera();
const currentRatio = camera.ratio;
// Imperceptible zoom change that triggers re-render
camera.animate(
{ ratio: currentRatio * 1.0001 },
{ duration: 50 }
);
camera.animate({ ratio: currentRatio * 1.0001 }, { duration: 50 });
sigma.refresh();
}, []);
@ -206,27 +213,27 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
labelRenderedSizeThreshold: 8,
labelDensity: 0.1,
labelGridCellSize: 70,
defaultNodeColor: '#6b7280',
defaultEdgeColor: '#2a2a3a',
defaultEdgeType: 'curved',
edgeProgramClasses: {
curved: EdgeCurveProgram,
},
// Custom hover renderer - dark background instead of white
defaultDrawNodeHover: (context, data, settings) => {
const label = data.label;
if (!label) return;
const size = settings.labelSize || 11;
const font = settings.labelFont || 'JetBrains Mono, monospace';
const weight = settings.labelWeight || '500';
context.font = `${weight} ${size}px ${font}`;
const textWidth = context.measureText(label).width;
const nodeSize = data.size || 8;
const x = data.x;
const y = data.y - nodeSize - 10;
@ -235,24 +242,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const height = size + paddingY * 2;
const width = textWidth + paddingX * 2;
const radius = 4;
// Dark background pill
context.fillStyle = '#12121c';
context.beginPath();
context.roundRect(x - width / 2, y - height / 2, width, height, radius);
context.fill();
// Border matching node color
context.strokeStyle = data.color || '#6366f1';
context.lineWidth = 2;
context.stroke();
// Label text - light color
context.fillStyle = '#f5f5f7';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(label, x, y);
// Also draw a subtle glow ring around the node
context.beginPath();
context.arc(data.x, data.y, nodeSize + 4, 0, Math.PI * 2);
@ -262,20 +269,20 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
context.stroke();
context.globalAlpha = 1;
},
minCameraRatio: 0.002,
maxCameraRatio: 50,
hideEdgesOnMove: true,
zIndex: true,
nodeReducer: (node, data) => {
const res = { ...data };
if (data.hidden) {
res.hidden = true;
return res;
}
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const blastRadius = blastRadiusRef.current;
@ -284,17 +291,17 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const hasBlastRadius = blastRadius.size > 0;
const isQueryHighlighted = highlighted.has(node);
const isBlastRadiusNode = blastRadius.has(node);
// Apply animation effects FIRST (before other highlighting)
const animation = animatedNodes.get(node);
if (animation) {
const now = Date.now();
const elapsed = now - animation.startTime;
const progress = Math.min(elapsed / animation.duration, 1);
// Calculate animation phase (0-1-0-1... oscillation)
const phase = (Math.sin(progress * Math.PI * 4) + 1) / 2;
if (animation.type === 'pulse') {
// Cyan pulse for search results
const sizeMultiplier = 1.5 + phase * 0.8;
@ -317,10 +324,10 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
res.zIndex = 5;
res.highlighted = true;
}
return res;
}
// Blast radius takes priority (red highlighting)
if (hasBlastRadius && !currentSelected) {
if (isBlastRadiusNode) {
@ -341,7 +348,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
}
return res;
}
if (hasHighlights && !currentSelected) {
if (isQueryHighlighted) {
res.color = '#06b6d4';
@ -355,13 +362,14 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
}
return res;
}
if (currentSelected) {
const graph = graphRef.current;
if (graph) {
const isSelected = node === currentSelected;
const isNeighbor = graph.hasEdge(node, currentSelected) || graph.hasEdge(currentSelected, node);
const isNeighbor =
graph.hasEdge(node, currentSelected) || graph.hasEdge(currentSelected, node);
if (isSelected) {
res.color = data.color;
res.size = (data.size || 8) * 1.8;
@ -378,13 +386,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
}
}
}
return res;
},
edgeReducer: (edge, data) => {
const res = { ...data };
// Check edge type visibility first
const visibleTypes = visibleEdgeTypesRef.current;
if (visibleTypes && data.relationType) {
@ -393,24 +401,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
return res;
}
}
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const blastRadius = blastRadiusRef.current;
const hasHighlights = highlighted.size > 0 || blastRadius.size > 0; // Check BOTH sets
if (hasHighlights && !currentSelected) {
const graph = graphRef.current;
if (graph) {
const [source, target] = graph.extremities(edge);
// Check if nodes are in EITHER set
const isSourceActive = highlighted.has(source) || blastRadius.has(source);
const isTargetActive = highlighted.has(target) || blastRadius.has(target);
const bothHighlighted = isSourceActive && isTargetActive;
const oneHighlighted = isSourceActive || isTargetActive;
if (bothHighlighted) {
// If both nodes are in blast radius, use red edge
if (blastRadius.has(source) && blastRadius.has(target)) {
@ -432,13 +440,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
}
return res;
}
if (currentSelected) {
const graph = graphRef.current;
if (graph) {
const [source, target] = graph.extremities(edge);
const isConnected = source === currentSelected || target === currentSelected;
if (isConnected) {
res.color = brightenColor(data.color, 1.5);
res.size = Math.max(3, (data.size || 1) * 4);
@ -450,7 +458,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
}
}
}
return res;
},
});
@ -511,49 +519,52 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const inferredSettings = forceAtlas2.inferSettings(graph);
const customSettings = getFA2Settings(nodeCount);
const settings = { ...inferredSettings, ...customSettings };
const layout = new FA2Layout(graph, { settings });
layoutRef.current = layout;
layout.start();
setIsLayoutRunning(true);
const duration = getLayoutDuration(nodeCount);
layoutTimeoutRef.current = setTimeout(() => {
if (layoutRef.current) {
layoutRef.current.stop();
layoutRef.current = null;
// Light noverlap cleanup
noverlap.assign(graph, NOVERLAP_SETTINGS);
sigmaRef.current?.refresh();
setIsLayoutRunning(false);
}
}, duration);
}, []);
const setGraph = useCallback((newGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
const sigma = sigmaRef.current;
if (!sigma) return;
const setGraph = useCallback(
(newGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
const sigma = sigmaRef.current;
if (!sigma) return;
if (layoutRef.current) {
layoutRef.current.kill();
layoutRef.current = null;
}
if (layoutTimeoutRef.current) {
clearTimeout(layoutTimeoutRef.current);
layoutTimeoutRef.current = null;
}
if (layoutRef.current) {
layoutRef.current.kill();
layoutRef.current = null;
}
if (layoutTimeoutRef.current) {
clearTimeout(layoutTimeoutRef.current);
layoutTimeoutRef.current = null;
}
graphRef.current = newGraph;
sigma.setGraph(newGraph);
setSelectedNode(null);
graphRef.current = newGraph;
sigma.setGraph(newGraph);
setSelectedNode(null);
runLayout(newGraph);
sigma.getCamera().animatedReset({ duration: 500 });
}, [runLayout, setSelectedNode]);
runLayout(newGraph);
sigma.getCamera().animatedReset({ duration: 500 });
},
[runLayout, setSelectedNode],
);
const focusNode = useCallback((nodeId: string) => {
const sigma = sigmaRef.current;
@ -562,20 +573,17 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
// Skip if already focused on this node (prevents double-click issues)
const alreadySelected = selectedNodeRef.current === nodeId;
// Set selection state directly (without the camera nudge from setSelectedNode)
selectedNodeRef.current = nodeId;
setSelectedNodeState(nodeId);
// Only animate camera if selecting a new node
if (!alreadySelected) {
const nodeAttrs = graph.getNodeAttributes(nodeId);
sigma.getCamera().animate(
{ x: nodeAttrs.x, y: nodeAttrs.y, ratio: 0.15 },
{ duration: 400 }
);
sigma.getCamera().animate({ x: nodeAttrs.x, y: nodeAttrs.y, ratio: 0.15 }, { duration: 400 });
}
sigma.refresh();
}, []);
@ -606,13 +614,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
if (layoutRef.current) {
layoutRef.current.stop();
layoutRef.current = null;
const graph = graphRef.current;
if (graph) {
noverlap.assign(graph, NOVERLAP_SETTINGS);
sigmaRef.current?.refresh();
}
setIsLayoutRunning(false);
}
}, []);

View file

@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
/*
TAILWIND V4 THEME CONFIGURATION
@ -51,7 +51,6 @@
/* Keyframes */
@keyframes breathe {
0%,
100% {
border-color: #2a2a3a;
@ -65,7 +64,6 @@
}
@keyframes pulse-glow {
0%,
100% {
transform: scale(1);
@ -116,7 +114,6 @@
REDUCED MOTION respect OS-level motion preferences (WCAG 2.3.3)
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
@ -242,10 +239,10 @@ body {
color: var(--color-text-secondary);
}
.chat-prose>h1:first-child,
.chat-prose>h2:first-child,
.chat-prose>h3:first-child,
.chat-prose>h4:first-child {
.chat-prose > h1:first-child,
.chat-prose > h2:first-child,
.chat-prose > h3:first-child,
.chat-prose > h4:first-child {
margin-top: 0;
}
@ -273,7 +270,7 @@ body {
}
/* Inline code - VS Code style */
.chat-prose code:not([class*="language-"]) {
.chat-prose code:not([class*='language-']) {
padding: 0.2em 0.5em;
background: rgba(110, 118, 129, 0.2);
border-radius: 6px;
@ -285,9 +282,9 @@ body {
}
/* Ensure inline code keeps its color inside links and other elements */
.chat-prose a code:not([class*="language-"]),
.chat-prose strong code:not([class*="language-"]),
.chat-prose em code:not([class*="language-"]) {
.chat-prose a code:not([class*='language-']),
.chat-prose strong code:not([class*='language-']),
.chat-prose em code:not([class*='language-']) {
color: #e6b450 !important;
}
@ -365,4 +362,4 @@ body {
.sigma-container canvas {
outline: none;
}
}

View file

@ -2,83 +2,83 @@ import type { NodeLabel } from 'gitnexus-shared';
// Node colors by type - slightly muted for less visual noise
export const NODE_COLORS: Record<NodeLabel, string> = {
Project: '#a855f7', // Purple - prominent
Package: '#8b5cf6', // Violet
Module: '#7c3aed', // Violet darker
Folder: '#6366f1', // Indigo
File: '#3b82f6', // Blue
Class: '#f59e0b', // Amber - stands out
Function: '#10b981', // Emerald
Method: '#14b8a6', // Teal
Variable: '#64748b', // Slate - muted (less important)
Interface: '#ec4899', // Pink
Enum: '#f97316', // Orange
Decorator: '#eab308', // Yellow
Import: '#475569', // Slate darker - very muted
Type: '#a78bfa', // Violet light
Project: '#a855f7', // Purple - prominent
Package: '#8b5cf6', // Violet
Module: '#7c3aed', // Violet darker
Folder: '#6366f1', // Indigo
File: '#3b82f6', // Blue
Class: '#f59e0b', // Amber - stands out
Function: '#10b981', // Emerald
Method: '#14b8a6', // Teal
Variable: '#64748b', // Slate - muted (less important)
Interface: '#ec4899', // Pink
Enum: '#f97316', // Orange
Decorator: '#eab308', // Yellow
Import: '#475569', // Slate darker - very muted
Type: '#a78bfa', // Violet light
CodeElement: '#64748b', // Slate - muted
Community: '#818cf8', // Indigo light - cluster indicator
Process: '#f43f5e', // Rose - execution flow indicator
Section: '#60a5fa', // Blue light - structural section
Struct: '#f59e0b', // Amber - like Class
Trait: '#ec4899', // Pink - like Interface
Impl: '#14b8a6', // Teal - like Method
TypeAlias: '#a78bfa', // Violet light - like Type
Const: '#64748b', // Slate - like Variable
Static: '#64748b', // Slate - like Variable
Namespace: '#7c3aed', // Violet - like Module
Union: '#f97316', // Orange - like Enum
Typedef: '#a78bfa', // Violet light - like Type
Macro: '#eab308', // Yellow - like Decorator
Property: '#64748b', // Slate - like Variable
Record: '#f59e0b', // Amber - like Class
Delegate: '#14b8a6', // Teal - like Method
Community: '#818cf8', // Indigo light - cluster indicator
Process: '#f43f5e', // Rose - execution flow indicator
Section: '#60a5fa', // Blue light - structural section
Struct: '#f59e0b', // Amber - like Class
Trait: '#ec4899', // Pink - like Interface
Impl: '#14b8a6', // Teal - like Method
TypeAlias: '#a78bfa', // Violet light - like Type
Const: '#64748b', // Slate - like Variable
Static: '#64748b', // Slate - like Variable
Namespace: '#7c3aed', // Violet - like Module
Union: '#f97316', // Orange - like Enum
Typedef: '#a78bfa', // Violet light - like Type
Macro: '#eab308', // Yellow - like Decorator
Property: '#64748b', // Slate - like Variable
Record: '#f59e0b', // Amber - like Class
Delegate: '#14b8a6', // Teal - like Method
Annotation: '#eab308', // Yellow - like Decorator
Constructor: '#10b981', // Emerald - like Function
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project
};
// Node sizes by type - clear visual hierarchy with dramatic size differences
// Structural nodes are MUCH larger to make hierarchy obvious
export const NODE_SIZES: Record<NodeLabel, number> = {
Project: 20, // Largest - root of everything
Package: 16, // Major structural element
Module: 13, // Important container
Folder: 10, // Structural - clearly bigger than files
File: 6, // Common element - smaller than folders
Class: 8, // Important code structure
Function: 4, // Common code element - small
Method: 3, // Smaller than function
Variable: 2, // Tiny - leaf node
Interface: 7, // Important type definition
Enum: 5, // Type definition
Decorator: 2, // Tiny modifier
Import: 1.5, // Very small - usually hidden anyway
Type: 3, // Type alias - small
CodeElement: 2, // Generic small
Community: 0, // Hidden by default - metadata node
Process: 0, // Hidden by default - metadata node
Section: 8, // Structural section - similar to Folder
Struct: 8, // Like Class
Trait: 7, // Like Interface
Impl: 3, // Like Method
TypeAlias: 3, // Like Type
Const: 2, // Like Variable
Static: 2, // Like Variable
Namespace: 13, // Like Module
Union: 5, // Like Enum
Typedef: 3, // Like Type
Macro: 2, // Like Decorator
Property: 2, // Like Variable
Record: 8, // Like Class
Delegate: 3, // Like Method
Annotation: 2, // Like Decorator
Constructor: 4, // Like Function
Template: 3, // Like Type
Route: 5, // Like Enum
Tool: 5, // Like Enum
Project: 20, // Largest - root of everything
Package: 16, // Major structural element
Module: 13, // Important container
Folder: 10, // Structural - clearly bigger than files
File: 6, // Common element - smaller than folders
Class: 8, // Important code structure
Function: 4, // Common code element - small
Method: 3, // Smaller than function
Variable: 2, // Tiny - leaf node
Interface: 7, // Important type definition
Enum: 5, // Type definition
Decorator: 2, // Tiny modifier
Import: 1.5, // Very small - usually hidden anyway
Type: 3, // Type alias - small
CodeElement: 2, // Generic small
Community: 0, // Hidden by default - metadata node
Process: 0, // Hidden by default - metadata node
Section: 8, // Structural section - similar to Folder
Struct: 8, // Like Class
Trait: 7, // Like Interface
Impl: 3, // Like Method
TypeAlias: 3, // Like Type
Const: 2, // Like Variable
Static: 2, // Like Variable
Namespace: 13, // Like Module
Union: 5, // Like Enum
Typedef: 3, // Like Type
Macro: 2, // Like Decorator
Property: 2, // Like Variable
Record: 8, // Like Class
Delegate: 3, // Like Method
Annotation: 2, // Like Decorator
Constructor: 4, // Like Function
Template: 3, // Like Type
Route: 5, // Like Enum
Tool: 5, // Like Enum
};
// Community color palette for cluster-based coloring

View file

@ -51,43 +51,43 @@ const getScaledNodeSize = (baseSize: number, nodeCount: number): number => {
const getNodeMass = (nodeType: NodeLabel, nodeCount: number): number => {
// Scale mass based on graph size
const baseMassMultiplier = nodeCount > 5000 ? 2 : nodeCount > 1000 ? 1.5 : 1;
switch (nodeType) {
case 'Project':
return 50 * baseMassMultiplier; // Heaviest - anchors everything
return 50 * baseMassMultiplier; // Heaviest - anchors everything
case 'Package':
return 30 * baseMassMultiplier; // Very heavy
return 30 * baseMassMultiplier; // Very heavy
case 'Module':
return 20 * baseMassMultiplier; // Heavy
return 20 * baseMassMultiplier; // Heavy
case 'Folder':
return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
case 'File':
return 3 * baseMassMultiplier; // Medium - follows folders
return 3 * baseMassMultiplier; // Medium - follows folders
case 'Class':
case 'Interface':
return 5 * baseMassMultiplier; // Medium-heavy
return 5 * baseMassMultiplier; // Medium-heavy
case 'Function':
case 'Method':
return 2 * baseMassMultiplier; // Light
return 2 * baseMassMultiplier; // Light
default:
return 1; // Default mass
return 1; // Default mass
}
};
/**
* Converts the KnowledgeGraph to a graphology Graph for Sigma.js
* Folders are positioned in a wide spread, children positioned NEAR their parents
*
*
* @param knowledgeGraph - The knowledge graph to convert
* @param communityMemberships - Optional map of nodeId -> communityIndex for community coloring
*/
export const knowledgeGraphToGraphology = (
knowledgeGraph: KnowledgeGraph,
communityMemberships?: Map<string, number>
communityMemberships?: Map<string, number>,
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>();
const nodeCount = knowledgeGraph.nodes.length;
// Build parent-child map from hierarchy relationships
// CONTAINS: Folder -> File
// DEFINES: File -> Function/Class/Interface/Method
@ -96,10 +96,10 @@ export const knowledgeGraphToGraphology = (
const parentToChildren = new Map<string, string[]>();
// child -> parent
const childToParent = new Map<string, string>();
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
knowledgeGraph.relationships.forEach(rel => {
knowledgeGraph.relationships.forEach((rel) => {
// These relationships represent parent-child hierarchy for positioning
if (hierarchyRelations.has(rel.type)) {
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
@ -110,14 +110,14 @@ export const knowledgeGraphToGraphology = (
childToParent.set(rel.targetId, rel.sourceId);
}
});
// Create node lookup
const nodeMap = new Map(knowledgeGraph.nodes.map(n => [n.id, n]));
const nodeMap = new Map(knowledgeGraph.nodes.map((n) => [n.id, n]));
// Separate structural nodes (folders, packages) from content nodes
const structuralTypes = new Set(['Project', 'Package', 'Module', 'Folder']);
const structuralNodes = knowledgeGraph.nodes.filter(n => structuralTypes.has(n.label));
const structuralNodes = knowledgeGraph.nodes.filter((n) => structuralTypes.has(n.label));
// Much wider spread for structural nodes - this is the key!
const structuralSpread = Math.sqrt(nodeCount) * 40;
// Small jitter for children around their parent
@ -131,11 +131,11 @@ export const knowledgeGraphToGraphology = (
const communities = new Set(communityMemberships.values());
const communityCount = communities.size;
const clusterSpread = structuralSpread * 0.8; // Clusters spread across 80% of graph
// Position cluster centers using golden angle for even distribution
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
let idx = 0;
communities.forEach(communityId => {
communities.forEach((communityId) => {
const angle = idx * goldenAngle;
const radius = clusterSpread * Math.sqrt((idx + 1) / communityCount);
clusterCenters.set(communityId, {
@ -157,17 +157,17 @@ export const knowledgeGraphToGraphology = (
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const angle = index * goldenAngle;
const radius = structuralSpread * Math.sqrt((index + 1) / Math.max(structuralNodes.length, 1));
// Add some randomness to prevent perfect patterns
const jitter = structuralSpread * 0.15;
const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
nodePositions.set(node.id, { x, y });
const baseSize = NODE_SIZES[node.label] || 8;
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
// Structural nodes keep their type-based color
graph.addNode(node.id, {
x,
@ -188,17 +188,17 @@ export const knowledgeGraphToGraphology = (
// Use BFS starting from structural nodes to ensure parents are positioned first
const addNodeWithPosition = (nodeId: string) => {
if (graph.hasNode(nodeId)) return;
const node = nodeMap.get(nodeId);
if (!node) return;
let x: number, y: number;
// Check if this is a symbol node with a community assignment
const communityIndex = communityMemberships?.get(nodeId);
const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']);
const clusterCenter = communityIndex !== undefined ? clusterCenters.get(communityIndex) : null;
if (clusterCenter && symbolTypes.has(node.label)) {
// CLUSTER-BASED POSITIONING: Position near cluster center with tight jitter
x = clusterCenter.x + (Math.random() - 0.5) * clusterJitter;
@ -207,7 +207,7 @@ export const knowledgeGraphToGraphology = (
// HIERARCHY-BASED POSITIONING: Position near parent
const parentId = childToParent.get(nodeId);
const parentPos = parentId ? nodePositions.get(parentId) : null;
if (parentPos) {
x = parentPos.x + (Math.random() - 0.5) * childJitter;
y = parentPos.y + (Math.random() - 0.5) * childJitter;
@ -217,21 +217,21 @@ export const knowledgeGraphToGraphology = (
y = (Math.random() - 0.5) * structuralSpread * 0.5;
}
}
nodePositions.set(nodeId, { x, y });
const baseSize = NODE_SIZES[node.label] || 8;
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
// Check if this node has a community assignment (reuse communityIndex from above)
const hasCommunity = communityIndex !== undefined;
// Symbol nodes get colored by community if available
const usesCommunityColor = hasCommunity && symbolTypes.has(node.label);
const nodeColor = usesCommunityColor
const nodeColor = usesCommunityColor
? getCommunityColor(communityIndex!)
: NODE_COLORS[node.label] || '#9ca3af';
graph.addNode(nodeId, {
x,
y,
@ -248,14 +248,14 @@ export const knowledgeGraphToGraphology = (
communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined,
});
};
// BFS from structural nodes - this ensures parent is ALWAYS positioned before child
const queue: string[] = [...structuralNodes.map(n => n.id)];
const queue: string[] = [...structuralNodes.map((n) => n.id)];
const visited = new Set<string>(queue);
while (queue.length > 0) {
const currentId = queue.shift()!;
// Get children of current node and add them
const children = parentToChildren.get(currentId) || [];
for (const childId of children) {
@ -266,7 +266,7 @@ export const knowledgeGraphToGraphology = (
}
}
}
// Add any orphan nodes that weren't reached (no parent relationship)
knowledgeGraph.nodes.forEach((node) => {
if (!graph.hasNode(node.id)) {
@ -276,33 +276,33 @@ export const knowledgeGraphToGraphology = (
// Add edges with distinct colors per relationship type
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
// Edge styles - each relationship type has a DISTINCT color for clarity
// Using varied hues so relationships are easily distinguishable
const EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
// STRUCTURAL - Greens (folder/file hierarchy)
CONTAINS: { color: '#2d5a3d', sizeMultiplier: 0.4 }, // Forest green - folder contains
CONTAINS: { color: '#2d5a3d', sizeMultiplier: 0.4 }, // Forest green - folder contains
// DEFINITIONS - Cyan/Teal (code definitions)
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
// DEPENDENCIES - Blue (imports between files)
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
// DEPENDENCIES - Blue (imports between files)
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
// FUNCTION FLOW - Purple (call graph)
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
// TYPE RELATIONSHIPS - Warm colors (OOP)
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
};
knowledgeGraph.relationships.forEach((rel) => {
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
const curvature = 0.12 + (Math.random() * 0.08);
const curvature = 0.12 + Math.random() * 0.08;
graph.addEdge(rel.sourceId, rel.targetId, {
size: edgeBaseSize * style.sizeMultiplier,
color: style.color,
@ -322,7 +322,7 @@ export const knowledgeGraphToGraphology = (
*/
export const filterGraphByLabels = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
visibleLabels: NodeLabel[]
visibleLabels: NodeLabel[],
): void => {
graph.forEachNode((nodeId, attributes) => {
const isVisible = visibleLabels.includes(attributes.nodeType);
@ -336,17 +336,17 @@ export const filterGraphByLabels = (
export const getNodesWithinHops = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
startNodeId: string,
maxHops: number
maxHops: number,
): Set<string> => {
const visited = new Set<string>();
const queue: { nodeId: string; depth: number }[] = [{ nodeId: startNodeId, depth: 0 }];
while (queue.length > 0) {
const { nodeId, depth } = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
if (depth < maxHops) {
graph.forEachNeighbor(nodeId, (neighborId) => {
if (!visited.has(neighborId)) {
@ -355,7 +355,7 @@ export const getNodesWithinHops = (
});
}
}
return visited;
};
@ -366,20 +366,20 @@ export const filterGraphByDepth = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
selectedNodeId: string | null,
maxHops: number | null,
visibleLabels: NodeLabel[]
visibleLabels: NodeLabel[],
): void => {
if (maxHops === null) {
filterGraphByLabels(graph, visibleLabels);
return;
}
if (selectedNodeId === null || !graph.hasNode(selectedNodeId)) {
filterGraphByLabels(graph, visibleLabels);
return;
}
const nodesInRange = getNodesWithinHops(graph, selectedNodeId, maxHops);
graph.forEachNode((nodeId, attributes) => {
const isLabelVisible = visibleLabels.includes(attributes.nodeType);
const isInRange = nodesInRange.has(nodeId);

View file

@ -1,7 +1,9 @@
// Shared regex patterns for grounding references in chat/markdown.
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
// Line numbers are optional.
export const FILE_REF_REGEX = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-](\d+))?)?\]\]/g;
export const FILE_REF_REGEX =
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-](\d+))?)?\]\]/g;
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
export const NODE_REF_REGEX = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
export const NODE_REF_REGEX =
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;

View file

@ -1,6 +1,6 @@
/**
* Mermaid Diagram Generator for Processes
*
*
* Generates Mermaid flowchart syntax from Process step data.
* Designed to show branching/merging when CALLS edges exist between steps.
*/
@ -24,9 +24,9 @@ export interface ProcessData {
label: string;
processType: 'intra_community' | 'cross_community';
steps: ProcessStep[];
edges?: ProcessEdge[]; // CALLS edges between steps for branching
edges?: ProcessEdge[]; // CALLS edges between steps for branching
clusters?: string[];
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
}
/**
@ -34,7 +34,7 @@ export interface ProcessData {
*/
export function generateProcessMermaid(process: ProcessData): string {
const { steps, edges, clusters } = process;
if (!steps || steps.length === 0) {
return 'graph TD\n A[No steps found]';
}
@ -43,16 +43,26 @@ export function generateProcessMermaid(process: ProcessData): string {
// Add class definitions for styling (rounded corners + colors)
lines.push(' %% Styles');
lines.push(' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef step fill:#1e293b,stroke:#22d3ee,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef terminal fill:#1e293b,stroke:#f472b6,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;');
lines.push(
' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
);
lines.push(
' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
);
lines.push(
' classDef step fill:#1e293b,stroke:#22d3ee,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
);
lines.push(
' classDef terminal fill:#1e293b,stroke:#f472b6,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
);
lines.push(
' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;',
);
// Track clusters for subgraph grouping
const clusterGroups = new Map<string, ProcessStep[]>();
const noCluster: ProcessStep[] = [];
for (const step of steps) {
if (step.cluster) {
const group = clusterGroups.get(step.cluster) || [];
@ -88,10 +98,12 @@ export function generateProcessMermaid(process: ProcessData): string {
if (useClusters) {
// Generate subgraphs for each cluster
let clusterIndex = 0;
for (const [clusterName, clusterSteps] of clusterGroups) {
lines.push(` subgraph ${sanitizeLabel(clusterName)}["${sanitizeLabel(clusterName)}"]:::cluster`);
lines.push(
` subgraph ${sanitizeLabel(clusterName)}["${sanitizeLabel(clusterName)}"]:::cluster`,
);
for (const step of clusterSteps) {
const id = nodeId(step);
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
@ -102,7 +114,7 @@ export function generateProcessMermaid(process: ProcessData): string {
lines.push(' end');
clusterIndex++;
}
// Add unclustered steps
for (const step of noCluster) {
const id = nodeId(step);
@ -125,7 +137,7 @@ export function generateProcessMermaid(process: ProcessData): string {
// Generate edges
if (edges && edges.length > 0) {
// Use actual CALLS edges for branching
const stepById = new Map(steps.map(s => [s.id, s]));
const stepById = new Map(steps.map((s) => [s.id, s]));
for (const edge of edges) {
const fromStep = stepById.get(edge.from);
const toStep = stepById.get(edge.to);
@ -150,8 +162,8 @@ export function generateProcessMermaid(process: ProcessData): string {
* Simple linear mermaid for quick preview
*/
export function generateSimpleMermaid(processLabel: string, stepCount: number): string {
const [entry, terminal] = processLabel.split(' → ').map(s => s.trim());
const [entry, terminal] = processLabel.split(' → ').map((s) => s.trim());
return `graph LR
classDef entry fill:#059669,stroke:#34d399,stroke-width:2px,color:#ffffff,rx:10,ry:10;
classDef terminal fill:#be185d,stroke:#f472b6,stroke-width:2px,color:#ffffff,rx:10,ry:10;

View file

@ -8,7 +8,10 @@ export const normalizePath = (p: string): string => {
* Follows the same heuristics previously embedded in useAppState:
* 1) exact match, 2) ends-with match (prefers shorter paths), 3) segment containment.
*/
export const resolveFilePath = (fileContents: Map<string, string>, requestedPath: string): string | null => {
export const resolveFilePath = (
fileContents: Map<string, string>,
requestedPath: string,
): string | null => {
const req = normalizePath(requestedPath).toLowerCase();
if (!req) return null;
@ -35,7 +38,10 @@ export const resolveFilePath = (fileContents: Map<string, string>, requestedPath
let idx = 0;
for (const s of segs) {
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
if (found === -1) { idx = -1; break; }
if (found === -1) {
idx = -1;
break;
}
idx = found + 1;
}
if (idx !== -1) return key;

View file

@ -1,3 +1,3 @@
export const generateId = (label: string, name: string): string => {
return `${label}:${name}`
}
return `${label}:${name}`;
};

View file

@ -6,5 +6,5 @@ import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);

View file

@ -13,7 +13,7 @@ import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
export interface BackendRepo {
name: string;
path: string;
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
indexedAt: string;
lastCommit?: string;
stats?: {
@ -251,7 +251,8 @@ const fetchWithTimeout = async (
if (error instanceof TypeError) {
throw new BackendError(
`Network error reaching GitNexus backend at ${_backendUrl}: ${error.message}`,
0, 'network',
0,
'network',
);
}
throw error;
@ -273,14 +274,16 @@ const assertOk = async (response: Response): Promise<void> => {
// Response body was not JSON
}
const code = response.status === 404 ? 'not_found'
: response.status >= 400 && response.status < 500 ? 'client'
: 'server';
const code =
response.status === 404
? 'not_found'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
throw new BackendError(message, response.status, code);
};
const repoParam = (repo?: string): string =>
repo ? `repo=${encodeURIComponent(repo)}` : '';
const repoParam = (repo?: string): string => (repo ? `repo=${encodeURIComponent(repo)}` : '');
// ── API Methods ────────────────────────────────────────────────────────────
@ -303,10 +306,7 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
* server goes down (after one retry to avoid false positives from transient
* network hiccups). Returns a cleanup function.
*/
export const connectHeartbeat = (
onConnect: () => void,
onDisconnect: () => void,
): (() => void) => {
export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void): (() => void) => {
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
@ -316,7 +316,12 @@ export const connectHeartbeat = (
const connect = () => {
if (closed) return;
es = new EventSource(`${_backendUrl}/api/heartbeat`);
es.onopen = () => { if (!closed) { attempt = 0; onConnect(); } };
es.onopen = () => {
if (!closed) {
attempt = 0;
onConnect();
}
};
es.onerror = () => {
es?.close();
es = null;
@ -342,9 +347,12 @@ export const connectHeartbeat = (
/** Delete a repo's index and unregister it. */
export const deleteRepo = async (repoName: string): Promise<void> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/repo?repo=${encodeURIComponent(repoName)}`, {
method: 'DELETE',
});
const response = await fetchWithTimeout(
`${_backendUrl}/api/repo?repo=${encodeURIComponent(repoName)}`,
{
method: 'DELETE',
},
);
await assertOk(response);
};
@ -377,9 +385,15 @@ export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
/** Fetch the graph (nodes + relationships). Content stripped by default. */
export const fetchGraph = async (
repo?: string,
opts?: { includeContent?: boolean; signal?: AbortSignal; onProgress?: (downloaded: number, total: number | null) => void },
opts?: {
includeContent?: boolean;
signal?: AbortSignal;
onProgress?: (downloaded: number, total: number | null) => void;
},
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : ''].filter(Boolean).join('&');
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '']
.filter(Boolean)
.join('&');
const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`;
const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000);
await assertOk(response);
@ -458,7 +472,9 @@ export const grep = async (
`pattern=${encodeURIComponent(pattern)}`,
repoParam(repo),
limit ? `limit=${limit}` : '',
].filter(Boolean).join('&');
]
.filter(Boolean)
.join('&');
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
await assertOk(response);
const body = await response.json();
@ -483,7 +499,9 @@ export const readFile = async (
repoParam(options?.repo),
options?.startLine !== undefined ? `startLine=${options.startLine}` : '',
options?.endLine !== undefined ? `endLine=${options.endLine}` : '',
].filter(Boolean).join('&');
]
.filter(Boolean)
.join('&');
const response = await fetchWithTimeout(`${_backendUrl}/api/file?${params}`);
await assertOk(response);
return response.json() as Promise<ReadFileResult>;
@ -491,7 +509,9 @@ export const readFile = async (
/** Fetch all processes for a repo. */
export const fetchProcesses = async (repo?: string): Promise<unknown> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/processes${repo ? `?${repoParam(repo)}` : ''}`);
const response = await fetchWithTimeout(
`${_backendUrl}/api/processes${repo ? `?${repoParam(repo)}` : ''}`,
);
await assertOk(response);
return response.json();
};
@ -507,7 +527,9 @@ export const fetchProcessDetail = async (repo: string, name: string): Promise<un
/** Fetch all clusters for a repo. */
export const fetchClusters = async (repo?: string): Promise<unknown> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/clusters${repo ? `?${repoParam(repo)}` : ''}`);
const response = await fetchWithTimeout(
`${_backendUrl}/api/clusters${repo ? `?${repoParam(repo)}` : ''}`,
);
await assertOk(response);
return response.json();
};
@ -524,21 +546,30 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
// ── Analyze API ────────────────────────────────────────────────────────────
/** Start a server-side analysis job. */
export const startAnalyze = async (
request: { url?: string; path?: string; force?: boolean; embeddings?: boolean },
): Promise<{ jobId: string; status: string }> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}, 30_000);
export const startAnalyze = async (request: {
url?: string;
path?: string;
force?: boolean;
embeddings?: boolean;
}): Promise<{ jobId: string; status: string }> => {
const response = await fetchWithTimeout(
`${_backendUrl}/api/analyze`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
30_000,
);
await assertOk(response);
return response.json() as Promise<{ jobId: string; status: string }>;
};
/** Poll analysis job status. */
export const getAnalyzeStatus = async (jobId: string): Promise<JobStatus> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/analyze/${encodeURIComponent(jobId)}`);
const response = await fetchWithTimeout(
`${_backendUrl}/api/analyze/${encodeURIComponent(jobId)}`,
);
await assertOk(response);
return response.json() as Promise<JobStatus>;
};
@ -573,11 +604,15 @@ export const streamAnalyzeProgress = (
/** Start server-side embedding generation. */
export const startEmbeddings = async (repo: string): Promise<{ jobId: string; status: string }> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/embed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo }),
}, 30_000);
const response = await fetchWithTimeout(
`${_backendUrl}/api/embed`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo }),
},
30_000,
);
await assertOk(response);
return response.json() as Promise<{ jobId: string; status: string }>;
};
@ -591,10 +626,9 @@ export const getEmbedStatus = async (jobId: string): Promise<JobStatus> => {
/** Cancel a running embedding job. */
export const cancelEmbeddings = async (jobId: string): Promise<void> => {
const response = await fetchWithTimeout(
`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}`,
{ method: 'DELETE' },
);
const response = await fetchWithTimeout(`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}`, {
method: 'DELETE',
});
await assertOk(response);
};
@ -605,14 +639,11 @@ export const streamEmbeddingProgress = (
onComplete: (data: { repoName?: string }) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<JobProgress>(
`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}/progress`,
{
onMessage: onProgress,
onComplete: onComplete as (data: unknown) => void,
onError,
},
);
return streamSSE<JobProgress>(`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}/progress`, {
onMessage: onProgress,
onComplete: onComplete as (data: unknown) => void,
onError,
});
};
// ── Convenience: connect to server ─────────────────────────────────────────

View file

@ -29,305 +29,292 @@ var createRandomIndex = randomIndexModule.createRandomIndex || randomIndexModule
var UndirectedLouvainIndex = indices.UndirectedLouvainIndex;
var DEFAULTS = {
attributes: {
community: 'community',
weight: 'weight'
},
randomness: 0.01,
randomWalk: true,
resolution: 1,
rng: Math.random,
weighted: false
attributes: {
community: 'community',
weight: 'weight',
},
randomness: 0.01,
randomWalk: true,
resolution: 1,
rng: Math.random,
weighted: false,
};
var EPSILON = 1e-10;
function tieBreaker(
bestCommunity,
currentCommunity,
targetCommunity,
delta,
bestDelta
) {
if (Math.abs(delta - bestDelta) < EPSILON) {
if (bestCommunity === currentCommunity) {
return false;
} else {
return targetCommunity > bestCommunity;
}
} else if (delta > bestDelta) {
return true;
}
function tieBreaker(bestCommunity, currentCommunity, targetCommunity, delta, bestDelta) {
if (Math.abs(delta - bestDelta) < EPSILON) {
if (bestCommunity === currentCommunity) {
return false;
} else {
return targetCommunity > bestCommunity;
}
} else if (delta > bestDelta) {
return true;
}
return false;
return false;
}
function undirectedLeiden(detailed, graph, options) {
var index = new UndirectedLouvainIndex(graph, {
attributes: {
weight: options.attributes.weight
},
keepDendrogram: detailed,
resolution: options.resolution,
weighted: options.weighted
});
var index = new UndirectedLouvainIndex(graph, {
attributes: {
weight: options.attributes.weight,
},
keepDendrogram: detailed,
resolution: options.resolution,
weighted: options.weighted,
});
var addenda = new UndirectedLeidenAddenda(index, {
randomness: options.randomness,
rng: options.rng
});
var addenda = new UndirectedLeidenAddenda(index, {
randomness: options.randomness,
rng: options.rng,
});
var randomIndex = createRandomIndex(options.rng);
var randomIndex = createRandomIndex(options.rng);
// Communities
var currentCommunity, targetCommunity;
var communities = new SparseMap(Float64Array, index.C);
// Communities
var currentCommunity, targetCommunity;
var communities = new SparseMap(Float64Array, index.C);
// Traversal
var queue = new SparseQueueSet(index.C),
start,
end,
weight,
ci,
ri,
s,
i,
j,
l;
// Traversal
var queue = new SparseQueueSet(index.C),
start,
end,
weight,
ci,
ri,
s,
i,
j,
l;
// Metrics
var degree, targetCommunityDegree;
// Metrics
var degree, targetCommunityDegree;
// Moves
var bestCommunity, bestDelta, deltaIsBetter, delta;
// Moves
var bestCommunity, bestDelta, deltaIsBetter, delta;
// Details
var deltaComputations = 0,
nodesVisited = 0,
moves = [],
currentMoves;
// Details
var deltaComputations = 0,
nodesVisited = 0,
moves = [],
currentMoves;
while (true) {
l = index.C;
while (true) {
l = index.C;
currentMoves = 0;
currentMoves = 0;
// Traversal of the graph
ri = options.randomWalk ? randomIndex(l) : 0;
// Traversal of the graph
ri = options.randomWalk ? randomIndex(l) : 0;
for (s = 0; s < l; s++, ri++) {
i = ri % l;
queue.enqueue(i);
}
for (s = 0; s < l; s++, ri++) {
i = ri % l;
queue.enqueue(i);
}
while (queue.size !== 0) {
i = queue.dequeue();
nodesVisited++;
while (queue.size !== 0) {
i = queue.dequeue();
nodesVisited++;
degree = 0;
communities.clear();
degree = 0;
communities.clear();
currentCommunity = index.belongings[i];
currentCommunity = index.belongings[i];
start = index.starts[i];
end = index.starts[i + 1];
start = index.starts[i];
end = index.starts[i + 1];
// Traversing neighbors
for (; start < end; start++) {
j = index.neighborhood[start];
weight = index.weights[start];
// Traversing neighbors
for (; start < end; start++) {
j = index.neighborhood[start];
weight = index.weights[start];
targetCommunity = index.belongings[j];
targetCommunity = index.belongings[j];
// Incrementing metrics
degree += weight;
addWeightToCommunity(communities, targetCommunity, weight);
}
// Incrementing metrics
degree += weight;
addWeightToCommunity(communities, targetCommunity, weight);
}
// Finding best community to move to
bestDelta = index.fastDeltaWithOwnCommunity(
i,
degree,
communities.get(currentCommunity) || 0,
currentCommunity
);
bestCommunity = currentCommunity;
// Finding best community to move to
bestDelta = index.fastDeltaWithOwnCommunity(
i,
degree,
communities.get(currentCommunity) || 0,
currentCommunity,
);
bestCommunity = currentCommunity;
for (ci = 0; ci < communities.size; ci++) {
targetCommunity = communities.dense[ci];
for (ci = 0; ci < communities.size; ci++) {
targetCommunity = communities.dense[ci];
if (targetCommunity === currentCommunity) continue;
if (targetCommunity === currentCommunity) continue;
targetCommunityDegree = communities.vals[ci];
targetCommunityDegree = communities.vals[ci];
deltaComputations++;
deltaComputations++;
delta = index.fastDelta(
i,
degree,
targetCommunityDegree,
targetCommunity
);
delta = index.fastDelta(i, degree, targetCommunityDegree, targetCommunity);
deltaIsBetter = tieBreaker(
bestCommunity,
currentCommunity,
targetCommunity,
delta,
bestDelta
);
deltaIsBetter = tieBreaker(
bestCommunity,
currentCommunity,
targetCommunity,
delta,
bestDelta,
);
if (deltaIsBetter) {
bestDelta = delta;
bestCommunity = targetCommunity;
}
}
if (deltaIsBetter) {
bestDelta = delta;
bestCommunity = targetCommunity;
}
}
if (bestDelta < 0) {
bestCommunity = index.isolate(i, degree);
if (bestDelta < 0) {
bestCommunity = index.isolate(i, degree);
if (bestCommunity === currentCommunity) continue;
} else {
if (bestCommunity === currentCommunity) {
continue;
} else {
index.move(i, degree, bestCommunity);
}
}
if (bestCommunity === currentCommunity) continue;
} else {
if (bestCommunity === currentCommunity) {
continue;
} else {
index.move(i, degree, bestCommunity);
}
}
currentMoves++;
currentMoves++;
// Adding neighbors from other communities to the queue
start = index.starts[i];
end = index.starts[i + 1];
// Adding neighbors from other communities to the queue
start = index.starts[i];
end = index.starts[i + 1];
for (; start < end; start++) {
j = index.neighborhood[start];
targetCommunity = index.belongings[j];
for (; start < end; start++) {
j = index.neighborhood[start];
targetCommunity = index.belongings[j];
if (targetCommunity !== bestCommunity) queue.enqueue(j);
}
}
if (targetCommunity !== bestCommunity) queue.enqueue(j);
}
}
moves.push(currentMoves);
moves.push(currentMoves);
if (currentMoves === 0) {
index.zoomOut();
break;
}
if (currentMoves === 0) {
index.zoomOut();
break;
}
if (!addenda.onlySingletons()) {
// We continue working on the induced graph
addenda.zoomOut();
continue;
}
if (!addenda.onlySingletons()) {
// We continue working on the induced graph
addenda.zoomOut();
continue;
}
break;
}
break;
}
var results = {
index: index,
deltaComputations: deltaComputations,
nodesVisited: nodesVisited,
moves: moves
};
var results = {
index: index,
deltaComputations: deltaComputations,
nodesVisited: nodesVisited,
moves: moves,
};
return results;
return results;
}
/**
* Function returning the communities mapping of the graph.
*/
function leiden(assign, detailed, graph, options) {
if (!isGraph(graph))
throw new Error(
'graphology-communities-leiden: the given graph is not a valid graphology instance.'
);
if (!isGraph(graph))
throw new Error(
'graphology-communities-leiden: the given graph is not a valid graphology instance.',
);
var type = inferType(graph);
var type = inferType(graph);
if (type === 'mixed')
throw new Error(
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.'
);
if (type === 'mixed')
throw new Error(
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.',
);
if (type === 'directed')
throw new Error(
'graphology-communities-leiden: not yet implemented for directed graphs.'
);
if (type === 'directed')
throw new Error('graphology-communities-leiden: not yet implemented for directed graphs.');
// Attributes name
options = resolveDefaults(options, DEFAULTS);
// Attributes name
options = resolveDefaults(options, DEFAULTS);
// Empty graph case
var c = 0;
// Empty graph case
var c = 0;
if (graph.size === 0) {
if (assign) {
graph.forEachNode(function (node) {
graph.setNodeAttribute(node, options.attributes.communities, c++);
});
if (graph.size === 0) {
if (assign) {
graph.forEachNode(function (node) {
graph.setNodeAttribute(node, options.attributes.communities, c++);
});
return;
}
return;
}
var communities = {};
var communities = {};
graph.forEachNode(function (node) {
communities[node] = c++;
});
graph.forEachNode(function (node) {
communities[node] = c++;
});
if (!detailed) return communities;
if (!detailed) return communities;
return {
communities: communities,
count: graph.order,
deltaComputations: 0,
dendrogram: null,
level: 0,
modularity: NaN,
moves: null,
nodesVisited: 0,
resolution: options.resolution
};
}
return {
communities: communities,
count: graph.order,
deltaComputations: 0,
dendrogram: null,
level: 0,
modularity: NaN,
moves: null,
nodesVisited: 0,
resolution: options.resolution,
};
}
var fn = undirectedLeiden;
var fn = undirectedLeiden;
var results = fn(detailed, graph, options);
var results = fn(detailed, graph, options);
var index = results.index;
var index = results.index;
// Standard output
if (!detailed) {
if (assign) {
index.assign(options.attributes.community);
return;
}
// Standard output
if (!detailed) {
if (assign) {
index.assign(options.attributes.community);
return;
}
return index.collect();
}
return index.collect();
}
// Detailed output
var output = {
count: index.C,
deltaComputations: results.deltaComputations,
dendrogram: index.dendrogram,
level: index.level,
modularity: index.modularity(),
moves: results.moves,
nodesVisited: results.nodesVisited,
resolution: options.resolution
};
// Detailed output
var output = {
count: index.C,
deltaComputations: results.deltaComputations,
dendrogram: index.dendrogram,
level: index.level,
modularity: index.modularity(),
moves: results.moves,
nodesVisited: results.nodesVisited,
resolution: options.resolution,
};
if (assign) {
index.assign(options.attributes.community);
return output;
}
if (assign) {
index.assign(options.attributes.community);
return output;
}
output.communities = index.collect();
output.communities = index.collect();
return output;
return output;
}
/**

View file

@ -14,379 +14,369 @@ import randomModule from 'pandemonium/random';
var createRandom = randomModule.createRandom || randomModule;
export function addWeightToCommunity(map, community, weight) {
var currentWeight = map.get(community);
var currentWeight = map.get(community);
if (typeof currentWeight === 'undefined') currentWeight = 0;
if (typeof currentWeight === 'undefined') currentWeight = 0;
currentWeight += weight;
currentWeight += weight;
map.set(community, currentWeight);
map.set(community, currentWeight);
}
export function UndirectedLeidenAddenda(index, options) {
options = options || {};
options = options || {};
var rng = options.rng || Math.random;
var randomness = 'randomness' in options ? options.randomness : 0.01;
var rng = options.rng || Math.random;
var randomness = 'randomness' in options ? options.randomness : 0.01;
this.index = index;
this.random = createRandom(rng);
this.randomness = randomness;
this.rng = rng;
this.index = index;
this.random = createRandom(rng);
this.randomness = randomness;
this.rng = rng;
var NodesPointerArray = index.counts.constructor;
var WeightsArray = index.weights.constructor;
var NodesPointerArray = index.counts.constructor;
var WeightsArray = index.weights.constructor;
var order = index.C;
this.resolution = index.resolution;
var order = index.C;
this.resolution = index.resolution;
// Used to group nodes by communities
this.B = index.C;
this.C = 0;
this.communitiesOffsets = new NodesPointerArray(order);
this.nodesSortedByCommunities = new NodesPointerArray(order);
this.communitiesBounds = new NodesPointerArray(order + 1);
// Used to group nodes by communities
this.B = index.C;
this.C = 0;
this.communitiesOffsets = new NodesPointerArray(order);
this.nodesSortedByCommunities = new NodesPointerArray(order);
this.communitiesBounds = new NodesPointerArray(order + 1);
// Used to merge nodes subsets
this.communityWeights = new WeightsArray(order);
this.degrees = new WeightsArray(order);
this.nonSingleton = new Uint8Array(order);
this.externalEdgeWeightPerCommunity = new WeightsArray(order);
this.belongings = new NodesPointerArray(order);
this.neighboringCommunities = new SparseMap(WeightsArray, order);
this.cumulativeIncrement = new Float64Array(order);
this.macroCommunities = null;
// Used to merge nodes subsets
this.communityWeights = new WeightsArray(order);
this.degrees = new WeightsArray(order);
this.nonSingleton = new Uint8Array(order);
this.externalEdgeWeightPerCommunity = new WeightsArray(order);
this.belongings = new NodesPointerArray(order);
this.neighboringCommunities = new SparseMap(WeightsArray, order);
this.cumulativeIncrement = new Float64Array(order);
this.macroCommunities = null;
}
UndirectedLeidenAddenda.prototype.groupByCommunities = function () {
var index = this.index;
var index = this.index;
var n, i, c, b, o;
var n, i, c, b, o;
n = 0;
o = 0;
n = 0;
o = 0;
for (i = 0; i < index.C; i++) {
c = index.counts[i];
for (i = 0; i < index.C; i++) {
c = index.counts[i];
if (c !== 0) {
this.communitiesBounds[o++] = n;
n += c;
this.communitiesOffsets[i] = n;
}
}
if (c !== 0) {
this.communitiesBounds[o++] = n;
n += c;
this.communitiesOffsets[i] = n;
}
}
this.communitiesBounds[o] = n;
this.communitiesBounds[o] = n;
o = 0;
o = 0;
for (i = 0; i < index.C; i++) {
b = index.belongings[i];
o = --this.communitiesOffsets[b];
this.nodesSortedByCommunities[o] = i;
}
for (i = 0; i < index.C; i++) {
b = index.belongings[i];
o = --this.communitiesOffsets[b];
this.nodesSortedByCommunities[o] = i;
}
this.B = index.C - index.U;
this.C = index.C;
this.B = index.C - index.U;
this.C = index.C;
};
UndirectedLeidenAddenda.prototype.communities = function () {
var communities = new Array(this.B);
var communities = new Array(this.B);
var i, j, community, start, stop;
var i, j, community, start, stop;
for (i = 0; i < this.B; i++) {
start = this.communitiesBounds[i];
stop = this.communitiesBounds[i + 1];
community = [];
for (i = 0; i < this.B; i++) {
start = this.communitiesBounds[i];
stop = this.communitiesBounds[i + 1];
community = [];
for (j = start; j < stop; j++) {
community.push(j);
}
for (j = start; j < stop; j++) {
community.push(j);
}
communities[i] = community;
}
communities[i] = community;
}
return communities;
return communities;
};
UndirectedLeidenAddenda.prototype.mergeNodesSubset = function (start, stop) {
var index = this.index;
var currentMacroCommunity =
index.belongings[this.nodesSortedByCommunities[start]];
var neighboringCommunities = this.neighboringCommunities;
var index = this.index;
var currentMacroCommunity = index.belongings[this.nodesSortedByCommunities[start]];
var neighboringCommunities = this.neighboringCommunities;
var totalNodeWeight = 0;
var totalNodeWeight = 0;
var i, j, w;
var ei, el, et;
var i, j, w;
var ei, el, et;
// Initializing singletons
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
// Initializing singletons
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
this.belongings[i] = i;
this.nonSingleton[i] = 0;
this.degrees[i] = 0;
totalNodeWeight += index.loops[i] / 2;
this.belongings[i] = i;
this.nonSingleton[i] = 0;
this.degrees[i] = 0;
totalNodeWeight += index.loops[i] / 2;
this.communityWeights[i] = index.loops[i];
this.externalEdgeWeightPerCommunity[i] = 0;
this.communityWeights[i] = index.loops[i];
this.externalEdgeWeightPerCommunity[i] = 0;
ei = index.starts[i];
el = index.starts[i + 1];
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
w = index.weights[ei];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
w = index.weights[ei];
this.degrees[i] += w;
this.degrees[i] += w;
if (index.belongings[et] !== currentMacroCommunity) continue;
if (index.belongings[et] !== currentMacroCommunity) continue;
totalNodeWeight += w;
this.externalEdgeWeightPerCommunity[i] += w;
this.communityWeights[i] += w;
}
}
totalNodeWeight += w;
this.externalEdgeWeightPerCommunity[i] += w;
this.communityWeights[i] += w;
}
}
var microDegrees = this.externalEdgeWeightPerCommunity.slice();
var microDegrees = this.externalEdgeWeightPerCommunity.slice();
var s, ri, ci;
var order = stop - start;
var s, ri, ci;
var order = stop - start;
var degree,
bestCommunity,
qualityValueIncrement,
maxQualityValueIncrement,
totalTransformedQualityValueIncrement,
targetCommunity,
targetCommunityDegree,
targetCommunityWeight;
var degree,
bestCommunity,
qualityValueIncrement,
maxQualityValueIncrement,
totalTransformedQualityValueIncrement,
targetCommunity,
targetCommunityDegree,
targetCommunityWeight;
var r, lo, hi, mid, chosenCommunity;
var r, lo, hi, mid, chosenCommunity;
ri = this.random(start, stop - 1);
ri = this.random(start, stop - 1);
for (s = start; s < stop; s++, ri++) {
j = start + (ri % order);
for (s = start; s < stop; s++, ri++) {
j = start + (ri % order);
i = this.nodesSortedByCommunities[j];
i = this.nodesSortedByCommunities[j];
if (this.nonSingleton[i] === 1) {
continue;
}
if (this.nonSingleton[i] === 1) {
continue;
}
if (
this.externalEdgeWeightPerCommunity[i] <
this.communityWeights[i] *
(totalNodeWeight / 2 - this.communityWeights[i]) *
this.resolution
) {
continue;
}
if (
this.externalEdgeWeightPerCommunity[i] <
this.communityWeights[i] * (totalNodeWeight / 2 - this.communityWeights[i]) * this.resolution
) {
continue;
}
this.communityWeights[i] = 0;
this.externalEdgeWeightPerCommunity[i] = 0;
this.communityWeights[i] = 0;
this.externalEdgeWeightPerCommunity[i] = 0;
neighboringCommunities.clear();
neighboringCommunities.set(i, 0);
neighboringCommunities.clear();
neighboringCommunities.set(i, 0);
degree = 0;
degree = 0;
ei = index.starts[i];
el = index.starts[i + 1];
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
if (index.belongings[et] !== currentMacroCommunity) continue;
if (index.belongings[et] !== currentMacroCommunity) continue;
w = index.weights[ei];
w = index.weights[ei];
degree += w;
degree += w;
addWeightToCommunity(neighboringCommunities, this.belongings[et], w);
}
addWeightToCommunity(neighboringCommunities, this.belongings[et], w);
}
bestCommunity = i;
maxQualityValueIncrement = 0;
totalTransformedQualityValueIncrement = 0;
bestCommunity = i;
maxQualityValueIncrement = 0;
totalTransformedQualityValueIncrement = 0;
for (ci = 0; ci < neighboringCommunities.size; ci++) {
targetCommunity = neighboringCommunities.dense[ci];
targetCommunityDegree = neighboringCommunities.vals[ci];
targetCommunityWeight = this.communityWeights[targetCommunity];
for (ci = 0; ci < neighboringCommunities.size; ci++) {
targetCommunity = neighboringCommunities.dense[ci];
targetCommunityDegree = neighboringCommunities.vals[ci];
targetCommunityWeight = this.communityWeights[targetCommunity];
if (
this.externalEdgeWeightPerCommunity[targetCommunity] >=
targetCommunityWeight *
(totalNodeWeight / 2 - targetCommunityWeight) *
this.resolution
) {
qualityValueIncrement =
targetCommunityDegree -
((degree + index.loops[i]) *
targetCommunityWeight *
this.resolution) /
totalNodeWeight;
if (
this.externalEdgeWeightPerCommunity[targetCommunity] >=
targetCommunityWeight * (totalNodeWeight / 2 - targetCommunityWeight) * this.resolution
) {
qualityValueIncrement =
targetCommunityDegree -
((degree + index.loops[i]) * targetCommunityWeight * this.resolution) / totalNodeWeight;
if (qualityValueIncrement > maxQualityValueIncrement) {
bestCommunity = targetCommunity;
maxQualityValueIncrement = qualityValueIncrement;
}
if (qualityValueIncrement > maxQualityValueIncrement) {
bestCommunity = targetCommunity;
maxQualityValueIncrement = qualityValueIncrement;
}
if (qualityValueIncrement >= 0)
totalTransformedQualityValueIncrement += Math.exp(
qualityValueIncrement / this.randomness
);
}
if (qualityValueIncrement >= 0)
totalTransformedQualityValueIncrement += Math.exp(
qualityValueIncrement / this.randomness,
);
}
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
}
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
}
if (
totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
totalTransformedQualityValueIncrement < Infinity
) {
r = totalTransformedQualityValueIncrement * this.rng();
lo = -1;
hi = neighboringCommunities.size + 1;
if (
totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
totalTransformedQualityValueIncrement < Infinity
) {
r = totalTransformedQualityValueIncrement * this.rng();
lo = -1;
hi = neighboringCommunities.size + 1;
while (lo < hi - 1) {
mid = (lo + hi) >>> 1;
while (lo < hi - 1) {
mid = (lo + hi) >>> 1;
if (this.cumulativeIncrement[mid] >= r) hi = mid;
else lo = mid;
}
if (this.cumulativeIncrement[mid] >= r) hi = mid;
else lo = mid;
}
chosenCommunity = neighboringCommunities.dense[hi];
} else {
chosenCommunity = bestCommunity;
}
chosenCommunity = neighboringCommunities.dense[hi];
} else {
chosenCommunity = bestCommunity;
}
this.communityWeights[chosenCommunity] += degree + index.loops[i];
this.communityWeights[chosenCommunity] += degree + index.loops[i];
ei = index.starts[i];
el = index.starts[i + 1];
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
if (index.belongings[et] !== currentMacroCommunity) continue;
if (index.belongings[et] !== currentMacroCommunity) continue;
targetCommunity = this.belongings[et];
targetCommunity = this.belongings[et];
if (targetCommunity === chosenCommunity) {
this.externalEdgeWeightPerCommunity[chosenCommunity] -=
microDegrees[et];
} else {
this.externalEdgeWeightPerCommunity[chosenCommunity] +=
microDegrees[et];
}
}
if (targetCommunity === chosenCommunity) {
this.externalEdgeWeightPerCommunity[chosenCommunity] -= microDegrees[et];
} else {
this.externalEdgeWeightPerCommunity[chosenCommunity] += microDegrees[et];
}
}
if (chosenCommunity !== i) {
this.belongings[i] = chosenCommunity;
this.nonSingleton[chosenCommunity] = 1;
this.C--;
}
}
if (chosenCommunity !== i) {
this.belongings[i] = chosenCommunity;
this.nonSingleton[chosenCommunity] = 1;
this.C--;
}
}
var microCommunities = this.neighboringCommunities;
microCommunities.clear();
var microCommunities = this.neighboringCommunities;
microCommunities.clear();
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
microCommunities.set(this.belongings[i], 1);
}
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
microCommunities.set(this.belongings[i], 1);
}
return microCommunities.dense.slice(0, microCommunities.size);
return microCommunities.dense.slice(0, microCommunities.size);
};
UndirectedLeidenAddenda.prototype.refinePartition = function () {
this.groupByCommunities();
this.groupByCommunities();
this.macroCommunities = new Array(this.B);
this.macroCommunities = new Array(this.B);
var i, start, stop, mapping;
var i, start, stop, mapping;
var bounds = this.communitiesBounds;
var bounds = this.communitiesBounds;
for (i = 0; i < this.B; i++) {
start = bounds[i];
stop = bounds[i + 1];
for (i = 0; i < this.B; i++) {
start = bounds[i];
stop = bounds[i + 1];
mapping = this.mergeNodesSubset(start, stop);
this.macroCommunities[i] = mapping;
}
mapping = this.mergeNodesSubset(start, stop);
this.macroCommunities[i] = mapping;
}
};
UndirectedLeidenAddenda.prototype.split = function () {
var index = this.index;
var isolates = this.neighboringCommunities;
var index = this.index;
var isolates = this.neighboringCommunities;
isolates.clear();
isolates.clear();
var i, community, isolated;
var i, community, isolated;
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
if (i !== community) continue;
if (i !== community) continue;
isolated = index.isolate(i, this.degrees[i]);
isolates.set(community, isolated);
}
isolated = index.isolate(i, this.degrees[i]);
isolates.set(community, isolated);
}
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
if (i === community) continue;
if (i === community) continue;
isolated = isolates.get(community);
index.move(i, this.degrees[i], isolated);
}
isolated = isolates.get(community);
index.move(i, this.degrees[i], isolated);
}
var j, macro;
var j, macro;
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
for (j = 0; j < macro.length; j++) macro[j] = isolates.get(macro[j]);
}
for (j = 0; j < macro.length; j++) macro[j] = isolates.get(macro[j]);
}
};
UndirectedLeidenAddenda.prototype.zoomOut = function () {
var index = this.index;
this.refinePartition();
this.split();
var index = this.index;
this.refinePartition();
this.split();
var newLabels = index.zoomOut();
var newLabels = index.zoomOut();
var macro, leader, follower;
var macro, leader, follower;
var i, j;
var i, j;
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
leader = newLabels[macro[0]];
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
leader = newLabels[macro[0]];
for (j = 1; j < macro.length; j++) {
follower = newLabels[macro[j]];
index.expensiveMove(follower, leader);
}
}
for (j = 1; j < macro.length; j++) {
follower = newLabels[macro[j]];
index.expensiveMove(follower, leader);
}
}
};
UndirectedLeidenAddenda.prototype.onlySingletons = function () {
var index = this.index;
var index = this.index;
var i;
var i;
for (i = 0; i < index.C; i++) {
if (index.counts[i] > 1) return false;
}
for (i = 0; i < index.C; i++) {
if (index.counts[i] > 1) return false;
}
return true;
return true;
};

View file

@ -29,7 +29,11 @@ export function createClassNode(name: string, filePath: string): GraphNode {
};
}
export function createProcessNode(id: string, label: string, type: 'cross_community' | 'intra_community' = 'cross_community'): GraphNode {
export function createProcessNode(
id: string,
label: string,
type: 'cross_community' | 'intra_community' = 'cross_community',
): GraphNode {
return {
id,
label: 'Process',

View file

@ -110,11 +110,24 @@ describe('loadServerGraph — data flow validation', () => {
it('reconstructs graph from server node/relationship arrays', () => {
const graph = createKnowledgeGraph();
const serverNodes = [
{ id: 'File:src/app.ts', label: 'File' as const, properties: { name: 'app.ts', filePath: 'src/app.ts' } },
{ id: 'Function:src/app.ts:main', label: 'Function' as const, properties: { name: 'main', filePath: 'src/app.ts', startLine: 1, endLine: 20 } },
{
id: 'File:src/app.ts',
label: 'File' as const,
properties: { name: 'app.ts', filePath: 'src/app.ts' },
},
{
id: 'Function:src/app.ts:main',
label: 'Function' as const,
properties: { name: 'main', filePath: 'src/app.ts', startLine: 1, endLine: 20 },
},
];
const serverRels = [
{ sourceId: 'File:src/app.ts', targetId: 'Function:src/app.ts:main', type: 'CONTAINS' as const, properties: {} },
{
sourceId: 'File:src/app.ts',
targetId: 'Function:src/app.ts:main',
type: 'CONTAINS' as const,
properties: {},
},
];
for (const node of serverNodes) graph.addNode(node);

View file

@ -22,7 +22,7 @@ describe('NODE_COLORS', () => {
describe('NODE_SIZES', () => {
it('gives Project the largest size', () => {
const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => a[1] > b[1] ? a : b);
const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => (a[1] > b[1] ? a : b));
expect(maxLabel[0]).toBe('Project');
});

View file

@ -4,8 +4,16 @@ import type { NodeLabel } from '../../src/core/graph/types';
import * as lucideIcons from '../../src/lib/lucide-icons';
const LEGEND_LABELS: NodeLabel[] = [
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type',
'Function', 'Method', 'Variable', 'Decorator',
'Folder',
'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
];
const ICON_MAP: Record<string, string> = {
@ -33,7 +41,9 @@ describe('filter panel icon mappings', () => {
const exportedNames = new Set(Object.keys(lucideIcons));
const requiredIcons = new Set(Object.values(ICON_MAP));
for (const iconName of requiredIcons) {
expect(exportedNames.has(iconName), `${iconName} should be exported from lucide-icons`).toBe(true);
expect(exportedNames.has(iconName), `${iconName} should be exported from lucide-icons`).toBe(
true,
);
}
});
@ -61,8 +71,16 @@ describe('color legend', () => {
it('legend labels match the order used in FileTreePanel', () => {
const expected: NodeLabel[] = [
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type',
'Function', 'Method', 'Variable', 'Decorator',
'Folder',
'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
];
expect(LEGEND_LABELS).toEqual(expected);
});

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph';
import { createFileNode, createFunctionNode, createCallsRelationship, createContainsRelationship } from '../fixtures/graph';
import {
createFileNode,
createFunctionNode,
createCallsRelationship,
createContainsRelationship,
} from '../fixtures/graph';
describe('createKnowledgeGraph', () => {
it('starts empty', () => {

View file

@ -14,7 +14,9 @@ describe('path-resolution utilities', () => {
});
it('prefers exact matches', () => {
expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe('src/components/Header.tsx');
expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe(
'src/components/Header.tsx',
);
});
it('resolves ends-with partials', () => {

View file

@ -13,7 +13,7 @@ describe('nodeById Map — O(1) lookup correctness', () => {
{ id: 'Class:b.ts:Bar', label: 'Class', name: 'Bar' },
{ id: 'File:c.ts', label: 'File', name: 'c.ts' },
];
const nodeById = new Map(nodes.map(n => [n.id, n]));
const nodeById = new Map(nodes.map((n) => [n.id, n]));
expect(nodeById.get('Function:a.ts:foo')?.name).toBe('foo');
expect(nodeById.get('Class:b.ts:Bar')?.name).toBe('Bar');
@ -26,7 +26,7 @@ describe('nodeById Map — O(1) lookup correctness', () => {
{ id: 'File:a.ts', label: 'File', name: 'first' },
{ id: 'File:a.ts', label: 'File', name: 'second' },
];
const nodeById = new Map(nodes.map(n => [n.id, n]));
const nodeById = new Map(nodes.map((n) => [n.id, n]));
expect(nodeById.get('File:a.ts')?.name).toBe('second');
expect(nodeById.size).toBe(1);

View file

@ -11,14 +11,11 @@ import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
// readOnly guard (regex) -- gitnexus-web/src/core/lbug/lbug-adapter.ts
// ---------------------------------------------------------------------------
const validLabel = (label: string): boolean =>
(NODE_TABLES as readonly string[]).includes(label);
const validLabel = (label: string): boolean => (NODE_TABLES as readonly string[]).includes(label);
const validRelType = (t: string): boolean =>
(REL_TYPES as readonly string[]).includes(t);
const validRelType = (t: string): boolean => (REL_TYPES as readonly string[]).includes(t);
const isSafeId = (id: string): boolean =>
/^[a-zA-Z0-9_:.\-/@]+$/.test(id);
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
const isWriteQuery = (cypher: string): boolean => {
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
@ -29,17 +26,32 @@ const isWriteQuery = (cypher: string): boolean => {
// validLabel
// ===========================================================================
describe('validLabel NODE_TABLES membership', () => {
it.each([
'Function', 'Class', 'File', 'Process', 'Community',
])('accepts known label "%s"', (label) => {
expect(validLabel(label)).toBe(true);
});
it.each(['Function', 'Class', 'File', 'Process', 'Community'])(
'accepts known label "%s"',
(label) => {
expect(validLabel(label)).toBe(true);
},
);
it.each([
'Struct', 'Enum', 'Trait', 'Impl', 'Macro', 'Typedef',
'Union', 'Namespace', 'TypeAlias', 'Const', 'Static',
'Property', 'Record', 'Delegate', 'Annotation',
'Constructor', 'Template', 'Module',
'Struct',
'Enum',
'Trait',
'Impl',
'Macro',
'Typedef',
'Union',
'Namespace',
'TypeAlias',
'Const',
'Static',
'Property',
'Record',
'Delegate',
'Annotation',
'Constructor',
'Template',
'Module',
])('accepts multi-language label "%s"', (label) => {
expect(validLabel(label)).toBe(true);
});
@ -59,7 +71,17 @@ describe('validLabel NODE_TABLES membership', () => {
});
it('NODE_TABLES contains all expected core labels', () => {
const core = ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process'];
const core = [
'File',
'Folder',
'Function',
'Class',
'Interface',
'Method',
'CodeElement',
'Community',
'Process',
];
for (const label of core) {
expect((NODE_TABLES as readonly string[]).includes(label)).toBe(true);
}
@ -70,9 +92,7 @@ describe('validLabel NODE_TABLES membership', () => {
// validRelType
// ===========================================================================
describe('validRelType REL_TYPES membership', () => {
it.each(
[...REL_TYPES]
)('accepts known relation type "%s"', (relType) => {
it.each([...REL_TYPES])('accepts known relation type "%s"', (relType) => {
expect(validRelType(relType)).toBe(true);
});
@ -112,11 +132,12 @@ describe('isSafeId identifier allowlist regex', () => {
expect(isSafeId(id)).toBe(true);
});
it.each([
['with spaces', 'Process:my process'],
])('rejects ID with unsafe chars: %s', (_desc, id) => {
expect(isSafeId(id)).toBe(false);
});
it.each([['with spaces', 'Process:my process']])(
'rejects ID with unsafe chars: %s',
(_desc, id) => {
expect(isSafeId(id)).toBe(false);
},
);
it('rejects empty string', () => {
expect(isSafeId('')).toBe(false);

View file

@ -19,10 +19,13 @@ describe('loadSettings', () => {
});
it('merges stored values with defaults', () => {
sessionStorage.setItem('gitnexus-llm-settings', JSON.stringify({
activeProvider: 'ollama',
ollama: { model: 'qwen3-coder:30b' },
}));
sessionStorage.setItem(
'gitnexus-llm-settings',
JSON.stringify({
activeProvider: 'ollama',
ollama: { model: 'qwen3-coder:30b' },
}),
);
const settings = loadSettings();
expect(settings.activeProvider).toBe('ollama');
@ -38,10 +41,13 @@ describe('loadSettings', () => {
});
it('migrates legacy localStorage to sessionStorage', () => {
localStorage.setItem('gitnexus-llm-settings', JSON.stringify({
activeProvider: 'ollama',
ollama: { model: 'migrated-model' },
}));
localStorage.setItem(
'gitnexus-llm-settings',
JSON.stringify({
activeProvider: 'ollama',
ollama: { model: 'migrated-model' },
}),
);
const settings = loadSettings();
expect(settings.ollama.model).toBe('migrated-model');
@ -111,7 +117,11 @@ describe('getActiveProviderConfig', () => {
describe('isProviderConfigured', () => {
it('returns false when provider requires API key and none is set', () => {
// Manually build a clean openai config with no API key
saveSettings({ ...loadSettings(), activeProvider: 'openai', openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 } });
saveSettings({
...loadSettings(),
activeProvider: 'openai',
openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 },
});
expect(isProviderConfigured()).toBe(false);
});

View file

@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View file

@ -8,10 +8,7 @@ const _require = createRequire(import.meta.url);
const gitnexusPkg = _require('../gitnexus/package.json');
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
plugins: [react(), tailwindcss()],
define: {
__REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')),
},
@ -20,9 +17,12 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../shared'),
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
__dirname,
'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs',
),
// Fix for mermaid d3-color prototype crash on Vercel (known issue with mermaid 10.9.0+ and Vite)
'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
mermaid: path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
},
},
server: {

View file

@ -14,8 +14,11 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
__dirname,
'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs',
),
mermaid: path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
},
},
test: {
@ -28,12 +31,12 @@ export default defineConfig({
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/workers/**', // Web workers (require worker env)
'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
'src/core/embeddings/**', // WASM (requires ML model)
'src/main.tsx', // Entry point
'src/vite-env.d.ts', // Type declarations
'src/workers/**', // Web workers (require worker env)
'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
'src/core/embeddings/**', // WASM (requires ML model)
'src/main.tsx', // Entry point
'src/vite-env.d.ts', // Type declarations
],
thresholds: {
statements: 10,
@ -44,4 +47,3 @@ export default defineConfig({
},
},
});

View file

@ -1,7 +1,5 @@
{
"permissions": {
"allow": [
"mcp__plugin_claude-mem_mcp-search__get_observations"
]
"allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"]
}
}

View file

@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
const tokens = cmd.split(/\s+/);
let foundCmd = false;
let skipNext = false;
const flagsWithValues = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '--glob', '-t', '--type', '--include', '--exclude']);
const flagsWithValues = new Set([
'-e',
'-f',
'-m',
'-A',
'-B',
'-C',
'-g',
'--glob',
'-t',
'--type',
'--include',
'--exclude',
]);
for (const token of tokens) {
if (skipNext) { skipNext = false; continue; }
if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue;
@ -110,18 +126,20 @@ function resolveCliPath() {
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
if (cliPath) {
return spawnSync(
process.execPath,
[cliPath, ...args],
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(process.execPath, [cliPath, ...args], {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
// On Windows, invoke npx.cmd directly (no shell needed)
return spawnSync(
isWin ? 'npx.cmd' : 'npx',
['-y', 'gitnexus', ...args],
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
/**
@ -147,7 +165,9 @@ function handlePreToolUse(input) {
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch { /* graceful failure */ }
} catch {
/* graceful failure */
}
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
@ -158,9 +178,11 @@ function handlePreToolUse(input) {
* Emit a PostToolUse hook response with additional context for the agent.
*/
function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message }
}));
console.log(
JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message },
}),
);
}
/**
@ -192,10 +214,15 @@ function handlePostToolUse(input) {
let currentHead = '';
try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
timeout: 3000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
currentHead = (headResult.stdout || '').trim();
} catch { return; }
} catch {
return;
}
if (!currentHead) return;
@ -204,16 +231,19 @@ function handlePostToolUse(input) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
} catch { /* no meta — treat as stale */ }
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse',
sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to update the knowledge graph.`
`Run \`${analyzeCmd}\` to update the knowledge graph.`,
);
}

View file

@ -93,7 +93,6 @@
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"

View file

@ -1,6 +1,6 @@
/**
* AI Context Generator
*
*
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
* CLAUDE.md is for Claude Code which only reads that file.
@ -20,7 +20,7 @@ interface RepoStats {
nodes?: number;
edges?: number;
communities?: number;
clusters?: number; // Aggregated cluster count (what tools show)
clusters?: number; // Aggregated cluster count (what tools show)
processes?: number;
}
@ -38,12 +38,20 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
* - Exact tool commands with parameters vague directives get ignored
* - Self-review checklist forces model to verify its own work
*/
function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[]): string {
const generatedRows = (generatedSkills && generatedSkills.length > 0)
? generatedSkills.map(s =>
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`
).join('\n')
: '';
function generateGitNexusContent(
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
? generatedSkills
.map(
(s) =>
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`,
)
.join('\n')
: '';
const skillsTable = `| Task | Read this skill file |
|------|---------------------|
@ -150,7 +158,6 @@ ${skillsTable}
${GITNEXUS_END_MARKER}`;
}
/**
* Check if a file exists
*/
@ -171,7 +178,7 @@ async function fileExists(filePath: string): Promise<boolean> {
*/
async function upsertGitNexusSection(
filePath: string,
content: string
content: string,
): Promise<'created' | 'updated' | 'appended'> {
const exists = await fileExists(filePath);
@ -213,27 +220,33 @@ async function installSkills(repoPath: string): Promise<string[]> {
const skills = [
{
name: 'gitnexus-exploring',
description: 'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
description:
'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
},
{
name: 'gitnexus-debugging',
description: 'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
description:
'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
},
{
name: 'gitnexus-impact-analysis',
description: 'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
description:
'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
},
{
name: 'gitnexus-refactoring',
description: 'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
description:
'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
},
{
name: 'gitnexus-guide',
description: 'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
description:
'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
},
{
name: 'gitnexus-cli',
description: 'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
description:
'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
},
];
@ -285,7 +298,7 @@ export async function generateAIContextFiles(
_storagePath: string,
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[]
generatedSkills?: GeneratedSkillInfo[],
): Promise<{ files: string[] }> {
const content = generateGitNexusContent(projectName, stats, generatedSkills);
const createdFiles: string[] = [];

View file

@ -18,7 +18,6 @@ import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import fs from 'fs/promises';
const HEAP_MB = 8192;
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
@ -50,10 +49,7 @@ export interface AnalyzeOptions {
skipGit?: boolean;
}
export const analyzeCommand = async (
inputPath?: string,
options?: AnalyzeOptions
) => {
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
if (options?.verbose) {
@ -69,7 +65,9 @@ export const analyzeCommand = async (
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
if (!options?.skipGit) {
console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
console.log(
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
@ -82,32 +80,41 @@ export const analyzeCommand = async (
const repoHasGit = hasGitDir(repoPath);
if (!repoHasGit && !options?.skipGit) {
console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
console.log(
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
if (!repoHasGit) {
console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
console.log(
' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
);
}
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
if (process.env.GITNEXUS_NO_GITIGNORE) {
console.log(' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n');
console.log(
' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n',
);
}
// ── CLI progress bar setup ─────────────────────────────────────────
const bar = new cliProgress.SingleBar({
format: ' {bar} {percentage}% | {phase}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
barGlue: '',
autopadding: true,
clearOnComplete: false,
stopOnComplete: false,
}, cliProgress.Presets.shades_grey);
const bar = new cliProgress.SingleBar(
{
format: ' {bar} {percentage}% | {phase}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
barGlue: '',
autopadding: true,
clearOnComplete: false,
stopOnComplete: false,
},
cliProgress.Presets.shades_grey,
);
bar.start(100, 0, { phase: 'Initializing...' });
@ -118,7 +125,9 @@ export const analyzeCommand = async (
aborted = true;
bar.stop();
console.log('\n Interrupted — cleaning up...');
closeLbug().catch(() => {}).finally(() => process.exit(130));
closeLbug()
.catch(() => {})
.finally(() => process.exit(130));
};
process.on('SIGINT', sigintHandler);
@ -128,7 +137,7 @@ export const analyzeCommand = async (
const origError = console.error.bind(console);
const barLog = (...args: any[]) => {
process.stdout.write('\x1b[2K\r');
origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
};
console.log = barLog;
console.warn = barLog;
@ -139,7 +148,10 @@ export const analyzeCommand = async (
let phaseStart = Date.now();
const updateBar = (value: number, phaseLabel: string) => {
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
if (phaseLabel !== lastPhaseLabel) {
lastPhaseLabel = phaseLabel;
phaseStart = Date.now();
}
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
bar.update(value, { phase: display });
@ -190,7 +202,11 @@ export const analyzeCommand = async (
try {
const { generateSkillFiles } = await import('./skill-gen.js');
const { generateAIContextFiles } = await import('./ai-context.js');
const skillResult = await generateSkillFiles(repoPath, result.repoName, result.pipelineResult);
const skillResult = await generateSkillFiles(
repoPath,
result.repoName,
result.pipelineResult,
);
if (skillResult.skills.length > 0) {
barLog(` Generated ${skillResult.skills.length} skill files`);
// Re-generate AI context files now that we have skill info
@ -203,19 +219,29 @@ export const analyzeCommand = async (
const label = c.heuristicLabel || c.label || 'Unknown';
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
}
aggregatedClusterCount = Array.from(groups.values()).filter((count: number) => count >= 5).length;
aggregatedClusterCount = Array.from(groups.values()).filter(
(count: number) => count >= 5,
).length;
}
const { storagePath: sp } = getStoragePaths(repoPath);
await generateAIContextFiles(repoPath, sp, result.repoName, {
files: s.files ?? 0,
nodes: s.nodes ?? 0,
edges: s.edges ?? 0,
communities: s.communities,
clusters: aggregatedClusterCount,
processes: s.processes,
}, skillResult.skills);
await generateAIContextFiles(
repoPath,
sp,
result.repoName,
{
files: s.files ?? 0,
nodes: s.nodes ?? 0,
edges: s.edges ?? 0,
communities: s.communities,
clusters: aggregatedClusterCount,
processes: s.processes,
},
skillResult.skills,
);
}
} catch { /* best-effort */ }
} catch {
/* best-effort */
}
}
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
@ -233,7 +259,9 @@ export const analyzeCommand = async (
// ── Summary ────────────────────────────────────────────────────
const s = result.stats;
console.log(`\n Repository indexed successfully (${totalTime}s)\n`);
console.log(` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${(s.communities ?? 0)} clusters | ${(s.processes ?? 0)} flows`);
console.log(
` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
);
console.log(` ${repoPath}`);
try {
@ -243,7 +271,6 @@ export const analyzeCommand = async (
}
console.log('');
} catch (err: any) {
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);

View file

@ -1,12 +1,12 @@
/**
* Augment CLI Command
*
*
* Fast-path command for platform hooks.
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
*
*
* Usage: gitnexus augment <pattern>
* Returns enriched text to stdout.
*
*
* Performance: Must cold-start fast (<500ms).
* Skips unnecessary initialization (no web server, no full DB warmup).
*/
@ -17,10 +17,10 @@ export async function augmentCommand(pattern: string): Promise<void> {
if (!pattern || pattern.length < 3) {
process.exit(0);
}
try {
const result = await augment(pattern, process.cwd());
if (result) {
// IMPORTANT: Write to stderr, NOT stdout.
// LadybugDB's native module captures stdout fd at OS level during init,

View file

@ -1,6 +1,6 @@
/**
* Clean Command
*
*
* Removes the .gitnexus index from the current repository.
* Also unregisters it from the global registry.
*/

View file

@ -1,23 +1,23 @@
/**
* Eval Server Lightweight HTTP server for SWE-bench evaluation
*
*
* Keeps LadybugDB warm in memory so tool calls from the agent are near-instant.
* Designed to run inside Docker containers during SWE-bench evaluation.
*
*
* KEY DESIGN: Returns LLM-friendly text, not raw JSON.
* Raw JSON wastes tokens and is hard for models to parse. The text formatter
* converts structured results into compact, readable output that models
* can immediately act on. Next-step hints guide the agent through a
* productive tool-chaining workflow (query context impact fix).
*
*
* Architecture:
* Agent bash cmd curl localhost:PORT/tool/query eval-server LocalBackend format text
*
*
* Usage:
* gitnexus eval-server # default port 4848
* gitnexus eval-server --port 4848 # explicit port
* gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle
*
*
* API:
* POST /tool/:name Call a tool. Body is JSON arguments. Returns formatted text.
* GET /health Health check. Returns {"status":"ok","repos":[...]}
@ -82,7 +82,9 @@ export function formatContextResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.status === 'ambiguous') {
const lines = [`Multiple symbols named '${result.candidates?.[0]?.name || '?'}'. Disambiguate with file path:\n`];
const lines = [
`Multiple symbols named '${result.candidates?.[0]?.name || '?'}'. Disambiguate with file path:\n`,
];
for (const c of result.candidates || []) {
lines.push(` ${c.kind} ${c.name}${c.filePath}:${c.line || '?'} (uid: ${c.uid})`);
}
@ -100,7 +102,10 @@ export function formatContextResult(result: any): string {
// Incoming refs (who calls/imports/extends this)
const incoming = result.incoming || {};
const incomingCount = Object.values(incoming).reduce((sum: number, arr: any) => sum + arr.length, 0) as number;
const incomingCount = Object.values(incoming).reduce(
(sum: number, arr: any) => sum + arr.length,
0,
) as number;
if (incomingCount > 0) {
lines.push(`Called/imported by (${incomingCount}):`);
for (const [relType, refs] of Object.entries(incoming)) {
@ -113,7 +118,10 @@ export function formatContextResult(result: any): string {
// Outgoing refs (what this calls/imports)
const outgoing = result.outgoing || {};
const outgoingCount = Object.values(outgoing).reduce((sum: number, arr: any) => sum + arr.length, 0) as number;
const outgoingCount = Object.values(outgoing).reduce(
(sum: number, arr: any) => sum + arr.length,
0,
) as number;
if (outgoingCount > 0) {
lines.push(`Calls/imports (${outgoingCount}):`);
for (const [relType, refs] of Object.entries(outgoing)) {
@ -158,8 +166,11 @@ export function formatImpactResult(result: any): string {
}
const lines: string[] = [];
const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`);
const dirLabel =
direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
lines.push(
`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`,
);
if (result.partial) {
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
}
@ -198,7 +209,7 @@ export function formatCypherResult(result: any): string {
const keys = Object.keys(result[0]);
const lines: string[] = [`${result.length} row(s):\n`];
for (const row of result.slice(0, 30)) {
const parts = keys.map(k => `${k}: ${row[k]}`);
const parts = keys.map((k) => `${k}: ${row[k]}`);
lines.push(` ${parts.join(' | ')}`);
}
if (result.length > 30) {
@ -254,7 +265,9 @@ export function formatListReposResult(result: any): string {
const lines = ['Indexed repositories:\n'];
for (const r of result) {
const stats = r.stats || {};
lines.push(` ${r.name}${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`);
lines.push(
` ${r.name}${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`,
);
lines.push(` Path: ${r.path}`);
lines.push(` Indexed: ${r.indexedAt}`);
}
@ -266,13 +279,20 @@ export function formatListReposResult(result: any): string {
*/
function formatToolResult(toolName: string, result: any): string {
switch (toolName) {
case 'query': return formatQueryResult(result);
case 'context': return formatContextResult(result);
case 'impact': return formatImpactResult(result);
case 'cypher': return formatCypherResult(result);
case 'detect_changes': return formatDetectChangesResult(result);
case 'list_repos': return formatListReposResult(result);
default: return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
case 'query':
return formatQueryResult(result);
case 'context':
return formatContextResult(result);
case 'impact':
return formatImpactResult(result);
case 'cypher':
return formatCypherResult(result);
case 'detect_changes':
return formatDetectChangesResult(result);
case 'list_repos':
return formatListReposResult(result);
default:
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
}
@ -317,7 +337,9 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
}
const repos = await backend.listRepos();
console.error(`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map(r => r.name).join(', ')}`);
console.error(
`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map((r) => r.name).join(', ')}`,
);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
@ -339,7 +361,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
if (req.method === 'GET' && req.url === '/health') {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ status: 'ok', repos: repos.map(r => r.name) }));
res.end(JSON.stringify({ status: 'ok', repos: repos.map((r) => r.name) }));
return;
}
@ -389,7 +411,6 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
res.setHeader('Content-Type', 'text/plain');
res.writeHead(404);
res.end('Not found. Use POST /tool/:name or GET /health');
} catch (err: any) {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(500);

Some files were not shown because too many files have changed in this diff Show more