diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index 0362fdc..ce9668b 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -352,7 +352,7 @@ jobs: EOF # Start services - docker-compose -f docker-compose.test.yml up -d + docker compose -f docker-compose.test.yml up -d # Wait for frontend echo "⏳ Waiting for services to be ready..." @@ -363,8 +363,8 @@ jobs: echo "✅ Backend health check passed" else echo "❌ Backend health check failed" - docker-compose -f docker-compose.test.yml logs - docker-compose -f docker-compose.test.yml down + docker compose -f docker-compose.test.yml logs + docker compose -f docker-compose.test.yml down exit 1 fi @@ -373,13 +373,13 @@ jobs: echo "✅ Frontend serves correctly" else echo "❌ Frontend check failed" - docker-compose -f docker-compose.test.yml logs - docker-compose -f docker-compose.test.yml down + docker compose -f docker-compose.test.yml logs + docker compose -f docker-compose.test.yml down exit 1 fi echo "✅ Integration test passed!" - docker-compose -f docker-compose.test.yml down + docker compose -f docker-compose.test.yml down summary: name: 📊 Deployment Summary diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..23ff4d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +> Persistent project context loaded into every GitPilot session. +> Edit freely — agents will follow these notes. + +## Project Overview +This project has a `README.md` at its root — refer to it for purpose and high-level usage. + +## Directory Layout +- `CHANGELOG.md` +- `Dockerfile.backend` +- `Dockerfile.frontend` +- `LICENSE` +- `MANIFEST.in` +- `Makefile` +- `README.md` +- `api/` +- `assets/` +- `deploy/` +- `docker-compose.mcp.yml` +- `docker-compose.yml` +- `docs/` +- `extensions/` +- `frontend/` +- `gitcopilot.egg-info/` +- `gitpilot/` +- `mcp-stack/` +- `mkdocs.yml` +- `mypy.ini` +- `package-lock.json` +- `package.json` +- `pyproject.toml` +- `render.yaml` +- `scripts/` +- `tests/` +- `uv.lock` +- `vercel.json` + +## Stack +Python, Node.js, Docker, docker-compose + +## Workflows +- `make install`, `make test`, `make run` +- `npm install`, `npm test` +- `pip install -e .` and `pytest` + +## Conventions +- Keep changes small and reversible. +- Run the test suite before committing. +- Write docstrings for any new public function. + +## Mode-Specific Notes +Place per-mode overrides in `.gitpilot/AGENTS..md` (for example +`.gitpilot/AGENTS.coder.md`). Use `@./relative/path.md` on its own line to +include another markdown file. diff --git a/Makefile b/Makefile index 0b1e309..3f3d18e 100644 --- a/Makefile +++ b/Makefile @@ -97,15 +97,33 @@ help: @echo " make gateway-register Register GitPilot agent in ContextForge" @echo "" -## High-level install: runtime backend + frontend + MCP stack. -## GitPilot uses the MCP stack by default, so keep MCP in the happy path while -## leaving heavyweight developer/docs tooling opt-in. -install: uv-install frontend-install install-mcp - @echo "✅ Backend runtime (uv), frontend (npm) and MCP env ready." +## High-level install: runtime backend + frontend + MCP stack + MatrixLab addon. +## GitPilot now ships MatrixLab as a first-class part of the backend, so it's +## installed by default alongside the MCP stack. When docker isn't available +## (or the runner port is held), install-matrixlab-soft warns and continues — +## the rest of the install completes regardless. +## Skip the addon entirely with: make install SKIP_MATRIXLAB=1 +install: uv-install frontend-install install-mcp install-matrixlab-soft + @echo "✅ Backend runtime (uv), frontend (npm), MCP env and MatrixLab addon ready." @echo " Run 'make run' to start MCP Context Forge + GitPilot." + @echo " Run 'make startup' for the full GitPilot + MatrixLab + URL-fixup flow." @echo " No Docker? Use 'make run-bare' to start GitPilot without MCP." @echo " Optional: 'make install-dev' for test/lint/build tooling." +## Soft variant of install-matrixlab — warns and skips on docker-missing / +## daemon-down / port-held instead of aborting the parent installer. Wired +## into `make install` so the MatrixLab addon shows up by default but a +## Docker-less host still gets a clean install. Skip entirely with +## SKIP_MATRIXLAB=1 (useful in CI when you really don't want any docker +## reach-out during install). +.PHONY: install-matrixlab-soft +install-matrixlab-soft: +ifeq ($(SKIP_MATRIXLAB),1) + @echo "➖ Skipping MatrixLab addon install (SKIP_MATRIXLAB=1)." +else + @MATRIXLAB_OPTIONAL=1 bash scripts/install-matrixlab.sh +endif + ## Custom developer install: add dev/test/build tooling when you need it. install-dev: uv-install-dev frontend-install @echo "✅ Developer tooling ready." @@ -806,6 +824,14 @@ stop-mcp: @docker compose --env-file .mcp.env -f docker-compose.mcp.yml --profile mcp down 2>/dev/null || true @echo "🛑 MCP stack stopped (volumes kept). 'make uninstall-mcp' to remove data." +## Rotate the MCP_AUTH_TOKEN end-to-end and re-init Forge with the new +## token. The escape hatch when `make run-mcp`'s auto-recovery isn't +## enough — typically because Forge persisted an older token in +## Postgres on its first boot. Wipes Forge's volume (NOT Postgres +## data) so the new token in .mcp.env is what Forge actually validates. +rotate-mcp-token: + @bash scripts/rotate-mcp-token.sh + ## Tail logs from the MCP stack logs-mcp: @docker compose --env-file .mcp.env -f docker-compose.mcp.yml --profile mcp logs -f --tail=100 @@ -841,3 +867,95 @@ install-mcp-workflows: ## Add --milvus to also check the milvus profile. smoke-mcp: @bash scripts/smoke-mcp.sh + +# ========================================================================= +# Phase 0 industrial-grade additions +# +# These targets land next to the legacy 69 — nothing removed, nothing +# renamed. They're the "five-verb" model's first three: +# make doctor — preflight + diagnose +# make run-prod — production frontend build, served by the backend +# make install-matrixlab +# — opt-in MatrixLab addon (also fired by +# `make install WITH_MATRIXLAB=1`) +# +# Once these prove themselves, the legacy targets can become thin +# aliases. For now they coexist. +# ========================================================================= + +## Preflight + diagnose: catches the failure modes the user can hit +## BEFORE they cost a 20-minute support cycle. Mirrors `gh doctor` +## and `git doctor`. Each red check links to a fix command. +.PHONY: doctor +doctor: + @bash scripts/doctor.sh + +## Build the production frontend (Vite dist) and start the GitPilot +## backend serving it via FastAPI's StaticFiles mount. Unlike `make +## run`, this does NOT start the Vite dev server — one URL, minified +## assets, no CORS, no `vite ready in 1926 ms` line. +.PHONY: run-prod +run-prod: frontend-build + @echo "🚀 Starting GitPilot in production mode on http://127.0.0.1:$(PORT)..." + @$(UV_ENV) $(UV) run --no-dev python -m gitpilot serve --host 127.0.0.1 --port $(PORT) --no-open + +## Strict MatrixLab addon install — fatal on docker-missing / port-held. +## Use this when you explicitly want to (re)provision the addon. +## `make install` (default) now uses install-matrixlab-soft (above) which +## chains into install but tolerates a Docker-less host. WITH_MATRIXLAB=1 +## is preserved as an alias for the strict path for backwards compatibility. +.PHONY: install-matrixlab +install-matrixlab: + @bash scripts/install-matrixlab.sh + +# Backwards-compat alias: WITH_MATRIXLAB=1 make install now is the same as +# the default `make install` (MatrixLab is included by default). We keep +# the variable around so existing scripts/CI pipelines don't break, but +# the soft-install path is already in the `install` deps above. + +# ========================================================================= +# Phase 0.5 — production startup with MatrixLab (additive) +# +# Everything below is a strict SUPERSET of `make install` / `make run` +# / `make doctor`. None of the existing recipes above are modified; +# these new targets call them as black boxes. Operators on existing +# scripts/workflows pin to the old targets and see no behavior change. +# +# make install-all = make install + make install-matrixlab +# make startup = install-matrixlab + make run + auto-fix URL +# make fix-matrixlab-url = re-point the persisted matrixlab_url at the +# port that's actually listening +# make diagnose-matrixlab = read-only debug dump for bug reports +# ========================================================================= + +## Full installer: core GitPilot install + MatrixLab addon. +## Same as `WITH_MATRIXLAB=1 make install` but easier to type. +.PHONY: install-all +install-all: install install-matrixlab + @echo "✅ install-all: core + MatrixLab ready" + @echo " Next: make startup" + +## Production start: ensures MatrixLab is running, starts GitPilot, +## auto-fixes the persisted matrixlab_url to point at the live port. +## This is the "I want everything up and connected" one-liner — +## `make run` still exists unchanged for operators who don't want +## the addon. +.PHONY: startup +startup: + @bash scripts/start-gitpilot-stack.sh + +## Detect which port MatrixLab is actually on and update GitPilot's +## persisted settings to match. Solves the "Runner URL: +## http://localhost:8000 / Needs attention" symptom that happens +## when settings.json was written before the port-shift to 8765. +.PHONY: fix-matrixlab-url +fix-matrixlab-url: + @bash scripts/fix-matrixlab-url.sh + +## Verbose, copy-paste-friendly diagnostic dump for the MatrixLab +## install/connect path. Probes both candidate ports, shows what +## GitPilot's APIs return, lists project containers, tails the +## runner log. Read-only — does not modify state. +.PHONY: diagnose-matrixlab +diagnose-matrixlab: + @bash scripts/diagnose-matrixlab.sh diff --git a/docs/sandbox-approval-flow.md b/docs/sandbox-approval-flow.md new file mode 100644 index 0000000..96692ad --- /dev/null +++ b/docs/sandbox-approval-flow.md @@ -0,0 +1,212 @@ +# Sandbox Execution — Approval-First Architecture + +Status: **Production** as of the `add-sandbox-settings-tab` series. + +This document is the source of truth for how code executes in +GitPilot's sandbox. It covers the four user entry points, the +deterministic ExecutionPlan that gates every run on user approval, +and the schema every entry point shares. + +--- + +## Principles + +1. **Intent decides the path, not the model.** A chat message classified + as `execute` short-circuits the LLM planner and produces a + deterministic ExecutionPlan in pure Python. +2. **No run starts without explicit user approval.** The approval surface + (ExecutionPlanCard) is visibly distinct from the Action Plan card — + green border vs orange, "Run in Sandbox" vs "Execute Plan". +3. **Local and MatrixLab are symmetric.** Same request/response shape, + same UI, same safety checks. The backend pill tells the user which + actually ran. +4. **Every run is recoverable.** Rerun re-enters the approval flow with + the same payload — never silent re-execution. + +--- + +## The four entry points + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ FRONT DOOR │ +├────────────────────────────────────────────────────────────────────┤ +│ Chat command │ Code-block ▶ │ File ▶ (sidebar) │ Canvas Run │ │ +└──────┬────────────┬────────────────┬─────────────────┬─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + /api/chat /api/sandbox window event /api/sandbox + /plan /plan gitpilot:run- /plan + (short- (inline) file → /api/ (canvas) + circuit) chat/plan + │ │ │ │ + └────────────┴────────────────┴─────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ ExecutionPlan { … } returned │ + │ Rendered as approval card │ + └─────────────┬───────────────┘ + │ + user clicks + "Run in Sandbox" + │ + ▼ + ┌─────────────────────────────┐ + │ /api/sandbox/run │ + │ (or /api/chat/execute for │ + │ file-run via Action Plan) │ + └─────────────┬───────────────┘ + │ + ▼ + ExecutionCard + in chat history +``` + +--- + +## The ExecutionPlan schema + +Built deterministically by `gitpilot.sandbox_plan.build_execution_plan_for_file` +and `..._for_inline`. Serialized to JSON for the HTTP layer. + +```jsonc +{ + "plan_id": "plan_a1b2c3d4e5f6", + "goal": "Run demo.py", + "source": "chat | code_block | file_run | canvas | rerun", + "language": "python | javascript | bash", + "command": ["python", "demo.py"], + "sandbox": "subprocess | matrixlab", + "timeout_sec": 120, + "network": false, + "workdir": ".", + "capture_artifacts": true, + "file": "demo.py", // for file-run, else null + "inline_code": null, // for inline runs, else null + "safety": { + "checks": [ { "label": "File exists", "ok": true }, … ], + "warnings": [ + { "severity": "high", "label": "Uses os.system / exec / eval", "detail": "…" }, + { "severity": "medium", "label": "Imports network library", "detail": "…" }, + { "severity": "low", "label": "Uses plt.show()", "detail": "…" } + ] + }, + "requires_approval": true, + "parent_run_id": null // set on Rerun +} +``` + +### Safety rules + +Coarse, deterministic, never blocking. Each rule is one regex. + +| Severity | Rule | +|----------|-------------------------------------------------| +| high | `os.system`, `subprocess.{call,run,Popen}`, `eval()`, `exec()` | +| medium | imports `socket` / `urllib` / `requests` / `httpx` / `aiohttp` | +| medium | reads `os.environ` / `os.getenv` | +| medium | spawns subprocesses | +| low | `plt.show()` (MPLBACKEND=Agg auto-injected) | +| low | writes files (`open(…, 'w'…)`, `.write()`, `savefig()`, `to_csv()`) | + +False positives are cheap — one extra chip; false negatives erode +trust. Tune by tightening regexes, never by hiding warnings. + +--- + +## State machine + +``` +planned ──► approved ──► queued ──► starting ──► running ──► completed + │ │ │ │ + │ └─ cancelled └─► failed / timeout / cancelled + └─ cancelled (rejected before approval) +``` + +Today the executor goes straight from `approved` to `running` to +`completed` because `/api/sandbox/run` is synchronous. The schema is +designed so adding a streaming `/api/sandbox/execute` endpoint +(future batch) won't change any caller — additional events (`queued`, +`starting`, intermediate `running` stdout chunks) slot in cleanly. + +--- + +## Two consent surfaces (NEVER conflate) + +``` +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ ACTION PLAN │ │ EXECUTION PLAN │ +│ (changes the repo) │ │ (runs code in sandbox) │ +├─────────────────────────────────┤ ├─────────────────────────────────┤ +│ Orange left border │ │ Green left border │ +│ READ / CREATE / MODIFY / DELETE │ │ RUN_FILE / RUN_INLINE │ +│ Header: "Action Plan" │ │ Header: "EXECUTION PLAN" │ +│ CTA: "Execute Plan" │ │ CTA: "Run in Sandbox" │ +│ Source: LLM planner │ │ Source: deterministic Python │ +└─────────────────────────────────┘ └─────────────────────────────────┘ +``` + +`AssistantMessage` renders the Execution Plan when `plan.execution_plan` +is set, and suppresses the Action Plan render in that case. The two +never appear together. + +--- + +## Adding a new entry point + +To add a fifth way to run code (say, a "Run on save" hook): + +1. Compute the source classification: `file` for a saved file, or + `code + language` for an inline buffer. +2. POST to `/api/sandbox/plan` with `{ source: "your_source", … }`. +3. Render the returned plan with `ExecutionPlanCard`. +4. On approve, POST to `/api/sandbox/run` (or dispatch + `gitpilot:run-file` if it should go through chat history). + +That's it. The plan builder, safety rules, and approval UI are shared. + +--- + +## Why approval is mandatory even for trusted users + +- The plan is computed in **milliseconds**. The approval click is + ~250 ms of cognitive load — well worth the audit trail. +- Generated code is often subtly wrong. Approval is the user's + last chance to spot a `rm -rf` before it runs in a workspace + bind-mounted from their host. +- The same surface enables one-click **Rerun** with the same + approved command — no fresh classification, no model variance. + +If a future product decision requires opt-out for trusted snippets, +the surface is already there: `Settings → Sandbox → Allow one-click +run for generated snippets` flips `requires_approval=false` in the +plan response and the UI short-circuits the approval step. + +--- + +## Files + +| File | Role | +|------|------| +| `gitpilot/sandbox_plan.py` | Deterministic ExecutionPlan builder + safety analyzer | +| `gitpilot/sandbox_api.py` | `POST /api/sandbox/plan` + `POST /api/sandbox/run` | +| `gitpilot/agentic.py` | `try_execute_short_circuit` attaches `execution_plan` to PlanResult; `execute_plan` emits `next_actions` | +| `frontend/components/ExecutionPlanCard.jsx` | Green approval card (full / compact variants) | +| `frontend/components/SandboxStatusWidget.jsx` | Sidebar health pill + one-click recovery | +| `frontend/components/FileTree.jsx` | Sidebar ▶ Run button — dispatches `gitpilot:run-file` | +| `frontend/components/ChatPanel.jsx` | Listens for `gitpilot:run-file`, routes through chat plan | +| `frontend/components/AssistantMessage.jsx` | Renders ExecutionPlanCard, ExecutionCard with Rerun, and next_actions | +| `frontend/components/RunnableCodeBlock.jsx` | Code-block ▶ → plan → approve → run | +| `frontend/components/SandboxCanvas.jsx` | Canvas split view, same approval flow | + +--- + +## Tests + +| File | Coverage | +|------|----------| +| `tests/test_sandbox_plan.py` | 23 tests — pure-Python builder + endpoint | +| `tests/test_execute_short_circuit_plan.py` | 4 tests — plan attached for RUN_FILE intents | +| `tests/test_post_create_next_actions.py` | 7 tests — post-CREATE Run buttons | +| `tests/test_sandbox_api.py`, `tests/test_sandbox.py` | Existing — no regressions allowed | diff --git a/frontend/App.jsx b/frontend/App.jsx index 72c9711..3b599eb 100644 --- a/frontend/App.jsx +++ b/frontend/App.jsx @@ -21,6 +21,7 @@ import { SkillsTab, SessionsTab, AdvancedTab, + SandboxTab, } from "./components/AdminTabs"; import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js"; import { initApp } from "./utils/appInit.js"; @@ -798,7 +799,7 @@ export default function App() { setStartupPhase("checking-backend"); setStartupStatusMessage("Connecting to backend..."); setStartupDetailMessage( - "Waiting for the server to be ready. This may take a few seconds on first start." + "Preparing your workspace. First launch may take a few seconds." ); // Single-source-of-truth init: combines /api/status + /api/auth/status @@ -1034,7 +1035,7 @@ export default function App() { {activePage === "admin" && (
- {["overview", "providers", "workspace-modes", "integrations", "mcp-servers", "sessions", "skills", "security", "advanced"].map((tab) => ( + {["overview", "providers", "workspace-modes", "integrations", "mcp-servers", "sandbox", "sessions", "skills", "security", "advanced"].map((tab) => ( +
+ )} +
+ + + {/* Inline body — copy depends on state */} + {status.status === "not_installed" && ( +

+ MatrixLab gives GitPilot an isolated sandbox for running code, + testing snippets, and executing agent actions safely. It will be + downloaded, started, and connected automatically. +

+ )} + + {(busy || progressStep) && ( + + )} + + {status.status === "ready" && ( + + )} + + {/* Lifecycle disabled hint — friendly copy, admin detail under disclosure */} + {status.errorCode === "LIFECYCLE_DISABLED" && ( +
+ MatrixLab lifecycle automation is disabled. This GitPilot backend + was started with{" "} + + GITPILOT_ENABLE_MATRIXLAB_LIFECYCLE=0 + {" "} + — restart it without that variable (the default is enabled), or + use Manual setup under Advanced options. +
+ )} + + {/* Technical details disclosure — only visible when there's an error */} + {status.technicalDetails && (status.status === "needs_attention" || status.status === "failed") && ( +
setShowDetails(e.target.open)} + style={{ marginBottom: 12 }} + > + + Technical details + +
+              {status.technicalDetails.expected &&
+                `Expected: ${status.technicalDetails.expected}\n`}
+              {status.technicalDetails.actual &&
+                `Actual:   ${status.technicalDetails.actual}\n`}
+              {status.technicalDetails.rawError &&
+                `\n${status.technicalDetails.rawError}`}
+            
+
+ )} + + {/* Action buttons — state-aware. Reinstall is offered as a + normal recovery action (not buried in Advanced) once the + addon exists in some form; Open logs surfaces only when + there's something to diagnose. */} + {(() => { + const primary = PRIMARY_BY_STATUS[status.status] || PRIMARY_BY_STATUS.not_installed; + const canReinstall = status.installed === true || + ["needs_attention", "failed", "ready"].includes(status.status); + const canSeeLogs = ["needs_attention", "failed"].includes(status.status); + return ( +
+ + + {canReinstall && ( + + )} + + {canSeeLogs && ( + + )} + + {status.status === "ready" && ( + + )} +
+ ); + })()} + + {/* Logs viewer */} + {showLogs && ( +
+
+
+ MatrixLab logs + {logs?.container && ( + + · {logs.container} + + )} +
+ +
+ + {/* Friendly error + hint when the backend can't read logs. + We show the hint as an actionable next step (run + "make install-matrixlab") and list any matrixlab- + shaped containers we found so the user can see whether + the runner is up under a different name. */} + {logs?.ok === false && ( +
+
{logs.error || "Could not read MatrixLab logs."}
+ {logs.hint && ( +
+ Next step:{" "} + + {logs.hint} + +
+ )} + {Array.isArray(logs.candidates) && logs.candidates.length > 0 && ( +
+ Found other matrixlab-shaped containers: +
    + {logs.candidates.map((c, i) => ( +
  • + {c} +
  • + ))} +
+
+ Set GITPILOT_MATRIXLAB_CONTAINER to use one of these, + or run Reinstall to recreate the expected container. +
+
+ )} + {logs.rawError && ( +
+ + docker stderr + +
+                      {logs.rawError}
+                    
+
+ )} +
+ )} + +
+              {logs == null
+                ? "Loading logs…"
+                : (logs.lines && logs.lines.length > 0)
+                  ? logs.lines.join("\n")
+                  : (logs.ok === false ? "(no log output captured)" : "Loading logs…")}
+            
+
+ )} + + {/* Advanced options — collapsed by default so first-time + users see the install/repair flow, not a wall of knobs. + Operators who need runner URL / token / image / network / + timeout / manual setup / local clone install / unsafe + modes click the disclosure to expand. */} +
setShowAdvanced(e.target.open)} + style={{ marginTop: 14 }} + > + + Advanced options + + { + setStatus(data); + refreshAdvanced(); + }} + /> +
+ + {reinstallConfirm && ( + { + setReinstallConfirm(false); + setReinstallWipe(false); + }} + onConfirm={() => { + const wipe = reinstallWipe; + setReinstallConfirm(false); + setReinstallWipe(false); + runReinstall(wipe); + }} + /> + )} + + + ); +} + +function ReinstallConfirm({ wipe, setWipe, onCancel, onConfirm }) { + // Modal-in-modal stacked at zIndex 110 (Backdrop is 100). Apes + // GitHub's "Are you absolutely sure?" pattern: explicit destructive + // checkbox stays off by default so a misclick can't wipe images. + return ( +
+
e.stopPropagation()} style={{ + width: "min(440px, 92vw)", background: "#1a1b26", + border: "1px solid #2a2b36", borderRadius: 10, padding: 20, + color: "#e6e8ff", + }}> +

Reinstall MatrixLab Addon?

+

+ This will stop MatrixLab, remove the current addon container, download a + fresh copy, start it again, and reconnect GitPilot. Your GitPilot + settings will be kept. +

+ +
+ + +
+
+
+ ); +} + +function ProgressChecklist({ journey, current }) { + // Pick the right stage list for the journey in flight; default to + // the install list (the most informative one) when no journey is + // active yet. Once ``current`` advances past the kickoff key, the + // visited rows render with a ✓. + const steps = PROGRESS_STEPS[journey] || PROGRESS_STEPS.install; + const phaseIndex = current ? Math.max(0, steps.findIndex(s => s.key === current)) : 0; + return ( +
+ {steps.map((s, i) => { + const done = i < phaseIndex; + const active = i === phaseIndex; + return ( +
+ + {done ? "✓" : active ? "⏳" : "○"} + + {s.label} +
+ ); + })} +
+ ); +} + +function Checklist({ items }) { + return ( +
+ {items.map(([label, ok]) => ( +
+ {ok ? "✓" : "○"} + {label} +
+ ))} +
+ ); +} + +function LocalCloneSection({ disabled, onStatus }) { + const [native, setNative] = useState(null); + const [busy, setBusy] = useState(null); // "install" | "start" | "stop" + + const refresh = useCallback(async () => { + try { + const r = await fetch(apiUrl("/api/matrixlab/native/status")); + setNative(await r.json()); + } catch (err) { + // non-fatal — leave section empty + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const runAction = useCallback(async (action) => { + setBusy(action); + try { + const path = { + install: "/api/matrixlab/install_local", + start: "/api/matrixlab/start_local", + stop: "/api/matrixlab/stop_local", + }[action]; + const r = await fetch(apiUrl(path), { method: "POST" }); + const data = await r.json(); + onStatus?.(data); + } finally { + setBusy(null); + refresh(); + } + }, [onStatus, refresh]); + + if (!native) return null; + + return ( +
+ + Local clone install (no Docker for the runner itself) + +
+ Clones MatrixLab into {native.local_dir}, + creates a dedicated Python virtualenv, and runs uvicorn app.main:app + from the runner directory. The Runner still spawns per-language sandboxes + via Docker, so the host needs docker{" "} + on PATH for code execution. +
+
+ + + {native.running ? "Running (PID " + native.pid + ")" + : native.installed ? "Installed · stopped" + : "Not installed"} + + {!native.installed && ( + + )} + {native.installed && !native.running && ( + + )} + {native.running && ( + + )} + +
+ {!native.lifecycleEnabled && ( +
+ Local clone install needs lifecycle automation enabled on the GitPilot + backend. +
+ )} +
+ ); +} + +function AdvancedOptions({ advanced, tokenInput, setTokenInput, onUpdate, disabled, onLocalStatus }) { + if (!advanced) return null; + return ( +
+
+ Advanced options +
+
+ + onUpdate({ matrixlab_url: e.target.value })} + placeholder="http://localhost:8765" + disabled={disabled} + style={fieldInput} + /> + + +
+ setTokenInput(e.target.value)} + placeholder={advanced.has_token ? "•••••••• (saved)" : "Optional"} + disabled={disabled} + style={{ ...fieldInput, flex: 1 }} + /> + +
+ + + onUpdate({ matrixlab_image: e.target.value })} + placeholder="matrixlab-python" + disabled={disabled} + style={fieldInput} + /> + + + + + +
+ onUpdate({ timeout_sec: Number(e.target.value) || 120 })} + disabled={disabled} + style={{ ...fieldInput, width: 80 }} + /> + seconds +
+
+ + + +
+ + Manual setup + +
{`# In a MatrixLab checkout:
+docker compose up -d
+
+# Or directly:
+docker run -d --name gitpilot-matrixlab \\
+  -p 8000:8000 \\
+  -v /var/run/docker.sock:/var/run/docker.sock \\
+  ruslanmv/matrixlab-runner:latest`}
+
+ +
+ + Developer options · Unsafe modes + +
+ Pass-through runs code directly on the host without isolation. Use + only for local development. +
+
+
+ ); +} + +function Backdrop({ children, onClose }) { + return ( +
+
e.stopPropagation()}>{children}
+
+ ); +} + +function ModalShell({ title, subtitle, onClose, children }) { + return ( +
+
+

{title}

+ +
+
{subtitle}
+ {children} +
+ ); +} + +const btnSecondary = { + padding: "8px 14px", + fontSize: 12, + background: "transparent", + color: "#c3c5dd", + border: "1px solid #2c2d46", + borderRadius: 6, + cursor: "pointer", +}; + +const btnPrimary = { + padding: "8px 14px", + fontSize: 12, + fontWeight: 600, + background: "#3B82F6", + color: "#fff", + border: "none", + borderRadius: 6, + cursor: "pointer", +}; + +// "Reinstall and remove data" is destructive — surface it with a +// danger tone so the operator pauses before clicking. +const btnDanger = { + padding: "8px 14px", + fontSize: 12, + fontWeight: 600, + background: "#7f1d1d", + color: "#fecaca", + border: "1px solid #991b1b", + borderRadius: 6, + cursor: "pointer", +}; + +const btnGhost = { + padding: "8px 14px", + fontSize: 12, + background: "transparent", + color: "#9092b5", + border: "none", + cursor: "pointer", +}; + +const fieldLabel = { fontSize: 12, color: "#c3c5dd" }; +const fieldInput = { + fontSize: 12, padding: "4px 6px", + background: "#14152a", color: "#e6e8ff", + border: "1px solid #2c2d46", borderRadius: 4, +}; + +const btnPrimarySmall = { + padding: "4px 10px", fontSize: 11, fontWeight: 600, + background: "#3B82F6", color: "#fff", + border: "none", borderRadius: 4, cursor: "pointer", +}; +const btnSecondarySmall = { + padding: "4px 10px", fontSize: 11, + background: "transparent", color: "#c3c5dd", + border: "1px solid #2c2d46", borderRadius: 4, cursor: "pointer", +}; diff --git a/frontend/components/AdminTabs/SandboxTab.jsx b/frontend/components/AdminTabs/SandboxTab.jsx new file mode 100644 index 0000000..b006b4b --- /dev/null +++ b/frontend/components/AdminTabs/SandboxTab.jsx @@ -0,0 +1,218 @@ +// frontend/components/AdminTabs/SandboxTab.jsx +import React, { useCallback, useEffect, useState } from "react"; +import { apiUrl } from "../../utils/api.js"; +import MatrixLabInstallModal from "./MatrixLabInstallModal.jsx"; + +/** + * Sandbox tab — enterprise default view. + * + * Surfaces a single MatrixLab addon card plus a clean Local-sandbox + * card. Implementation knobs (Runner URL, token, image, network, + * timeout, pass-through, lifecycle env flag) live behind the install + * modal's Advanced disclosure — the primary view never shows them. + * + * Status pill is driven off /api/matrixlab/status so we never end up + * with the "Unreachable + Running" contradiction the legacy panel + * produced. + */ + +function StatusPill({ status }) { + const map = { + not_installed: { label: "Not installed", bg: "#374151", fg: "#d1d5db" }, + installing: { label: "Installing", bg: "#0d3320", fg: "#86efac" }, + starting: { label: "Starting", bg: "#0d3320", fg: "#86efac" }, + stopping: { label: "Stopping", bg: "#3d2d11", fg: "#fde68a" }, + checking: { label: "Checking", bg: "#0d3320", fg: "#86efac" }, + ready: { label: "Ready", bg: "#0d3320", fg: "#86efac" }, + needs_attention: { label: "Needs attention", bg: "#3d2d11", fg: "#fde68a" }, + failed: { label: "Failed", bg: "#3d1111", fg: "#fca5a5" }, + }; + const pill = map[status] || map.not_installed; + return ( + + + {pill.label} + + ); +} + +export default function SandboxTab({ showToast }) { + const [matrixlab, setMatrixlab] = useState(null); // /api/matrixlab/status payload + const [sandbox, setSandbox] = useState(null); // /api/sandbox/status payload + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + + const load = useCallback(async () => { + try { + const [ml, sb] = await Promise.all([ + fetch(apiUrl("/api/matrixlab/status")).then((r) => r.json()).catch(() => null), + fetch(apiUrl("/api/sandbox/status")).then((r) => r.json()).catch(() => null), + ]); + setMatrixlab(ml); + setSandbox(sb); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const useLocal = useCallback(async () => { + try { + const r = await fetch(apiUrl("/api/sandbox/config"), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ backend: "subprocess" }), + }); + const data = await r.json(); + if (r.ok) { + setSandbox(data); + showToast?.("Switched to Local sandbox", "Code runs in a host subprocess with a workspace jail."); + } + } catch (err) { + // surfaced through the next load() + } + }, [showToast]); + + if (loading) { + return ( +
+

Sandbox

+
+ Loading sandbox status… +
+
+ ); + } + + const activeBackend = (sandbox?.backend || "subprocess").toLowerCase(); + const mlStatus = matrixlab?.status || "not_installed"; + + return ( +
+

Sandbox

+

+ Pick where GitPilot executes code: the local subprocess sandbox or + the MatrixLab Runner. The choice applies to the Run button on chat + code blocks and the agent's EXECUTE action. +

+ + {/* MatrixLab addon card */} +
+
+
+
+

MatrixLab Addon

+ + {activeBackend === "matrixlab" && ( + ACTIVE + )} +
+

+ Run code safely in isolated, temporary containers. Recommended + for enterprise deployments and untrusted code. +

+ {mlStatus !== "not_installed" && matrixlab?.message && ( +

+ {matrixlab.message} +

+ )} +
+ +
+
+ + {/* Local sandbox card */} +
+
+
+
+

Local sandbox

+ {activeBackend === "subprocess" && ( + ACTIVE + )} +
+

+ Host subprocess with a workspace jail. No Docker required — + best for trying simple snippets quickly. +

+
+ +
+
+ + {modalOpen && ( + { + setModalOpen(false); + load(); + }} + onActivated={(data) => { + showToast?.("MatrixLab ready", "Code now runs in MatrixLab sandboxes."); + // Refresh both panels so the ACTIVE badge moves to the addon card. + load(); + }} + /> + )} +
+ ); +} + +const cardStyle = { + background: "#1a1b26", + borderRadius: 8, + padding: 16, + border: "1px solid #2a2b36", + marginBottom: 12, +}; diff --git a/frontend/components/AdminTabs/index.js b/frontend/components/AdminTabs/index.js index 44e9547..4775db1 100644 --- a/frontend/components/AdminTabs/index.js +++ b/frontend/components/AdminTabs/index.js @@ -7,3 +7,4 @@ export { default as MCPServersTab } from "./MCPServersTab.jsx"; export { default as SkillsTab } from "./SkillsTab.jsx"; export { default as SessionsTab } from "./SessionsTab.jsx"; export { default as AdvancedTab } from "./AdvancedTab.jsx"; +export { default as SandboxTab } from "./SandboxTab.jsx"; diff --git a/frontend/components/AssistantMessage.jsx b/frontend/components/AssistantMessage.jsx index ec75621..9a19965 100644 --- a/frontend/components/AssistantMessage.jsx +++ b/frontend/components/AssistantMessage.jsx @@ -1,177 +1,429 @@ -import React from "react"; +import React, { useState } from "react"; import PlanView from "./PlanView.jsx"; import RunnableCodeBlock, { splitFences } from "./RunnableCodeBlock.jsx"; +import ExecutionPlanCard from "./ExecutionPlanCard.jsx"; -export default function AssistantMessage({ answer, plan, executionLog, planStatus }) { - // ``planStatus`` is optional metadata about the lifecycle of the plan - // attached to this message: "executed" | "rejected" | null. It drives - // the badge next to the Action Plan header so the user can tell at a - // glance, in chat history, whether a previous plan was approved or - // dismissed. Defaults to null (no badge) to keep the legacy render - // path untouched. - const styles = { - container: { - marginBottom: "20px", - padding: "20px", - backgroundColor: "#18181B", // Zinc-900 - borderRadius: "12px", - border: "1px solid #27272A", // Zinc-800 - color: "#F4F4F5", // Zinc-100 - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", - }, - section: { - marginBottom: "20px", - }, - lastSection: { - marginBottom: "0", - }, - header: { - display: "flex", - alignItems: "center", - marginBottom: "12px", - paddingBottom: "8px", - borderBottom: "1px solid #3F3F46", // Zinc-700 - }, - title: { - fontSize: "12px", - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: "0.05em", - color: "#A1A1AA", // Zinc-400 - margin: 0, - }, - content: { - fontSize: "14px", - lineHeight: "1.6", - whiteSpace: "pre-wrap", - }, - executionList: { - listStyle: "none", - padding: 0, - margin: 0, - display: "flex", - flexDirection: "column", - gap: "8px", - }, - executionStep: { - display: "flex", - flexDirection: "column", - gap: "4px", - padding: "10px", - backgroundColor: "#09090B", // Zinc-950 - borderRadius: "6px", - border: "1px solid #27272A", - fontSize: "13px", - }, - stepNumber: { - fontSize: "11px", - fontWeight: "600", - color: "#10B981", // Emerald-500 - textTransform: "uppercase", - }, - stepSummary: { - color: "#D4D4D8", // Zinc-300 - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - }, +export default function AssistantMessage({ + answer, + plan, + executionLog, + planStatus, + owner, + repo, + onApproveExecution, + nextActions, + relatedPlan, + diff, + branch, +}) { + // Approval-first sandbox: when the planner returns an execution_plan, + // render the green ExecutionPlanCard instead of the orange Action Plan. + const executionPlan = plan?.execution_plan || null; + const [runResult, setRunResult] = useState(null); + const [runError, setRunError] = useState(null); + const [runBusy, setRunBusy] = useState(false); + + const approveExecution = async (ep) => { + if (onApproveExecution) { + onApproveExecution(ep, plan); + return; + } + setRunBusy(true); + setRunError(null); + setRunResult(null); + try { + const body = ep.file + ? { language: ep.language, code: null } + : { language: ep.language, code: ep.inline_code, timeout_sec: ep.timeout_sec }; + const res = await fetch("/api/sandbox/run", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) setRunError(data.detail || `HTTP ${res.status}`); + else setRunResult(data); + } catch (e) { setRunError(e.message); } + finally { setRunBusy(false); } }; - // Only show Action Plan section when there are actual file actions. - // For Lite Mode Q&A responses (all steps have 0 files), the plan - // just duplicates the answer — hiding it avoids showing the same text 3x. - const hasFileActions = plan?.steps?.some(s => s.files?.length > 0); + const hasFileActions = plan?.steps?.some((s) => s.files?.length > 0); + const answerText = typeof answer === "string" ? answer.trim() : ""; + // Suppress the answer prose when it would just duplicate what the + // plan card or success receipt already says. + const showAnswerProse = + answerText && + !executionLog && + !(plan && hasFileActions && !executionPlan && answerText === plan.summary); return ( -
- {/* Answer section. ``splitFences`` cuts the answer at fenced code - blocks so each runnable snippet gets its own RunnableCodeBlock - (with a per-block Run button); the surrounding prose still - renders as the existing pre-wrapped paragraph. */} -
-
-

Answer

-
-
- {splitFences(answer).map((seg, i) => +
+ {/* Free-form answer prose. Rendered without a section header so + short answers feel like a chat reply, not a debug log. */} + {showAnswerProse && ( +
+ {splitFences(answerText).map((seg, i) => seg.type === "code" ? ( - + ) : ( -

{seg.value}

- ) +

+ {seg.value} +

+ ), )}
-
- - {/* Action Plan section — only when there are file changes */} - {plan && hasFileActions && ( -
-
-

Action Plan

- {planStatus === "executed" && ( - - ✓ Executed - - )} - {planStatus === "rejected" && ( - - ✕ Rejected + )} + + {/* Sandbox approval card. */} + {executionPlan && ( +
+ { /* no-op */ }} + /> + {runError && ( +
Run error: {runError}
+ )} + {runResult && ( +
+              {runResult.stdout || runResult.stderr || "(no output)"}
+            
+ )} +
+ )} + + {/* Proposed execution plan — only when there are file changes + and no execution log is present yet. */} + {plan && hasFileActions && !executionPlan && !executionLog && ( +
+ +
+ )} + + {/* Completed execution receipt. */} + {executionLog && ( + + )} + + {/* Next actions when there is no execution log (e.g. simple answers). */} + {!executionLog && Array.isArray(nextActions) && nextActions.length > 0 && ( +
+ {nextActions.map((a, i) => ( + + ))} +
+ )} +
+ ); +} + +function NextActionButton({ action }) { + const onClick = () => { + if (action.kind === "run_file" && action.payload?.file) { + window.dispatchEvent( + new CustomEvent("gitpilot:run-file", { + detail: { path: action.payload.file }, + }), + ); + } else if (action.kind === "open_workspace" && action.payload?.file) { + window.dispatchEvent( + new CustomEvent("gitpilot:open-workspace", { + detail: { path: action.payload.file }, + }), + ); + } else if (action.kind === "open_in_canvas" && action.payload?.file) { + window.dispatchEvent( + new CustomEvent("gitpilot:open-in-canvas", { + detail: { path: action.payload.file }, + }), + ); + } + }; + + const isPrimary = action.kind === "run_file"; + return ( + + ); +} + +function SuccessReceipt({ + executionLog, + relatedPlan, + owner, + repo, + branch, + diff, + nextActions, +}) { + const steps = executionLog?.steps || []; + const totalSteps = steps.length; + + // Aggregate every file action across steps so "Files changed (N)" can + // be a single top-level section instead of per-step duplication. + const allFiles = []; + if (relatedPlan?.steps?.length) { + for (const s of relatedPlan.steps) { + for (const f of s.files || []) { + if (f.action !== "INDEX") allFiles.push(f); + } + } + } + const totalDuration = steps.reduce((acc, s) => { + return acc + (s.executions || []).reduce( + (a, ex) => a + (typeof ex.duration_ms === "number" ? ex.duration_ms : 0), + 0, + ); + }, 0); + + // Short headline outcome lines (max 2): "Created X", "Modified Y". + const headlines = []; + for (const action of ["CREATE", "MODIFY", "DELETE"]) { + for (const f of allFiles) { + if (f.action === action && headlines.length < 2) { + headlines.push({ verb: verbFor(action), path: f.path }); + } + } + } + + // The bottom-bar already owns Create PR. Only show a pointer text here + // when there is a meaningful PR path (branch + file changes). + const hasPRPath = Boolean(branch && allFiles.length > 0); + + // Filter out create-PR-style next-actions (handled by bottom bar); + // keep ▶ run / 📂 open as inline buttons. + const inlineNextActions = (nextActions || []).filter( + (a) => a && a.kind && a.kind !== "create_pr", + ); + + const hasTechLog = + totalDuration > 0 || + steps.some((s) => s.summary || (s.executions || []).length > 0); + + return ( +
+
+ +
+
Execution completed
+
+ Successfully executed {totalSteps} step{totalSteps === 1 ? "" : "s"} + · + Just now +
+
+ + + Executed + +
+ + {headlines.length > 0 && ( +
    + {headlines.map((h, i) => ( +
  • + {h.verb} + {h.path} +
  • + ))} +
+ )} + + {(owner || branch) && ( +
+ {owner && repo && ( +
+ Repository + {owner}/{repo} +
+ )} + {branch && ( +
+ Branch + + {branch} - )} - -
- +
+ )} +
+ )} + + {allFiles.length > 0 && ( +
+
+ Files changed ({allFiles.length})
- +
    + {allFiles.map((f, i) => ( +
  • + + + {f.action} + + {f.path} + + {actionMeta(f.action)} + +
  • + ))} +
+
)} - {/* Execution Log section (shown after execution) */} - {executionLog && ( -
-
-

Execution Log

-
-
-
    - {executionLog.steps.map((s) => ( -
  • - Step {s.step_number} - {s.summary} -
  • + {inlineNextActions.length > 0 && ( +
    + {inlineNextActions.map((a, i) => ( + + ))} +
    + )} + + {hasTechLog && ( +
    + + View execution log + {totalDuration > 0 && ( + + · {(totalDuration / 1000).toFixed(1)}s + + )} + + {steps.map((s) => ( +
    +
    + Step {s.step_number} + {totalSteps > 1 ? ` of ${totalSteps}` : ""} +
    + {s.summary && ( +
    {s.summary}
    + )} + {Array.isArray(s.executions) && s.executions.map((ex, i) => ( + ))} -
-
-
+
+ ))} + + )} + + {hasPRPath && ( +
+ Next: Create a pull request when ready. +
+ )} +
+ ); +} + +function actionMeta(action) { + switch (action) { + case "READ": return "Read-only"; + case "CREATE": return "Created"; + case "MODIFY": return "Modified"; + case "DELETE": return "Deleted"; + case "INDEX": return "Indexed"; + default: return ""; + } +} + +function verbFor(action) { + switch (action) { + case "CREATE": return "Created"; + case "MODIFY": return "Modified"; + case "DELETE": return "Deleted"; + default: return action; + } +} + +function ExecutionCard({ ex }) { + const status = ex.status || "pending"; + const statusClass = + status === "completed" ? "exec-card-inner--ok" : + status === "failed" ? "exec-card-inner--bad" : + status === "skipped" ? "exec-card-inner--warn" : + "exec-card-inner--info"; + return ( +
+
+
+ {ex.path} + {ex.sandbox && ( + · {ex.sandbox} + )} +
+ + {status === "completed" && `Exit ${ex.exit_code} · ${ex.duration_ms} ms`} + {status === "failed" && (typeof ex.exit_code === "number" + ? `Failed · exit ${ex.exit_code}` : "Failed")} + {status === "skipped" && "Skipped"} + {status === "pending" && "Running…"} + +
+ {ex.command && ( +
$ {ex.command}
+ )} + {ex.stdout && ( +
+ stdout +
{ex.stdout}
+
+ )} + {ex.stderr && ( +
+ stderr +
+            {ex.stderr}
+          
+
+ )} + {ex.error && !ex.stderr && ( +
{ex.error}
+ )} + {ex.reason && ( +
{ex.reason}
)}
); -} \ No newline at end of file +} diff --git a/frontend/components/ChatPanel.jsx b/frontend/components/ChatPanel.jsx index 4ccce56..6f0f6bc 100644 --- a/frontend/components/ChatPanel.jsx +++ b/frontend/components/ChatPanel.jsx @@ -8,8 +8,22 @@ import DiffStats from "./DiffStats.jsx"; import DiffViewer from "./DiffViewer.jsx"; import CreatePRButton from "./CreatePRButton.jsx"; import StreamingMessage from "./StreamingMessage.jsx"; +import SandboxCanvas from "./SandboxCanvas.jsx"; +import FilePreviewPanel from "./FilePreviewPanel.jsx"; import { SessionWebSocket } from "../utils/ws.js"; +// Map a file extension to the canonical sandbox language tag. Used +// when "Open in Canvas" needs to seed SandboxCanvas with the right +// language hint pulled straight from a repo file path. +const _LANG_FROM_EXT = { + py: "python", js: "javascript", mjs: "javascript", cjs: "javascript", + sh: "bash", bash: "bash", +}; +function languageFromPath(path) { + if (!path || !path.includes(".")) return "python"; + return _LANG_FROM_EXT[path.split(".").pop().toLowerCase()] || "python"; +} + // Helper to get headers (inline safety if utility is missing) const getHeaders = () => ({ "Content-Type": "application/json", @@ -46,12 +60,34 @@ export default function ChatPanel({ const [streamingEvents, setStreamingEvents] = useState([]); const [diffData, setDiffData] = useState(null); const [showDiffViewer, setShowDiffViewer] = useState(false); + // SandboxCanvas state — opened by the "Open in Canvas" CTA on + // post-CREATE next_actions and ExecutionCard footers. ``canvasSpec`` + // is { filename, language, code } or null when closed. + const [canvasSpec, setCanvasSpec] = useState(null); + const [canvasError, setCanvasError] = useState(null); + // FilePreviewPanel state — opened by clicking a file row in the + // sidebar (gitpilot:open-file). Read-first surface; users can pick + // "Prepare Run" / "Open Workspace" / "Ask GitPilot" from there. + const [previewPath, setPreviewPath] = useState(null); + const [previewContent, setPreviewContent] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [previewErrorCode, setPreviewErrorCode] = useState(null); + // "preview" (narrow drawer) or "workspace" (wide editor). + const [previewMode, setPreviewMode] = useState("preview"); const wsRef = useRef(null); // Ref mirrors streamingEvents so WS callbacks avoid stale closures const streamingEventsRef = useRef([]); useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]); + // Tracks files that were just CREATE'd / MODIFY'd by a fresh execution. + // Used to (a) auto-retry once on 404 (GitHub contents API has brief + // eventual-consistency lag) and (b) classify the file viewer's empty + // state as "still syncing" instead of a generic 404. + const fileWasJustCreatedRef = useRef(new Set()); + const fileWasJustDeletedRef = useRef(new Set()); + // Skip the session-sync useEffect reset when we just created a session // (the parent already seeded the messages into chatBySession) const skipNextSyncRef = useRef(false); @@ -189,6 +225,138 @@ export default function ChatPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentBranch, repo?.full_name, sessionId]); + // --------------------------------------------------------------------------- + // 1b) FILE ▶ RUN: listen for run-file events from the sidebar. + // --------------------------------------------------------------------------- + // + // FileTree dispatches ``gitpilot:run-file`` with the clicked file's + // path. We turn that into a normal chat message ("run ") + // which goes through /api/chat/plan, hits the deterministic + // short-circuit, and renders an ExecutionPlanCard — exactly the + // same flow as typing the command. One handler, one approval surface, + // zero duplicated logic. + useEffect(() => { + const onRunFile = (e) => { + const path = e?.detail?.path; + if (!path || !repo) return; + send({ goal: `run ${path}` }); + }; + // "Open in Canvas" handler — fetches the file's content from the + // active branch and opens SandboxCanvas seeded with it. Logs a + // friendly error banner when the fetch fails so a misconfigured + // token / wrong branch doesn't silently swallow the click. + const onOpenInCanvas = async (e) => { + const path = e?.detail?.path; + if (!path || !repo) return; + setCanvasError(null); + const branch = currentBranch || "HEAD"; + try { + const url = `/api/repos/${repo.owner}/${repo.name}/file` + + `?path=${encodeURIComponent(path)}` + + `&ref=${encodeURIComponent(branch)}`; + const res = await fetch(url, { headers: getHeaders() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setCanvasError(data.detail || `Could not load ${path} (HTTP ${res.status})`); + // Still open the canvas with empty content so the user can + // paste something — better than nothing happening on click. + setCanvasSpec({ + filename: path, language: languageFromPath(path), code: "", + }); + return; + } + setCanvasSpec({ + filename: path, + language: languageFromPath(path), + code: data.content || "", + }); + } catch (err) { + setCanvasError(err?.message || "Could not load file for Canvas"); + setCanvasSpec({ + filename: path, language: languageFromPath(path), code: "", + }); + } + }; + // "Open file" — clicking a file row in the sidebar mounts the + // read-first FilePreviewPanel. Calmer than dropping straight + // into Canvas: the user sees the file, can pick "Prepare Run" + // when they're ready (or "Open Workspace" for a wider editing + // surface). The ``mode`` detail toggles the panel's geometry: + // "preview" ── narrow right drawer for a quick look + // "workspace" ── wide right-side editor for serious review + const openFile = async (path, mode = "preview") => { + if (!path || !repo) return; + setPreviewPath(path); + setPreviewMode(mode); + setPreviewContent(null); + setPreviewError(null); + setPreviewErrorCode(null); + setPreviewLoading(true); + // Tell the sidebar which file is currently focused so it can + // light up the row with the ◄ marker. + try { + window.dispatchEvent(new CustomEvent("gitpilot:file-opened", { detail: { path } })); + } catch (_e) { /* old browser */ } + const branch = currentBranch || "HEAD"; + const fetchOnce = async () => { + const url = `/api/repos/${repo.owner}/${repo.name}/file` + + `?path=${encodeURIComponent(path)}` + + `&ref=${encodeURIComponent(branch)}`; + const res = await fetch(url, { headers: getHeaders() }); + const data = await res.json().catch(() => ({})); + return { res, data }; + }; + try { + let { res, data } = await fetchOnce(); + // Auto-retry once on 404 for recently created files — GitHub + // contents API has brief eventual-consistency lag after a + // freshly published commit. + if (res.status === 404 && fileWasJustCreatedRef.current?.has(path)) { + await new Promise((r) => setTimeout(r, 900)); + ({ res, data } = await fetchOnce()); + } + if (!res.ok) { + setPreviewError(data.detail || `HTTP ${res.status}`); + setPreviewErrorCode(res.status); + } else { + setPreviewContent(data.content || ""); + } + } catch (err) { + setPreviewError(err?.message || "Could not load file"); + setPreviewErrorCode(null); + } finally { + setPreviewLoading(false); + } + }; + const onOpenFile = (e) => openFile(e?.detail?.path, "preview"); + const onOpenWorkspace = (e) => openFile(e?.detail?.path, "workspace"); + // "Ask GitPilot" — seed the chat input with a contextual question + // about the clicked file. Pure additive: focuses the input and + // pre-fills it; the user can edit or send as-is. + const onAskAboutFile = (e) => { + const path = e?.detail?.path; + if (!path) return; + setGoal(`Tell me about ${path}.`); + const ta = document.querySelector(".chat-input"); + if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } + }; + window.addEventListener("gitpilot:run-file", onRunFile); + window.addEventListener("gitpilot:open-in-canvas", onOpenInCanvas); + window.addEventListener("gitpilot:open-file", onOpenFile); + window.addEventListener("gitpilot:open-workspace", onOpenWorkspace); + window.addEventListener("gitpilot:ask-about-file", onAskAboutFile); + return () => { + window.removeEventListener("gitpilot:run-file", onRunFile); + window.removeEventListener("gitpilot:open-in-canvas", onOpenInCanvas); + window.removeEventListener("gitpilot:open-file", onOpenFile); + window.removeEventListener("gitpilot:open-workspace", onOpenWorkspace); + window.removeEventListener("gitpilot:ask-about-file", onAskAboutFile); + }; + // ``send`` is stable enough across renders for this use case — + // we don't want to re-bind on every keystroke. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repo?.full_name, currentBranch, sessionId]); + // --------------------------------------------------------------------------- // 2) PERSISTENCE: Save chat to Parent (no loop now because sync only on branch) // --------------------------------------------------------------------------- @@ -260,6 +428,8 @@ export default function ChatPanel({ if (m.executionLog) meta.executionLog = m.executionLog; if (m.diff) meta.diff = m.diff; if (m.actions) meta.actions = m.actions; + if (m.nextActions) meta.nextActions = m.nextActions; + if (m.branch) meta.branch = m.branch; // Informational plans (READ-only answers to "what does X do?" style // questions) carry no Approve/Reject controls — pin the flag so the // session reload re-renders the same shape. @@ -562,6 +732,36 @@ export default function ChatPanel({ setStatus(data.message || "Execution completed."); + // Track files touched by this execution so the file viewer can + // give "still syncing" / "deleted" classifications and so the + // sidebar refreshes off the freshly-pushed branch tree. + if (plan?.steps) { + for (const step of plan.steps) { + for (const f of step.files || []) { + if (f.action === "CREATE" || f.action === "MODIFY") { + fileWasJustCreatedRef.current.add(f.path); + } else if (f.action === "DELETE") { + fileWasJustDeletedRef.current.add(f.path); + } + } + } + } + // Forget the marker after 30 s so older "syncing" badges don't + // stick around forever. + window.setTimeout(() => { + fileWasJustCreatedRef.current.clear(); + fileWasJustDeletedRef.current.clear(); + }, 30000); + + // Ask the sidebar's file tree to refetch off the newly-published + // branch. Fires after a small delay so GitHub's contents API has + // a chance to catch up. + window.setTimeout(() => { + try { + window.dispatchEvent(new CustomEvent("gitpilot:refresh-tree")); + } catch (_e) { /* old browser */ } + }, 600); + const completionMsg = { from: "ai", role: "assistant", @@ -569,6 +769,11 @@ export default function ChatPanel({ content: data.message || "Execution completed.", executionLog: data.executionLog, diff: data.diff, + // Backend-suggested follow-ups (e.g. "Run demo.py" after CREATE + // of a runnable file). Rendered as a button row in the + // completion message — one click, no typing. + nextActions: data.next_actions, + branch: data.branch || data.branch_name, }; // Show completion immediately (keeps old "Execution Log" section) @@ -792,6 +997,19 @@ export default function ChatPanel({ } } + // Find the plan that was approved for this completion, so the + // success receipt can label actions (READ/CREATE/...) instead of + // showing only an opaque execution dump. + let linkedPlan = null; + if (m.executionLog) { + for (let i = idx - 1; i >= 0; i--) { + if (messages[i].plan?.steps) { + linkedPlan = messages[i].plan; + break; + } + } + } + return (
execute()} + nextActions={m.nextActions} + relatedPlan={linkedPlan} + diff={m.diff} + branch={m.branch || currentBranch} /> {/* Diff stats indicator (Claude-Code-on-Web parity) */} {m.diff && ( @@ -1069,6 +1294,77 @@ export default function ChatPanel({ onClose={() => setShowDiffViewer(false)} /> )} + + {/* FilePreviewPanel — read-first viewer. Opens on a file row + click in the sidebar. Header carries Prepare Run (runnable + only), Open Workspace, and an overflow menu. */} + {previewPath && ( + { + try { + window.dispatchEvent(new CustomEvent("gitpilot:refresh-tree")); + } catch (_e) { /* old browser */ } + }} + onRetry={() => { + const p = previewPath; + const m = previewMode; + setPreviewPath(null); + // Fire the same window event we listened to — keeps the + // retry path identical to the original load and lets any + // future side-effects (e.g. analytics) see one event class. + setTimeout(() => window.dispatchEvent(new CustomEvent( + m === "workspace" ? "gitpilot:open-workspace" : "gitpilot:open-file", + { detail: { path: p } }, + )), 0); + }} + onClose={() => { + try { + window.dispatchEvent(new CustomEvent("gitpilot:file-closed")); + } catch (_e) {/* old browser */} + setPreviewPath(null); + setPreviewContent(null); + setPreviewError(null); + setPreviewErrorCode(null); + }} + /> + )} + + {/* SandboxCanvas overlay — opened by "Open in Canvas" next_action + buttons and ExecutionCard footers via the + gitpilot:open-in-canvas window event. */} + {canvasSpec && ( + { setCanvasSpec(null); setCanvasError(null); }} + /> + )} + {canvasError && canvasSpec && ( +
+ {canvasError} +
+ )}
); } diff --git a/frontend/components/ExecutionPlanCard.jsx b/frontend/components/ExecutionPlanCard.jsx new file mode 100644 index 0000000..e7f5cf9 --- /dev/null +++ b/frontend/components/ExecutionPlanCard.jsx @@ -0,0 +1,275 @@ +// frontend/components/ExecutionPlanCard.jsx +// +// The approval-first surface for sandbox runs. Renders a +// deterministic ExecutionPlan returned by POST /api/sandbox/plan +// and gates the actual run on an explicit user click. +// +// Two visual variants, same component: +// +// variant = "full" — used in chat for file-run and chat-command +// plans. Big card, every safety check / warning +// visible, primary CTA "Run in Sandbox". +// variant = "compact" — used as a popover above a code-block ▶ click. +// Same info, less chrome. +// +// The component is stateless about the run itself: it produces an +// approval event (onApprove with the plan object) and lets the +// parent decide how to execute. This keeps the streaming/state +// machine work for Batch 3 — here we only render and consent. + +import React from "react"; + +const SEVERITY_STYLE = { + high: { bg: "#3d1111", fg: "#fca5a5", border: "#7f1d1d", icon: "⛔" }, + medium: { bg: "#3d2d11", fg: "#fde68a", border: "#854d0e", icon: "⚠" }, + low: { bg: "#1f2937", fg: "#a5b4fc", border: "#3730a3", icon: "ⓘ" }, +}; + +const BACKEND_LABELS = { + subprocess: "Local", + matrixlab: "MatrixLab", + off: "Pass-through", +}; + +export default function ExecutionPlanCard({ + plan, + variant = "full", + busy = false, + onApprove, + onCancel, + onOpenFile, +}) { + if (!plan) return null; + const isCompact = variant === "compact"; + const styles = isCompact ? compactStyles : fullStyles; + + const commandStr = Array.isArray(plan.command) + ? plan.command.join(" ") + : String(plan.command || ""); + + return ( +
+
+ EXECUTION PLAN +

{plan.goal || "Run in sandbox"}

+
+ +
+ {plan.file && ( + {plan.file}} /> + )} + {commandStr}} /> + + + + {plan.workdir && plan.workdir !== "." && ( + {plan.workdir}} /> + )} +
+ + {/* Safety checks — always green, no opinion */} + {Array.isArray(plan.safety?.checks) && plan.safety.checks.length > 0 && ( +
    + {plan.safety.checks.map((c, i) => ( +
  • + + {c.label} +
  • + ))} +
+ )} + + {/* Warnings — non-blocking, sorted high → low by the backend */} + {Array.isArray(plan.safety?.warnings) && plan.safety.warnings.length > 0 && ( +
    + {plan.safety.warnings.map((w, i) => { + const st = SEVERITY_STYLE[w.severity] || SEVERITY_STYLE.low; + return ( +
  • + {st.icon} + + {w.label} + {w.detail && — {w.detail}} + +
  • + ); + })} +
+ )} + + {plan.inline_code && ( +
+ + Snippet to run ({plan.inline_code.length} chars) + +
{plan.inline_code}
+
+ )} + +
+ + {plan.file && onOpenFile && ( + + )} + +
+
+ ); +} + +function Field({ label, value }) { + return ( + <> +
{label}
+
{value}
+ + ); +} + +// --------------------------------------------------------------------------- +// Approve helper — single source of truth so chat, codeblock, canvas +// all build the plan the same way. Returns the plan object or throws. +// --------------------------------------------------------------------------- + +export async function fetchExecutionPlan(payload) { + const res = await fetch("/api/sandbox/plan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.detail || `Plan failed (HTTP ${res.status})`); + } + return data.plan; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const dtStyle = { + fontSize: 11, color: "#9092b5", textTransform: "uppercase", + letterSpacing: "0.05em", margin: 0, +}; +const ddStyle = { margin: "0 0 6px", fontSize: 13, color: "#e4e4e7" }; + +const fullStyles = { + card: { + margin: "8px 0", + background: "#0d1117", + border: "1px solid #1f2937", + borderLeft: "3px solid #10B981", + borderRadius: 10, + padding: 16, + color: "#e4e4e7", + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }, + header: { display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }, + badge: { + fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", + padding: "2px 8px", borderRadius: 4, + background: "#0d3320", color: "#86efac", textTransform: "uppercase", + }, + title: { margin: 0, fontSize: 15, fontWeight: 600 }, + fields: { + display: "grid", gridTemplateColumns: "120px 1fr", + rowGap: 4, columnGap: 12, margin: "10px 0", + }, + checks: { + listStyle: "none", padding: 0, margin: "8px 0", + display: "flex", flexWrap: "wrap", gap: 6, + }, + check: { + fontSize: 11, color: "#86efac", + background: "rgba(16,185,129,0.08)", + border: "1px solid rgba(16,185,129,0.25)", + borderRadius: 4, padding: "2px 8px", + }, + checkIcon: { marginRight: 4 }, + warnings: { listStyle: "none", padding: 0, margin: "10px 0 4px" }, + warning: { + fontSize: 12, + padding: "6px 10px", borderRadius: 4, border: "1px solid", + marginBottom: 4, display: "flex", gap: 8, alignItems: "flex-start", + }, + warnIcon: { fontSize: 14, lineHeight: 1 }, + warnDetail: { opacity: 0.8 }, + snippetWrap: { + margin: "8px 0", + border: "1px solid #1f2937", borderRadius: 6, padding: "6px 10px", + }, + snippetLabel: { fontSize: 11, color: "#9092b5", cursor: "pointer" }, + snippet: { + margin: "6px 0 0", padding: 8, fontSize: 12, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + background: "#000", color: "#d4d4d8", borderRadius: 4, + maxHeight: 240, overflow: "auto", whiteSpace: "pre-wrap", + }, + footer: { display: "flex", gap: 8, marginTop: 12 }, + primary: { + background: "#10B981", color: "#052e1c", border: "0", + borderRadius: 6, padding: "8px 16px", fontSize: 13, fontWeight: 600, + cursor: "pointer", + }, + secondary: { + background: "transparent", color: "#a1a1aa", + border: "1px solid #3F3F46", borderRadius: 6, + padding: "7px 14px", fontSize: 13, cursor: "pointer", + }, + tertiary: { + background: "transparent", color: "#71717a", + border: "1px solid #27272a", borderRadius: 6, + padding: "7px 14px", fontSize: 13, cursor: "pointer", + marginLeft: "auto", + }, +}; + +// Compact variant — same structure, denser padding, no inline snippet +// expander. Used for code-block Run confirmation popovers. +const compactStyles = { + ...fullStyles, + card: { + ...fullStyles.card, + padding: 10, + margin: "6px 0", + borderRadius: 8, + }, + title: { margin: 0, fontSize: 13, fontWeight: 600 }, + fields: { + ...fullStyles.fields, + gridTemplateColumns: "90px 1fr", + rowGap: 2, + margin: "6px 0", + }, + primary: { ...fullStyles.primary, padding: "6px 12px", fontSize: 12 }, + secondary: { ...fullStyles.secondary, padding: "5px 10px", fontSize: 12 }, + tertiary: { ...fullStyles.tertiary, padding: "5px 10px", fontSize: 12 }, +}; diff --git a/frontend/components/FilePreviewPanel.jsx b/frontend/components/FilePreviewPanel.jsx new file mode 100644 index 0000000..6dd06d2 --- /dev/null +++ b/frontend/components/FilePreviewPanel.jsx @@ -0,0 +1,463 @@ +// frontend/components/FilePreviewPanel.jsx +// +// Read-first file viewer with two density modes. +// +// mode="preview" Narrow right-side drawer for a quick look. +// ~520px wide, primary CTA "Prepare Run". +// mode="workspace" Wide right-side editor for serious review. +// ~85vw, same actions, much more reading room. +// +// Header vocabulary follows the enterprise verbs the design review +// nailed down: +// +// [▶ Prepare Run] only on runnable files (hidden on README etc.) +// [Open Workspace] upgrades preview → workspace (or back) +// [⋯] overflow: Ask GitPilot · Open Canvas · +// Copy path · Copy contents +// +// "Prepare Run" never runs anything directly — it dispatches the +// gitpilot:run-file event which lands on the green ExecutionPlanCard +// in chat, where the user approves before the sandbox starts. +// +// Error state: when the file content fetch fails we hide every action +// that depends on having content (Prepare Run / Canvas / Ask). Only +// safe actions remain (Retry / Copy path). + +import React, { useEffect, useMemo, useRef, useState } from "react"; + +const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]); + +const LANG_LABEL = { + py: "Python", js: "JavaScript", mjs: "JavaScript", cjs: "JavaScript", + sh: "Shell", bash: "Shell", + md: "Markdown", json: "JSON", yml: "YAML", yaml: "YAML", toml: "TOML", + html: "HTML", css: "CSS", ts: "TypeScript", tsx: "TypeScript", + rs: "Rust", go: "Go", java: "Java", c: "C", cpp: "C++", h: "C/C++", +}; + +function extOf(path) { + if (!path || !path.includes(".")) return ""; + return path.split(".").pop().toLowerCase(); +} + +function bytesPretty(n) { + if (n == null) return ""; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +// --------------------------------------------------------------------------- +// Overflow menu — secondary actions live here so the header stays calm. +// --------------------------------------------------------------------------- + +function OverflowMenu({ path, content, runnable, onClose }) { + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") onClose?.(); }; + const onDown = () => onClose?.(); + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onDown); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onDown); + }; + }, [onClose]); + + const copy = (text) => { + if (navigator?.clipboard && text != null) navigator.clipboard.writeText(text).catch(() => {}); + }; + const items = [ + { label: "Ask GitPilot", + onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } })) }, + { label: "Open in Canvas", runnable: true, + onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } })) }, + { divider: true }, + { label: "Copy path", onClick: () => copy(path) }, + { label: "Copy contents", onClick: () => copy(content), disabled: !content }, + ]; + + return ( +
e.stopPropagation()} + style={{ + position: "absolute", right: 6, top: 36, zIndex: 50, + minWidth: 200, + background: "#18181B", border: "1px solid #3F3F46", + borderRadius: 6, padding: "4px 0", + boxShadow: "0 8px 20px rgba(0,0,0,0.45)", + }}> + {items.map((it, i) => { + if (it.divider) return
; + if (it.runnable && !runnable) return null; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- + +export default function FilePreviewPanel({ + path, + content, + loading, + error, + errorCode, // numeric HTTP status when known (e.g. 404) + notFoundKind, // "deleted" | "syncing" | "unavailable" + mode = "preview", // "preview" | "workspace" + branch, + onModeChange, + onClose, + onRetry, + onRefreshTree, +}) { + const ext = extOf(path); + const lang = LANG_LABEL[ext] || ext.toUpperCase() || "Text"; + const runnable = RUNNABLE_FILE_EXTS.has(ext); + const size = content ? new Blob([content]).size : null; + const filename = path ? path.split("/").pop() : ""; + const [menuOpen, setMenuOpen] = useState(false); + const menuBtnRef = useRef(null); + + // Esc closes — keyboard contract every modal/drawer in GitPilot uses. + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") onClose?.(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const prepareRun = () => { + window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } })); + onClose?.(); + }; + const toggleWorkspace = () => { + if (onModeChange) onModeChange(mode === "workspace" ? "preview" : "workspace"); + }; + + const lines = useMemo(() => (content || "").split("\n"), [content]); + + // Width contract: preview = peek, workspace = serious reading. + const widthCss = mode === "workspace" + ? "min(1280px, 86vw)" + : "min(520px, 44vw)"; + + // Decide which actions to show. Two rules: + // 1. While loading, secondary actions exist but disabled. + // 2. On error, hide every action that requires content; we show + // Retry + Copy path only. + const showActions = !error; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Calm empty state — replaces the harsh red "Couldn't load this file" +// box. Same component, classifies the situation into one of: +// deleted → execution removed the file from this branch +// syncing → execution created the file but content isn't ready yet +// unavailable → file simply isn't on this branch +// --------------------------------------------------------------------------- + +function NotAvailableCard({ path, branch, error, errorCode, kind, onRetry, onRefreshTree }) { + const isNotFound = + errorCode === 404 || + /not\s*found/i.test(String(error || "")) || + /HTTP\s*404/i.test(String(error || "")); + + const effectiveKind = isNotFound ? (kind || "unavailable") : "error"; + + const copy = { + deleted: { + icon: "🗑", + title: "File deleted", + body: `${path ? path.split("/").pop() : "This file"} was removed by the latest execution. View the execution summary for details.`, + tone: "muted", + }, + syncing: { + icon: "↻", + title: "File still syncing", + body: "GitPilot just created this file. Its content is being published to the active branch — try again in a moment.", + tone: "info", + }, + unavailable: { + icon: "○", + title: "File unavailable", + body: "This file isn't available on the current branch. It may have been moved, renamed, or never committed here.", + tone: "muted", + }, + error: { + icon: "!", + title: "Couldn't load this file", + body: "Something went wrong fetching the file content. Try again, or refresh the file tree.", + tone: "warn", + }, + }[effectiveKind]; + + return ( +
+ +
{copy.title}
+
{copy.body}
+ +
+
Path
+
{path}
+ {branch && ( + <> +
Branch
+
{branch}
+ + )} +
+ +
+ + {onRefreshTree && ( + + )} + +
+ + {effectiveKind === "error" && error && ( +
+ Technical details +
{String(error)}
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const s = { + shell: { + position: "fixed", + top: 0, right: 0, bottom: 0, + // ``width`` is set per-mode in the component. + background: "#0d0e17", + borderLeft: "1px solid #2a2b36", + display: "flex", flexDirection: "column", + zIndex: 90, + color: "#e4e4e7", + fontFamily: "system-ui, sans-serif", + boxShadow: "-12px 0 32px rgba(0,0,0,0.45)", + transition: "width 0.15s ease", + }, + header: { + padding: "12px 16px 10px", + borderBottom: "1px solid #2a2b36", + background: "#14152a", + }, + titleRow: { display: "flex", justifyContent: "space-between", gap: 12 }, + filename: { + fontSize: 16, fontWeight: 600, + fontFamily: "ui-monospace, monospace", + whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", + }, + subpath: { + fontSize: 11, color: "#9092b5", marginTop: 2, + fontFamily: "ui-monospace, monospace", + whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", + }, + dot: { margin: "0 6px", color: "#52525B" }, + metaPills: { display: "flex", alignItems: "flex-start", gap: 6, flexShrink: 0 }, + pill: { + fontSize: 11, padding: "2px 8px", borderRadius: 4, + background: "#1e3a5f", color: "#93c5fd", + border: "1px solid #3B82F6", + }, + pillDim: { + fontSize: 11, padding: "2px 8px", borderRadius: 4, + background: "#0d0e17", color: "#9092b5", + border: "1px solid #2c2d46", + }, + actions: { display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap", alignItems: "center" }, + btnPrimary: { + padding: "6px 14px", fontSize: 12, fontWeight: 600, + background: "#10B981", color: "#052e1c", + border: "0", borderRadius: 6, cursor: "pointer", + }, + btnSecondary: { + padding: "6px 12px", fontSize: 12, + background: "transparent", color: "#c3c5dd", + border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer", + }, + btnClose: { + marginLeft: 6, + padding: "2px 8px", fontSize: 14, + background: "transparent", color: "#9092b5", + border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer", + }, + body: { flex: 1, overflow: "auto", padding: "12px 0" }, + empty: { padding: 30, textAlign: "center", color: "#9092b5", fontSize: 13 }, + code: { + margin: 0, padding: 0, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12.5, lineHeight: 1.55, + color: "#D4D4D8", + background: "transparent", + whiteSpace: "pre", + }, + lineRow: { display: "grid", gridTemplateColumns: "48px 1fr", gap: 12, padding: "0 18px" }, + lineNo: { + color: "#52525B", textAlign: "right", + userSelect: "none", + fontVariantNumeric: "tabular-nums", + }, + lineCode: { whiteSpace: "pre-wrap", wordBreak: "break-word" }, +}; diff --git a/frontend/components/FileTree.jsx b/frontend/components/FileTree.jsx index 74352c4..bc5cb67 100644 --- a/frontend/components/FileTree.jsx +++ b/frontend/components/FileTree.jsx @@ -10,6 +10,28 @@ export default function FileTree({ repo, refreshTrigger, branch }) { const [isSwitchingBranch, setIsSwitchingBranch] = useState(false); const [error, setError] = useState(null); const [localRefresh, setLocalRefresh] = useState(0); + // Search query for the in-sidebar filter. Empty string == no filter. + // Filter runs case-insensitively on path basenames *and* full paths + // so users can pinpoint a file even in deeply nested folders. + const [query, setQuery] = useState(""); + // Which file is currently focused in the preview panel — populated + // by the ``gitpilot:file-opened`` event ChatPanel emits when the + // preview finishes loading. Drives the ◄ marker + active row tint. + const [selectedPath, setSelectedPath] = useState(null); + + useEffect(() => { + const onOpened = (e) => setSelectedPath(e?.detail?.path || null); + const onClosed = () => setSelectedPath(null); + const onRefresh = () => setLocalRefresh((n) => n + 1); + window.addEventListener("gitpilot:file-opened", onOpened); + window.addEventListener("gitpilot:file-closed", onClosed); + window.addEventListener("gitpilot:refresh-tree", onRefresh); + return () => { + window.removeEventListener("gitpilot:file-opened", onOpened); + window.removeEventListener("gitpilot:file-closed", onClosed); + window.removeEventListener("gitpilot:refresh-tree", onRefresh); + }; + }, []); useEffect(() => { if (!repo) return; @@ -205,50 +227,290 @@ export default function FileTree({ repo, refreshTrigger, branch }) { )} {tree.length > 0 && ( -
- {tree.map((node) => ( - - ))} -
+ <> + {/* In-sidebar file search. Enterprise repos run to hundreds + of files; the explorer needs a quick narrowing affordance + before any of the contextual menus matter. */} + setQuery(e.target.value)} + placeholder="🔍 Search files…" + spellCheck={false} + style={{ + width: "100%", boxSizing: "border-box", + margin: "4px 0 8px", + padding: "6px 10px", fontSize: 12, + background: "#0d0e17", color: "#e4e4e7", + border: "1px solid #27272A", borderRadius: 6, + outline: "none", + fontFamily: "system-ui, sans-serif", + }} + /> + +
+ {tree.map((node) => ( + + ))} +
+ )}
); } +// Files with these extensions get a "Prepare Run" menu item. Mirrors +// the backend's _RUNNABLE_EXTENSIONS so the menu only offers a run +// where the sandbox planner would actually accept the file. +const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]); + +function isRunnableFile(name, type) { + if (type === "tree") return false; + if (!name || !name.includes(".")) return false; + const ext = name.split(".").pop().toLowerCase(); + return RUNNABLE_FILE_EXTS.has(ext); +} + +// Window-event dispatch helpers — keep FileTree decoupled from +// ChatPanel. Same pattern as the existing approval-flow events. +function dispatchOpenFile(path) { + try { + window.dispatchEvent(new CustomEvent("gitpilot:open-file", { detail: { path } })); + } catch (_e) { /* old browser */ } +} +function dispatchOpenInCanvas(path) { + try { + window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } })); + } catch (_e) { /* old browser */ } +} +function dispatchRunFile(path) { + try { + window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } })); + } catch (_e) { /* old browser */ } +} +function dispatchAskAboutFile(path) { + try { + window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } })); + } catch (_e) { /* old browser */ } +} +function copyToClipboard(text) { + if (navigator?.clipboard) navigator.clipboard.writeText(text).catch(() => {}); +} + +// Tiny dropdown — positioned absolutely below the row's ⋯ trigger. +// Closes on outside click or Escape. Built-in