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 - dependencies
- title: "\U0001F4DD Other Changes" - title: "\U0001F4DD Other Changes"
labels: labels:
- "*" - '*'
exclude: exclude:
labels: labels:
- dependencies - dependencies

View file

@ -4,6 +4,19 @@ on:
workflow_call: workflow_call:
jobs: 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: typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10

View file

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

View file

@ -53,7 +53,7 @@ jobs:
pull-requests: write pull-requests: write
issues: write issues: write
id-token: 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: steps:
# For PR-related triggers, resolve the fork repo so we can checkout correctly. # For PR-related triggers, resolve the fork repo so we can checkout correctly.
- name: Resolve PR context - name: Resolve PR context

View file

@ -1,33 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pre-commit hook (husky): typecheck + unit tests for both packages. # Pre-commit hook: format staged files + typecheck.
# Mirrors CI checks from ci-quality.yml and ci-tests.yml. # Tests run in CI (ci-tests.yml), not here.
# Skip with: git commit --no-verify # 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)" 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) WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1) CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
if [ -n "$WEB_CHANGED" ]; then if [ -n "$WEB_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus-web (tsc -b)..." echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
cd "$ROOT/gitnexus-web" && npx tsc -b --noEmit cd "$ROOT/gitnexus-web" && ./node_modules/.bin/tsc -b --noEmit || exit 1
echo "pre-commit: running gitnexus-web unit tests..."
npx vitest run --reporter=dot
fi fi
if [ -n "$CLI_CHANGED" ]; then if [ -n "$CLI_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus..." echo "pre-commit: typechecking gitnexus..."
cd "$ROOT/gitnexus" && npx tsc --noEmit cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
echo "pre-commit: running gitnexus unit tests (default project)..."
npx vitest run --project default --reporter=dot
fi fi
echo "pre-commit: all checks passed" 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 # Claude Haiku 4.5 — fast, cheap, good baseline
# Via OpenRouter (set OPENROUTER_API_KEY in .env) # Via OpenRouter (set OPENROUTER_API_KEY in .env)
model: model:
model_name: "openrouter/anthropic/claude-haiku-4.5" model_name: 'openrouter/anthropic/claude-haiku-4.5'
cost_tracking: "ignore_errors" cost_tracking: 'ignore_errors'
model_kwargs: model_kwargs:
max_tokens: 8192 max_tokens: 8192
temperature: 0 temperature: 0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses. # The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
model: model:
model_class: litellm_textbased 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```" action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
cost_tracking: "ignore_errors" cost_tracking: 'ignore_errors'
model_kwargs: model_kwargs:
max_tokens: 8192 max_tokens: 8192
temperature: 0 temperature: 0

View file

@ -1,9 +1,9 @@
# Baseline mode — no GitNexus, pure mini-swe-agent (control group) # Baseline mode — no GitNexus, pure mini-swe-agent (control group)
agent: agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent" agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: "baseline" gitnexus_mode: 'baseline'
step_limit: 30 step_limit: 30
cost_limit: 3.0 cost_limit: 3.0
environment: 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. # Use this mode to isolate the value of explicit tools without grep augmentation.
agent: agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent" agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: "native" gitnexus_mode: 'native'
step_limit: 30 step_limit: 30
cost_limit: 3.0 cost_limit: 3.0
track_gitnexus_usage: true track_gitnexus_usage: true
environment: environment:
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment" environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
enable_gitnexus: true enable_gitnexus: true
skip_embeddings: true skip_embeddings: true
gitnexus_timeout: 120 gitnexus_timeout: 120

View file

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

View file

@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
const tokens = cmd.split(/\s+/); const tokens = cmd.split(/\s+/);
let foundCmd = false; let foundCmd = false;
let skipNext = 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) { for (const token of tokens) {
if (skipNext) { skipNext = false; continue; } if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) { if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true; if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue; continue;
@ -98,33 +114,42 @@ function runGitNexusCli(args, cwd, timeout) {
// Detect whether 'gitnexus' is on PATH (cheap check, no execution) // Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false; let useDirectBinary = false;
try { try {
const which = spawnSync( const which = spawnSync(isWin ? 'where' : 'which', ['gitnexus'], {
isWin ? 'where' : 'which', ['gitnexus'], encoding: 'utf-8',
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } timeout: 3000,
); stdio: ['pipe', 'pipe', 'pipe'],
});
useDirectBinary = which.status === 0; useDirectBinary = which.status === 0;
} catch { /* not on PATH */ } } catch {
/* not on PATH */
}
if (useDirectBinary) { if (useDirectBinary) {
return spawnSync( return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
isWin ? 'gitnexus.cmd' : 'gitnexus', args, encoding: 'utf-8',
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] } timeout,
); cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
} }
// npx fallback needs shell on Windows since npx is a .cmd script // npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync( return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], encoding: 'utf-8',
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } timeout: timeout + 5000,
); cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
} }
/** /**
* Emit a hook response with additional context for the agent. * Emit a hook response with additional context for the agent.
*/ */
function sendHookResponse(hookEventName, message) { function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({ console.log(
hookSpecificOutput: { hookEventName, additionalContext: message } JSON.stringify({
})); hookSpecificOutput: { hookEventName, additionalContext: message },
}),
);
} }
/** /**
@ -149,7 +174,9 @@ function handlePreToolUse(input) {
if (!child.error && child.status === 0) { if (!child.error && child.status === 0) {
result = child.stderr || ''; result = child.stderr || '';
} }
} catch { /* graceful failure */ } } catch {
/* graceful failure */
}
if (result && result.trim()) { if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim()); sendHookResponse('PreToolUse', result.trim());
@ -185,10 +212,15 @@ function handlePostToolUse(input) {
let currentHead = ''; let currentHead = '';
try { try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { 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(); currentHead = (headResult.stdout || '').trim();
} catch { return; } } catch {
return;
}
if (!currentHead) return; if (!currentHead) return;
@ -197,16 +229,19 @@ function handlePostToolUse(input) {
try { try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || ''; lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0); hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch { /* no meta — treat as stale */ } } catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed // If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return; if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse', sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `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, REL_TYPES,
EMBEDDING_TABLE_NAME, EMBEDDING_TABLE_NAME,
} from './lbug/schema-constants.js'; } from './lbug/schema-constants.js';
export type { export type { NodeTableName, RelType } from './lbug/schema-constants.js';
NodeTableName,
RelType,
} from './lbug/schema-constants.js';
// Language support // Language support
export { SupportedLanguages } from './languages.js'; export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js'; export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
// Pipeline progress // Pipeline progress
export type { export type { PipelinePhase, PipelineProgress } from './pipeline.js';
PipelinePhase,
PipelineProgress,
} from './pipeline.js';

View file

@ -12,7 +12,13 @@
import { SupportedLanguages } from './languages.js'; import { SupportedLanguages } from './languages.js';
/** Ruby extensionless filenames recognised as Ruby source */ /** 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. * 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..." * TypeScript emits a compile error: "Property 'NewLang' is missing in type..."
*/ */
const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = { const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'], [SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'],
[SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'], [SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'],
[SupportedLanguages.Python]: ['.py'], [SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'], [SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'], [SupportedLanguages.C]: ['.c'],
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'], [SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
[SupportedLanguages.CSharp]: ['.cs'], [SupportedLanguages.CSharp]: ['.cs'],
[SupportedLanguages.Go]: ['.go'], [SupportedLanguages.Go]: ['.go'],
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'], [SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
[SupportedLanguages.Rust]: ['.rs'], [SupportedLanguages.Rust]: ['.rs'],
[SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'], [SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
[SupportedLanguages.Kotlin]: ['.kt', '.kts'], [SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'], [SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness } satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */ /** Pre-built reverse lookup: extension → language (built once at module load). */
const extToLang = new Map<string, SupportedLanguages>(); 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) { for (const ext of exts) {
extToLang.set(ext, lang); extToLang.set(ext, lang);
} }
@ -75,37 +84,50 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
* TypeScript emits a compile error. * TypeScript emits a compile error.
*/ */
const SYNTAX_MAP: Record<SupportedLanguages, string> = { const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.JavaScript]: 'javascript', [SupportedLanguages.JavaScript]: 'javascript',
[SupportedLanguages.TypeScript]: 'typescript', [SupportedLanguages.TypeScript]: 'typescript',
[SupportedLanguages.Python]: 'python', [SupportedLanguages.Python]: 'python',
[SupportedLanguages.Java]: 'java', [SupportedLanguages.Java]: 'java',
[SupportedLanguages.C]: 'c', [SupportedLanguages.C]: 'c',
[SupportedLanguages.CPlusPlus]: 'cpp', [SupportedLanguages.CPlusPlus]: 'cpp',
[SupportedLanguages.CSharp]: 'csharp', [SupportedLanguages.CSharp]: 'csharp',
[SupportedLanguages.Go]: 'go', [SupportedLanguages.Go]: 'go',
[SupportedLanguages.Ruby]: 'ruby', [SupportedLanguages.Ruby]: 'ruby',
[SupportedLanguages.Rust]: 'rust', [SupportedLanguages.Rust]: 'rust',
[SupportedLanguages.PHP]: 'php', [SupportedLanguages.PHP]: 'php',
[SupportedLanguages.Kotlin]: 'kotlin', [SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift', [SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Cobol]: 'cobol', [SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness } satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */ /** Non-code file extensions → Prism-compatible syntax identifiers */
const AUXILIARY_SYNTAX_MAP: Record<string, string> = { const AUXILIARY_SYNTAX_MAP: Record<string, string> = {
json: 'json', yaml: 'yaml', yml: 'yaml', json: 'json',
md: 'markdown', mdx: 'markdown', yaml: 'yaml',
html: 'markup', htm: 'markup', erb: 'markup', xml: 'markup', yml: 'yaml',
css: 'css', scss: 'css', sass: 'css', md: 'markdown',
sh: 'bash', bash: 'bash', zsh: 'bash', mdx: 'markdown',
sql: 'sql', toml: 'toml', ini: 'ini', 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', dockerfile: 'docker',
}; };
/** Extensionless filenames → Prism-compatible syntax identifiers */ /** Extensionless filenames → Prism-compatible syntax identifiers */
const AUXILIARY_BASENAME_MAP: Record<string, string> = { 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 = [ export const NODE_TABLES = [
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process', 'Section', 'File',
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', 'Folder',
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module', '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', 'Route',
'Tool', 'Tool',
] as const; ] 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_TABLE_NAME = 'CodeRelation';
export const REL_TYPES = [ export const REL_TYPES = [
'CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'CONTAINS',
'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'DEFINES',
'MEMBER_OF', 'STEP_IN_PROCESS', 'IMPORTS',
'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', 'CALLS',
'WRAPS', 'QUERIES', '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; ] as const;
export type RelType = typeof REL_TYPES[number]; export type RelType = (typeof REL_TYPES)[number];
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding'; 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; const debugTest = process.env.DEBUG_E2E ? test : test.skip;
async function connectToServer(page: import('@playwright/test').Page) { 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()}`); 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'); const transformAfterReset = await diagramDiv.getAttribute('style');
console.log('Transform AFTER reset:', transformAfterReset); 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 // Verify transform actually changed back
expect(transformAfterZoom).not.toBe(transformBefore); 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 // Click it again to toggle back on
await lightbulbBtn.click(); await lightbulbBtn.click();
await page.waitForTimeout(500); 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( test.skip(
!!process.env.CI || process.env.PWDEBUG !== '1', !!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 }) => { test('manual recording session', async ({ page }) => {
page.on('console', msg => { page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') { if (msg.type() === 'error' || msg.type() === 'warning') {
console.log(`[${msg.type()}] ${msg.text()}`); 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.goto('http://localhost:5173');
await page.pause(); 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 // 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 }); await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 });
// Step 3 is always rendered // 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 }) => { 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) => { await page.route(`${BACKEND_URL}/api/repo`, async (route) => {
if (blockBackend) return route.abort('connectionrefused'); 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) => { await page.route(`${BACKEND_URL}/api/graph**`, async (route) => {
if (blockBackend) return route.abort('connectionrefused'); 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' } }), route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
); );
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => 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('/'); 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' } }), route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
); );
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) => 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; if (process.env.E2E) return;
try { try {
const res = await fetch(`${BACKEND_URL}/api/repos`); 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(); 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 { } catch {
test.skip(true, SKIP_MSG); 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') }); await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
// Click the project badge (has a chevron) // 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(); await badge.click();
// Repo dropdown should be visible // 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 }); await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Open repo dropdown // 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(); await badge.click();
// Click "Analyze a new repository..." // Click "Analyze a new repository..."

View file

@ -21,11 +21,17 @@ test.beforeAll(async () => {
fetch(`${BACKEND_URL}/api/repos`), fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL), 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'); test.skip(true, 'gitnexus serve not available on :4747');
return; 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'); test.skip(true, 'Vite dev server not available on :5173');
return; return;
} }
@ -85,7 +91,9 @@ test.describe('Processes Panel', () => {
await page.getByRole('button', { name: 'Nexus AI' }).click(); await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').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 }); await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
const processRow = page.locator('[data-testid="process-row"]').first(); 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.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click(); await viewBtn.click();
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 }); 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) => { 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.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').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(); const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 }); await expect(processRow).toBeVisible({ timeout: 10_000 });
@ -129,10 +142,14 @@ test.describe('Turn Off All Highlights', () => {
await fileItem.click(); await fileItem.click();
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]'); 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 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 }); await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
}); });
}); });

View file

@ -4,9 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitNexus</title> <title>GitNexus</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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
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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

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

View file

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

View file

@ -25,49 +25,44 @@ interface AnalyzeOnboardingProps {
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => { export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
return ( 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 */} {/* 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="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<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 -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */} {/* Header */}
<div className="relative mb-6"> <div className="relative mb-6">
<div className="text-center"> <div className="text-center">
{/* Eyebrow */} {/* Eyebrow */}
<div className="inline-flex items-center gap-1.5 mb-2"> <div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-accent/70" /> <Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] text-accent/80 font-medium uppercase tracking-widest"> <span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus GitNexus
</span> </span>
</div> </div>
{/* Icon */} {/* 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"> <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="w-7 h-7 text-accent" /> <Github className="h-7 w-7 text-accent" />
</div> </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 Analyze your first repository
</h2> </h2>
<p className="text-sm text-text-secondary mt-1.5 leading-relaxed max-w-xs mx-auto"> <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 Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live
build a live knowledge graph right in your browser. knowledge graph right in your browser.
</p> </p>
</div> </div>
</div> </div>
{/* Analyzer form */} {/* Analyzer form */}
<div className="relative"> <div className="relative">
<RepoAnalyzer <RepoAnalyzer variant="onboarding" onComplete={onComplete} />
variant="onboarding"
onComplete={onComplete}
/>
</div> </div>
{/* Footer hint */} {/* 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 Public repos only &middot; Cloned locally by the server &middot; No data leaves your machine
</p> </p>
</div> </div>

View file

@ -49,33 +49,26 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
<div className="space-y-4"> <div className="space-y-4">
{/* Phase label + elapsed */} {/* Phase label + elapsed */}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-text-secondary font-medium">{label}</span> <span className="font-medium text-text-secondary">{label}</span>
<span className="text-text-muted font-mono text-xs">{formatElapsed(elapsed)}</span> <span className="font-mono text-xs text-text-muted">{formatElapsed(elapsed)}</span>
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="h-2 bg-elevated rounded-full overflow-hidden"> <div className="h-2 overflow-hidden rounded-full bg-elevated">
<div <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}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
{/* Percent + cancel */} {/* Percent + cancel */}
<div className="flex items-center justify-between"> <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 <button
onClick={onCancel} onClick={onCancel}
className=" 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"
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
"
> >
<X className="w-3.5 h-3.5" /> <X className="h-3.5 w-3.5" />
Cancel Cancel
</button> </button>
</div> </div>

View file

@ -1,5 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState'; import { useAppState } from '../hooks/useAppState';
@ -47,7 +58,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const nodeById = useMemo(() => { const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>(); 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]); }, [graph]);
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@ -85,34 +96,40 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
} }
}, [panelWidth]); }, [panelWidth]);
const startResize = useCallback((e: React.MouseEvent) => { const startResize = useCallback(
e.preventDefault(); (e: React.MouseEvent) => {
e.stopPropagation(); e.preventDefault();
resizeRef.current = { startX: e.clientX, startWidth: panelWidth }; e.stopPropagation();
document.body.style.cursor = 'col-resize'; resizeRef.current = { startX: e.clientX, startWidth: panelWidth };
document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
const state = resizeRef.current; const state = resizeRef.current;
if (!state) return; if (!state) return;
const delta = ev.clientX - state.startX; const delta = ev.clientX - state.startX;
const next = Math.max(420, Math.min(state.startWidth + delta, 900)); const next = Math.max(420, Math.min(state.startWidth + delta, 900));
setPanelWidth(next); setPanelWidth(next);
}; };
const onUp = () => { const onUp = () => {
resizeRef.current = null; resizeRef.current = null;
document.body.style.cursor = ''; document.body.style.cursor = '';
document.body.style.userSelect = ''; document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp); window.removeEventListener('mouseup', onUp);
}; };
window.addEventListener('mousemove', onMove); window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
}, [panelWidth]); },
[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: // When the user clicks a citation badge in chat, focus the corresponding snippet card:
// - expand the panel if collapsed // - expand the panel if collapsed
@ -126,12 +143,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const { filePath, startLine, endLine } = codeReferenceFocus; const { filePath, startLine, endLine } = codeReferenceFocus;
const target = const target =
aiReferences.find(r => aiReferences.find(
r.filePath === filePath && (r) => r.filePath === filePath && r.startLine === startLine && r.endLine === endLine,
r.startLine === startLine && ) ?? aiReferences.find((r) => r.filePath === filePath);
r.endLine === endLine
) ??
aiReferences.find(r => r.filePath === filePath);
if (!target) return; if (!target) return;
@ -158,13 +172,21 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
rafIds.push(outerRafId); rafIds.push(outerRafId);
return () => { return () => {
rafIds.forEach(id => cancelAnimationFrame(id)); rafIds.forEach((id) => cancelAnimationFrame(id));
}; };
}, [codeReferenceFocus?.ts, aiReferences]); }, [codeReferenceFocus?.ts, aiReferences]);
const refsWithSnippets = useMemo(() => { const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => { 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]); }, [aiReferences]);
@ -207,20 +229,29 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
endLine: (endLine ?? startLine) + CONTEXT_LINES, endLine: (endLine ?? startLine) + CONTEXT_LINES,
}; };
readFile(selectedFilePath, options).then(result => { readFile(selectedFilePath, options)
if (!cancelled) { .then((result) => {
setFileResult(result); if (!cancelled) {
setIsLoadingFile(false); setFileResult(result);
} setIsLoadingFile(false);
}).catch(() => { }
if (!cancelled) { })
setFileResult(null); .catch(() => {
setIsLoadingFile(false); if (!cancelled) {
} setFileResult(null);
}); setIsLoadingFile(false);
}
});
return () => { cancelled = true; }; return () => {
}, [selectedFilePath, selectedNode?.properties?.startLine, selectedNode?.properties?.endLine, selectedIsFile]); cancelled = true;
};
}, [
selectedFilePath,
selectedNode?.properties?.startLine,
selectedNode?.properties?.endLine,
selectedIsFile,
]);
// Scroll to the selected node's startLine after content loads // Scroll to the selected node's startLine after content loads
useEffect(() => { useEffect(() => {
@ -234,8 +265,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
if (cancelled) return; if (cancelled) return;
const container = selectedViewerRef.current; const container = selectedViewerRef.current;
if (!container) return; if (!container) return;
const lineEl = container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement const lineEl =
?? container.querySelectorAll('.linenumber')[startLine] as HTMLElement; (container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
if (lineEl) { if (lineEl) {
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else { } else {
@ -247,27 +279,30 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
rafIds.push(innerRaf); rafIds.push(innerRaf);
}); });
const rafIds = [outerRaf]; const rafIds = [outerRaf];
return () => { cancelled = true; rafIds.forEach(id => cancelAnimationFrame(id)); }; return () => {
cancelled = true;
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [selectedFileContent, selectedNode?.properties?.startLine]); }, [selectedFileContent, selectedNode?.properties?.startLine]);
if (isCollapsed) { if (isCollapsed) {
return ( 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 <button
onClick={() => setIsCollapsed(false)} 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" title="Expand Code Panel"
> >
<PanelLeft className="w-5 h-5" /> <PanelLeft className="h-5 w-5" />
</button> </button>
<div className="w-6 h-px bg-border-subtle my-1" /> <div className="my-1 h-px w-6 bg-border-subtle" />
{showSelectedViewer && ( {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 SELECTED
</div> </div>
)} )}
{showCitations && ( {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} AI {aiReferences.length}
</div> </div>
)} )}
@ -277,67 +312,72 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
return ( return (
<aside <aside
ref={(el) => { panelRef.current = el; }} ref={(el) => {
className="h-full bg-surface/95 backdrop-blur-md border-r border-border-subtle flex flex-col animate-slide-in relative shadow-2xl" 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 }} style={{ width: panelWidth }}
> >
{/* Resize handle */} {/* Resize handle */}
<div <div
onMouseDown={startResize} 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" title="Drag to resize"
/> />
{/* Header */} {/* 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"> <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> <span className="text-sm font-semibold text-text-primary">Code Inspector</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{showCitations && ( {showCitations && (
<button <button
onClick={() => clearCodeReferences()} 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" title="Clear AI citations"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
)} )}
<button <button
onClick={() => setIsCollapsed(true)} 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" title="Collapse Panel"
> >
<PanelLeftClose className="w-4 h-4" /> <PanelLeftClose className="h-4 w-4" />
</button> </button>
</div> </div>
</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) */} {/* Top: Selected file viewer (when a node is selected) */}
{showSelectedViewer && ( {showSelectedViewer && (
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} min-h-0 flex flex-col`}> <div className={`${showCitations ? 'h-[42%]' : 'flex-1'} flex min-h-0 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-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 px-2 py-0.5 bg-amber-500/15 rounded-md border border-amber-500/25"> <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="w-3 h-3 text-amber-400" /> <MousePointerClick className="h-3 w-3 text-amber-400" />
<span className="text-[10px] text-amber-300 font-semibold uppercase tracking-wide">Selected</span> <span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
Selected
</span>
</div> </div>
<FileCode className="w-3.5 h-3.5 text-amber-400/70 ml-1" /> <FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
<span className="text-xs text-text-primary font-mono truncate flex-1"> <span className="flex-1 truncate font-mono text-xs text-text-primary">
{selectedNode?.properties?.filePath?.split('/').pop() ?? selectedNode?.properties?.name} {selectedNode?.properties?.filePath?.split('/').pop() ??
selectedNode?.properties?.name}
</span> </span>
<button <button
onClick={() => setSelectedNode(null)} 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" title="Clear selection"
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </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 ? ( {isLoadingFile ? (
<div className="flex items-center justify-center py-8 gap-2 text-text-muted"> <div className="flex items-center justify-center gap-2 py-8 text-text-muted">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading source...</span> <span className="text-sm">Loading source...</span>
</div> </div>
) : selectedFileContent ? ( ) : selectedFileContent ? (
@ -377,7 +417,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
) : ( ) : (
<div className="px-3 py-3 text-sm text-text-muted"> <div className="px-3 py-3 text-sm text-text-muted">
{selectedIsFile ? ( {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.</> <>Select a file node to preview its contents.</>
)} )}
@ -394,128 +437,147 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{/* Bottom: AI citations list */} {/* Bottom: AI citations list */}
{showCitations && ( {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 */} {/* 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-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 px-2 py-0.5 bg-cyan-500/15 rounded-md border border-cyan-500/25"> <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="w-3 h-3 text-cyan-400" /> <Sparkles className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] text-cyan-300 font-semibold uppercase tracking-wide">AI Citations</span> <span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
</div> AI Citations
<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'}
</span> </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> </div>
<span className="ml-1 text-xs text-text-muted">
{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}
</span>
</div> </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>
</div> </div>
)} )}

View file

@ -12,10 +12,7 @@ interface DropZoneProps {
// ── Crossfade wrapper ─────────────────────────────────────────────────────── // ── Crossfade wrapper ───────────────────────────────────────────────────────
// Captures the outgoing children during fade-out, then swaps to the new children on fade-in. // Captures the outgoing children during fade-out, then swaps to the new children on fade-in.
function Crossfade({ activeKey, children }: { function Crossfade({ activeKey, children }: { activeKey: string; children: React.ReactNode }) {
activeKey: string;
children: React.ReactNode;
}) {
const [displayedKey, setDisplayedKey] = useState(activeKey); const [displayedKey, setDisplayedKey] = useState(activeKey);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const snapshotRef = useRef<React.ReactNode>(children); const snapshotRef = useRef<React.ReactNode>(children);
@ -36,7 +33,9 @@ function Crossfade({ activeKey, children }: {
setIsTransitioning(false); setIsTransitioning(false);
}, 300); }, 300);
} }
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [activeKey, displayedKey]); }, [activeKey, displayedKey]);
return ( return (
@ -56,30 +55,34 @@ function Crossfade({ activeKey, children }: {
function SuccessCard() { function SuccessCard() {
return ( 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 */} {/* 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"> <div className="relative">
{/* Animated check icon */} {/* 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)]"> <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="w-8 h-8 text-emerald-400" /> <Check className="h-8 w-8 text-emerald-400" />
</div> </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 Server Connected
</h2> </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... Preparing your code knowledge graph...
</p> </p>
{/* Subtle progress hint */} {/* Subtle progress hint */}
<div className="mt-6 flex items-center justify-center gap-2"> <div className="mt-6 flex items-center justify-center gap-2">
<div className="flex gap-1"> <div className="flex gap-1">
{[0, 1, 2].map(i => ( {[0, 1, 2].map((i) => (
<div <div
key={i} 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` }} style={{ animationDelay: `${i * 200}ms` }}
/> />
))} ))}
@ -92,26 +95,30 @@ function SuccessCard() {
function LoadingCard({ message }: { message: string }) { function LoadingCard({ message }: { message: string }) {
return ( 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 */} {/* 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"> <div className="relative">
{/* Spinner */} {/* 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"> <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="w-8 h-8 text-accent animate-spin" /> <Loader2 className="h-8 w-8 animate-spin text-accent" />
</div> </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...'} {message || 'Connecting...'}
</h2> </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 This may take a moment for large repositories
</p> </p>
{/* Decorative sparkle */} {/* Decorative sparkle */}
<div className="mt-5 flex items-center justify-center"> <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> </div>
</div> </div>
@ -124,14 +131,23 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Backend polling for server detection // 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 [initialProbeComplete, setInitialProbeComplete] = useState(false);
const autoConnectRan = useRef(false); const autoConnectRan = useRef(false);
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Connection state // Connection state
// 'analyze' = server up but zero repos indexed — show URL input // '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 [loadingMessage, setLoadingMessage] = useState('');
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -279,17 +295,17 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const displayPhase = !initialProbeComplete ? null : phase; const displayPhase = !initialProbeComplete ? null : phase;
return ( 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 */} {/* Background gradient effects */}
<div className="fixed inset-0 pointer-events-none"> <div className="pointer-events-none fixed inset-0">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" /> <div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-accent/10 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="absolute right-1/4 bottom-1/4 h-96 w-96 rounded-full bg-node-interface/10 blur-3xl" />
</div> </div>
<div className="relative w-full max-w-lg"> <div className="relative w-full max-w-lg">
{/* Error — floats above the card */} {/* Error — floats above the card */}
{error && ( {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} {error}
</div> </div>
)} )}
@ -297,12 +313,8 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
{/* Crossfade between phases */} {/* Crossfade between phases */}
{displayPhase && ( {displayPhase && (
<Crossfade activeKey={displayPhase}> <Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && ( {displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
<OnboardingGuide isPolling={isPolling} /> {displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={handleAnalyzeComplete} />}
)}
{displayPhase === 'analyze' && (
<AnalyzeOnboarding onComplete={handleAnalyzeComplete} />
)}
{displayPhase === 'success' && <SuccessCard />} {displayPhase === 'success' && <SuccessCard />}
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />} {displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
</Crossfade> </Crossfade>

View file

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

View file

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

View file

@ -1,8 +1,23 @@
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react'; 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 { useSigma } from '../hooks/useSigma';
import { useAppState } from '../hooks/useAppState'; 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 type { GraphNode } from 'gitnexus-shared';
import { QueryFAB } from './QueryFAB'; import { QueryFAB } from './QueryFAB';
import Graph from 'graphology'; import Graph from 'graphology';
@ -41,7 +56,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
for (const id of aiToolHighlightedNodeIds) next.add(id); for (const id of aiToolHighlightedNodeIds) next.add(id);
// Note: blast radius nodes are handled separately with red color // Note: blast radius nodes are handled separately with red color
return next; return next;
}, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]); }, [
highlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
isAIHighlightsEnabled,
]);
// Blast radius nodes (only when AI highlights enabled) // Blast radius nodes (only when AI highlights enabled)
const effectiveBlastRadiusNodeIds = useMemo(() => { const effectiveBlastRadiusNodeIds = useMemo(() => {
@ -57,26 +77,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
const nodeById = useMemo(() => { const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>(); 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]); }, [graph]);
const handleNodeClick = useCallback((nodeId: string) => { const handleNodeClick = useCallback(
if (!graph) return; (nodeId: string) => {
const node = nodeById.get(nodeId); if (!graph) return;
if (node) { const node = nodeById.get(nodeId);
setSelectedNode(node); if (node) {
openCodePanel(); setSelectedNode(node);
} openCodePanel();
}, [graph, nodeById, setSelectedNode, openCodePanel]); }
},
[graph, nodeById, setSelectedNode, openCodePanel],
);
const handleNodeHover = useCallback((nodeId: string | null) => { const handleNodeHover = useCallback(
if (!nodeId || !graph) { (nodeId: string | null) => {
setHoveredNodeName(null); if (!nodeId || !graph) {
return; setHoveredNodeName(null);
} return;
const node = nodeById.get(nodeId); }
setHoveredNodeName(node ? node.properties.name : null); const node = nodeById.get(nodeId);
}, [graph, nodeById]); setHoveredNodeName(node ? node.properties.name : null);
},
[graph, nodeById],
);
const handleStageClick = useCallback(() => { const handleStageClick = useCallback(() => {
setSelectedNode(null); setSelectedNode(null);
@ -91,7 +117,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
setSigmaSelectedNode(null); setSigmaSelectedNode(null);
} }
toggleAIHighlights(); toggleAIHighlights();
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]); }, [
isAIHighlightsEnabled,
clearAIToolHighlights,
clearAICitationHighlights,
clearBlastRadius,
setSelectedNode,
toggleAIHighlights,
]);
const { const {
containerRef, containerRef,
@ -117,19 +150,23 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}); });
// Expose focusNode to parent via ref // Expose focusNode to parent via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(
focusNode: (nodeId: string) => { ref,
// Also update app state so the selection syncs properly () => ({
if (graph) { focusNode: (nodeId: string) => {
const node = nodeById.get(nodeId); // Also update app state so the selection syncs properly
if (node) { if (graph) {
setSelectedNode(node); const node = nodeById.get(nodeId);
openCodePanel(); if (node) {
setSelectedNode(node);
openCodePanel();
}
} }
} focusNode(nodeId);
focusNode(nodeId); },
} }),
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]); [focusNode, graph, nodeById, setSelectedNode, openCodePanel],
);
// Update Sigma graph when KnowledgeGraph changes // Update Sigma graph when KnowledgeGraph changes
useEffect(() => { useEffect(() => {
@ -138,7 +175,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
// Build communityMemberships map from MEMBER_OF relationships // Build communityMemberships map from MEMBER_OF relationships
// MEMBER_OF edges: nodeId -> communityId (stored as targetId) // MEMBER_OF edges: nodeId -> communityId (stored as targetId)
const communityMemberships = new Map<string, number>(); const communityMemberships = new Map<string, number>();
graph.relationships.forEach(rel => { graph.relationships.forEach((rel) => {
if (rel.type === 'MEMBER_OF') { if (rel.type === 'MEMBER_OF') {
// Find the community node to get its index // Find the community node to get its index
const communityNode = nodeById.get(rel.targetId); const communityNode = nodeById.get(rel.targetId);
@ -165,7 +202,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels); filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
sigma.refresh(); 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]); }, [visibleLabels, depthFilter, appSelectedNode]);
// Sync app selected node with sigma // Sync app selected node with sigma
@ -192,16 +229,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]); }, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
return ( return (
<div className="relative w-full h-full bg-void"> <div className="relative h-full w-full bg-void">
{/* Background gradient */} {/* Background gradient */}
<div className="absolute inset-0 pointer-events-none"> <div className="pointer-events-none absolute inset-0">
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: ` background: `
radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%), radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%),
linear-gradient(to bottom, #06060a, #0a0a10) linear-gradient(to bottom, #06060a, #0a0a10)
` `,
}} }}
/> />
</div> </div>
@ -209,29 +246,27 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{/* Sigma container */} {/* Sigma container */}
<div <div
ref={containerRef} 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 */} {/* Hovered node tooltip - only show when NOT selected */}
{hoveredNodeName && !sigmaSelectedNode && ( {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> <span className="font-mono text-sm text-text-primary">{hoveredNodeName}</span>
</div> </div>
)} )}
{/* Selection info bar */} {/* Selection info bar */}
{sigmaSelectedNode && appSelectedNode && ( {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="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="w-2 h-2 bg-accent rounded-full animate-pulse" /> <div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
<span className="font-mono text-sm text-text-primary"> <span className="font-mono text-sm text-text-primary">
{appSelectedNode.properties.name} {appSelectedNode.properties.name}
</span> </span>
<span className="text-xs text-text-muted"> <span className="text-xs text-text-muted">({appSelectedNode.label})</span>
({appSelectedNode.label})
</span>
<button <button
onClick={handleClearSelection} 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 Clear
</button> </button>
@ -239,40 +274,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
)} )}
{/* Graph Controls - Bottom Right */} {/* 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 <button
onClick={zoomIn} 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" title="Zoom In"
> >
<ZoomIn className="w-4 h-4" /> <ZoomIn className="h-4 w-4" />
</button> </button>
<button <button
onClick={zoomOut} 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" title="Zoom Out"
> >
<ZoomOut className="w-4 h-4" /> <ZoomOut className="h-4 w-4" />
</button> </button>
<button <button
onClick={resetZoom} 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" title="Fit to Screen"
> >
<Maximize2 className="w-4 h-4" /> <Maximize2 className="h-4 w-4" />
</button> </button>
{/* Divider */} {/* Divider */}
<div className="h-px bg-border-subtle my-1" /> <div className="my-1 h-px bg-border-subtle" />
{/* Focus on selected */} {/* Focus on selected */}
{appSelectedNode && ( {appSelectedNode && (
<button <button
onClick={handleFocusSelected} 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" title="Focus on Selected Node"
> >
<Focus className="w-4 h-4" /> <Focus className="h-4 w-4" />
</button> </button>
)} )}
@ -280,41 +315,35 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{sigmaSelectedNode && ( {sigmaSelectedNode && (
<button <button
onClick={handleClearSelection} 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" title="Clear Selection"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="h-4 w-4" />
</button> </button>
)} )}
{/* Divider */} {/* Divider */}
<div className="h-px bg-border-subtle my-1" /> <div className="my-1 h-px bg-border-subtle" />
{/* Layout control */} {/* Layout control */}
<button <button
onClick={isLayoutRunning ? stopLayout : startLayout} onClick={isLayoutRunning ? stopLayout : startLayout}
className={` className={`flex h-9 w-9 items-center justify-center rounded-md border transition-all ${
w-9 h-9 flex items-center justify-center border rounded-md transition-all isLayoutRunning
${isLayoutRunning ? 'animate-pulse border-accent bg-accent text-white shadow-glow'
? 'bg-accent border-accent text-white shadow-glow animate-pulse' : 'border-border-subtle bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary' } `}
}
`}
title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'} title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'}
> >
{isLayoutRunning ? ( {isLayoutRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</button> </button>
</div> </div>
{/* Layout running indicator */} {/* Layout running indicator */}
{isLayoutRunning && ( {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="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="w-2 h-2 bg-emerald-400 rounded-full animate-ping" /> <div className="h-2 w-2 animate-ping rounded-full bg-emerald-400" />
<span className="text-xs text-emerald-400 font-medium">Layout optimizing...</span> <span className="text-xs font-medium text-emerald-400">Layout optimizing...</span>
</div> </div>
)} )}
@ -327,13 +356,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
onClick={handleToggleAIHighlights} onClick={handleToggleAIHighlights}
className={ className={
isAIHighlightsEnabled 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' ? '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'
: '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-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'} title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
data-testid="ai-highlights-toggle" 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> </button>
</div> </div>
</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 { 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 { useState, useMemo, useRef, useEffect } from 'react';
import { GraphNode } from 'gitnexus-shared'; import { GraphNode } from 'gitnexus-shared';
import { EmbeddingStatus } from './EmbeddingStatus'; import { EmbeddingStatus } from './EmbeddingStatus';
@ -29,7 +48,13 @@ interface HeaderProps {
onReposChanged?: (repos: BackendRepo[]) => void; onReposChanged?: (repos: BackendRepo[]) => void;
} }
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnalyzeComplete, onReposChanged }: HeaderProps) => { export const Header = ({
onFocusNode,
availableRepos = [],
onSwitchRepo,
onAnalyzeComplete,
onReposChanged,
}: HeaderProps) => {
const { const {
projectName, projectName,
graph, graph,
@ -37,7 +62,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
isRightPanelOpen, isRightPanelOpen,
rightPanelTab, rightPanelTab,
setSettingsPanelOpen, setSettingsPanelOpen,
setHelpDialogBoxOpen setHelpDialogBoxOpen,
} = useAppState(); } = useAppState();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
@ -60,7 +85,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return graph.nodes 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 .slice(0, 10); // Limit to 10 results
}, [graph, searchQuery]); }, [graph, searchQuery]);
@ -81,7 +106,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
// Cleanup re-analyze SSE on unmount // Cleanup re-analyze SSE on unmount
useEffect(() => { useEffect(() => {
return () => { reanalyzeSseRef.current?.abort(); }; return () => {
reanalyzeSseRef.current?.abort();
};
}, []); }, []);
// Keyboard shortcut (Cmd+K / Ctrl+K) // Keyboard shortcut (Cmd+K / Ctrl+K)
@ -107,10 +134,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); 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') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0)); setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const selected = searchResults[selectedIndex]; const selected = searchResults[selectedIndex];
@ -129,37 +156,40 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
}; };
return ( 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 */} {/* Left section */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2.5"> <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> </div>
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span> <span className="text-[15px] font-semibold tracking-tight">GitNexus</span>
</div> </div>
{/* Project badge + repo dropdown */} {/* Project badge + repo dropdown */}
{projectName && ( {projectName && (
<div className="relative" ref={repoDropdownRef}> <div className="relative" ref={repoDropdownRef}>
<button <button
onClick={() => { setIsRepoDropdownOpen(prev => !prev); setShowAnalyzer(false); }} onClick={() => {
className={` setIsRepoDropdownOpen((prev) => !prev);
flex items-center gap-2 px-3 py-1.5 border rounded-lg text-sm transition-all cursor-pointer setShowAnalyzer(false);
${isRepoDropdownOpen }}
? 'bg-accent/10 border-accent/40 text-text-primary' className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all ${
: 'bg-surface border-border-subtle text-text-secondary hover:bg-hover hover:border-border-default' 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="h-1.5 w-1.5 animate-pulse rounded-full bg-node-function" />
<span className="truncate max-w-[160px]">{projectName}</span> <span className="max-w-[160px] truncate">{projectName}</span>
<ChevronDown className={`w-3 h-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`} /> <ChevronDown
className={`h-3 w-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`}
/>
</button> </button>
{isRepoDropdownOpen && ( {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 ? ( {showAnalyzer ? (
<div className="p-4"> <div className="p-4">
<RepoAnalyzer <RepoAnalyzer
@ -177,15 +207,15 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Repo list */} {/* Repo list */}
{availableRepos.length > 0 && ( {availableRepos.length > 0 && (
<div> <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 Repositories
</div> </div>
{availableRepos.map(repo => ( {availableRepos.map((repo) => (
<div <div
key={repo.name} key={repo.name}
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${ className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
repo.name === projectName repo.name === projectName
? 'bg-accent/10 border-l-2 border-accent' ? 'border-l-2 border-accent bg-accent/10'
: 'hover:bg-hover' : 'hover:bg-hover'
}`} }`}
> >
@ -194,12 +224,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
if (repo.name !== projectName) onSwitchRepo?.(repo.name); if (repo.name !== projectName) onSwitchRepo?.(repo.name);
setIsRepoDropdownOpen(false); 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" /> <FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
<span className="flex-1 truncate text-sm text-text-primary font-mono">{repo.name}</span> <span className="flex-1 truncate font-mono text-sm text-text-primary">
{repo.name}
</span>
{repo.name === projectName && ( {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> </button>
{/* Re-analyze */} {/* Re-analyze */}
@ -208,9 +242,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
e.stopPropagation(); e.stopPropagation();
if (reanalyzing) return; // already running if (reanalyzing) return; // already running
setReanalyzing(repo.name); setReanalyzing(repo.name);
setReanalyzeProgress({ phase: 'queued', percent: 0, message: 'Starting...' }); setReanalyzeProgress({
phase: 'queued',
percent: 0,
message: 'Starting...',
});
try { try {
const { jobId } = await startAnalyze({ path: repo.path, force: true }); const { jobId } = await startAnalyze({
path: repo.path,
force: true,
});
reanalyzeSseRef.current = streamAnalyzeProgress( reanalyzeSseRef.current = streamAnalyzeProgress(
jobId, jobId,
(p) => setReanalyzeProgress(p), (p) => setReanalyzeProgress(p),
@ -234,14 +275,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
} }
}} }}
disabled={!!reanalyzing} disabled={!!reanalyzing}
className={`p-1 rounded transition-all cursor-pointer ${ className={`cursor-pointer rounded p-1 transition-all ${
reanalyzing === repo.name reanalyzing === repo.name
? 'text-accent' ? 'text-accent'
: 'text-text-muted/0 group-hover:text-text-muted hover:!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> </button>
{/* Delete */} {/* Delete */}
<button <button
@ -269,10 +316,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
console.error('Failed to delete repo:', err); 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}`} title={`Delete ${repo.name}`}
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
))} ))}
@ -281,16 +328,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Re-analyze progress bar */} {/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && ( {reanalyzing && reanalyzeProgress && (
<div className="px-4 py-2.5 border-t border-border-subtle bg-accent/5"> <div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
<div className="flex items-center gap-2 mb-1.5"> <div className="mb-1.5 flex items-center gap-2">
<Loader2 className="w-3 h-3 text-accent animate-spin shrink-0" /> <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
<span className="text-xs text-text-secondary truncate"> <span className="truncate text-xs text-text-secondary">
Re-analyzing {reanalyzing}: {reanalyzeProgress.message} Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
</span> </span>
</div> </div>
<div className="h-1 bg-elevated rounded-full overflow-hidden"> <div className="h-1 overflow-hidden rounded-full bg-elevated">
<div <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)}%` }} style={{ width: `${Math.max(2, reanalyzeProgress.percent)}%` }}
/> />
</div> </div>
@ -298,14 +345,22 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
)} )}
{/* Analyze new */} {/* 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 <button
onClick={() => setShowAnalyzer(true)} onClick={() => setShowAnalyzer(true)}
disabled={!!reanalyzing} 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" /> <Sparkles className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="text-sm text-text-secondary">Analyze a new repository...</span> <span className="text-sm text-text-secondary">
Analyze a new repository...
</span>
</button> </button>
</div> </div>
</> </>
@ -317,9 +372,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
</div> </div>
{/* Center - Search */} {/* Center - Search */}
<div className="flex-1 max-w-md mx-6 relative" ref={searchRef}> <div className="relative mx-6 max-w-md flex-1" 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"> <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="w-4 h-4 text-text-muted flex-shrink-0" /> <Search className="h-4 w-4 flex-shrink-0 text-text-muted" />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -332,16 +387,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
}} }}
onFocus={() => setIsSearchOpen(true)} onFocus={() => setIsSearchOpen(true)}
onKeyDown={handleKeyDown} 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 K
</kbd> </kbd>
</div> </div>
{/* Search Results Dropdown */} {/* Search Results Dropdown */}
{isSearchOpen && searchQuery.trim() && ( {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 ? ( {searchResults.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted"> <div className="px-4 py-3 text-sm text-text-muted">
No nodes found for &ldquo;{searchQuery}&rdquo; No nodes found for &ldquo;{searchQuery}&rdquo;
@ -352,19 +407,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
<button <button
key={node.id} key={node.id}
onClick={() => handleSelectNode(node)} onClick={() => handleSelectNode(node)}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors cursor-pointer ${index === selectedIndex className={`flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors ${
? 'bg-accent/20 text-text-primary' index === selectedIndex
: 'hover:bg-hover text-text-secondary' ? 'bg-accent/20 text-text-primary'
}`} : 'text-text-secondary hover:bg-hover'
}`}
> >
<span <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' }} style={{ backgroundColor: NODE_TYPE_COLORS[node.label] || '#6b7280' }}
/> />
<span className="flex-1 truncate text-sm font-medium"> <span className="flex-1 truncate text-sm font-medium">
{node.properties.name} {node.properties.name}
</span> </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} {node.label}
</span> </span>
</button> </button>
@ -382,17 +438,17 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
href="https://github.com/abhigyanpatwari/GitNexus" href="https://github.com/abhigyanpatwari/GitNexus"
target="_blank" target="_blank"
rel="noopener noreferrer" 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> <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> <span className="hidden sm:inline"></span>
</a> </a>
{/* Stats */} {/* Stats */}
{graph && ( {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>{nodeCount} nodes</span>
<span>{edgeCount} edges</span> <span>{edgeCount} edges</span>
</div> </div>
@ -404,34 +460,32 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
{/* Icon buttons */} {/* Icon buttons */}
<button <button
onClick={() => setSettingsPanelOpen(true)} 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" title="AI Settings"
> >
<Settings className="w-4.5 h-4.5" /> <Settings className="h-4.5 w-4.5" />
</button> </button>
<button <button
title="Help" title="Help"
onClick={() => setHelpDialogBoxOpen(true)} 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"> 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="w-4.5 h-4.5" /> >
<HelpCircle className="h-4.5 w-4.5" />
</button> </button>
{/* AI Button */} {/* AI Button */}
<button <button
onClick={openChatPanel} onClick={openChatPanel}
className={` className={`flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium transition-all ${
flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-all isRightPanelOpen && rightPanelTab === 'chat'
${isRightPanelOpen && rightPanelTab === 'chat'
? 'bg-accent text-white shadow-glow' ? '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> <span>Nexus AI</span>
</button> </button>
</div> </div>
</header> </header>
); );
}; };

File diff suppressed because it is too large Load diff

View file

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

View file

@ -9,213 +9,222 @@ import { Copy, Check } from '@/lib/lucide-icons';
// Custom syntax theme // Custom syntax theme
const customTheme = { const customTheme = {
...vscDarkPlus, ...vscDarkPlus,
'pre[class*="language-"]': { 'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'], ...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10', background: '#0a0a10',
margin: 0, margin: 0,
padding: '16px 0', padding: '16px 0',
fontSize: '13px', fontSize: '13px',
lineHeight: '1.6', lineHeight: '1.6',
}, },
'code[class*="language-"]': { 'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'], ...vscDarkPlus['code[class*="language-"]'],
background: 'transparent', background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace', fontFamily: '"JetBrains Mono", "Fira Code", monospace',
}, },
}; };
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
onLinkClick?: (href: string) => void; onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean; showCopyButton?: boolean;
} }
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content, content,
onLinkClick, onLinkClick,
toolCalls, toolCalls,
showCopyButton = false showCopyButton = false,
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (copyTimerRef.current) { if (copyTimerRef.current) {
clearTimeout(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);
}
}; };
}, []);
// Helper to format text for display (convert [[links]] to markdown links) const handleCopy = async () => {
const formatMarkdownForDisplay = (md: string) => { try {
// Avoid rewriting inside fenced code blocks. await navigator.clipboard.writeText(content);
const parts = md.split('```'); setCopied(true);
for (let i = 0; i < parts.length; i += 2) { if (copyTimerRef.current) {
// Pattern 1: File grounding - [[file.ext]] clearTimeout(copyTimerRef.current);
parts[i] = parts[i].replace( }
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-]\d+)?)?)\]\]/g, copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
(_m, inner: string) => { } catch (err) {
const trimmed = inner.trim(); console.error('Failed to copy:', err);
const href = `code-ref:${encodeURIComponent(trimmed)}`; }
return `[${trimmed}](${href})`; };
}
);
// Pattern 2: Node grounding - [[Type:Name]] // Helper to format text for display (convert [[links]] to markdown links)
parts[i] = parts[i].replace( const formatMarkdownForDisplay = (md: string) => {
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g, // Avoid rewriting inside fenced code blocks.
(_m, nodeType: string, nodeName: string) => { const parts = md.split('```');
const trimmed = `${nodeType}:${nodeName.trim()}`; for (let i = 0; i < parts.length; i += 2) {
const href = `node-ref:${encodeURIComponent(trimmed)}`; // Pattern 1: File grounding - [[file.ext]]
return `[${trimmed}](${href})`; parts[i] = parts[i].replace(
} /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-]\d+)?)?)\]\]/g,
); (_m, inner: string) => {
} const trimmed = inner.trim();
return parts.join('```'); const href = `code-ref:${encodeURIComponent(trimmed)}`;
}; return `[${trimmed}](${href})`;
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>
);
}, },
code: ({ className, children, ...props }: any) => { );
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) { // Pattern 2: Node grounding - [[Type:Name]]
return <code {...props}>{children}</code>; parts[i] = parts[i].replace(
} /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const language = match ? match[1] : 'text'; const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
// Render Mermaid diagrams return `[${trimmed}](${href})`;
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 parts.join('```');
};
return ( const handleLinkClick = React.useCallback(
<div className="text-text-primary text-sm"> (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
<ReactMarkdown if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
remarkPlugins={[remarkGfm]} e.preventDefault();
urlTransform={(url) => { onLinkClick?.(href);
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url; }
// Default behavior for http/https/etc // External links open in new tab (default behavior)
return url; },
}} [onLinkClick],
components={markdownComponents} );
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} <span className="text-inherit">{children}</span>
</ReactMarkdown> </a>
);
}
{/* Copy Button */} // External links
{showCopyButton && ( return (
<div className="mt-2 flex justify-end"> <a
<button href={hrefStr}
onClick={handleCopy} className="text-accent underline underline-offset-2 hover:text-purple-300"
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" target="_blank"
title="Copy to clipboard" rel="noopener noreferrer"
> {...props}
{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> {children}
</button> </a>
</div> );
)} },
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 */} if (isInline) {
{toolCalls && toolCalls.length > 0 && ( return <code {...props}>{children}</code>;
<div className="mt-3 space-y-2"> }
{toolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} /> const language = match ? match[1] : 'text';
))}
</div> // 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> </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 // Render the diagram
const { svg: renderedSvg } = await mermaid.render(id, code.trim()); 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); setSvg(sanitizedSvg);
setError(null); setError(null);
} catch (err) { } catch (err) {
// Silent catch for streaming: // Silent catch for streaming:
// If render fails (common during partial streaming), we: // If render fails (common during partial streaming), we:
// 1. Log to console for debugging // 1. Log to console for debugging
// 2. Do NOT set error state (avoids flashing red box) // 2. Do NOT set error state (avoids flashing red box)
@ -93,29 +96,31 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
}, [code]); }, [code]);
// Create a pseudo ProcessData for the modal (with custom rawMermaid property) // Create a pseudo ProcessData for the modal (with custom rawMermaid property)
const processData: any = showModal ? { const processData: any = showModal
id: 'ai-generated', ? {
label: 'AI Generated Diagram', id: 'ai-generated',
processType: 'intra_community', label: 'AI Generated Diagram',
steps: [], // Empty - we'll render raw mermaid processType: 'intra_community',
edges: [], steps: [], // Empty - we'll render raw mermaid
clusters: [], edges: [],
rawMermaid: code, // Pass raw mermaid code clusters: [],
} : null; rawMermaid: code, // Pass raw mermaid code
}
: null;
if (error) { if (error) {
return ( return (
<div className="my-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg"> <div className="my-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-4">
<div className="flex items-center gap-2 text-rose-300 text-sm mb-2"> <div className="mb-2 flex items-center gap-2 text-sm text-rose-300">
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="h-4 w-4" />
<span className="font-medium">Diagram Error</span> <span className="font-medium">Diagram Error</span>
</div> </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"> <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 Show source
</summary> </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} {code}
</pre> </pre>
</details> </details>
@ -125,42 +130,40 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
return ( return (
<> <>
<div className="my-3 relative group"> <div className="group relative my-3">
<div className="relative bg-gradient-to-b from-surface to-elevated border border-border-subtle rounded-xl overflow-hidden"> <div className="relative overflow-hidden rounded-xl border border-border-subtle bg-gradient-to-b from-surface to-elevated">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle"> <div className="flex items-center justify-between border-b border-border-subtle bg-surface/60 px-3 py-2">
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium"> <span className="text-[10px] font-medium tracking-wider text-text-muted uppercase">
Diagram Diagram
</span> </span>
<button <button
onClick={() => setShowModal(true)} 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" title="Expand"
> >
<Maximize2 className="w-3.5 h-3.5" /> <Maximize2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
{/* Diagram container */} {/* Diagram container */}
<div <div
ref={containerRef} ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]" 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'] }) }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
}),
}}
/> />
</div> </div>
</div> </div>
{/* Use ProcessFlowModal for expansion */} {/* Use ProcessFlowModal for expansion */}
{showModal && processData && ( {showModal && processData && (
<Suspense <Suspense fallback={<div className="p-4 text-sm text-text-muted">Loading diagram</div>}>
fallback={ <ProcessFlowModal process={processData} onClose={() => setShowModal(false)} />
<div className="p-4 text-sm text-text-muted">Loading diagram</div>
}
>
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
</Suspense> </Suspense>
)} )}
</> </>

View file

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

View file

@ -1,6 +1,6 @@
/** /**
* Process Flow Modal * Process Flow Modal
* *
* Displays a Mermaid flowchart for a process in a centered modal popup. * 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'; import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
interface ProcessFlowModalProps { interface ProcessFlowModalProps {
process: ProcessData | null; process: ProcessData | null;
onClose: () => void; onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void; onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean; isFullScreen?: boolean;
} }
// Initialize mermaid with cyan/purple theme matching GitNexus // Initialize mermaid with cyan/purple theme matching GitNexus
// Initialize mermaid with cyan/purple theme matching GitNexus // Initialize mermaid with cyan/purple theme matching GitNexus
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base', theme: 'base',
themeVariables: { themeVariables: {
primaryColor: '#1e293b', // node bg primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9', primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee', primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8', lineColor: '#94a3b8',
secondaryColor: '#1e293b', secondaryColor: '#1e293b',
tertiaryColor: '#0f172a', tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee', nodeBorder: '#22d3ee',
clusterBkg: '#1e293b', clusterBkg: '#1e293b',
clusterBorder: '#475569', clusterBorder: '#475569',
titleColor: '#f1f5f9', titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a', edgeLabelBackground: '#0f172a',
}, },
flowchart: { flowchart: {
curve: 'basis', curve: 'basis',
padding: 50, padding: 50,
nodeSpacing: 120, nodeSpacing: 120,
rankSpacing: 140, rankSpacing: 140,
htmlLabels: true, htmlLabels: true,
}, },
}); });
// Suppress distinct syntax error overlay // Suppress distinct syntax error overlay
mermaid.parseError = (err) => { mermaid.parseError = (err) => {
// Suppress visual error - we handle errors in the render try/catch // Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err); console.debug('Mermaid parse error (suppressed):', err);
}; };
export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false }: ProcessFlowModalProps) => { export const ProcessFlowModal = ({
const containerRef = useRef<HTMLDivElement>(null); process,
const diagramRef = useRef<HTMLDivElement>(null); onClose,
const scrollContainerRef = useRef<HTMLDivElement>(null); onFocusInGraph,
isFullScreen = false,
// Full process map gets higher default zoom (667%) and max zoom (3000%) }: ProcessFlowModalProps) => {
const defaultZoom = isFullScreen ? 6.67 : 1; const containerRef = useRef<HTMLDivElement>(null);
const maxZoom = isFullScreen ? 30 : 10; const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
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]);
// Handle zoom with scroll wheel // Full process map gets higher default zoom (667%) and max zoom (3000%)
useEffect(() => { const defaultZoom = isFullScreen ? 6.67 : 1;
const handleWheel = (e: WheelEvent) => { const maxZoom = isFullScreen ? 30 : 10;
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom(prev => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
const container = scrollContainerRef.current; const [zoom, setZoom] = useState(defaultZoom);
if (container) { const [pan, setPan] = useState({ x: 0, y: 0 });
container.addEventListener('wheel', handleWheel, { passive: false }); const [isPanning, setIsPanning] = useState(false);
return () => container.removeEventListener('wheel', handleWheel); const [panStart, setPanStart] = useState({ x: 0, y: 0 });
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
// Handle keyboard zoom // Reset zoom when switching between full screen and regular mode
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { setZoom(defaultZoom);
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; setPan({ x: 0, y: 0 });
if (e.key === '+' || e.key === '=') { }, [isFullScreen, defaultZoom]);
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]);
// Zoom in/out handlers // Handle zoom with scroll wheel
const handleZoomIn = useCallback(() => { useEffect(() => {
setZoom(prev => Math.min(prev + 0.25, maxZoom)); const handleWheel = (e: WheelEvent) => {
}, [maxZoom]); e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom((prev) => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
const handleZoomOut = useCallback(() => { const container = scrollContainerRef.current;
setZoom(prev => Math.max(prev - 0.25, 0.1)); 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 // Handle keyboard zoom
const handleMouseDown = useCallback((e: React.MouseEvent) => { useEffect(() => {
setIsPanning(true); const handleKeyDown = (e: KeyboardEvent) => {
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
}, [pan]); 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) => { // Zoom in/out handlers
if (!isPanning) return; const handleZoomIn = useCallback(() => {
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); setZoom((prev) => Math.min(prev + 0.25, maxZoom));
}, [isPanning, panStart]); }, [maxZoom]);
const handleMouseUp = useCallback(() => { const handleZoomOut = useCallback(() => {
setIsPanning(false); setZoom((prev) => Math.max(prev - 0.25, 0.1));
}, []); }, []);
const resetView = useCallback(() => { // Handle pan with mouse drag
setZoom(defaultZoom); const handleMouseDown = useCallback(
setPan({ x: 0, y: 0 }); (e: React.MouseEvent) => {
}, [defaultZoom]); setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
},
[pan],
);
// Render mermaid diagram const handleMouseMove = useCallback(
useEffect(() => { (e: React.MouseEvent) => {
if (!process || !diagramRef.current) return; if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
},
[isPanning, panStart],
);
const renderDiagram = async () => { const handleMouseUp = useCallback(() => {
try { setIsPanning(false);
// 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 const resetView = useCallback(() => {
diagramRef.current!.innerHTML = ''; setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
const { svg } = await mermaid.render(id, mermaidCode); // Render mermaid diagram
if (!diagramRef.current) return; useEffect(() => {
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }); if (!process || !diagramRef.current) return;
} 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 = ` 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-center p-8">
<div class="text-red-400 text-sm font-medium mb-2"> <div class="text-red-400 text-sm font-medium mb-2">
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'} ${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
</div> </div>
<div class="text-slate-400 text-xs max-w-md"> <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".` isSizeError
: `Unable to render diagram. Steps: ${process.steps?.length || 0}` ? `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>
</div> </div>
`; `;
} }
}; };
renderDiagram(); renderDiagram();
}, [process]); }, [process]);
// Close on escape // Close on escape
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
}; };
window.addEventListener('keydown', handleEscape); window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]); }, [onClose]);
// Close on backdrop click // Close on backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => { const handleBackdropClick = useCallback(
if (e.target === containerRef.current) { (e: React.MouseEvent) => {
onClose(); if (e.target === containerRef.current) {
}
}, [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);
onClose(); 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 ( // Focus in graph
<div const handleFocusInGraph = useCallback(() => {
ref={containerRef} if (!process || !onFocusInGraph) return;
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in" const nodeIds = process.steps.map((s) => s.id);
onClick={handleBackdropClick} onFocusInGraph(nodeIds, process.id);
data-testid="process-modal" onClose();
> }, [process, onFocusInGraph, onClose]);
{/* 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" />
{/* Header */} if (!process) return null;
<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>
{/* Diagram */} return (
<div <div
ref={scrollContainerRef} ref={containerRef}
className={`flex-1 p-8 flex items-center justify-center relative z-10 overflow-hidden ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`} className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-black/20"
onMouseDown={handleMouseDown} onClick={handleBackdropClick}
onMouseMove={handleMouseMove} data-testid="process-modal"
onMouseUp={handleMouseUp} >
onMouseLeave={handleMouseUp} {/* Glassmorphism Modal */}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }} <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 ${
<div isFullScreen ? 'h-[95vh] w-[98%] max-w-none' : 'max-h-[90vh] w-[95%] max-w-5xl'
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={{ {/* Subtle gradient overlay for extra glass feel */}
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
}}
/>
</div>
{/* Footer Actions */} {/* Header */}
<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"> <div className="relative z-10 border-b border-white/10 px-6 py-5">
{/* Zoom controls */} <h2 className="text-lg font-semibold text-white">Process: {process.label}</h2>
<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>
</div> </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 * Processes Panel
* *
* Lists all detected processes grouped by type (cross-community / intra-community). * Lists all detected processes grouped by type (cross-community / intra-community).
* Clicking a process opens the ProcessFlowModal with a flowchart. * Clicking a process opens the ProcessFlowModal with a flowchart.
*/ */
import { useState, useMemo, useCallback, useEffect } from 'react'; 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 { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal'; import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator'; 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); const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
export const ProcessesPanel = () => { export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState(); const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null); const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['cross', 'intra'])); const [expandedSections, setExpandedSections] = useState<Set<string>>(
const [loadingProcess, setLoadingProcess] = useState<string | null>(null); new Set(['cross', 'intra']),
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null); );
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
// Extract processes from graph // Extract processes from graph
const processes = useMemo(() => { const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] }; 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 cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: 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) { for (const node of processNodes) {
const item = { const item = {
id: node.id, id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id, label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0, stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [], clusters: node.properties.communities || [],
}; };
if (node.properties.processType === 'cross_community') { if (node.properties.processType === 'cross_community') {
cross.push(item); cross.push(item);
} else { } else {
intra.push(item); intra.push(item);
} }
} }
// Sort by step count (most complex first) // Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount); cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount); intra.sort((a, b) => b.stepCount - a.stepCount);
return { cross, intra }; return { cross, intra };
}, [graph]); }, [graph]);
// Filter by search // Filter by search
const filteredProcesses = useMemo(() => { const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes; if (!searchQuery.trim()) return processes;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return { return {
cross: processes.cross.filter(p => p.label.toLowerCase().includes(query)), cross: processes.cross.filter((p) => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter(p => p.label.toLowerCase().includes(query)), intra: processes.intra.filter((p) => p.label.toLowerCase().includes(query)),
}; };
}, [processes, searchQuery]); }, [processes, searchQuery]);
// Toggle section expansion // Toggle section expansion
const toggleSection = useCallback((section: string) => { const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => { setExpandedSections((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(section)) { if (next.has(section)) {
next.delete(section); next.delete(section);
} else { } else {
next.add(section); next.add(section);
} }
return next; return next;
}); });
}, []); }, []);
// Load ALL processes and combine into one mega-diagram // Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => { const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all'); setLoadingProcess('all');
try { try {
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId); 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 // Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>(); const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = []; 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 // 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 // Optimization: Fetch all steps in one query if possible
const allStepsQuery = ` const allStepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) 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 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) { for (const row of stepsResult) {
const stepId = row.id || row[0]; const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) { if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, { allStepsMap.set(stepId, {
id: stepId, id: stepId,
name: row.name || row[1] || 'Unknown', name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2], filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0, stepNumber: row.stepNumber || row.step || row[3] || 0,
}); });
} }
} }
const allSteps = Array.from(allStepsMap.values()); const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map(s => s.id).filter(isSafeId); const stepIds = allSteps.map((s) => s.id).filter(isSafeId);
// Query for all CALLS edges between the combined steps // Query for all CALLS edges between the combined steps
if (stepIds.length > 0) { if (stepIds.length > 0) {
// Batch query if too many steps // Batch query if too many steps
const edgesQuery = ` const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to) MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.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(',')}] AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type 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 { try {
// Query for process steps const edgesResult = await runQuery(edgesQuery);
const stepsQuery = ` 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, "''")}'}) 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 RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
ORDER BY r.step ORDER BY r.step
`; `;
const stepsResult = await runQuery(stepsQuery); const stepsResult = await runQuery(stepsQuery);
const steps: ProcessStep[] = stepsResult.map((row: any) => ({ const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0], id: row.id || row[0],
name: row.name || row[1] || 'Unknown', name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2], filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0, stepNumber: row.stepNumber || row.step || row[3] || 0,
})); }));
// Get step IDs for edge query // Get step IDs for edge query
const stepIds = steps.map(s => s.id).filter(isSafeId); const stepIds = steps.map((s) => s.id).filter(isSafeId);
// Query for CALLS edges between the steps in this process // Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = []; let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) { if (stepIds.length > 0) {
const edgesQuery = ` const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to) MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.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(',')}] AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type RETURN from.id AS fromId, to.id AS toId, r.type AS type
`; `;
try { try {
const edgesResult = await runQuery(edgesQuery); const edgesResult = await runQuery(edgesQuery);
edges = edgesResult edges = edgesResult
.map((row: any) => ({ .map((row: any) => ({
from: row.fromId || row[0], from: row.fromId || row[0],
to: row.toId || row[1], to: row.toId || row[1],
type: row.type || row[2] || 'CALLS', type: row.type || row[2] || 'CALLS',
})) }))
.filter(edge => edge.from !== edge.to); // Remove self-loops .filter((edge) => edge.from !== edge.to); // Remove self-loops
} catch (err) { } catch (err) {
console.warn('Could not fetch edges:', err); console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear // 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;
} }
// Check if we have cached steps // Get clusters for this process
if (processStepsCache.has(processId)) { const processNode = graph?.nodes.find((n) => n.id === processId);
const stepIds = processStepsCache.get(processId)!; const clusters = processNode?.properties.communities || [];
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Load steps for this process const processData: ProcessData = {
setLoadingProcess(processId); id: processId,
try { label,
const stepsQuery = ` 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, "''")}'}) MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id RETURN s.id AS id
`; `;
const stepsResult = await runQuery(stepsQuery); const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]); const stepIds = stepsResult.map((row: any) => row.id || row[0]);
// Cache the result // Cache the result
setProcessStepsCache(prev => new Map(prev).set(processId, stepIds)); setProcessStepsCache((prev) => new Map(prev).set(processId, stepIds));
// Set focus // Set focus
setHighlightedNodeIds(new Set(stepIds)); setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId); setFocusedProcessId(processId);
} catch (error) { } catch (error) {
console.error('Failed to load process steps for focus:', error); console.error('Failed to load process steps for focus:', error);
} finally { } finally {
setLoadingProcess(null); setLoadingProcess(null);
} }
}, [focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds]); },
[focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds],
);
// Focus in graph callback - toggles highlight (used by modal) // Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback((nodeIds: string[], processId: string) => { const handleFocusInGraph = useCallback(
// Check if this process is already focused (nodeIds: string[], processId: string) => {
if (focusedProcessId === processId) { // Check if this process is already focused
// Clear focus if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set()); // Clear focus
setFocusedProcessId(null); setHighlightedNodeIds(new Set());
} else { setFocusedProcessId(null);
// Set focus and cache } else {
setHighlightedNodeIds(new Set(nodeIds)); // Set focus and cache
setFocusedProcessId(processId); setHighlightedNodeIds(new Set(nodeIds));
setProcessStepsCache(prev => new Map(prev).set(processId, nodeIds)); setFocusedProcessId(processId);
} setProcessStepsCache((prev) => new Map(prev).set(processId, nodeIds));
}, [focusedProcessId, setHighlightedNodeIds]); }
},
[focusedProcessId, setHighlightedNodeIds],
);
// Clear focused process when highlights are cleared externally // Clear focused process when highlights are cleared externally
useEffect(() => { useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) { if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(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>
);
} }
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
{/* Header with search */} <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-surface">
<div className="p-3 border-b border-border-subtle"> <GitBranch className="h-7 w-7 text-text-muted" />
<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> </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 // Individual process item
interface ProcessItemProps { interface ProcessItemProps {
process: { id: string; label: string; stepCount: number; clusters: string[] }; process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean; isLoading: boolean;
isSelected: boolean; isSelected: boolean;
isFocused: boolean; isFocused: boolean;
onView: () => void; onView: () => void;
onToggleFocus: () => void; onToggleFocus: () => void;
} }
const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onToggleFocus }: ProcessItemProps) => { const ProcessItem = ({
// Determine row styling - focused gets special highlight process,
const rowClass = isFocused isLoading,
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30' isSelected,
: isSelected isFocused,
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30' 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 ( 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}`}> <div
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" /> data-testid="process-row"
<div className="flex-1 min-w-0"> className={`group mx-2 flex items-center gap-2 rounded-lg px-4 py-2 transition-all hover:bg-hover ${rowClass}`}
<div className="text-sm text-text-primary truncate">{process.label}</div> >
<div className="flex items-center gap-2 text-xs text-text-muted"> <GitBranch className="h-4 w-4 flex-shrink-0 text-text-muted" />
<span>{process.stepCount} steps</span> <div className="min-w-0 flex-1">
{process.clusters.length > 0 && ( <div className="truncate text-sm text-text-primary">{process.label}</div>
<> <div className="flex items-center gap-2 text-xs text-text-muted">
<span></span> <span>{process.stepCount} steps</span>
<span>{process.clusters.length} clusters</span> {process.clusters.length > 0 && (
</> <>
)} <span></span>
</div> <span>{process.clusters.length} clusters</span>
</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>
</div> </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 { 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'; import { useAppState } from '../hooks/useAppState';
const EXAMPLE_QUERIES = [ const EXAMPLE_QUERIES = [
@ -26,7 +35,15 @@ const EXAMPLE_QUERIES = [
]; ];
export const QueryFAB = () => { 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 [isExpanded, setIsExpanded] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@ -95,12 +112,12 @@ export const QueryFAB = () => {
const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/; const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/;
const nodeIds = rows const nodeIds = rows
.flatMap(row => { .flatMap((row) => {
const ids: string[] = []; const ids: string[] = [];
if (Array.isArray(row)) { if (Array.isArray(row)) {
// Array format - check all elements for node ID patterns // Array format - check all elements for node ID patterns
row.forEach(val => { row.forEach((val) => {
if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) { if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) {
ids.push(val); ids.push(val);
} }
@ -169,25 +186,12 @@ export const QueryFAB = () => {
return ( return (
<button <button
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
className=" 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)]"
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
"
> >
<Terminal className="w-4 h-4" /> <Terminal className="h-4 w-4" />
<span>Query</span> <span>Query</span>
{queryResult && queryResult.nodeIds.length > 0 && ( {queryResult && queryResult.nodeIds.length > 0 && (
<span className=" <span className="ml-1 rounded-md bg-white/20 px-1.5 py-0.5 text-xs font-semibold">
px-1.5 py-0.5 ml-1
bg-white/20 rounded-md
text-xs font-semibold
">
{queryResult.nodeIds.length} {queryResult.nodeIds.length}
</span> </span>
)} )}
@ -198,28 +202,20 @@ export const QueryFAB = () => {
return ( return (
<div <div
ref={panelRef} ref={panelRef}
className=" 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"
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
"
> >
<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="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"> <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="w-4 h-4 text-white" /> <Terminal className="h-4 w-4 text-white" />
</div> </div>
<span className="font-medium text-sm">Cypher Query</span> <span className="text-sm font-medium">Cypher Query</span>
</div> </div>
<button <button
onClick={handleClose} 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> </button>
</div> </div>
@ -232,52 +228,30 @@ export const QueryFAB = () => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="MATCH (n:Function) RETURN n.name, n.filePath LIMIT 10" placeholder="MATCH (n:Function) RETURN n.name, n.filePath LIMIT 10"
rows={3} rows={3}
className=" 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"
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
"
/> />
</div> </div>
<div className="flex items-center justify-between mt-3"> <div className="mt-3 flex items-center justify-between">
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowExamples(!showExamples)} onClick={() => setShowExamples(!showExamples)}
className=" 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"
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
"
> >
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="h-3.5 w-3.5" />
<span>Examples</span> <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> </button>
{showExamples && ( {showExamples && (
<div className=" <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">
absolute bottom-full left-0 mb-2
w-64 py-1
bg-surface border border-border-subtle rounded-lg
shadow-xl
animate-fade-in
">
{EXAMPLE_QUERIES.map((example) => ( {EXAMPLE_QUERIES.map((example) => (
<button <button
key={example.label} key={example.label}
onClick={() => handleSelectExample(example.query)} onClick={() => handleSelectExample(example.query)}
className=" className="w-full px-3 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
w-full px-3 py-2 text-left
text-sm text-text-secondary
hover:bg-hover hover:text-text-primary
transition-colors
"
> >
{example.label} {example.label}
</button> </button>
@ -290,12 +264,7 @@ export const QueryFAB = () => {
{query && ( {query && (
<button <button
onClick={handleClear} onClick={handleClear}
className=" className="rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
px-3 py-1.5
text-xs text-text-secondary
hover:text-text-primary hover:bg-hover
rounded-md transition-colors
"
> >
Clear Clear
</button> </button>
@ -303,76 +272,74 @@ export const QueryFAB = () => {
<button <button
onClick={handleRunQuery} onClick={handleRunQuery}
disabled={!query.trim() || isRunning} disabled={!query.trim() || isRunning}
className=" 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"
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
"
> >
{isRunning ? ( {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> <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> </button>
</div> </div>
</div> </div>
</div> </div>
{error && ( {error && (
<div className="px-4 py-2 bg-red-500/10 border-t border-red-500/20"> <div className="border-t border-red-500/20 bg-red-500/10 px-4 py-2">
<p className="text-xs text-red-400 font-mono">{error}</p> <p className="font-mono text-xs text-red-400">{error}</p>
</div> </div>
)} )}
{queryResult && !error && ( {queryResult && !error && (
<div className="border-t border-cyan-500/20"> <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"> <div className="flex items-center gap-3 text-xs">
<span className="text-text-secondary"> <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> </span>
{queryResult.nodeIds.length > 0 && ( {queryResult.nodeIds.length > 0 && (
<span className="text-text-secondary"> <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>
)} )}
<span className="text-text-muted"> <span className="text-text-muted">{queryResult.executionTime.toFixed(1)}ms</span>
{queryResult.executionTime.toFixed(1)}ms
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{queryResult.nodeIds.length > 0 && ( {queryResult.nodeIds.length > 0 && (
<button <button
onClick={clearQueryHighlights} 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 Clear
</button> </button>
)} )}
<button <button
onClick={() => setShowResults(!showResults)} 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" /> <Table className="h-3 w-3" />
{showResults ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />} {showResults ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</button> </button>
</div> </div>
</div> </div>
{showResults && queryResult.rows.length > 0 && ( {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"> <table className="w-full text-xs">
<thead className="bg-surface sticky top-0"> <thead className="sticky top-0 bg-surface">
<tr> <tr>
{Object.keys(queryResult.rows[0]).map((key) => ( {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} {key}
</th> </th>
))} ))}
@ -380,9 +347,12 @@ export const QueryFAB = () => {
</thead> </thead>
<tbody> <tbody>
{queryResult.rows.slice(0, 50).map((row, i) => ( {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) => ( {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 ?? '')} {typeof val === 'object' ? JSON.stringify(val) : String(val ?? '')}
</td> </td>
))} ))}
@ -391,7 +361,7 @@ export const QueryFAB = () => {
</tbody> </tbody>
</table> </table>
{queryResult.rows.length > 50 && ( {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 Showing 50 of {queryResult.rows.length} rows
</div> </div>
)} )}
@ -402,4 +372,3 @@ export const QueryFAB = () => {
</div> </div>
); );
}; };

View file

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

View file

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

View file

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

View file

@ -11,28 +11,29 @@ export const StatusBar = () => {
// Detect primary language // Detect primary language
const primaryLanguage = useMemo(() => { const primaryLanguage = useMemo(() => {
if (!graph) return null; if (!graph) return null;
const languages = graph.nodes const languages = graph.nodes.map((n) => n.properties.language).filter(Boolean);
.map(n => n.properties.language)
.filter(Boolean);
if (languages.length === 0) return null; if (languages.length === 0) return null;
const counts = languages.reduce((acc, lang) => { const counts = languages.reduce(
acc[lang!] = (acc[lang!] || 0) + 1; (acc, lang) => {
return acc; acc[lang!] = (acc[lang!] || 0) + 1;
}, {} as Record<string, number>); return acc;
},
{} as Record<string, number>,
);
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0]; return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
}, [graph]); }, [graph]);
return ( 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 */} {/* Left - Status */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{progress && progress.phase !== 'complete' ? ( {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 <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}%` }} style={{ width: `${progress.percent}%` }}
/> />
</div> </div>
@ -40,7 +41,7 @@ export const StatusBar = () => {
</> </>
) : ( ) : (
<div className="flex items-center gap-1.5" data-testid="status-ready"> <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> <span>Ready</span>
</div> </div>
)} )}
@ -51,11 +52,13 @@ export const StatusBar = () => {
href="https://github.com/sponsors/abhigyanpatwari" href="https://github.com/sponsors/abhigyanpatwari"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" /> <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 group-hover:text-pink-300 transition-colors">Sponsor</span> <span className="text-[11px] font-medium text-pink-400 transition-colors group-hover:text-pink-300">
<span className="text-[10px] text-pink-300/50 group-hover:text-pink-300/80 italic hidden md:inline transition-colors"> 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 😅 need to buy some API credits to run SWE-bench 😅
</span> </span>
</a> </a>

View file

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

View file

@ -41,27 +41,27 @@ export const WebGPUFallbackDialog = ({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
<div <div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`} className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose} onClick={onClose}
/> />
{/* Dialog */} {/* Dialog */}
<div <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'}`} 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 */} {/* 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 <button
onClick={onClose} 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> </button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Animated emoji */} {/* Animated emoji */}
<div <div
className={`text-5xl ${isAnimating ? 'animate-bounce' : ''}`} className={`text-5xl ${isAnimating ? 'animate-bounce' : ''}`}
onAnimationEnd={() => setIsAnimating(false)} onAnimationEnd={() => setIsAnimating(false)}
onClick={() => setIsAnimating(true)} onClick={() => setIsAnimating(true)}
@ -69,10 +69,8 @@ export const WebGPUFallbackDialog = ({
🤔 🤔
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-text-primary"> <h2 className="text-lg font-semibold text-text-primary">WebGPU said "nope"</h2>
WebGPU said "nope" <p className="mt-0.5 text-sm text-text-muted">
</h2>
<p className="text-sm text-text-muted mt-0.5">
Your browser doesn't support GPU acceleration Your browser doesn't support GPU acceleration
</p> </p>
</div> </div>
@ -80,65 +78,68 @@ export const WebGPUFallbackDialog = ({
</div> </div>
{/* Content */} {/* Content */}
<div className="px-6 py-5 space-y-4"> <div className="space-y-4 px-6 py-5">
<p className="text-sm text-text-secondary leading-relaxed"> <p className="text-sm leading-relaxed text-text-secondary">
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as
won't be as smart. The graph still works fine though! smart. The graph still works fine though!
</p> </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"> <p className="text-sm text-text-secondary">
<span className="font-medium text-text-primary">Your options:</span> <span className="font-medium text-text-primary">Your options:</span>
</p> </p>
<ul className="mt-2 space-y-1.5 text-sm text-text-muted"> <ul className="mt-2 space-y-1.5 text-sm text-text-muted">
<li className="flex items-start gap-2"> <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> <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 && ( {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> </span>
</li> </li>
<li className="flex items-start gap-2"> <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> <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> </span>
</li> </li>
</ul> </ul>
</div> </div>
{isSmallCodebase && ( {isSmallCodebase && (
<p className="text-xs text-node-function flex items-center gap-1.5 bg-node-function/10 px-3 py-2 rounded-lg"> <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="w-3.5 h-3.5" /> <Rocket className="h-3.5 w-3.5" />
Small codebase detected! CPU should be fine. Small codebase detected! CPU should be fine.
</p> </p>
)} )}
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">💡 Tip: Try Chrome or Edge for WebGPU support</p>
💡 Tip: Try Chrome or Edge for WebGPU support
</p>
</div> </div>
{/* Actions */} {/* 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 <button
onClick={onSkip} 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 Skip Embeddings
</button> </button>
<button <button
onClick={onUseCPU} 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 isSmallCodebase
? 'bg-node-function text-white hover:bg-node-function/90' ? '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)'} Use CPU {isSmallCodebase ? '(Recommended)' : '(Slow)'}
</button> </button>
</div> </div>
@ -146,4 +147,3 @@ export const WebGPUFallbackDialog = ({
</div> </div>
); );
}; };

View file

@ -36,36 +36,34 @@ export const ProviderConfigCard = ({
children, children,
}: ProviderConfigCardProps) => { }: ProviderConfigCardProps) => {
return ( 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 className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold text-text-primary">{title}</h3> <h3 className="text-sm font-semibold text-text-primary">{title}</h3>
{description ? ( {description ? <p className="text-xs text-text-muted">{description}</p> : null}
<p className="text-xs text-text-muted">{description}</p>
) : null}
</div> </div>
</div> </div>
{apiKey && ( {apiKey && (
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary"> <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 API Key
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type={apiKey.isVisible ? 'text' : 'password'} type={apiKey.isVisible ? 'text' : 'password'}
value={apiKey.value} value={apiKey.value}
onChange={e => apiKey.onChange(e.target.value)} onChange={(e) => apiKey.onChange(e.target.value)}
placeholder={apiKey.placeholder} 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 <button
type="button" type="button"
onClick={apiKey.onToggleVisibility} 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> </button>
</div> </div>
{apiKey.helperText && ( {apiKey.helperText && (
@ -94,13 +92,11 @@ export const ProviderConfigCard = ({
<input <input
type="text" type="text"
value={model.value} value={model.value}
onChange={e => model.onChange(e.target.value)} onChange={(e) => model.onChange(e.target.value)}
placeholder={model.placeholder} 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 ? ( {model.helperText ? <p className="text-xs text-text-muted">{model.helperText}</p> : null}
<p className="text-xs text-text-muted">{model.helperText}</p>
) : null}
</div> </div>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
/** /**
* LLM Provider Types * LLM Provider Types
* *
* Type definitions for multi-provider LLM support. * Type definitions for multi-provider LLM support.
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5. * Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
*/ */
@ -9,7 +9,15 @@
* Supported LLM providers * Supported LLM providers
*/ */
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants'; 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 * Base configuration shared by all providers
@ -27,8 +35,8 @@ export interface BaseProviderConfig {
export interface OpenAIConfig extends BaseProviderConfig { export interface OpenAIConfig extends BaseProviderConfig {
provider: 'openai'; provider: 'openai';
apiKey: string; apiKey: string;
model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo' model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'
baseUrl?: string; // optional, for custom endpoints or proxies baseUrl?: string; // optional, for custom endpoints or proxies
} }
/** /**
@ -37,9 +45,9 @@ export interface OpenAIConfig extends BaseProviderConfig {
export interface AzureOpenAIConfig extends BaseProviderConfig { export interface AzureOpenAIConfig extends BaseProviderConfig {
provider: 'azure-openai'; provider: 'azure-openai';
apiKey: string; apiKey: string;
endpoint: string; // e.g., https://your-resource.openai.azure.com endpoint: string; // e.g., https://your-resource.openai.azure.com
deploymentName: string; 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 { export interface GeminiConfig extends BaseProviderConfig {
provider: 'gemini'; provider: 'gemini';
apiKey: string; 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 { export interface AnthropicConfig extends BaseProviderConfig {
provider: 'anthropic'; provider: 'anthropic';
apiKey: string; 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 { export interface OllamaConfig extends BaseProviderConfig {
provider: 'ollama'; provider: 'ollama';
baseUrl?: string; // defaults to http://localhost:11434 baseUrl?: string; // defaults to http://localhost:11434
model: string; model: string;
} }
@ -75,8 +83,8 @@ export interface OllamaConfig extends BaseProviderConfig {
export interface OpenRouterConfig extends BaseProviderConfig { export interface OpenRouterConfig extends BaseProviderConfig {
provider: 'openrouter'; provider: 'openrouter';
apiKey: string; apiKey: string;
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo' model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
baseUrl?: string; // defaults to https://openrouter.ai/api/v1 baseUrl?: string; // defaults to https://openrouter.ai/api/v1
} }
/** /**
@ -85,7 +93,7 @@ export interface OpenRouterConfig extends BaseProviderConfig {
export interface MiniMaxConfig extends BaseProviderConfig { export interface MiniMaxConfig extends BaseProviderConfig {
provider: 'minimax'; provider: 'minimax';
apiKey: string; 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 { export interface GLMConfig extends BaseProviderConfig {
provider: 'glm'; provider: 'glm';
apiKey: string; apiKey: string;
model: string; // e.g., 'GLM-4.7', 'GLM-4.5', 'GLM-4.5-Air', 'GLM-5' 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 baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
} }
/** /**
* Union type for all provider configurations * 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) * Stored settings (what goes to localStorage)
@ -380,4 +396,3 @@ NOTES:
- For vector search, join CodeEmbedding.nodeId to the appropriate table's id - For vector search, join CodeEmbedding.nodeId to the appropriate table's id
- Use LIMIT to avoid returning too many results - 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 [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const toggleLabelVisibility = useCallback((label: NodeLabel) => { const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels(prev => setVisibleLabels((prev) =>
prev.includes(label) ? prev.filter(l => l !== label) : [...prev, label] prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label],
); );
}, []); }, []);
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => { const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
setVisibleEdgeTypes(prev => setVisibleEdgeTypes((prev) =>
prev.includes(edgeType) ? prev.filter(e => e !== edgeType) : [...prev, edgeType] prev.includes(edgeType) ? prev.filter((e) => e !== edgeType) : [...prev, edgeType],
); );
}, []); }, []);
const value = useMemo<GraphStateContextValue>(() => ({ const value = useMemo<GraphStateContextValue>(
graph, () => ({
setGraph, graph,
selectedNode, setGraph,
setSelectedNode, selectedNode,
visibleLabels, setSelectedNode,
toggleLabelVisibility, visibleLabels,
visibleEdgeTypes, toggleLabelVisibility,
toggleEdgeVisibility, visibleEdgeTypes,
depthFilter, toggleEdgeVisibility,
setDepthFilter, depthFilter,
highlightedNodeIds, setDepthFilter,
setHighlightedNodeIds, highlightedNodeIds,
}), [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]); setHighlightedNodeIds,
}),
return ( [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
<GraphStateContext.Provider value={value}>
{children}
</GraphStateContext.Provider>
); );
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
}; };
export const useGraphState = (): GraphStateContextValue => { 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 { useState, useEffect, useCallback, useRef } from 'react';
import { import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
probeBackend,
setBackendUrl as setServiceUrl,
} from '../services/backend-client';
import { DEFAULT_BACKEND_URL } from '../config/ui-constants'; import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
// ── localStorage keys ──────────────────────────────────────────────────────── // ── localStorage keys ────────────────────────────────────────────────────────
@ -117,7 +114,7 @@ export function useBackend(): UseBackendResult {
pollingTimerRef.current = null; pollingTimerRef.current = null;
} }
// Probe immediately, then restart the polling chain if still disconnected // Probe immediately, then restart the polling chain if still disconnected
void probeRef.current().then(ok => { void probeRef.current().then((ok) => {
if (!ok && isPolling) { if (!ok && isPolling) {
// Restart the setTimeout chain — schedule is captured in startPolling's closure, // Restart the setTimeout chain — schedule is captured in startPolling's closure,
// so we re-call startPolling which clears+restarts cleanly. // so we re-call startPolling which clears+restarts cleanly.

View file

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

View file

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

View file

@ -2,83 +2,83 @@ import type { NodeLabel } from 'gitnexus-shared';
// Node colors by type - slightly muted for less visual noise // Node colors by type - slightly muted for less visual noise
export const NODE_COLORS: Record<NodeLabel, string> = { export const NODE_COLORS: Record<NodeLabel, string> = {
Project: '#a855f7', // Purple - prominent Project: '#a855f7', // Purple - prominent
Package: '#8b5cf6', // Violet Package: '#8b5cf6', // Violet
Module: '#7c3aed', // Violet darker Module: '#7c3aed', // Violet darker
Folder: '#6366f1', // Indigo Folder: '#6366f1', // Indigo
File: '#3b82f6', // Blue File: '#3b82f6', // Blue
Class: '#f59e0b', // Amber - stands out Class: '#f59e0b', // Amber - stands out
Function: '#10b981', // Emerald Function: '#10b981', // Emerald
Method: '#14b8a6', // Teal Method: '#14b8a6', // Teal
Variable: '#64748b', // Slate - muted (less important) Variable: '#64748b', // Slate - muted (less important)
Interface: '#ec4899', // Pink Interface: '#ec4899', // Pink
Enum: '#f97316', // Orange Enum: '#f97316', // Orange
Decorator: '#eab308', // Yellow Decorator: '#eab308', // Yellow
Import: '#475569', // Slate darker - very muted Import: '#475569', // Slate darker - very muted
Type: '#a78bfa', // Violet light Type: '#a78bfa', // Violet light
CodeElement: '#64748b', // Slate - muted CodeElement: '#64748b', // Slate - muted
Community: '#818cf8', // Indigo light - cluster indicator Community: '#818cf8', // Indigo light - cluster indicator
Process: '#f43f5e', // Rose - execution flow indicator Process: '#f43f5e', // Rose - execution flow indicator
Section: '#60a5fa', // Blue light - structural section Section: '#60a5fa', // Blue light - structural section
Struct: '#f59e0b', // Amber - like Class Struct: '#f59e0b', // Amber - like Class
Trait: '#ec4899', // Pink - like Interface Trait: '#ec4899', // Pink - like Interface
Impl: '#14b8a6', // Teal - like Method Impl: '#14b8a6', // Teal - like Method
TypeAlias: '#a78bfa', // Violet light - like Type TypeAlias: '#a78bfa', // Violet light - like Type
Const: '#64748b', // Slate - like Variable Const: '#64748b', // Slate - like Variable
Static: '#64748b', // Slate - like Variable Static: '#64748b', // Slate - like Variable
Namespace: '#7c3aed', // Violet - like Module Namespace: '#7c3aed', // Violet - like Module
Union: '#f97316', // Orange - like Enum Union: '#f97316', // Orange - like Enum
Typedef: '#a78bfa', // Violet light - like Type Typedef: '#a78bfa', // Violet light - like Type
Macro: '#eab308', // Yellow - like Decorator Macro: '#eab308', // Yellow - like Decorator
Property: '#64748b', // Slate - like Variable Property: '#64748b', // Slate - like Variable
Record: '#f59e0b', // Amber - like Class Record: '#f59e0b', // Amber - like Class
Delegate: '#14b8a6', // Teal - like Method Delegate: '#14b8a6', // Teal - like Method
Annotation: '#eab308', // Yellow - like Decorator Annotation: '#eab308', // Yellow - like Decorator
Constructor: '#10b981', // Emerald - like Function Constructor: '#10b981', // Emerald - like Function
Template: '#a78bfa', // Violet light - like Type Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process Route: '#f43f5e', // Rose - like Process
Tool: '#a855f7', // Purple - like Project Tool: '#a855f7', // Purple - like Project
}; };
// Node sizes by type - clear visual hierarchy with dramatic size differences // Node sizes by type - clear visual hierarchy with dramatic size differences
// Structural nodes are MUCH larger to make hierarchy obvious // Structural nodes are MUCH larger to make hierarchy obvious
export const NODE_SIZES: Record<NodeLabel, number> = { export const NODE_SIZES: Record<NodeLabel, number> = {
Project: 20, // Largest - root of everything Project: 20, // Largest - root of everything
Package: 16, // Major structural element Package: 16, // Major structural element
Module: 13, // Important container Module: 13, // Important container
Folder: 10, // Structural - clearly bigger than files Folder: 10, // Structural - clearly bigger than files
File: 6, // Common element - smaller than folders File: 6, // Common element - smaller than folders
Class: 8, // Important code structure Class: 8, // Important code structure
Function: 4, // Common code element - small Function: 4, // Common code element - small
Method: 3, // Smaller than function Method: 3, // Smaller than function
Variable: 2, // Tiny - leaf node Variable: 2, // Tiny - leaf node
Interface: 7, // Important type definition Interface: 7, // Important type definition
Enum: 5, // Type definition Enum: 5, // Type definition
Decorator: 2, // Tiny modifier Decorator: 2, // Tiny modifier
Import: 1.5, // Very small - usually hidden anyway Import: 1.5, // Very small - usually hidden anyway
Type: 3, // Type alias - small Type: 3, // Type alias - small
CodeElement: 2, // Generic small CodeElement: 2, // Generic small
Community: 0, // Hidden by default - metadata node Community: 0, // Hidden by default - metadata node
Process: 0, // Hidden by default - metadata node Process: 0, // Hidden by default - metadata node
Section: 8, // Structural section - similar to Folder Section: 8, // Structural section - similar to Folder
Struct: 8, // Like Class Struct: 8, // Like Class
Trait: 7, // Like Interface Trait: 7, // Like Interface
Impl: 3, // Like Method Impl: 3, // Like Method
TypeAlias: 3, // Like Type TypeAlias: 3, // Like Type
Const: 2, // Like Variable Const: 2, // Like Variable
Static: 2, // Like Variable Static: 2, // Like Variable
Namespace: 13, // Like Module Namespace: 13, // Like Module
Union: 5, // Like Enum Union: 5, // Like Enum
Typedef: 3, // Like Type Typedef: 3, // Like Type
Macro: 2, // Like Decorator Macro: 2, // Like Decorator
Property: 2, // Like Variable Property: 2, // Like Variable
Record: 8, // Like Class Record: 8, // Like Class
Delegate: 3, // Like Method Delegate: 3, // Like Method
Annotation: 2, // Like Decorator Annotation: 2, // Like Decorator
Constructor: 4, // Like Function Constructor: 4, // Like Function
Template: 3, // Like Type Template: 3, // Like Type
Route: 5, // Like Enum Route: 5, // Like Enum
Tool: 5, // Like Enum Tool: 5, // Like Enum
}; };
// Community color palette for cluster-based coloring // 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 => { const getNodeMass = (nodeType: NodeLabel, nodeCount: number): number => {
// Scale mass based on graph size // Scale mass based on graph size
const baseMassMultiplier = nodeCount > 5000 ? 2 : nodeCount > 1000 ? 1.5 : 1; const baseMassMultiplier = nodeCount > 5000 ? 2 : nodeCount > 1000 ? 1.5 : 1;
switch (nodeType) { switch (nodeType) {
case 'Project': case 'Project':
return 50 * baseMassMultiplier; // Heaviest - anchors everything return 50 * baseMassMultiplier; // Heaviest - anchors everything
case 'Package': case 'Package':
return 30 * baseMassMultiplier; // Very heavy return 30 * baseMassMultiplier; // Very heavy
case 'Module': case 'Module':
return 20 * baseMassMultiplier; // Heavy return 20 * baseMassMultiplier; // Heavy
case 'Folder': case 'Folder':
return 15 * baseMassMultiplier; // Heavy - blasts folders apart! return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
case 'File': case 'File':
return 3 * baseMassMultiplier; // Medium - follows folders return 3 * baseMassMultiplier; // Medium - follows folders
case 'Class': case 'Class':
case 'Interface': case 'Interface':
return 5 * baseMassMultiplier; // Medium-heavy return 5 * baseMassMultiplier; // Medium-heavy
case 'Function': case 'Function':
case 'Method': case 'Method':
return 2 * baseMassMultiplier; // Light return 2 * baseMassMultiplier; // Light
default: default:
return 1; // Default mass return 1; // Default mass
} }
}; };
/** /**
* Converts the KnowledgeGraph to a graphology Graph for Sigma.js * Converts the KnowledgeGraph to a graphology Graph for Sigma.js
* Folders are positioned in a wide spread, children positioned NEAR their parents * Folders are positioned in a wide spread, children positioned NEAR their parents
* *
* @param knowledgeGraph - The knowledge graph to convert * @param knowledgeGraph - The knowledge graph to convert
* @param communityMemberships - Optional map of nodeId -> communityIndex for community coloring * @param communityMemberships - Optional map of nodeId -> communityIndex for community coloring
*/ */
export const knowledgeGraphToGraphology = ( export const knowledgeGraphToGraphology = (
knowledgeGraph: KnowledgeGraph, knowledgeGraph: KnowledgeGraph,
communityMemberships?: Map<string, number> communityMemberships?: Map<string, number>,
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => { ): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>(); const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>();
const nodeCount = knowledgeGraph.nodes.length; const nodeCount = knowledgeGraph.nodes.length;
// Build parent-child map from hierarchy relationships // Build parent-child map from hierarchy relationships
// CONTAINS: Folder -> File // CONTAINS: Folder -> File
// DEFINES: File -> Function/Class/Interface/Method // DEFINES: File -> Function/Class/Interface/Method
@ -96,10 +96,10 @@ export const knowledgeGraphToGraphology = (
const parentToChildren = new Map<string, string[]>(); const parentToChildren = new Map<string, string[]>();
// child -> parent // child -> parent
const childToParent = new Map<string, string>(); const childToParent = new Map<string, string>();
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
knowledgeGraph.relationships.forEach(rel => { knowledgeGraph.relationships.forEach((rel) => {
// These relationships represent parent-child hierarchy for positioning // These relationships represent parent-child hierarchy for positioning
if (hierarchyRelations.has(rel.type)) { if (hierarchyRelations.has(rel.type)) {
// source CONTAINS/DEFINES/IMPORTS target, so source is parent // source CONTAINS/DEFINES/IMPORTS target, so source is parent
@ -110,14 +110,14 @@ export const knowledgeGraphToGraphology = (
childToParent.set(rel.targetId, rel.sourceId); childToParent.set(rel.targetId, rel.sourceId);
} }
}); });
// Create node lookup // 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 // Separate structural nodes (folders, packages) from content nodes
const structuralTypes = new Set(['Project', 'Package', 'Module', 'Folder']); 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! // Much wider spread for structural nodes - this is the key!
const structuralSpread = Math.sqrt(nodeCount) * 40; const structuralSpread = Math.sqrt(nodeCount) * 40;
// Small jitter for children around their parent // Small jitter for children around their parent
@ -131,11 +131,11 @@ export const knowledgeGraphToGraphology = (
const communities = new Set(communityMemberships.values()); const communities = new Set(communityMemberships.values());
const communityCount = communities.size; const communityCount = communities.size;
const clusterSpread = structuralSpread * 0.8; // Clusters spread across 80% of graph const clusterSpread = structuralSpread * 0.8; // Clusters spread across 80% of graph
// Position cluster centers using golden angle for even distribution // Position cluster centers using golden angle for even distribution
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); const goldenAngle = Math.PI * (3 - Math.sqrt(5));
let idx = 0; let idx = 0;
communities.forEach(communityId => { communities.forEach((communityId) => {
const angle = idx * goldenAngle; const angle = idx * goldenAngle;
const radius = clusterSpread * Math.sqrt((idx + 1) / communityCount); const radius = clusterSpread * Math.sqrt((idx + 1) / communityCount);
clusterCenters.set(communityId, { clusterCenters.set(communityId, {
@ -157,17 +157,17 @@ export const knowledgeGraphToGraphology = (
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const angle = index * goldenAngle; const angle = index * goldenAngle;
const radius = structuralSpread * Math.sqrt((index + 1) / Math.max(structuralNodes.length, 1)); const radius = structuralSpread * Math.sqrt((index + 1) / Math.max(structuralNodes.length, 1));
// Add some randomness to prevent perfect patterns // Add some randomness to prevent perfect patterns
const jitter = structuralSpread * 0.15; const jitter = structuralSpread * 0.15;
const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter; const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter; const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
nodePositions.set(node.id, { x, y }); nodePositions.set(node.id, { x, y });
const baseSize = NODE_SIZES[node.label] || 8; const baseSize = NODE_SIZES[node.label] || 8;
const scaledSize = getScaledNodeSize(baseSize, nodeCount); const scaledSize = getScaledNodeSize(baseSize, nodeCount);
// Structural nodes keep their type-based color // Structural nodes keep their type-based color
graph.addNode(node.id, { graph.addNode(node.id, {
x, x,
@ -188,17 +188,17 @@ export const knowledgeGraphToGraphology = (
// Use BFS starting from structural nodes to ensure parents are positioned first // Use BFS starting from structural nodes to ensure parents are positioned first
const addNodeWithPosition = (nodeId: string) => { const addNodeWithPosition = (nodeId: string) => {
if (graph.hasNode(nodeId)) return; if (graph.hasNode(nodeId)) return;
const node = nodeMap.get(nodeId); const node = nodeMap.get(nodeId);
if (!node) return; if (!node) return;
let x: number, y: number; let x: number, y: number;
// Check if this is a symbol node with a community assignment // Check if this is a symbol node with a community assignment
const communityIndex = communityMemberships?.get(nodeId); const communityIndex = communityMemberships?.get(nodeId);
const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']); const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']);
const clusterCenter = communityIndex !== undefined ? clusterCenters.get(communityIndex) : null; const clusterCenter = communityIndex !== undefined ? clusterCenters.get(communityIndex) : null;
if (clusterCenter && symbolTypes.has(node.label)) { if (clusterCenter && symbolTypes.has(node.label)) {
// CLUSTER-BASED POSITIONING: Position near cluster center with tight jitter // CLUSTER-BASED POSITIONING: Position near cluster center with tight jitter
x = clusterCenter.x + (Math.random() - 0.5) * clusterJitter; x = clusterCenter.x + (Math.random() - 0.5) * clusterJitter;
@ -207,7 +207,7 @@ export const knowledgeGraphToGraphology = (
// HIERARCHY-BASED POSITIONING: Position near parent // HIERARCHY-BASED POSITIONING: Position near parent
const parentId = childToParent.get(nodeId); const parentId = childToParent.get(nodeId);
const parentPos = parentId ? nodePositions.get(parentId) : null; const parentPos = parentId ? nodePositions.get(parentId) : null;
if (parentPos) { if (parentPos) {
x = parentPos.x + (Math.random() - 0.5) * childJitter; x = parentPos.x + (Math.random() - 0.5) * childJitter;
y = parentPos.y + (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; y = (Math.random() - 0.5) * structuralSpread * 0.5;
} }
} }
nodePositions.set(nodeId, { x, y }); nodePositions.set(nodeId, { x, y });
const baseSize = NODE_SIZES[node.label] || 8; const baseSize = NODE_SIZES[node.label] || 8;
const scaledSize = getScaledNodeSize(baseSize, nodeCount); const scaledSize = getScaledNodeSize(baseSize, nodeCount);
// Check if this node has a community assignment (reuse communityIndex from above) // Check if this node has a community assignment (reuse communityIndex from above)
const hasCommunity = communityIndex !== undefined; const hasCommunity = communityIndex !== undefined;
// Symbol nodes get colored by community if available // Symbol nodes get colored by community if available
const usesCommunityColor = hasCommunity && symbolTypes.has(node.label); const usesCommunityColor = hasCommunity && symbolTypes.has(node.label);
const nodeColor = usesCommunityColor const nodeColor = usesCommunityColor
? getCommunityColor(communityIndex!) ? getCommunityColor(communityIndex!)
: NODE_COLORS[node.label] || '#9ca3af'; : NODE_COLORS[node.label] || '#9ca3af';
graph.addNode(nodeId, { graph.addNode(nodeId, {
x, x,
y, y,
@ -248,14 +248,14 @@ export const knowledgeGraphToGraphology = (
communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined, communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined,
}); });
}; };
// BFS from structural nodes - this ensures parent is ALWAYS positioned before child // 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); const visited = new Set<string>(queue);
while (queue.length > 0) { while (queue.length > 0) {
const currentId = queue.shift()!; const currentId = queue.shift()!;
// Get children of current node and add them // Get children of current node and add them
const children = parentToChildren.get(currentId) || []; const children = parentToChildren.get(currentId) || [];
for (const childId of children) { for (const childId of children) {
@ -266,7 +266,7 @@ export const knowledgeGraphToGraphology = (
} }
} }
} }
// Add any orphan nodes that weren't reached (no parent relationship) // Add any orphan nodes that weren't reached (no parent relationship)
knowledgeGraph.nodes.forEach((node) => { knowledgeGraph.nodes.forEach((node) => {
if (!graph.hasNode(node.id)) { if (!graph.hasNode(node.id)) {
@ -276,33 +276,33 @@ export const knowledgeGraphToGraphology = (
// Add edges with distinct colors per relationship type // Add edges with distinct colors per relationship type
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
// Edge styles - each relationship type has a DISTINCT color for clarity // Edge styles - each relationship type has a DISTINCT color for clarity
// Using varied hues so relationships are easily distinguishable // Using varied hues so relationships are easily distinguishable
const EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = { const EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
// STRUCTURAL - Greens (folder/file hierarchy) // 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) // DEFINITIONS - Cyan/Teal (code definitions)
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
// DEPENDENCIES - Blue (imports between files) // DEPENDENCIES - Blue (imports between files)
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
// FUNCTION FLOW - Purple (call graph) // 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) // TYPE RELATIONSHIPS - Warm colors (OOP)
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
}; };
knowledgeGraph.relationships.forEach((rel) => { knowledgeGraph.relationships.forEach((rel) => {
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) { if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
if (!graph.hasEdge(rel.sourceId, rel.targetId)) { if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; 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, { graph.addEdge(rel.sourceId, rel.targetId, {
size: edgeBaseSize * style.sizeMultiplier, size: edgeBaseSize * style.sizeMultiplier,
color: style.color, color: style.color,
@ -322,7 +322,7 @@ export const knowledgeGraphToGraphology = (
*/ */
export const filterGraphByLabels = ( export const filterGraphByLabels = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>, graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
visibleLabels: NodeLabel[] visibleLabels: NodeLabel[],
): void => { ): void => {
graph.forEachNode((nodeId, attributes) => { graph.forEachNode((nodeId, attributes) => {
const isVisible = visibleLabels.includes(attributes.nodeType); const isVisible = visibleLabels.includes(attributes.nodeType);
@ -336,17 +336,17 @@ export const filterGraphByLabels = (
export const getNodesWithinHops = ( export const getNodesWithinHops = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>, graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
startNodeId: string, startNodeId: string,
maxHops: number maxHops: number,
): Set<string> => { ): Set<string> => {
const visited = new Set<string>(); const visited = new Set<string>();
const queue: { nodeId: string; depth: number }[] = [{ nodeId: startNodeId, depth: 0 }]; const queue: { nodeId: string; depth: number }[] = [{ nodeId: startNodeId, depth: 0 }];
while (queue.length > 0) { while (queue.length > 0) {
const { nodeId, depth } = queue.shift()!; const { nodeId, depth } = queue.shift()!;
if (visited.has(nodeId)) continue; if (visited.has(nodeId)) continue;
visited.add(nodeId); visited.add(nodeId);
if (depth < maxHops) { if (depth < maxHops) {
graph.forEachNeighbor(nodeId, (neighborId) => { graph.forEachNeighbor(nodeId, (neighborId) => {
if (!visited.has(neighborId)) { if (!visited.has(neighborId)) {
@ -355,7 +355,7 @@ export const getNodesWithinHops = (
}); });
} }
} }
return visited; return visited;
}; };
@ -366,20 +366,20 @@ export const filterGraphByDepth = (
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>, graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
selectedNodeId: string | null, selectedNodeId: string | null,
maxHops: number | null, maxHops: number | null,
visibleLabels: NodeLabel[] visibleLabels: NodeLabel[],
): void => { ): void => {
if (maxHops === null) { if (maxHops === null) {
filterGraphByLabels(graph, visibleLabels); filterGraphByLabels(graph, visibleLabels);
return; return;
} }
if (selectedNodeId === null || !graph.hasNode(selectedNodeId)) { if (selectedNodeId === null || !graph.hasNode(selectedNodeId)) {
filterGraphByLabels(graph, visibleLabels); filterGraphByLabels(graph, visibleLabels);
return; return;
} }
const nodesInRange = getNodesWithinHops(graph, selectedNodeId, maxHops); const nodesInRange = getNodesWithinHops(graph, selectedNodeId, maxHops);
graph.forEachNode((nodeId, attributes) => { graph.forEachNode((nodeId, attributes) => {
const isLabelVisible = visibleLabels.includes(attributes.nodeType); const isLabelVisible = visibleLabels.includes(attributes.nodeType);
const isInRange = nodesInRange.has(nodeId); const isInRange = nodesInRange.has(nodeId);

View file

@ -1,7 +1,9 @@
// Shared regex patterns for grounding references in chat/markdown. // 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]] // Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
// Line numbers are optional. // 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]] // 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 * Mermaid Diagram Generator for Processes
* *
* Generates Mermaid flowchart syntax from Process step data. * Generates Mermaid flowchart syntax from Process step data.
* Designed to show branching/merging when CALLS edges exist between steps. * Designed to show branching/merging when CALLS edges exist between steps.
*/ */
@ -24,9 +24,9 @@ export interface ProcessData {
label: string; label: string;
processType: 'intra_community' | 'cross_community'; processType: 'intra_community' | 'cross_community';
steps: ProcessStep[]; steps: ProcessStep[];
edges?: ProcessEdge[]; // CALLS edges between steps for branching edges?: ProcessEdge[]; // CALLS edges between steps for branching
clusters?: string[]; 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 { export function generateProcessMermaid(process: ProcessData): string {
const { steps, edges, clusters } = process; const { steps, edges, clusters } = process;
if (!steps || steps.length === 0) { if (!steps || steps.length === 0) {
return 'graph TD\n A[No steps found]'; 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) // Add class definitions for styling (rounded corners + colors)
lines.push(' %% Styles'); 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(
lines.push(' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;'); ' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,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(
lines.push(' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;'); ' 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 // Track clusters for subgraph grouping
const clusterGroups = new Map<string, ProcessStep[]>(); const clusterGroups = new Map<string, ProcessStep[]>();
const noCluster: ProcessStep[] = []; const noCluster: ProcessStep[] = [];
for (const step of steps) { for (const step of steps) {
if (step.cluster) { if (step.cluster) {
const group = clusterGroups.get(step.cluster) || []; const group = clusterGroups.get(step.cluster) || [];
@ -88,10 +98,12 @@ export function generateProcessMermaid(process: ProcessData): string {
if (useClusters) { if (useClusters) {
// Generate subgraphs for each cluster // Generate subgraphs for each cluster
let clusterIndex = 0; let clusterIndex = 0;
for (const [clusterName, clusterSteps] of clusterGroups) { 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) { for (const step of clusterSteps) {
const id = nodeId(step); const id = nodeId(step);
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`; const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
@ -102,7 +114,7 @@ export function generateProcessMermaid(process: ProcessData): string {
lines.push(' end'); lines.push(' end');
clusterIndex++; clusterIndex++;
} }
// Add unclustered steps // Add unclustered steps
for (const step of noCluster) { for (const step of noCluster) {
const id = nodeId(step); const id = nodeId(step);
@ -125,7 +137,7 @@ export function generateProcessMermaid(process: ProcessData): string {
// Generate edges // Generate edges
if (edges && edges.length > 0) { if (edges && edges.length > 0) {
// Use actual CALLS edges for branching // 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) { for (const edge of edges) {
const fromStep = stepById.get(edge.from); const fromStep = stepById.get(edge.from);
const toStep = stepById.get(edge.to); const toStep = stepById.get(edge.to);
@ -150,8 +162,8 @@ export function generateProcessMermaid(process: ProcessData): string {
* Simple linear mermaid for quick preview * Simple linear mermaid for quick preview
*/ */
export function generateSimpleMermaid(processLabel: string, stepCount: number): string { 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 return `graph LR
classDef entry fill:#059669,stroke:#34d399,stroke-width:2px,color:#ffffff,rx:10,ry:10; 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; 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: * Follows the same heuristics previously embedded in useAppState:
* 1) exact match, 2) ends-with match (prefers shorter paths), 3) segment containment. * 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(); const req = normalizePath(requestedPath).toLowerCase();
if (!req) return null; if (!req) return null;
@ -35,7 +38,10 @@ export const resolveFilePath = (fileContents: Map<string, string>, requestedPath
let idx = 0; let idx = 0;
for (const s of segs) { for (const s of segs) {
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s)); 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; idx = found + 1;
} }
if (idx !== -1) return key; if (idx !== -1) return key;

View file

@ -1,3 +1,3 @@
export const generateId = (label: string, name: string): string => { 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( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
); );

View file

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

View file

@ -29,305 +29,292 @@ var createRandomIndex = randomIndexModule.createRandomIndex || randomIndexModule
var UndirectedLouvainIndex = indices.UndirectedLouvainIndex; var UndirectedLouvainIndex = indices.UndirectedLouvainIndex;
var DEFAULTS = { var DEFAULTS = {
attributes: { attributes: {
community: 'community', community: 'community',
weight: 'weight' weight: 'weight',
}, },
randomness: 0.01, randomness: 0.01,
randomWalk: true, randomWalk: true,
resolution: 1, resolution: 1,
rng: Math.random, rng: Math.random,
weighted: false weighted: false,
}; };
var EPSILON = 1e-10; var EPSILON = 1e-10;
function tieBreaker( function tieBreaker(bestCommunity, currentCommunity, targetCommunity, delta, bestDelta) {
bestCommunity, if (Math.abs(delta - bestDelta) < EPSILON) {
currentCommunity, if (bestCommunity === currentCommunity) {
targetCommunity, return false;
delta, } else {
bestDelta return targetCommunity > bestCommunity;
) { }
if (Math.abs(delta - bestDelta) < EPSILON) { } else if (delta > bestDelta) {
if (bestCommunity === currentCommunity) { return true;
return false; }
} else {
return targetCommunity > bestCommunity;
}
} else if (delta > bestDelta) {
return true;
}
return false; return false;
} }
function undirectedLeiden(detailed, graph, options) { function undirectedLeiden(detailed, graph, options) {
var index = new UndirectedLouvainIndex(graph, { var index = new UndirectedLouvainIndex(graph, {
attributes: { attributes: {
weight: options.attributes.weight weight: options.attributes.weight,
}, },
keepDendrogram: detailed, keepDendrogram: detailed,
resolution: options.resolution, resolution: options.resolution,
weighted: options.weighted weighted: options.weighted,
}); });
var addenda = new UndirectedLeidenAddenda(index, { var addenda = new UndirectedLeidenAddenda(index, {
randomness: options.randomness, randomness: options.randomness,
rng: options.rng rng: options.rng,
}); });
var randomIndex = createRandomIndex(options.rng); var randomIndex = createRandomIndex(options.rng);
// Communities // Communities
var currentCommunity, targetCommunity; var currentCommunity, targetCommunity;
var communities = new SparseMap(Float64Array, index.C); var communities = new SparseMap(Float64Array, index.C);
// Traversal // Traversal
var queue = new SparseQueueSet(index.C), var queue = new SparseQueueSet(index.C),
start, start,
end, end,
weight, weight,
ci, ci,
ri, ri,
s, s,
i, i,
j, j,
l; l;
// Metrics // Metrics
var degree, targetCommunityDegree; var degree, targetCommunityDegree;
// Moves // Moves
var bestCommunity, bestDelta, deltaIsBetter, delta; var bestCommunity, bestDelta, deltaIsBetter, delta;
// Details // Details
var deltaComputations = 0, var deltaComputations = 0,
nodesVisited = 0, nodesVisited = 0,
moves = [], moves = [],
currentMoves; currentMoves;
while (true) { while (true) {
l = index.C; l = index.C;
currentMoves = 0; currentMoves = 0;
// Traversal of the graph // Traversal of the graph
ri = options.randomWalk ? randomIndex(l) : 0; ri = options.randomWalk ? randomIndex(l) : 0;
for (s = 0; s < l; s++, ri++) { for (s = 0; s < l; s++, ri++) {
i = ri % l; i = ri % l;
queue.enqueue(i); queue.enqueue(i);
} }
while (queue.size !== 0) { while (queue.size !== 0) {
i = queue.dequeue(); i = queue.dequeue();
nodesVisited++; nodesVisited++;
degree = 0; degree = 0;
communities.clear(); communities.clear();
currentCommunity = index.belongings[i]; currentCommunity = index.belongings[i];
start = index.starts[i]; start = index.starts[i];
end = index.starts[i + 1]; end = index.starts[i + 1];
// Traversing neighbors // Traversing neighbors
for (; start < end; start++) { for (; start < end; start++) {
j = index.neighborhood[start]; j = index.neighborhood[start];
weight = index.weights[start]; weight = index.weights[start];
targetCommunity = index.belongings[j]; targetCommunity = index.belongings[j];
// Incrementing metrics // Incrementing metrics
degree += weight; degree += weight;
addWeightToCommunity(communities, targetCommunity, weight); addWeightToCommunity(communities, targetCommunity, weight);
} }
// Finding best community to move to // Finding best community to move to
bestDelta = index.fastDeltaWithOwnCommunity( bestDelta = index.fastDeltaWithOwnCommunity(
i, i,
degree, degree,
communities.get(currentCommunity) || 0, communities.get(currentCommunity) || 0,
currentCommunity currentCommunity,
); );
bestCommunity = currentCommunity; bestCommunity = currentCommunity;
for (ci = 0; ci < communities.size; ci++) { for (ci = 0; ci < communities.size; ci++) {
targetCommunity = communities.dense[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( delta = index.fastDelta(i, degree, targetCommunityDegree, targetCommunity);
i,
degree,
targetCommunityDegree,
targetCommunity
);
deltaIsBetter = tieBreaker( deltaIsBetter = tieBreaker(
bestCommunity, bestCommunity,
currentCommunity, currentCommunity,
targetCommunity, targetCommunity,
delta, delta,
bestDelta bestDelta,
); );
if (deltaIsBetter) { if (deltaIsBetter) {
bestDelta = delta; bestDelta = delta;
bestCommunity = targetCommunity; bestCommunity = targetCommunity;
} }
} }
if (bestDelta < 0) { if (bestDelta < 0) {
bestCommunity = index.isolate(i, degree); bestCommunity = index.isolate(i, degree);
if (bestCommunity === currentCommunity) continue; if (bestCommunity === currentCommunity) continue;
} else { } else {
if (bestCommunity === currentCommunity) { if (bestCommunity === currentCommunity) {
continue; continue;
} else { } else {
index.move(i, degree, bestCommunity); index.move(i, degree, bestCommunity);
} }
} }
currentMoves++; currentMoves++;
// Adding neighbors from other communities to the queue // Adding neighbors from other communities to the queue
start = index.starts[i]; start = index.starts[i];
end = index.starts[i + 1]; end = index.starts[i + 1];
for (; start < end; start++) { for (; start < end; start++) {
j = index.neighborhood[start]; j = index.neighborhood[start];
targetCommunity = index.belongings[j]; 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) { if (currentMoves === 0) {
index.zoomOut(); index.zoomOut();
break; break;
} }
if (!addenda.onlySingletons()) { if (!addenda.onlySingletons()) {
// We continue working on the induced graph // We continue working on the induced graph
addenda.zoomOut(); addenda.zoomOut();
continue; continue;
} }
break; break;
} }
var results = { var results = {
index: index, index: index,
deltaComputations: deltaComputations, deltaComputations: deltaComputations,
nodesVisited: nodesVisited, nodesVisited: nodesVisited,
moves: moves moves: moves,
}; };
return results; return results;
} }
/** /**
* Function returning the communities mapping of the graph. * Function returning the communities mapping of the graph.
*/ */
function leiden(assign, detailed, graph, options) { function leiden(assign, detailed, graph, options) {
if (!isGraph(graph)) if (!isGraph(graph))
throw new Error( throw new Error(
'graphology-communities-leiden: the given graph is not a valid graphology instance.' 'graphology-communities-leiden: the given graph is not a valid graphology instance.',
); );
var type = inferType(graph); var type = inferType(graph);
if (type === 'mixed') if (type === 'mixed')
throw new Error( throw new Error(
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.' 'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.',
); );
if (type === 'directed') if (type === 'directed')
throw new Error( throw new Error('graphology-communities-leiden: not yet implemented for directed graphs.');
'graphology-communities-leiden: not yet implemented for directed graphs.'
);
// Attributes name // Attributes name
options = resolveDefaults(options, DEFAULTS); options = resolveDefaults(options, DEFAULTS);
// Empty graph case // Empty graph case
var c = 0; var c = 0;
if (graph.size === 0) { if (graph.size === 0) {
if (assign) { if (assign) {
graph.forEachNode(function (node) { graph.forEachNode(function (node) {
graph.setNodeAttribute(node, options.attributes.communities, c++); graph.setNodeAttribute(node, options.attributes.communities, c++);
}); });
return; return;
} }
var communities = {}; var communities = {};
graph.forEachNode(function (node) { graph.forEachNode(function (node) {
communities[node] = c++; communities[node] = c++;
}); });
if (!detailed) return communities; if (!detailed) return communities;
return { return {
communities: communities, communities: communities,
count: graph.order, count: graph.order,
deltaComputations: 0, deltaComputations: 0,
dendrogram: null, dendrogram: null,
level: 0, level: 0,
modularity: NaN, modularity: NaN,
moves: null, moves: null,
nodesVisited: 0, nodesVisited: 0,
resolution: options.resolution 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 // Standard output
if (!detailed) { if (!detailed) {
if (assign) { if (assign) {
index.assign(options.attributes.community); index.assign(options.attributes.community);
return; return;
} }
return index.collect(); return index.collect();
} }
// Detailed output // Detailed output
var output = { var output = {
count: index.C, count: index.C,
deltaComputations: results.deltaComputations, deltaComputations: results.deltaComputations,
dendrogram: index.dendrogram, dendrogram: index.dendrogram,
level: index.level, level: index.level,
modularity: index.modularity(), modularity: index.modularity(),
moves: results.moves, moves: results.moves,
nodesVisited: results.nodesVisited, nodesVisited: results.nodesVisited,
resolution: options.resolution resolution: options.resolution,
}; };
if (assign) { if (assign) {
index.assign(options.attributes.community); index.assign(options.attributes.community);
return output; 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; var createRandom = randomModule.createRandom || randomModule;
export function addWeightToCommunity(map, community, weight) { 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) { export function UndirectedLeidenAddenda(index, options) {
options = options || {}; options = options || {};
var rng = options.rng || Math.random; var rng = options.rng || Math.random;
var randomness = 'randomness' in options ? options.randomness : 0.01; var randomness = 'randomness' in options ? options.randomness : 0.01;
this.index = index; this.index = index;
this.random = createRandom(rng); this.random = createRandom(rng);
this.randomness = randomness; this.randomness = randomness;
this.rng = rng; this.rng = rng;
var NodesPointerArray = index.counts.constructor; var NodesPointerArray = index.counts.constructor;
var WeightsArray = index.weights.constructor; var WeightsArray = index.weights.constructor;
var order = index.C; var order = index.C;
this.resolution = index.resolution; this.resolution = index.resolution;
// Used to group nodes by communities // Used to group nodes by communities
this.B = index.C; this.B = index.C;
this.C = 0; this.C = 0;
this.communitiesOffsets = new NodesPointerArray(order); this.communitiesOffsets = new NodesPointerArray(order);
this.nodesSortedByCommunities = new NodesPointerArray(order); this.nodesSortedByCommunities = new NodesPointerArray(order);
this.communitiesBounds = new NodesPointerArray(order + 1); this.communitiesBounds = new NodesPointerArray(order + 1);
// Used to merge nodes subsets // Used to merge nodes subsets
this.communityWeights = new WeightsArray(order); this.communityWeights = new WeightsArray(order);
this.degrees = new WeightsArray(order); this.degrees = new WeightsArray(order);
this.nonSingleton = new Uint8Array(order); this.nonSingleton = new Uint8Array(order);
this.externalEdgeWeightPerCommunity = new WeightsArray(order); this.externalEdgeWeightPerCommunity = new WeightsArray(order);
this.belongings = new NodesPointerArray(order); this.belongings = new NodesPointerArray(order);
this.neighboringCommunities = new SparseMap(WeightsArray, order); this.neighboringCommunities = new SparseMap(WeightsArray, order);
this.cumulativeIncrement = new Float64Array(order); this.cumulativeIncrement = new Float64Array(order);
this.macroCommunities = null; this.macroCommunities = null;
} }
UndirectedLeidenAddenda.prototype.groupByCommunities = function () { 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; n = 0;
o = 0; o = 0;
for (i = 0; i < index.C; i++) { for (i = 0; i < index.C; i++) {
c = index.counts[i]; c = index.counts[i];
if (c !== 0) { if (c !== 0) {
this.communitiesBounds[o++] = n; this.communitiesBounds[o++] = n;
n += c; n += c;
this.communitiesOffsets[i] = n; this.communitiesOffsets[i] = n;
} }
} }
this.communitiesBounds[o] = n; this.communitiesBounds[o] = n;
o = 0; o = 0;
for (i = 0; i < index.C; i++) { for (i = 0; i < index.C; i++) {
b = index.belongings[i]; b = index.belongings[i];
o = --this.communitiesOffsets[b]; o = --this.communitiesOffsets[b];
this.nodesSortedByCommunities[o] = i; this.nodesSortedByCommunities[o] = i;
} }
this.B = index.C - index.U; this.B = index.C - index.U;
this.C = index.C; this.C = index.C;
}; };
UndirectedLeidenAddenda.prototype.communities = function () { 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++) { for (i = 0; i < this.B; i++) {
start = this.communitiesBounds[i]; start = this.communitiesBounds[i];
stop = this.communitiesBounds[i + 1]; stop = this.communitiesBounds[i + 1];
community = []; community = [];
for (j = start; j < stop; j++) { for (j = start; j < stop; j++) {
community.push(j); community.push(j);
} }
communities[i] = community; communities[i] = community;
} }
return communities; return communities;
}; };
UndirectedLeidenAddenda.prototype.mergeNodesSubset = function (start, stop) { UndirectedLeidenAddenda.prototype.mergeNodesSubset = function (start, stop) {
var index = this.index; var index = this.index;
var currentMacroCommunity = var currentMacroCommunity = index.belongings[this.nodesSortedByCommunities[start]];
index.belongings[this.nodesSortedByCommunities[start]]; var neighboringCommunities = this.neighboringCommunities;
var neighboringCommunities = this.neighboringCommunities;
var totalNodeWeight = 0; var totalNodeWeight = 0;
var i, j, w; var i, j, w;
var ei, el, et; var ei, el, et;
// Initializing singletons // Initializing singletons
for (j = start; j < stop; j++) { for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j]; i = this.nodesSortedByCommunities[j];
this.belongings[i] = i; this.belongings[i] = i;
this.nonSingleton[i] = 0; this.nonSingleton[i] = 0;
this.degrees[i] = 0; this.degrees[i] = 0;
totalNodeWeight += index.loops[i] / 2; totalNodeWeight += index.loops[i] / 2;
this.communityWeights[i] = index.loops[i]; this.communityWeights[i] = index.loops[i];
this.externalEdgeWeightPerCommunity[i] = 0; this.externalEdgeWeightPerCommunity[i] = 0;
ei = index.starts[i]; ei = index.starts[i];
el = index.starts[i + 1]; el = index.starts[i + 1];
for (; ei < el; ei++) { for (; ei < el; ei++) {
et = index.neighborhood[ei]; et = index.neighborhood[ei];
w = index.weights[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; totalNodeWeight += w;
this.externalEdgeWeightPerCommunity[i] += w; this.externalEdgeWeightPerCommunity[i] += w;
this.communityWeights[i] += w; this.communityWeights[i] += w;
} }
} }
var microDegrees = this.externalEdgeWeightPerCommunity.slice(); var microDegrees = this.externalEdgeWeightPerCommunity.slice();
var s, ri, ci; var s, ri, ci;
var order = stop - start; var order = stop - start;
var degree, var degree,
bestCommunity, bestCommunity,
qualityValueIncrement, qualityValueIncrement,
maxQualityValueIncrement, maxQualityValueIncrement,
totalTransformedQualityValueIncrement, totalTransformedQualityValueIncrement,
targetCommunity, targetCommunity,
targetCommunityDegree, targetCommunityDegree,
targetCommunityWeight; 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++) { for (s = start; s < stop; s++, ri++) {
j = start + (ri % order); j = start + (ri % order);
i = this.nodesSortedByCommunities[j]; i = this.nodesSortedByCommunities[j];
if (this.nonSingleton[i] === 1) { if (this.nonSingleton[i] === 1) {
continue; continue;
} }
if ( if (
this.externalEdgeWeightPerCommunity[i] < this.externalEdgeWeightPerCommunity[i] <
this.communityWeights[i] * this.communityWeights[i] * (totalNodeWeight / 2 - this.communityWeights[i]) * this.resolution
(totalNodeWeight / 2 - this.communityWeights[i]) * ) {
this.resolution continue;
) { }
continue;
}
this.communityWeights[i] = 0; this.communityWeights[i] = 0;
this.externalEdgeWeightPerCommunity[i] = 0; this.externalEdgeWeightPerCommunity[i] = 0;
neighboringCommunities.clear(); neighboringCommunities.clear();
neighboringCommunities.set(i, 0); neighboringCommunities.set(i, 0);
degree = 0; degree = 0;
ei = index.starts[i]; ei = index.starts[i];
el = index.starts[i + 1]; el = index.starts[i + 1];
for (; ei < el; ei++) { for (; ei < el; ei++) {
et = index.neighborhood[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; bestCommunity = i;
maxQualityValueIncrement = 0; maxQualityValueIncrement = 0;
totalTransformedQualityValueIncrement = 0; totalTransformedQualityValueIncrement = 0;
for (ci = 0; ci < neighboringCommunities.size; ci++) { for (ci = 0; ci < neighboringCommunities.size; ci++) {
targetCommunity = neighboringCommunities.dense[ci]; targetCommunity = neighboringCommunities.dense[ci];
targetCommunityDegree = neighboringCommunities.vals[ci]; targetCommunityDegree = neighboringCommunities.vals[ci];
targetCommunityWeight = this.communityWeights[targetCommunity]; targetCommunityWeight = this.communityWeights[targetCommunity];
if ( if (
this.externalEdgeWeightPerCommunity[targetCommunity] >= this.externalEdgeWeightPerCommunity[targetCommunity] >=
targetCommunityWeight * targetCommunityWeight * (totalNodeWeight / 2 - targetCommunityWeight) * this.resolution
(totalNodeWeight / 2 - targetCommunityWeight) * ) {
this.resolution qualityValueIncrement =
) { targetCommunityDegree -
qualityValueIncrement = ((degree + index.loops[i]) * targetCommunityWeight * this.resolution) / totalNodeWeight;
targetCommunityDegree -
((degree + index.loops[i]) *
targetCommunityWeight *
this.resolution) /
totalNodeWeight;
if (qualityValueIncrement > maxQualityValueIncrement) { if (qualityValueIncrement > maxQualityValueIncrement) {
bestCommunity = targetCommunity; bestCommunity = targetCommunity;
maxQualityValueIncrement = qualityValueIncrement; maxQualityValueIncrement = qualityValueIncrement;
} }
if (qualityValueIncrement >= 0) if (qualityValueIncrement >= 0)
totalTransformedQualityValueIncrement += Math.exp( totalTransformedQualityValueIncrement += Math.exp(
qualityValueIncrement / this.randomness qualityValueIncrement / this.randomness,
); );
} }
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement; this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
} }
if ( if (
totalTransformedQualityValueIncrement < Number.MAX_VALUE && totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
totalTransformedQualityValueIncrement < Infinity totalTransformedQualityValueIncrement < Infinity
) { ) {
r = totalTransformedQualityValueIncrement * this.rng(); r = totalTransformedQualityValueIncrement * this.rng();
lo = -1; lo = -1;
hi = neighboringCommunities.size + 1; hi = neighboringCommunities.size + 1;
while (lo < hi - 1) { while (lo < hi - 1) {
mid = (lo + hi) >>> 1; mid = (lo + hi) >>> 1;
if (this.cumulativeIncrement[mid] >= r) hi = mid; if (this.cumulativeIncrement[mid] >= r) hi = mid;
else lo = mid; else lo = mid;
} }
chosenCommunity = neighboringCommunities.dense[hi]; chosenCommunity = neighboringCommunities.dense[hi];
} else { } else {
chosenCommunity = bestCommunity; chosenCommunity = bestCommunity;
} }
this.communityWeights[chosenCommunity] += degree + index.loops[i]; this.communityWeights[chosenCommunity] += degree + index.loops[i];
ei = index.starts[i]; ei = index.starts[i];
el = index.starts[i + 1]; el = index.starts[i + 1];
for (; ei < el; ei++) { for (; ei < el; ei++) {
et = index.neighborhood[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) { if (targetCommunity === chosenCommunity) {
this.externalEdgeWeightPerCommunity[chosenCommunity] -= this.externalEdgeWeightPerCommunity[chosenCommunity] -= microDegrees[et];
microDegrees[et]; } else {
} else { this.externalEdgeWeightPerCommunity[chosenCommunity] += microDegrees[et];
this.externalEdgeWeightPerCommunity[chosenCommunity] += }
microDegrees[et]; }
}
}
if (chosenCommunity !== i) { if (chosenCommunity !== i) {
this.belongings[i] = chosenCommunity; this.belongings[i] = chosenCommunity;
this.nonSingleton[chosenCommunity] = 1; this.nonSingleton[chosenCommunity] = 1;
this.C--; this.C--;
} }
} }
var microCommunities = this.neighboringCommunities; var microCommunities = this.neighboringCommunities;
microCommunities.clear(); microCommunities.clear();
for (j = start; j < stop; j++) { for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j]; i = this.nodesSortedByCommunities[j];
microCommunities.set(this.belongings[i], 1); microCommunities.set(this.belongings[i], 1);
} }
return microCommunities.dense.slice(0, microCommunities.size); return microCommunities.dense.slice(0, microCommunities.size);
}; };
UndirectedLeidenAddenda.prototype.refinePartition = function () { 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++) { for (i = 0; i < this.B; i++) {
start = bounds[i]; start = bounds[i];
stop = bounds[i + 1]; stop = bounds[i + 1];
mapping = this.mergeNodesSubset(start, stop); mapping = this.mergeNodesSubset(start, stop);
this.macroCommunities[i] = mapping; this.macroCommunities[i] = mapping;
} }
}; };
UndirectedLeidenAddenda.prototype.split = function () { UndirectedLeidenAddenda.prototype.split = function () {
var index = this.index; var index = this.index;
var isolates = this.neighboringCommunities; var isolates = this.neighboringCommunities;
isolates.clear(); isolates.clear();
var i, community, isolated; var i, community, isolated;
for (i = 0; i < index.C; i++) { for (i = 0; i < index.C; i++) {
community = this.belongings[i]; community = this.belongings[i];
if (i !== community) continue; if (i !== community) continue;
isolated = index.isolate(i, this.degrees[i]); isolated = index.isolate(i, this.degrees[i]);
isolates.set(community, isolated); isolates.set(community, isolated);
} }
for (i = 0; i < index.C; i++) { for (i = 0; i < index.C; i++) {
community = this.belongings[i]; community = this.belongings[i];
if (i === community) continue; if (i === community) continue;
isolated = isolates.get(community); isolated = isolates.get(community);
index.move(i, this.degrees[i], isolated); index.move(i, this.degrees[i], isolated);
} }
var j, macro; var j, macro;
for (i = 0; i < this.macroCommunities.length; i++) { for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[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 () { UndirectedLeidenAddenda.prototype.zoomOut = function () {
var index = this.index; var index = this.index;
this.refinePartition(); this.refinePartition();
this.split(); 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++) { for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i]; macro = this.macroCommunities[i];
leader = newLabels[macro[0]]; leader = newLabels[macro[0]];
for (j = 1; j < macro.length; j++) { for (j = 1; j < macro.length; j++) {
follower = newLabels[macro[j]]; follower = newLabels[macro[j]];
index.expensiveMove(follower, leader); index.expensiveMove(follower, leader);
} }
} }
}; };
UndirectedLeidenAddenda.prototype.onlySingletons = function () { UndirectedLeidenAddenda.prototype.onlySingletons = function () {
var index = this.index; var index = this.index;
var i; var i;
for (i = 0; i < index.C; i++) { for (i = 0; i < index.C; i++) {
if (index.counts[i] > 1) return false; 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 { return {
id, id,
label: 'Process', label: 'Process',

View file

@ -110,11 +110,24 @@ describe('loadServerGraph — data flow validation', () => {
it('reconstructs graph from server node/relationship arrays', () => { it('reconstructs graph from server node/relationship arrays', () => {
const graph = createKnowledgeGraph(); const graph = createKnowledgeGraph();
const serverNodes = [ 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 = [ 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); for (const node of serverNodes) graph.addNode(node);

View file

@ -22,7 +22,7 @@ describe('NODE_COLORS', () => {
describe('NODE_SIZES', () => { describe('NODE_SIZES', () => {
it('gives Project the largest size', () => { 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'); 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'; import * as lucideIcons from '../../src/lib/lucide-icons';
const LEGEND_LABELS: NodeLabel[] = [ const LEGEND_LABELS: NodeLabel[] = [
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Folder',
'Function', 'Method', 'Variable', 'Decorator', 'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
]; ];
const ICON_MAP: Record<string, string> = { const ICON_MAP: Record<string, string> = {
@ -33,7 +41,9 @@ describe('filter panel icon mappings', () => {
const exportedNames = new Set(Object.keys(lucideIcons)); const exportedNames = new Set(Object.keys(lucideIcons));
const requiredIcons = new Set(Object.values(ICON_MAP)); const requiredIcons = new Set(Object.values(ICON_MAP));
for (const iconName of requiredIcons) { 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', () => { it('legend labels match the order used in FileTreePanel', () => {
const expected: NodeLabel[] = [ const expected: NodeLabel[] = [
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Folder',
'Function', 'Method', 'Variable', 'Decorator', 'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
]; ];
expect(LEGEND_LABELS).toEqual(expected); expect(LEGEND_LABELS).toEqual(expected);
}); });

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph'; 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', () => { describe('createKnowledgeGraph', () => {
it('starts empty', () => { it('starts empty', () => {

View file

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

View file

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

View file

@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "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'); const gitnexusPkg = _require('../gitnexus/package.json');
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), tailwindcss()],
react(),
tailwindcss(),
],
define: { define: {
__REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')), __REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')),
}, },
@ -20,9 +17,12 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../shared'), '@shared': path.resolve(__dirname, '../shared'),
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic // 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) // 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: { server: {

View file

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

View file

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

View file

@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
const tokens = cmd.split(/\s+/); const tokens = cmd.split(/\s+/);
let foundCmd = false; let foundCmd = false;
let skipNext = 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) { for (const token of tokens) {
if (skipNext) { skipNext = false; continue; } if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) { if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true; if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue; continue;
@ -110,18 +126,20 @@ function resolveCliPath() {
function runGitNexusCli(cliPath, args, cwd, timeout) { function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32'; const isWin = process.platform === 'win32';
if (cliPath) { if (cliPath) {
return spawnSync( return spawnSync(process.execPath, [cliPath, ...args], {
process.execPath, encoding: 'utf-8',
[cliPath, ...args], timeout,
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] } cwd,
); stdio: ['pipe', 'pipe', 'pipe'],
});
} }
// On Windows, invoke npx.cmd directly (no shell needed) // On Windows, invoke npx.cmd directly (no shell needed)
return spawnSync( return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
isWin ? 'npx.cmd' : 'npx', encoding: 'utf-8',
['-y', 'gitnexus', ...args], timeout: timeout + 5000,
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } cwd,
); stdio: ['pipe', 'pipe', 'pipe'],
});
} }
/** /**
@ -147,7 +165,9 @@ function handlePreToolUse(input) {
if (!child.error && child.status === 0) { if (!child.error && child.status === 0) {
result = child.stderr || ''; result = child.stderr || '';
} }
} catch { /* graceful failure */ } } catch {
/* graceful failure */
}
if (result && result.trim()) { if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim()); sendHookResponse('PreToolUse', result.trim());
@ -158,9 +178,11 @@ function handlePreToolUse(input) {
* Emit a PostToolUse hook response with additional context for the agent. * Emit a PostToolUse hook response with additional context for the agent.
*/ */
function sendHookResponse(hookEventName, message) { function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({ console.log(
hookSpecificOutput: { hookEventName, additionalContext: message } JSON.stringify({
})); hookSpecificOutput: { hookEventName, additionalContext: message },
}),
);
} }
/** /**
@ -192,10 +214,15 @@ function handlePostToolUse(input) {
let currentHead = ''; let currentHead = '';
try { try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { 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(); currentHead = (headResult.stdout || '').trim();
} catch { return; } } catch {
return;
}
if (!currentHead) return; if (!currentHead) return;
@ -204,16 +231,19 @@ function handlePostToolUse(input) {
try { try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || ''; lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0); hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch { /* no meta — treat as stale */ } } catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed // If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return; if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse', sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `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/node": "^20.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vitest": "^4.0.18" "vitest": "^4.0.18"

View file

@ -1,6 +1,6 @@
/** /**
* AI Context Generator * AI Context Generator
* *
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context. * Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc. * AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
* CLAUDE.md is for Claude Code which only reads that file. * CLAUDE.md is for Claude Code which only reads that file.
@ -20,7 +20,7 @@ interface RepoStats {
nodes?: number; nodes?: number;
edges?: number; edges?: number;
communities?: number; communities?: number;
clusters?: number; // Aggregated cluster count (what tools show) clusters?: number; // Aggregated cluster count (what tools show)
processes?: number; processes?: number;
} }
@ -38,12 +38,20 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
* - Exact tool commands with parameters vague directives get ignored * - Exact tool commands with parameters vague directives get ignored
* - Self-review checklist forces model to verify its own work * - Self-review checklist forces model to verify its own work
*/ */
function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[]): string { function generateGitNexusContent(
const generatedRows = (generatedSkills && generatedSkills.length > 0) projectName: string,
? generatedSkills.map(s => stats: RepoStats,
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |` generatedSkills?: GeneratedSkillInfo[],
).join('\n') ): 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 | const skillsTable = `| Task | Read this skill file |
|------|---------------------| |------|---------------------|
@ -150,7 +158,6 @@ ${skillsTable}
${GITNEXUS_END_MARKER}`; ${GITNEXUS_END_MARKER}`;
} }
/** /**
* Check if a file exists * Check if a file exists
*/ */
@ -171,7 +178,7 @@ async function fileExists(filePath: string): Promise<boolean> {
*/ */
async function upsertGitNexusSection( async function upsertGitNexusSection(
filePath: string, filePath: string,
content: string content: string,
): Promise<'created' | 'updated' | 'appended'> { ): Promise<'created' | 'updated' | 'appended'> {
const exists = await fileExists(filePath); const exists = await fileExists(filePath);
@ -213,27 +220,33 @@ async function installSkills(repoPath: string): Promise<string[]> {
const skills = [ const skills = [
{ {
name: 'gitnexus-exploring', 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', 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', 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', 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', 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', 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, _storagePath: string,
projectName: string, projectName: string,
stats: RepoStats, stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[] generatedSkills?: GeneratedSkillInfo[],
): Promise<{ files: string[] }> { ): Promise<{ files: string[] }> {
const content = generateGitNexusContent(projectName, stats, generatedSkills); const content = generateGitNexusContent(projectName, stats, generatedSkills);
const createdFiles: string[] = []; const createdFiles: string[] = [];

View file

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

View file

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

View file

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

View file

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

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