mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
fd7fb5bf1f
commit
bf09eab95b
353 changed files with 26992 additions and 17009 deletions
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Prettier initial formatting (2026-03-28)
|
||||||
|
afcc3d1523f99c77ff67c4fd1af12334660113f6
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
.husky/* text eol=lf
|
||||||
2
.github/release.yml
vendored
2
.github/release.yml
vendored
|
|
@ -35,7 +35,7 @@ changelog:
|
||||||
- dependencies
|
- dependencies
|
||||||
- title: "\U0001F4DD Other Changes"
|
- title: "\U0001F4DD Other Changes"
|
||||||
labels:
|
labels:
|
||||||
- "*"
|
- '*'
|
||||||
exclude:
|
exclude:
|
||||||
labels:
|
labels:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
|
||||||
13
.github/workflows/ci-quality.yml
vendored
13
.github/workflows/ci-quality.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
6
.github/workflows/ci-report.yml
vendored
6
.github/workflows/ci-report.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
16
.prettierignore
Normal 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
10
.prettierrc
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 · Cloned locally by the server · No data leaves your machine
|
Public repos only · Cloned locally by the server · No data leaves your machine
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 “{searchQuery}”
|
No nodes found for “{searchQuery}”
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────
|
||||||
|
|
|
||||||
441
gitnexus-web/src/vendor/leiden/index.js
vendored
441
gitnexus-web/src/vendor/leiden/index.js
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
504
gitnexus-web/src/vendor/leiden/utils.js
vendored
504
gitnexus-web/src/vendor/leiden/utils.js
vendored
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
6
gitnexus-web/test/fixtures/graph.ts
vendored
6
gitnexus-web/test/fixtures/graph.ts
vendored
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"]
|
||||||
"mcp__plugin_claude-mem_mcp-search__get_observations"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Reference in a new issue