mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
6 commits
f48fc141b9
...
4b2772288d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2772288d | |||
| 5c3ca6e615 | |||
| 5df29c81af | |||
|
|
eedef5f756 | ||
|
|
eb98ff4c30 | ||
|
|
860ce93ad7 |
14 changed files with 245 additions and 12196 deletions
|
|
@ -49,6 +49,27 @@ SUPPORTED_MODELS: list[ModelOption] = [
|
|||
"default_effort": "high",
|
||||
"supports_images": False,
|
||||
},
|
||||
{
|
||||
"id": "fireworks:accounts/fireworks/models/minimax-m3",
|
||||
"label": "MiniMax M3",
|
||||
"efforts": ["medium", "high"],
|
||||
"default_effort": "high",
|
||||
"supports_images": True,
|
||||
},
|
||||
{
|
||||
"id": "fireworks:accounts/fireworks/models/gpt-oss-120b",
|
||||
"label": "gpt-oss-120b",
|
||||
"efforts": ["low", "medium", "high"],
|
||||
"default_effort": "medium",
|
||||
"supports_images": False,
|
||||
},
|
||||
{
|
||||
"id": "fireworks:accounts/fireworks/models/deepseek-v4-flash",
|
||||
"label": "DeepSeek V4 Flash",
|
||||
"efforts": ["none", "medium", "high"],
|
||||
"default_effort": "high",
|
||||
"supports_images": False,
|
||||
},
|
||||
]
|
||||
|
||||
SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
# Open SWE — Migration Plan: Self-Hosted AWS → Managed LangGraph Cloud + Vercel
|
||||
|
||||
**Repo:** `Sea-Haven-Industries/open-swe` (private) · **AWS:** 328440206208 / us-east-1
|
||||
**Author:** Adam Moussa · **Date:** 2026-06-29 · **Status:** DRAFT — owes a `/sh-plan-review` before prod cutover (Phase C gate)
|
||||
**Author:** Adam Moussa · **Date:** 2026-06-29 (final topology added 2026-06-30) · **Status:** EXECUTED — managed cutover live; §§3–11 below are the original (now-historical) phased plan, **superseded by §1a for all current-state facts (URLs, project layout, env)**.
|
||||
|
||||
> **READ §1a FIRST.** The phased plan (§§2–11) and the Phase A/C spike notes capture how we got here and still hold for rationale, cost, and rollback. But the spike-era specifics they cite — the single `open-swe-dashboard` Vercel project, the `open-swe-dev-hosted-…`/`open-swe-v3-…` deployment URLs, the `ui/vercel.json` same-origin rewrite, the single GitHub App — are **stale**. §1a is the authoritative final topology and wins on every conflict.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -30,6 +32,75 @@ A self-hosted `langgraph up` + RDS plan (already `/sh-plan-review`'d to APPROVE-
|
|||
|
||||
---
|
||||
|
||||
## 1a. Final, verified topology (AUTHORITATIVE — supersedes spike-era values)
|
||||
|
||||
This is the live managed deployment as of 2026-06-30. Where any later section disagrees (old URLs, a single Vercel project, a single GitHub App, the `ui/vercel.json` rewrite), **this section wins**.
|
||||
|
||||
### Backend — managed LangGraph Cloud (two deployments, one LangSmith workspace)
|
||||
Both deployments live in the **same LangSmith workspace**; the **same workspace API key authenticates both** (including the Store API — so per-deployment store writes use that one key with the per-deployment URL).
|
||||
|
||||
| Deployment | URL | Git connection |
|
||||
|---|---|---|
|
||||
| **dev** | `https://open-swe-dev-fb737aa219605c8bbdb30ecbb33f30c0.us.langgraph.app` | branch `dev` |
|
||||
| **prod** | `https://open-swe-prod-d6c7bb63aaa651b6a1d92f9492b1d983.us.langgraph.app` | branch `main` (auto-deploys on push to `main`) |
|
||||
|
||||
> The dev deployment was **renamed `open-swe-dev`, deleted, and recreated** — which minted the **new URL hash** above. The spike-era `open-swe-v3-…` / `open-swe-dev-hosted-…` URLs are **dead/superseded**. Deleting + recreating a deployment is the one operation that changes the URL hash (otherwise stable across revisions) — when it happens, update **every** reference (Vercel env, GitHub App webhooks, OAuth callbacks, docs).
|
||||
|
||||
### UI — Vercel (ONE project, two environments)
|
||||
**One** Vercel project `open-swe-prod` (team `sea-haven`, id `prj_OOh6yjXMp4ah3Ws3Y7XRQxjmMmQU`). The old separate `open-swe-dashboard` project was **DELETED**.
|
||||
|
||||
| Vercel environment | Branch | Backend | Custom domain |
|
||||
|---|---|---|---|
|
||||
| production | `main` | prod deployment URL | `openswe.seahaven.com` |
|
||||
| custom **`dev`** (id `env_SMI23PULAJXk0GhwE0HLVhp5J3ZS`) | `dev` | dev deployment URL | `openswe-dev.seahaven.com` |
|
||||
|
||||
- A **per-environment** env var `LANGGRAPH_BACKEND_URL` (prod env = prod URL, dev env = dev URL) drives the `/dashboard/api/*` proxy.
|
||||
- Project settings: `framework=null`, `outputDirectory` cleared, root directory `ui`.
|
||||
- **Proxy mechanism (current, after PR #76):** **Nitro `routeRules`** in `ui/vite.config.ts` read `process.env.LANGGRAPH_BACKEND_URL` and Nitro's Vercel preset compiles them into `.vercel/output/config.json` (Build Output API) at build time — a CDN-level proxy (not redirect, so the `osw_session` cookie stays first-party). PR #75's hand-rolled `ui/scripts/build-vercel-output.mjs` was the broken first attempt and is **gone** (`ui/scripts/` no longer exists). **Never** add a manual script that `rm`s `.vercel/output` — Nitro's Vercel preset auto-emits it.
|
||||
|
||||
### DNS — Route 53 zone `seahaven.com` (`Z06652411XKH89KTZD3XA`)
|
||||
- `openswe.seahaven.com` → CNAME `cname.vercel-dns.com` (prod env)
|
||||
- `openswe-dev.seahaven.com` → CNAME to Vercel (dev env)
|
||||
|
||||
### GitHub Apps — TWO (dev/prod isolated; each its own webhook URL)
|
||||
| App | app_id | install | client_id | org | members scope | repos |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **prod** `seahaven-openswe` | `4146115` | `142615168` | `Iv23lil96pKQNNDUn5yp` | `Sea-Haven-Industries` | members:**write** | all |
|
||||
| **dev** `seahaven-openswe-dev` | `4162963` | `143023302` | `Iv23licQwJvGAPJj1HJe` | `seahaven-open-swe-dev` | members:**read** | all |
|
||||
|
||||
- **Promotion App** `seahaven-promotion` (actor `4170147`) is the sole non-admin fast-forward-push bypass on the `main` ruleset `18238334` — its FF-push of `dev → main` is what triggers the managed prod build.
|
||||
|
||||
### Env per deployment (set in LangGraph Cloud config + Vercel env — NOT Secrets Manager)
|
||||
| Var | dev | prod |
|
||||
|---|---|---|
|
||||
| `LANGGRAPH_URL` | own (dev) deployment URL | own (prod) deployment URL |
|
||||
| `DASHBOARD_BASE_URL` / `DASHBOARD_API_BASE_URL` | `https://openswe-dev.seahaven.com` | `https://openswe.seahaven.com` |
|
||||
| `VITE_DASHBOARD_API_BASE_URL` | empty (same-origin via Vercel proxy) | empty |
|
||||
| `ALLOWED_GITHUB_ORGS` | dev org (`seahaven-open-swe-dev`) | `Sea-Haven-Industries` |
|
||||
| `CONFIGURED_ADMINS` | `amoussa1229,adam@seahavenind.com` | `amoussa1229,adam@seahavenind.com` |
|
||||
|
||||
`DASHBOARD_BASE_URL` / `DASHBOARD_API_BASE_URL` **must include `https://`** (see gotcha 3). Secret **values** are still sourced from `open-swe-{dev,prod}/*` Secrets Manager + SSM (the remaining source of truth) and set into the LangGraph Cloud + Vercel env stores — the accepted `secrets-and-config.md` deviation.
|
||||
|
||||
### Bedrock IAM (PR #74, still OPEN)
|
||||
Two IAM users `open-swe-dev-bedrock` + `open-swe-prod-bedrock`, each attached to customer-managed policy `open-swe-bedrock-invoke` (least-privilege `bedrock:InvokeModel[WithResponseStream]` on the `us.anthropic.claude-opus-4-8` inference-profile ARN + its 3 routed foundation-model ARNs in us-east-1/us-east-2/us-west-2). Default model `bedrock_converse:us.anthropic.claude-opus-4-8` + 3 Fireworks models. Static access keys live only in the deployment env (dev key → dev, prod key → prod).
|
||||
|
||||
### User store — per-deployment
|
||||
**Each** managed deployment has its **own** Store. The GitHub→email mapping `amoussa1229 → adam@seahavenind.com` (namespace `["user_mappings"]`, key = lowercased login, record `{github_login, work_email, status:"active", source, created_at, updated_at}`) was written to **both** the dev and prod stores directly. New users need a mapping **per-deployment** (write each store directly, or use the dashboard admin User-mappings UI — the `work_email` field was added by PR #65 fix #4).
|
||||
|
||||
### AWS decommission (PR #64)
|
||||
Self-host CDK stacks destroyed. Residual: `CDKToolkit` (shared, **preserved**); ~50 `RETAIN`'d Secrets Manager shells + 3 S3 asset buckets (**pending cleanup**); AWS **Bedrock** (live dependency, kept).
|
||||
|
||||
### Operational gotchas (hard-won — carry these into any runbook)
|
||||
1. **Per-deployment store → seed user mappings per-deployment.** A missing mapping makes `process_github_issue` silently early-return ("No email mapping … skipping"): the webhook returns 200/accepted but produces **no reaction and no run**. Seed dev **and** prod.
|
||||
2. **Org-login gate uses the App *installation* token**, so the App must be org-installed with **Members:read**. OAuth working ≠ membership check working — they use **separate creds** (CLIENT_ID/SECRET for OAuth vs APP_ID/INSTALLATION_ID/PRIVATE_KEY for the install token). A mangled multi-line `GITHUB_APP_PRIVATE_KEY` breaks the install token (and thus the gate) while OAuth still works.
|
||||
3. **`DASHBOARD_API_BASE_URL` must be `https://`** — an `http://` value makes GitHub reject the OAuth callback with "redirect_uri not associated."
|
||||
4. **`osw_oauth_state` cookie is host-only** — start login on the **same host** as `DASHBOARD_API_BASE_URL`, or you get "oauth state mismatch."
|
||||
5. **Webhooks go DIRECT to the langgraph URL** (`/webhooks/*`). Vercel only proxies `/dashboard/api/*`. The app is **same-origin only** (no CORS).
|
||||
6. **On Vercel CI, Nitro's Vercel preset auto-emits `.vercel/output`** — drive the proxy via Nitro `routeRules` from `LANGGRAPH_BACKEND_URL`; never a manual script that `rm`s `.vercel/output`.
|
||||
7. **Deleting + recreating a LangGraph deployment mints a NEW URL hash** (otherwise stable across revisions) — update every reference (Vercel env, webhooks, OAuth, docs).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture: Before → After
|
||||
|
||||
### Before (self-hosted AWS — LIVE as of 2026-06-29)
|
||||
|
|
@ -49,23 +120,29 @@ Browser (dashboard) ─────────────▶ openswe.seahaven.
|
|||
Sandbox: LangSmith cloud (DEFAULT_SANDBOX_SNAPSHOT_ID + GitHub proxy)
|
||||
```
|
||||
|
||||
### After (managed)
|
||||
### After (managed — FINAL, see §1a for exact values)
|
||||
```
|
||||
GitHub/Slack/Linear ──webhook──▶ *.langgraph.app (or hooks.seahaven.com CNAME → TODO §10)
|
||||
Browser (dashboard) ─────────────▶ open-swe-dashboard.vercel.app (stable alias / custom domain)
|
||||
│ same-origin rewrite /dashboard/api/* (ui/vercel.json)
|
||||
┌──────────────── DEV lane ────────────────┐ ┌──────────────── PROD lane ───────────────┐
|
||||
GitHub(dev org)/Slack/Linear │ webhook → open-swe-dev-….us.langgraph.app │ │ webhook → open-swe-prod-….us.langgraph.app│ GitHub(SHI org)/Slack/Linear
|
||||
App seahaven-openswe-dev ───┘ (DIRECT to langgraph URL, /webhooks/*) │ │ (DIRECT to langgraph URL, /webhooks/*) └─── App seahaven-openswe
|
||||
▼ ▼
|
||||
Browser ▶ openswe-dev.seahaven.com ─┐ ┌─▶ openswe.seahaven.com ◀ Browser
|
||||
│ ONE Vercel project `open-swe-prod` (team sea-haven)
|
||||
│ ├─ env `dev` (branch dev) → proxies /dashboard/api/* → dev langgraph URL
|
||||
│ └─ env production (branch main) → proxies /dashboard/api/* → prod langgraph URL
|
||||
└─ proxy compiled by Nitro routeRules from per-env LANGGRAPH_BACKEND_URL (PR #76)
|
||||
▼
|
||||
LangGraph Cloud "Deployment" (managed, git-connected to `main` for prod / `dev` for dev)
|
||||
├─ serves the 6 graphs (agent, reviewer, analyzer, chat, scheduler, ci_monitor)
|
||||
├─ serves the custom http.app (agent.webapp:app = webhooks + dashboard API + OAuth)
|
||||
├─ durable Postgres store + checkpointer (issue #9 SOLVED) — autoscaled 1→10 replicas
|
||||
└─ env/secrets in the Deployment config (NOT Secrets Manager) — Adam accepted deviation
|
||||
TWO LangGraph Cloud deployments (same LangSmith workspace; one workspace key auths both incl. Store)
|
||||
├─ dev ← branch `dev` · prod ← branch `main` (push-to-main auto-deploys prod)
|
||||
├─ each serves the graphs + the custom http.app (agent.webapp:app = webhooks + dashboard API + OAuth)
|
||||
├─ each has its OWN durable Postgres store + checkpointer (issue #9 SOLVED) — user mappings per-deployment
|
||||
└─ env/secrets in the Deployment + Vercel config (NOT Secrets Manager) — Adam accepted deviation
|
||||
▼
|
||||
Sandbox: LangSmith cloud (UNCHANGED — DEFAULT_SANDBOX_SNAPSHOT_ID + GitHub-App proxy)
|
||||
Auth: GitHub App seahaven-openswe (UNCHANGED — App 4146115 / Install 142615168)
|
||||
Bedrock: IAM users open-swe-{dev,prod}-bedrock + policy open-swe-bedrock-invoke (static keys in deploy env)
|
||||
```
|
||||
|
||||
**What changes shape:** runtime host (EC2 → managed PaaS), durability (in-memory → managed Postgres), CD (bespoke S3/SSM/packer → git-connected auto-build), config home (Secrets Manager/SSM → Deployment+Vercel env). **What stays:** the GitHub App, the LangSmith sandbox plane, CI (lint/format/unit/Playwright), the app code itself.
|
||||
**What changes shape:** runtime host (EC2 → managed PaaS), durability (in-memory → managed Postgres, one store **per deployment**), CD (bespoke S3/SSM/packer → git-connected auto-build), config home (Secrets Manager/SSM → Deployment+Vercel env), ingress topology (shared ALB + one App → two dev/prod-isolated GitHub Apps each hitting its own `*.langgraph.app` directly), UI proxy (`ui/vercel.json` rewrite → Nitro `routeRules`). **What stays:** the LangSmith sandbox plane, CI (lint/format/unit/Playwright), the app code itself, and Secrets Manager/SSM as the secret-**value** source of truth.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -74,11 +151,11 @@ Browser (dashboard) ─────────────▶ open-swe-dashboar
|
|||
### Phase A — Dev spike (MOSTLY DONE)
|
||||
Goal: prove managed serves our custom app + durability, at $0, before committing prod $.
|
||||
|
||||
**Proven this session:**
|
||||
**Proven this session** (spike-era specifics — superseded by §1a; URLs/project below are DEAD):
|
||||
- ✅ Dev backend deployed to LangGraph Cloud, connected to branch `dev`:
|
||||
`https://open-swe-dev-hosted-e76c2b0e8a7955fe8ad3110a7a54e5d0.us.langgraph.app`
|
||||
~~`https://open-swe-dev-hosted-e76c2b0e8a7955fe8ad3110a7a54e5d0.us.langgraph.app`~~ → final dev URL in §1a (`open-swe-dev-fb737aa…`; the deployment was later deleted + recreated)
|
||||
(Bedrock + a Fireworks key set; AWS creds for Bedrock deferred — see §10 open decision).
|
||||
- ✅ UI deployed to Vercel — team `sea-haven`, project `open-swe-dashboard`, `https://open-swe-dashboard.vercel.app`; `ui/vercel.json` rewrite repointed at the dev deployment.
|
||||
- ✅ UI deployed to Vercel — team `sea-haven`, ~~project `open-swe-dashboard`, `https://open-swe-dashboard.vercel.app`~~ (DELETED); now the **single** project `open-swe-prod` with dev/prod environments (§1a); proxy is Nitro `routeRules`, not the `ui/vercel.json` rewrite.
|
||||
- ✅ Managed serves the custom `http.app` (dashboard API + webhooks) — **no platform auth gate** in front of our routes (webhook returns 401 sig-enforced, so signatures still govern).
|
||||
- ✅ Vercel same-origin rewrite → backend works.
|
||||
- ✅ GitHub OAuth dashboard login end-to-end.
|
||||
|
|
@ -102,7 +179,7 @@ Land the 6 code fixes (§5), codify env/config, and resolve the Bedrock-auth dec
|
|||
|
||||
### Phase C — Prod deployment
|
||||
- [ ] C1. Create a **prod LangGraph Cloud deployment** tracking branch `main` (the durable autoscaled 1→10 tier, not the free Dev tier). Record its `*.langgraph.app` URL.
|
||||
- [ ] C2. Create the **prod Vercel project/target** (or promote the existing `open-swe-dashboard` to production); set its env (same-origin mode: `VITE_DASHBOARD_API_BASE_URL` empty).
|
||||
- [x] C2. ~~Create the **prod Vercel project/target** (or promote the existing `open-swe-dashboard` to production)~~ — **DONE differently:** one project `open-swe-prod` with a production env (`main`) + a custom `dev` env (`dev`), each with its own `LANGGRAPH_BACKEND_URL`. See §1a.
|
||||
- [ ] C3. Set the **prod env triad** (§4) on the prod deployment + Vercel:
|
||||
- `LANGGRAPH_URL` = the prod `*.langgraph.app` URL
|
||||
- `DASHBOARD_BASE_URL` + `DASHBOARD_API_BASE_URL` = the prod Vercel origin (with `https://` scheme — fix #2)
|
||||
|
|
@ -139,15 +216,19 @@ Only after managed prod is proven + soaked. See §7 for the precise retire-vs-ke
|
|||
|
||||
## 4. Env / Config Reference
|
||||
|
||||
### The prod triad (per INSTALLATION.md §10, lines 630–658)
|
||||
| Var | Prod value | Notes |
|
||||
### The prod triad (per INSTALLATION.md §10, lines 630–658) — see §1a for the exact dev/prod values
|
||||
| Var | Value (per deployment/env) | Notes |
|
||||
|---|---|---|
|
||||
| `LANGGRAPH_URL` | the deployment URL (`https://...langgraph.app`) | **NOT** localhost. Drives `thread_ops.langgraph_url()` (fix #1). |
|
||||
| `DASHBOARD_BASE_URL` | the Vercel origin | same-origin rewrite mode |
|
||||
| `DASHBOARD_API_BASE_URL` | the Vercel origin, **with `https://` scheme** | scheme required or OAuth `redirect_uri` is schemeless and GitHub rejects (fix #2) |
|
||||
| `VITE_DASHBOARD_API_BASE_URL` | **empty** | same-origin mode; UI calls relative `/dashboard/api/*`, Vercel rewrites to backend |
|
||||
| `LANGGRAPH_URL` | the **own** deployment URL (`https://...langgraph.app`) | **NOT** localhost. Drives `thread_ops.langgraph_url()` (fix #1). dev→dev URL, prod→prod URL. |
|
||||
| `DASHBOARD_BASE_URL` | the own dashboard origin, **with `https://`** (`https://openswe-dev.seahaven.com` / `https://openswe.seahaven.com`) | |
|
||||
| `DASHBOARD_API_BASE_URL` | same as above, **with `https://` scheme** | scheme required or OAuth `redirect_uri` is schemeless and GitHub rejects (fix #2) |
|
||||
| `VITE_DASHBOARD_API_BASE_URL` | **empty** | same-origin mode; UI calls relative `/dashboard/api/*`, the Vercel Nitro proxy rewrites to backend |
|
||||
| `LANGGRAPH_BACKEND_URL` | **Vercel env var**, per Vercel environment (dev env = dev URL, prod env = prod URL) | drives the Nitro `routeRules` `/dashboard/api/*` proxy at build time (PR #76) — required on Vercel builds |
|
||||
| `ALLOWED_GITHUB_ORGS` | `Sea-Haven-Industries` (prod) / `seahaven-open-swe-dev` (dev) | org-login gate; checked via the App **installation** token (gotcha 2) |
|
||||
|
||||
GitHub App dashboard OAuth callback = `<DASHBOARD_API_BASE_URL>/dashboard/api/auth/callback` (the Vercel prod origin).
|
||||
GitHub App dashboard OAuth callback = `<DASHBOARD_API_BASE_URL>/dashboard/api/auth/callback` (the own dashboard origin — prod App on `openswe.seahaven.com`, dev App on `openswe-dev.seahaven.com`).
|
||||
|
||||
**UI proxy mechanism (final, PR #76):** the `/dashboard/api/*` proxy is **Nitro `routeRules`** in `ui/vite.config.ts` reading `process.env.LANGGRAPH_BACKEND_URL`, compiled by Nitro's Vercel preset into `.vercel/output/config.json`. This **supersedes** the spike-era `ui/vercel.json` same-origin rewrite and PR #75's hand-rolled `ui/scripts/build-vercel-output.mjs` (deleted). `ui/vercel.json` now only carries `framework:null` + `buildCommand: bun run build`.
|
||||
|
||||
### Where secrets/env live now
|
||||
**LangGraph Cloud Deployment config + Vercel env** — NOT AWS Secrets Manager. This **deviates from the Sea Haven `secrets-and-config.md` "Secrets Manager for all sensitive" handbook rule** — **Adam ACCEPTED this deviation** (managed has no instance role / no fetch-config boot hook; the platform's own secret store is the mechanism).
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ def _save_plan_step(_messages: list[BaseMessage]) -> AIMessage:
|
|||
tool_calls=[
|
||||
{
|
||||
"name": "save_plan",
|
||||
"args": {"plan_file_path": PLAN_FILE_PATH},
|
||||
"args": {"plan_markdown": PLAN_MARKDOWN},
|
||||
"id": "call-save-plan",
|
||||
}
|
||||
],
|
||||
|
|
@ -297,22 +297,6 @@ SCRIPT_LIBRARY: dict[str, tuple[StepSpec, ...]] = {
|
|||
),
|
||||
_dynamic_step(_reply_step),
|
||||
),
|
||||
"breakout": (
|
||||
_tool_step(
|
||||
"Starting a separate Slack thread for the breakout task.",
|
||||
{
|
||||
"title": "Add greet() helper",
|
||||
"instructions": "Please add a greet() helper and open a draft PR in the default repository. Use the current Slack request as context, and report progress in this new thread.",
|
||||
},
|
||||
"call-breakout",
|
||||
),
|
||||
_tool_step(
|
||||
"Confirming the breakout thread was started.",
|
||||
"slack_thread_reply",
|
||||
{"message": "I started a separate Open SWE thread for that aspect."},
|
||||
"call-breakout-reply",
|
||||
),
|
||||
),
|
||||
"plan": (
|
||||
_tool_step(
|
||||
"This is worth planning first — entering plan mode.",
|
||||
|
|
@ -335,11 +319,6 @@ def _is_plan_request(text: str) -> bool:
|
|||
return "plan" in text.lower()
|
||||
|
||||
|
||||
def _is_breakout_request(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return "break out" in t or "separate thread" in t or "split out" in t
|
||||
|
||||
|
||||
def _is_approval(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return "approved" in t and "implement" in t
|
||||
|
|
@ -354,9 +333,6 @@ SCRIPT_RULES: tuple[ScriptRule, ...] = (
|
|||
ScriptRule("implement", lambda ctx: _is_approval(ctx.last_text)),
|
||||
ScriptRule("plan", lambda ctx: _is_revision(ctx.last_text)),
|
||||
ScriptRule("plan", lambda ctx: ctx.human_count <= 1 and _is_plan_request(ctx.first_text)),
|
||||
ScriptRule(
|
||||
"breakout", lambda ctx: ctx.human_count <= 1 and _is_breakout_request(ctx.first_text)
|
||||
),
|
||||
ScriptRule("implement", lambda ctx: ctx.human_count <= 1),
|
||||
ScriptRule("followup", lambda _ctx: True),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ export default function globalSetup() {
|
|||
if (existsSync(shell) && !process.env.E2E_FORCE_UI_BUILD) return;
|
||||
|
||||
if (!existsSync(resolve(ui, "node_modules"))) {
|
||||
execSync("corepack pnpm install --frozen-lockfile", { cwd: ui, stdio: "inherit" });
|
||||
execSync("bun install", { cwd: ui, stdio: "inherit" });
|
||||
}
|
||||
execSync("corepack pnpm run build", {
|
||||
execSync("bun run build", {
|
||||
cwd: ui,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITE_DASHBOARD_API_BASE_URL: `http://127.0.0.1:${port}` },
|
||||
|
|
|
|||
|
|
@ -37,26 +37,6 @@ test.describe("Open SWE full flow", () => {
|
|||
await expect(page.locator('.pr[data-pr="1"]')).toContainText("greet.py");
|
||||
});
|
||||
|
||||
test("Slack breakout request starts a new top-level Open SWE thread", async ({ page }) => {
|
||||
await page.locator("#text").fill("<@U0BOT> please break out adding a greet() helper into a separate thread");
|
||||
await page.locator("#send").click();
|
||||
|
||||
const breakout = page
|
||||
.locator(".msg.bot")
|
||||
.filter({ hasText: /Open SWE breakout thread:\* Add greet\(\) helper/ });
|
||||
await expect(breakout).toBeVisible({ timeout: 60_000 });
|
||||
const breakoutThreadTs = await breakout.getAttribute("data-thread-ts");
|
||||
expect(breakoutThreadTs).toBeTruthy();
|
||||
|
||||
const breakoutThreadMessages = page.locator(`.msg.bot[data-thread-ts="${breakoutThreadTs}"]`);
|
||||
await expect(breakoutThreadMessages.locator('a[href*="/agents/"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator(".msg.bot").filter({ hasText: "I started a separate Open SWE thread" }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
test("a message that does not mention the bot produces no run and no PR", async ({ page }) => {
|
||||
await page.locator("#mention").uncheck();
|
||||
await page.locator("#text").fill("just chatting with the team, nothing for the bot");
|
||||
|
|
|
|||
|
|
@ -126,11 +126,10 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
await addComment(collab, "Reviewer: please also add a docstring.");
|
||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(2);
|
||||
|
||||
// 5. The owner sees the collaborator's comment (polled), then approves and
|
||||
// returns to the main conversation while implementation starts.
|
||||
// 5. The owner sees the collaborator's comment (polled), then approves.
|
||||
await expect(owner.getByTestId("plan-comment")).toHaveCount(2, { timeout: 30_000 });
|
||||
await owner.getByTestId("approve-plan").click();
|
||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}$`));
|
||||
await expect(owner.getByTestId("plan-decision")).toContainText(/implementing/i);
|
||||
|
||||
// 6. The agent implements, opens a PR, and links it back in the Slack thread,
|
||||
// echoing the reviewers' feedback — which proves the comments were stored
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
import pytest
|
||||
|
||||
from agent.dashboard.options import SUPPORTED_MODELS
|
||||
from agent.dashboard.options import (
|
||||
SUPPORTED_MODEL_IDS,
|
||||
SUPPORTED_MODELS,
|
||||
model_supports_effort,
|
||||
model_supports_images,
|
||||
)
|
||||
from agent.utils.model import (
|
||||
fallback_model_id_for,
|
||||
fireworks_reasoning_effort_for,
|
||||
provider_model_kwargs,
|
||||
)
|
||||
|
||||
_FIREWORKS_PREFIX = "fireworks:accounts/fireworks/models/"
|
||||
|
||||
NEW_FIREWORKS_MODELS = {
|
||||
"minimax-m3": (["medium", "high"], "high", True),
|
||||
"gpt-oss-120b": (["low", "medium", "high"], "medium", False),
|
||||
"deepseek-v4-flash": (["none", "medium", "high"], "high", False),
|
||||
}
|
||||
|
||||
_ALL_EFFORTS = ("none", "low", "medium", "high", "xhigh", "max")
|
||||
|
||||
|
||||
def test_fireworks_reasoning_effort_maps_effort() -> None:
|
||||
for effort in ("none", "low", "medium", "high", "xhigh", "max"):
|
||||
|
|
@ -56,6 +71,50 @@ def test_provider_model_kwargs_for_fireworks_unknown_effort_omits_reasoning() ->
|
|||
assert "model_kwargs" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", sorted(NEW_FIREWORKS_MODELS))
|
||||
def test_new_fireworks_model_is_supported(slug: str) -> None:
|
||||
model_id = _FIREWORKS_PREFIX + slug
|
||||
assert model_id in SUPPORTED_MODEL_IDS
|
||||
model = next(m for m in SUPPORTED_MODELS if m["id"] == model_id)
|
||||
efforts, default_effort, supports_images = NEW_FIREWORKS_MODELS[slug]
|
||||
assert model["efforts"] == efforts
|
||||
assert model["default_effort"] == default_effort
|
||||
assert model["supports_images"] is supports_images
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", sorted(NEW_FIREWORKS_MODELS))
|
||||
def test_new_fireworks_model_supports_only_listed_efforts(slug: str) -> None:
|
||||
model_id = _FIREWORKS_PREFIX + slug
|
||||
efforts = NEW_FIREWORKS_MODELS[slug][0]
|
||||
for effort in efforts:
|
||||
assert model_supports_effort(model_id, effort) is True
|
||||
for effort in _ALL_EFFORTS:
|
||||
if effort not in efforts:
|
||||
assert model_supports_effort(model_id, effort) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slug",
|
||||
[
|
||||
"qwen3-coder-480b-a35b-instruct",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-instruct-0905",
|
||||
"glm-4p6",
|
||||
"mistral-large-3-fp8",
|
||||
"deepseek-v3p2",
|
||||
"qwen3-30b-a3b-instruct-2507",
|
||||
],
|
||||
)
|
||||
def test_unavailable_fireworks_models_are_gated_out(slug: str) -> None:
|
||||
assert _FIREWORKS_PREFIX + slug not in SUPPORTED_MODEL_IDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", sorted(NEW_FIREWORKS_MODELS))
|
||||
def test_only_minimax_m3_supports_images(slug: str) -> None:
|
||||
model_id = _FIREWORKS_PREFIX + slug
|
||||
assert model_supports_images(model_id) is (slug == "minimax-m3")
|
||||
|
||||
|
||||
def test_fireworks_falls_back_to_bedrock() -> None:
|
||||
assert (
|
||||
fallback_model_id_for("fireworks:accounts/fireworks/models/deepseek-v4-pro")
|
||||
|
|
|
|||
12
ui/AGENTS.md
12
ui/AGENTS.md
|
|
@ -1,12 +0,0 @@
|
|||
# AGENTS.md
|
||||
|
||||
This file applies to all work under `ui/`.
|
||||
|
||||
## Package manager
|
||||
|
||||
- Use **pnpm** for dashboard dependency management and script execution.
|
||||
- Run UI scripts with `pnpm run <script>` (for example, `pnpm run typecheck`, `pnpm run lint`, `pnpm run test`, `pnpm run build`).
|
||||
- Install or update UI dependencies with `pnpm install` / `pnpm add` only.
|
||||
- Do **not** use npm in this directory: no `npm install`, `npm ci`, `npm run`, `npx`, or npm lockfile changes.
|
||||
- Do **not** use Bun in this directory: no `bun install`, `bun add`, `bun run`, `bunx`, or Bun lockfile changes.
|
||||
- If a command must use npm or Bun, it belongs outside `ui/` in a subtree that explicitly owns that package-manager configuration and lockfiles.
|
||||
|
|
@ -7,7 +7,7 @@ This is a template for a new TanStack Start project with React, TypeScript, and
|
|||
To add components to your app, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest add button
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
This will place the ui components in the `components` directory.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"name": "open-swe-dashboard",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint",
|
||||
|
|
@ -56,7 +55,6 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"httpxy": "0.5.3",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
|
|
|
|||
11946
ui/pnpm-lock.yaml
generated
11946
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { PlanComment, PlanData } from "@/lib/plan"
|
||||
import {
|
||||
|
|
@ -8,7 +7,6 @@ import {
|
|||
deletePlanComment,
|
||||
getPlanComments,
|
||||
rejectPlan,
|
||||
updatePlan,
|
||||
} from "@/lib/plan"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Markdown } from "@/components/agents/ported"
|
||||
|
|
@ -49,7 +47,6 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
|||
}
|
||||
|
||||
export function PlanReview({ plan }: { plan: PlanData }) {
|
||||
const navigate = useNavigate()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const [comments, setComments] = useState<Array<PlanComment>>([])
|
||||
const [draft, setDraft] = useState("")
|
||||
|
|
@ -58,50 +55,6 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
const [busy, setBusy] = useState<"approve" | "reject" | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Locally track the displayed markdown so a manual edit shows immediately; the
|
||||
// route's query stops polling once a plan exists, so the prop won't refetch.
|
||||
const [markdown, setMarkdown] = useState(plan.markdown)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editDraft, setEditDraft] = useState(plan.markdown)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reflect external plan updates (e.g. an agent revision) while not editing.
|
||||
useEffect(() => {
|
||||
if (!editing) setMarkdown(plan.markdown)
|
||||
}, [plan.markdown, editing])
|
||||
|
||||
const canEdit =
|
||||
plan.isOwner && plan.status !== "approved" && plan.status !== "cancelled"
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setEditDraft(markdown)
|
||||
setEditing(true)
|
||||
setError(null)
|
||||
}, [markdown])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const saveEdit = useCallback(async () => {
|
||||
const next = editDraft.trim()
|
||||
if (!next) {
|
||||
setError("The plan cannot be empty.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await updatePlan(plan.threadId, next)
|
||||
setMarkdown(result.markdown)
|
||||
setEditing(false)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [editDraft, plan.threadId])
|
||||
|
||||
// Poll so reviewers see each other's comments without a realtime transport.
|
||||
useEffect(() => {
|
||||
|
|
@ -155,42 +108,39 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
setBusy(kind)
|
||||
setError(null)
|
||||
try {
|
||||
if (kind === "approve") {
|
||||
await approvePlan(plan.threadId)
|
||||
await navigate({
|
||||
to: "/agents/$threadId",
|
||||
params: { threadId: plan.threadId },
|
||||
})
|
||||
return
|
||||
}
|
||||
await rejectPlan(plan.threadId)
|
||||
setDecision("Changes requested — the agent is revising the plan.")
|
||||
if (kind === "approve") await approvePlan(plan.threadId)
|
||||
else await rejectPlan(plan.threadId)
|
||||
setDecision(
|
||||
kind === "approve"
|
||||
? "Plan approved — the agent is implementing it."
|
||||
: "Changes requested — the agent is revising the plan."
|
||||
)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[navigate, plan.threadId]
|
||||
[plan.threadId]
|
||||
)
|
||||
|
||||
const copyPlan = useCallback(async () => {
|
||||
setError(null)
|
||||
if (await copyToClipboard(markdown)) {
|
||||
if (await copyToClipboard(plan.markdown)) {
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} else {
|
||||
setError("Couldn't copy the plan to the clipboard.")
|
||||
}
|
||||
}, [markdown])
|
||||
}, [plan.markdown])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="plan-review"
|
||||
className="flex min-h-0 flex-1 flex-col bg-[var(--ui-bg)] text-[var(--ui-text)]"
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-[var(--ui-border)] px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-4 md:px-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--ui-border)] px-6 py-3">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-[var(--ui-text)]">
|
||||
Implementation plan
|
||||
</h1>
|
||||
|
|
@ -200,105 +150,58 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
<span data-testid="plan-status">{plan.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 md:shrink-0 md:justify-end">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{decision && (
|
||||
<span
|
||||
data-testid="plan-decision"
|
||||
className="w-full text-xs text-[var(--ui-text-dim)] md:w-auto"
|
||||
className="text-xs text-[var(--ui-text-dim)]"
|
||||
>
|
||||
{decision}
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
data-testid="cancel-edit-plan"
|
||||
variant="secondary"
|
||||
disabled={saving}
|
||||
onClick={cancelEditing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="save-plan"
|
||||
disabled={saving || !editDraft.trim()}
|
||||
onClick={() => void saveEdit()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && (
|
||||
<Button
|
||||
data-testid="edit-plan"
|
||||
variant="secondary"
|
||||
disabled={busy !== null || decision !== null}
|
||||
onClick={startEditing}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="copy-plan"
|
||||
variant="secondary"
|
||||
disabled={!markdown.trim()}
|
||||
onClick={() => void copyPlan()}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
{plan.isOwner && (
|
||||
<Button
|
||||
data-testid="approve-plan"
|
||||
disabled={busy !== null || decision !== null}
|
||||
onClick={() => void decide("approve")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="reject-plan"
|
||||
variant="secondary"
|
||||
// Requesting changes feeds the comments to the agent, so it's
|
||||
// meaningless with none — disable until at least one is left.
|
||||
disabled={
|
||||
busy !== null || decision !== null || comments.length === 0
|
||||
}
|
||||
title={
|
||||
comments.length === 0
|
||||
? "Leave a comment first to request changes"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void decide("reject")}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
data-testid="copy-plan"
|
||||
variant="secondary"
|
||||
disabled={!plan.markdown.trim()}
|
||||
onClick={() => void copyPlan()}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
{plan.isOwner && (
|
||||
<Button
|
||||
data-testid="approve-plan"
|
||||
disabled={busy !== null || decision !== null}
|
||||
onClick={() => void decide("approve")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="reject-plan"
|
||||
variant="secondary"
|
||||
// Requesting changes feeds the comments to the agent, so it's
|
||||
// meaningless with none — disable until at least one is left.
|
||||
disabled={busy !== null || decision !== null || comments.length === 0}
|
||||
title={
|
||||
comments.length === 0
|
||||
? "Leave a comment first to request changes"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void decide("reject")}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto md:flex-row md:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
className="min-w-0 px-4 py-4 md:min-h-0 md:flex-1 md:overflow-auto md:px-6"
|
||||
className="min-h-0 flex-1 overflow-auto px-6 py-4"
|
||||
data-testid="plan-document"
|
||||
data-color-scheme={resolvedTheme}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{error && (
|
||||
<p className="text-xs text-[color:var(--ui-danger)]">{error}</p>
|
||||
)}
|
||||
<textarea
|
||||
data-testid="plan-editor"
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="min-h-[20rem] w-full flex-1 resize-none rounded-md border border-[var(--ui-border)] bg-[var(--ui-bg)] px-3 py-2 font-mono text-sm text-[var(--ui-text)] outline-none focus:border-[var(--ui-accent)]"
|
||||
/>
|
||||
</div>
|
||||
) : markdown.trim() ? (
|
||||
<Markdown content={markdown} />
|
||||
{plan.markdown.trim() ? (
|
||||
<Markdown content={plan.markdown} />
|
||||
) : (
|
||||
<p className="text-sm text-[var(--ui-text-dim)]">
|
||||
The plan hasn't been written yet.
|
||||
|
|
@ -306,14 +209,14 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
<aside className="flex w-80 shrink-0 flex-col border-l border-[var(--ui-border)]">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--ui-text)]">
|
||||
Comments
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-80 space-y-3 overflow-auto px-4 py-3 md:max-h-none md:min-h-0 md:flex-1"
|
||||
className="min-h-0 flex-1 space-y-3 overflow-auto px-4 py-3"
|
||||
data-testid="plan-comments"
|
||||
>
|
||||
{comments.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -113,16 +113,6 @@ export function deletePlanComment(
|
|||
)
|
||||
}
|
||||
|
||||
export function updatePlan(
|
||||
threadId: string,
|
||||
markdown: string
|
||||
): Promise<{ status: PlanStatus; markdown: string }> {
|
||||
return req(`/plan/${encodeURIComponent(threadId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ markdown }),
|
||||
})
|
||||
}
|
||||
|
||||
export function approvePlan(threadId: string): Promise<{ status: string }> {
|
||||
return req(`/plan/${encodeURIComponent(threadId)}/approve`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const Route = createFileRoute("/agents/$threadId_/plan")({
|
|||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center px-4 py-6 max-md:pt-14 md:p-6">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center p-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -100,7 +100,7 @@ function PlanPage() {
|
|||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 pt-14 md:px-6 md:pt-3">
|
||||
<div className="border-b border-[var(--ui-border)] px-6 pt-3">
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
<PlanReview plan={plan} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue