From 1750c833ee0cd85ca1db3e45f28163a63a57cf6d Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Fri, 27 Mar 2026 13:11:23 +0700 Subject: [PATCH 01/18] fix(frontend): upgrade Docker Node.js from v21 (EOL) to v22 LTS (#12561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Upgrade the frontend **Docker image** from **Node.js v21** (EOL since June 2024) to **Node.js v22 LTS** (supported through April 2027). > **Scope:** This only affects the **Dockerfile** used for local development (`docker compose`) and CI. It does **not** affect Vercel (which manages its own Node.js runtime) or Kubernetes (the frontend Helm chart was removed in Dec 2025 — the frontend is deployed exclusively via Vercel). ## Why - Node v21.7.3 has a **known TransformStream race condition bug** causing `TypeError: controller[kState].transformAlgorithm is not a function` — this is [BUILDER-3KF](https://significant-gravitas.sentry.io/issues/BUILDER-3KF) with **567,000+ Sentry events** - The error is entirely in Node.js internals (`node:internal/webstreams/transformstream`), zero first-party code - Node 21 is **not an LTS release** and has been EOL since June 2024 - `package.json` already declares `"engines": { "node": "22.x" }` — the Dockerfile was inconsistent - Node 22.x LTS (v22.22.1) fixes the TransformStream bug - Next.js 15.4.x requires Node 18.18+, so Node 22 is fully compatible ## Changes - `autogpt_platform/frontend/Dockerfile`: `node:21-alpine` → `node:22.22-alpine3.23` (both `base` and `prod` stages) ## Test plan - [ ] Verify frontend Docker image builds successfully via `docker compose` - [ ] Verify frontend starts and serves pages correctly in local Docker environment - [ ] Monitor Sentry for BUILDER-3KF — should drop to zero for Docker-based runs --- autogpt_platform/frontend/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/frontend/Dockerfile b/autogpt_platform/frontend/Dockerfile index ab2708f1f9..476a9a8ed3 100644 --- a/autogpt_platform/frontend/Dockerfile +++ b/autogpt_platform/frontend/Dockerfile @@ -1,5 +1,5 @@ # Base stage for both dev and prod -FROM node:21-alpine AS base +FROM node:22.22-alpine3.23 AS base WORKDIR /app RUN corepack enable COPY autogpt_platform/frontend/package.json autogpt_platform/frontend/pnpm-lock.yaml ./ @@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_SOURCEMAPS="false" RUN if [ "$NEXT_PUBLIC_PW_TEST" = "true" ]; then NEXT_PUBLIC_PW_TEST=true NODE_OPTIONS="--max-old-space-size=8192" pnpm build; else NODE_OPTIONS="--max-old-space-size=8192" pnpm build; fi # Prod stage - based on NextJS reference Dockerfile https://github.com/vercel/next.js/blob/64271354533ed16da51be5dce85f0dbd15f17517/examples/with-docker/Dockerfile -FROM node:21-alpine AS prod +FROM node:22.22-alpine3.23 AS prod ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 WORKDIR /app From 43c81910ae9f94ffb7d5b05b62e3675e99c2457e Mon Sep 17 00:00:00 2001 From: An Vy Le Date: Mon, 6 Apr 2026 19:14:11 +0200 Subject: [PATCH 02/18] fix(backend/copilot): skip AI blocks without model property in fix_ai_model_parameter (#12688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Why / What / How **Why:** Some AI-category blocks do not expose a `"model"` input property in their `inputSchema`. The `fix_ai_model_parameter` fixer was unconditionally injecting a default model value (e.g. `"gpt-4o"`) into any node whose block has category `"AI"`, regardless of whether that block actually accepts a `model` input. This causes the agent JSON to include an invalid field for those blocks. **What:** Guard the model-injection logic with a check that `"model"` exists in the block's `inputSchema.properties` before attempting to set or validate the field. AI blocks that have no model selector are now skipped entirely. **How:** In `fix_ai_model_parameter`, after confirming `is_ai_block`, extract `input_properties` from the block's `inputSchema.properties` and `continue` if `"model"` is absent. The subsequent `model_schema` lookup is also simplified to reuse the already-fetched `input_properties` dict. A regression test is added to cover this case. ### Changes 🏗️ - `backend/copilot/tools/agent_generator/fixer.py`: In `fix_ai_model_parameter`, skip AI-category nodes whose block `inputSchema.properties` does not contain a `"model"` key; reuse `input_properties` for the subsequent `model_schema` lookup. - `backend/copilot/tools/agent_generator/fixer_test.py`: Add `test_ai_block_without_model_property_is_skipped` to `TestFixAiModelParameter`. ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] Run `poetry run pytest backend/copilot/tools/agent_generator/fixer_test.py` — all 50 tests pass (49 pre-existing + 1 new) Co-authored-by: Claude Sonnet 4.6 --- .../copilot/tools/agent_generator/fixer.py | 10 +++++--- .../tools/agent_generator/fixer_test.py | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py index 50d0e1925a..adebd89bf1 100644 --- a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py +++ b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer.py @@ -890,6 +890,12 @@ class AgentFixer: ) if is_ai_block: + # Skip AI blocks that don't expose a "model" input property + # (some AI-category blocks have no model selector at all). + input_properties = block.get("inputSchema", {}).get("properties", {}) + if "model" not in input_properties: + continue + node_id = node.get("id") input_default = node.get("input_default", {}) current_model = input_default.get("model") @@ -898,9 +904,7 @@ class AgentFixer: # Blocks with a block-specific enum on the model field (e.g. # PerplexityBlock) use their own enum values; others use the # generic set. - model_schema = ( - block.get("inputSchema", {}).get("properties", {}).get("model", {}) - ) + model_schema = input_properties.get("model", {}) block_model_enum = model_schema.get("enum") if block_model_enum: diff --git a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py index 07d71a941c..2319ad6760 100644 --- a/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/agent_generator/fixer_test.py @@ -580,6 +580,29 @@ class TestFixAiModelParameter: assert result["nodes"][0]["input_default"]["model"] == "perplexity/sonar" + def test_ai_block_without_model_property_is_skipped(self): + """AI-category blocks that have no 'model' input property should not + have a model injected — they simply don't expose a model selector.""" + fixer = AgentFixer() + block_id = generate_uuid() + node = _make_node(node_id="n1", block_id=block_id, input_default={}) + agent = _make_agent(nodes=[node]) + + blocks = [ + { + "id": block_id, + "name": "SomeAIBlock", + "categories": [{"category": "AI"}], + "inputSchema": { + "properties": {"prompt": {"type": "string"}}, + }, + } + ] + + result = fixer.fix_ai_model_parameter(agent, blocks) + + assert "model" not in result["nodes"][0]["input_default"] + class TestFixAgentExecutorBlocks: """Tests for fix_agent_executor_blocks.""" From 243b12778f1561e719eabff76e74b68a9f6940b9 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Tue, 7 Apr 2026 14:04:08 +0500 Subject: [PATCH 03/18] =?UTF-8?q?dx:=20improve=20pr-test=20skill=20?= =?UTF-8?q?=E2=80=94=20inline=20screenshots,=20flow=20captions,=20and=20te?= =?UTF-8?q?st=20evaluation=20(#12692)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes ### 1. Inline image enforcement (Step 7) - Added `CRITICAL` warning: never post a bare directory tree link - Added post-comment verification block that greps for `![` tags and exits 1 if none found — agents can't silently skip inline embedding ### 2. Structured screenshot captions (Step 6) - `SCREENSHOT_EXPLANATIONS` now requires **Flow** (which scenario), **Steps** (exact actions taken), **Evidence** (what this proves) - Good/bad example included so agents know what format is expected - A bare "shows the page" caption is explicitly rejected ### 3. Test completeness evaluation (Step 8) — new step After posting screenshots, the agent must evaluate coverage against the test plan and post a formal GitHub review: - **`APPROVE`** — every scenario tested with screenshot + DB/API evidence, no blockers - **`REQUEST_CHANGES`** — lists exact gaps: untested scenarios, missing evidence, confirmed bugs - Per-scenario checklist (✅/❌) required in the review body - Cannot auto-approve without ticking every item in the test plan ## Why - Agents were posting `https://github.com/.../tree/test-screenshots/...` instead of `![name](url)` inline - Screenshot captions were too vague to be useful ("shows the page") - No mechanism to catch incomplete test runs — agent could skip scenarios and still post a passing report ## Checklist - [x] `.claude/skills/pr-test/SKILL.md` updated - [x] No production code changes — skill/dx only - [x] Pre-commit hooks pass --- .claude/skills/pr-test/SKILL.md | 152 +++++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 10 deletions(-) diff --git a/.claude/skills/pr-test/SKILL.md b/.claude/skills/pr-test/SKILL.md index b915cc55ab..d7491de7dc 100644 --- a/.claude/skills/pr-test/SKILL.md +++ b/.claude/skills/pr-test/SKILL.md @@ -530,9 +530,19 @@ After showing all screenshots, output a **detailed** summary table: # but Homebrew bash is 5.x; Linux typically has bash 5.x). If running on Bash <4, use a # plain variable with a lookup function instead. declare -A SCREENSHOT_EXPLANATIONS=( - ["01-login-page.png"]="Shows the login page loaded successfully with SSO options visible." - ["02-builder-with-block.png"]="The builder canvas displays the newly added block connected to the trigger." - # ... one entry per screenshot, using the same explanations you showed the user above + # Each explanation MUST answer three things: + # 1. FLOW: Which test scenario / user journey is this part of? + # 2. STEPS: What exact actions were taken to reach this state? + # 3. EVIDENCE: What does this screenshot prove (pass/fail/data)? + # + # Good example: + # ["03-cost-log-after-run.png"]="Flow: LLM block cost tracking. Steps: Logged in as tester@gmail.com → ran 'Cost Test Agent' → waited for COMPLETED status. Evidence: PlatformCostLog table shows 1 new row with cost_microdollars=1234 and correct user_id." + # + # Bad example (too vague — never do this): + # ["03-cost-log.png"]="Shows the cost log table." + ["01-login-page.png"]="Flow: Login flow. Steps: Opened /login. Evidence: Login page renders with email/password fields and SSO options visible." + ["02-builder-with-block.png"]="Flow: Block execution. Steps: Logged in → /build → added LLM block. Evidence: Builder canvas shows block connected to trigger, ready to run." + # ... one entry per screenshot using the flow/steps/evidence format above ) TEST_RESULTS_TABLE="| 1 | Login flow | PASS | N/A | 01-login-before.png, 02-login-after.png | @@ -547,6 +557,9 @@ Upload screenshots to the PR using the GitHub Git API (no local git operations **This step is MANDATORY. Every test run MUST post a PR comment with screenshots. No exceptions.** +> **CRITICAL — NEVER post a bare directory link like `https://github.com/.../tree/...`.** +> Every screenshot MUST appear as `![name](raw_url)` inline in the PR comment so reviewers can see them without clicking any links. After posting, the verification step below greps the comment for `![` tags and exits 1 if none are found — the test run is considered incomplete until this passes. + ```bash # Upload screenshots via GitHub Git API (creates blobs, tree, commit, and ref remotely) REPO="Significant-Gravitas/AutoGPT" @@ -582,12 +595,25 @@ for img in "${SCREENSHOT_FILES[@]}"; do done TREE_JSON+=']' -# Step 2: Create tree, commit, and branch ref +# Step 2: Create tree, commit (with parent), and branch ref TREE_SHA=$(echo "$TREE_JSON" | jq -c '{tree: .}' | gh api "repos/${REPO}/git/trees" --input - --jq '.sha') -COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ - -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ - -f tree="$TREE_SHA" \ - --jq '.sha') + +# Resolve existing branch tip as parent (avoids orphan commits on repeat runs) +PARENT_SHA=$(gh api "repos/${REPO}/git/refs/heads/${SCREENSHOTS_BRANCH}" --jq '.object.sha' 2>/dev/null || true) +if [ -n "$PARENT_SHA" ]; then + COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ + -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ + -f tree="$TREE_SHA" \ + -f "parents[]=$PARENT_SHA" \ + --jq '.sha') +else + # First commit on this branch — no parent + COMMIT_SHA=$(gh api "repos/${REPO}/git/commits" \ + -f message="test: add E2E test screenshots for PR #${PR_NUMBER}" \ + -f tree="$TREE_SHA" \ + --jq '.sha') +fi + gh api "repos/${REPO}/git/refs" \ -f ref="refs/heads/${SCREENSHOTS_BRANCH}" \ -f sha="$COMMIT_SHA" 2>/dev/null \ @@ -656,17 +682,123 @@ ${IMAGE_MARKDOWN} ${FAILED_SECTION} INNEREOF -gh api "repos/${REPO}/issues/$PR_NUMBER/comments" -F body=@"$COMMENT_FILE" +POSTED_BODY=$(gh api "repos/${REPO}/issues/$PR_NUMBER/comments" -F body=@"$COMMENT_FILE" --jq '.body') rm -f "$COMMENT_FILE" ``` **The PR comment MUST include:** 1. A summary table of all scenarios with PASS/FAIL and before/after API evidence 2. Every successfully uploaded screenshot rendered inline; any failed uploads listed with manual attachment instructions -3. A 1-2 sentence explanation below each screenshot describing what it proves +3. A structured explanation below each screenshot covering: **Flow** (which scenario), **Steps** (exact actions taken to reach this state), **Evidence** (what this proves — pass/fail/data values). A bare "shows the page" caption is not acceptable. This approach uses the GitHub Git API to create blobs, trees, commits, and refs entirely server-side. No local `git checkout` or `git push` — safe for worktrees and won't interfere with the PR branch. +**Verify inline rendering after posting — this is required, not optional:** + +```bash +# 1. Confirm the posted comment body contains inline image markdown syntax +if ! echo "$POSTED_BODY" | grep -q '!\['; then + echo "❌ FAIL: No inline image tags in posted comment body. Re-check IMAGE_MARKDOWN and re-post." + exit 1 +fi + +# 2. Verify at least one raw URL actually resolves (catches wrong branch name, wrong path, etc.) +FIRST_IMG_URL=$(echo "$POSTED_BODY" | grep -o 'https://raw.githubusercontent.com[^)]*' | head -1) +if [ -n "$FIRST_IMG_URL" ]; then + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$FIRST_IMG_URL") + if [ "$HTTP_STATUS" = "200" ]; then + echo "✅ Inline images confirmed and raw URL resolves (HTTP 200)" + else + echo "❌ FAIL: Raw image URL returned HTTP $HTTP_STATUS — images will not render inline." + echo " URL: $FIRST_IMG_URL" + echo " Check branch name, path, and that the push succeeded." + exit 1 + fi +else + echo "⚠️ Could not extract a raw URL from the comment — verify manually." +fi +``` + +## Step 8: Evaluate test completeness and post a GitHub review + +After posting the PR comment, evaluate whether the test run actually covered everything it needed to. This is NOT a rubber-stamp — be critical. Then post a formal GitHub review so the PR author and reviewers can see the verdict. + +### 8a. Evaluate against the test plan + +Re-read `$RESULTS_DIR/test-plan.md` (written in Step 2) and `$RESULTS_DIR/test-report.md` (written in Step 5). For each scenario in the plan, answer: + +> **Note:** `test-report.md` is written in Step 5. If it doesn't exist, write it before proceeding here — see the Step 5 template. Do not skip evaluation because the file is missing; create it from your notes instead. + +| Question | Pass criteria | +|----------|--------------| +| Was it tested? | Explicit steps were executed, not just described | +| Is there screenshot evidence? | At least one before/after screenshot per scenario | +| Did the core feature work correctly? | Expected state matches actual state | +| Were negative cases tested? | At least one failure/rejection case per feature | +| Was DB/API state verified (not just UI)? | Raw API response or DB query confirms state change | + +Build a verdict: +- **APPROVE** — every scenario tested, evidence present, no bugs found or all bugs are minor/known +- **REQUEST_CHANGES** — one or more: untested scenarios, missing evidence, bugs found, data not verified + +### 8b. Post the GitHub review + +```bash +EVAL_FILE=$(mktemp) + +# === STEP A: Write header === +cat > "$EVAL_FILE" << 'ENDEVAL' +## 🧪 Test Evaluation + +### Coverage checklist +ENDEVAL + +# === STEP B: Append ONE line per scenario — do this BEFORE calculating verdict === +# Format: "- ✅ **Scenario N – name**: " +# or "- ❌ **Scenario N – name**: " +# Examples: +# echo "- ✅ **Scenario 1 – Login flow**: tested, screenshot evidence present, auth token verified via API" >> "$EVAL_FILE" +# echo "- ❌ **Scenario 3 – Cost logging**: NOT verified in DB — UI showed entry but raw SQL query was skipped" >> "$EVAL_FILE" +# +# !!! IMPORTANT: append ALL scenario lines here before proceeding to STEP C !!! + +# === STEP C: Derive verdict from the checklist — runs AFTER all lines are appended === +FAIL_COUNT=$(grep -c "^- ❌" "$EVAL_FILE" || true) +if [ "$FAIL_COUNT" -eq 0 ]; then + VERDICT="APPROVE" +else + VERDICT="REQUEST_CHANGES" +fi + +# === STEP D: Append verdict section === +cat >> "$EVAL_FILE" << ENDVERDICT + +### Verdict +ENDVERDICT + +if [ "$VERDICT" = "APPROVE" ]; then + echo "✅ All scenarios covered with evidence. No blocking issues found." >> "$EVAL_FILE" +else + echo "❌ $FAIL_COUNT scenario(s) incomplete or have confirmed bugs. See ❌ items above." >> "$EVAL_FILE" + echo "" >> "$EVAL_FILE" + echo "**Required before merge:** address each ❌ item above." >> "$EVAL_FILE" +fi + +# === STEP E: Post the review === +gh api "repos/${REPO}/pulls/$PR_NUMBER/reviews" \ + --method POST \ + -f body="$(cat "$EVAL_FILE")" \ + -f event="$VERDICT" + +rm -f "$EVAL_FILE" +``` + +**Rules:** +- Never auto-approve without checking every scenario in the test plan +- `REQUEST_CHANGES` if ANY scenario is untested, lacks DB/API evidence, or has a confirmed bug +- The evaluation body must list every scenario explicitly (✅ or ❌) — not just the failures +- If you find new bugs during evaluation, add them to the request-changes body and (if `--fix` flag is set) fix them before posting + ## Fix mode (--fix flag) When `--fix` is present, the standard is HIGHER. Do not just note issues — FIX them immediately. From ca748ee12aacfb0d5759d8000c7b54402890aeeb Mon Sep 17 00:00:00 2001 From: Ubbe Date: Tue, 7 Apr 2026 17:58:36 +0700 Subject: [PATCH 04/18] =?UTF-8?q?feat(frontend):=20refine=20AutoPilot=20on?= =?UTF-8?q?boarding=20=E2=80=94=20branding,=20auto-advance,=20soft=20cap,?= =?UTF-8?q?=20polish=20(#12686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Why / What / How **Why:** The onboarding flow had inconsistent branding ("Autopilot" vs "AutoPilot"), a heavy progress bar that dominated the header, an extra click on the role screen, and no guidance on how many pain points to select — leading to users selecting everything or nothing useful. **What:** Copy & brand fixes, UX improvements (auto-advance, soft cap), and visual polish (progress bar, checkmark badges, purple focus inputs). **How:** - Replaced all "Autopilot" with "AutoPilot" (capital P) across screens 1-3 - Removed the `?` tooltip on screen 1 (users will learn about AutoPilot from the access email) - Changed name label to conversational "What should I call you?" - Screen 2: auto-advances 350ms after role selection (except "Other" which still shows input + button) - Screen 3: soft cap of 3 selections with green confirmation text and shake animation on overflow attempt - Thinned progress bar from ~10px to 3px (Linear/Notion style) - Added purple checkmark badges on selected cards - Updated Input atom focus state to purple ring ### Changes 🏗️ - **WelcomeStep**: "AutoPilot" branding, removed tooltip, conversational label - **RoleStep**: Updated subtitle, auto-advance on non-"Other" role select, Continue button only for "Other" - **PainPointsStep**: Soft cap of 3 with dynamic helper text and shake animation - **usePainPointsStep**: Added `atLimit`/`shaking` state, wrapped `togglePainPoint` with cap logic - **store.ts**: `togglePainPoint` returns early when at 3 and adding - **ProgressBar**: 3px height, removed glow shadow - **SelectableCard**: Added purple checkmark badge on selected state - **Input atom**: Focus ring changed from zinc to purple - **tailwind.config.ts**: Added `shake` keyframe and `animate-shake` utility ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] Navigate through full onboarding flow (screens 1→2→3→4) - [ ] Verify "AutoPilot" branding on all screens (no "Autopilot") - [ ] Verify screen 2 auto-advances after tapping a role (non-"Other") - [ ] Verify "Other" role still shows text input and Continue button - [ ] Verify Back button works correctly from screen 2 and 3 - [ ] Select 3 pain points and verify green "3 selected" text - [ ] Attempt 4th selection and verify shake animation + swap message - [ ] Deselect one and verify can select a different one - [ ] Verify checkmark badges appear on selected cards - [ ] Verify progress bar is thin (3px) and subtle - [ ] Verify input focus state is purple across onboarding inputs - [ ] Verify "Something else" + other text input still works on screen 3 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../onboarding/__tests__/store.test.ts | 23 +++ .../onboarding/components/ProgressBar.tsx | 4 +- .../onboarding/components/SelectableCard.tsx | 8 +- .../onboarding/steps/PainPointsStep.tsx | 28 +++- .../(no-navbar)/onboarding/steps/RoleStep.tsx | 65 +++++--- .../onboarding/steps/WelcomeStep.tsx | 35 +--- .../steps/__tests__/PainPointsStep.test.tsx | 154 ++++++++++++++++++ .../steps/__tests__/RoleStep.test.tsx | 123 ++++++++++++++ .../onboarding/steps/usePainPointsStep.ts | 27 ++- .../src/app/(no-navbar)/onboarding/store.ts | 3 + .../src/components/atoms/Input/Input.tsx | 2 +- .../frontend/src/tests/onboarding.spec.ts | 22 +-- .../frontend/src/tests/utils/onboarding.ts | 8 +- autogpt_platform/frontend/tailwind.config.ts | 8 + 14 files changed, 422 insertions(+), 88 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx create mode 100644 autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts index 251fc54579..f28d1fc2cb 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/__tests__/store.test.ts @@ -66,6 +66,29 @@ describe("useOnboardingWizardStore", () => { "no tests", ]); }); + + it("ignores new selections when at the max limit", () => { + useOnboardingWizardStore.getState().togglePainPoint("a"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + useOnboardingWizardStore.getState().togglePainPoint("c"); + useOnboardingWizardStore.getState().togglePainPoint("d"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("still allows deselecting when at the max limit", () => { + useOnboardingWizardStore.getState().togglePainPoint("a"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + useOnboardingWizardStore.getState().togglePainPoint("c"); + useOnboardingWizardStore.getState().togglePainPoint("b"); + expect(useOnboardingWizardStore.getState().painPoints).toEqual([ + "a", + "c", + ]); + }); }); describe("setOtherPainPoint", () => { diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx index aee653d93f..71819d7d4c 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/ProgressBar.tsx @@ -7,9 +7,9 @@ export function ProgressBar({ currentStep, totalSteps }: Props) { const percent = (currentStep / totalSteps) * 100; return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx index 7559ff3e21..574f02fd7b 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/SelectableCard.tsx @@ -2,6 +2,7 @@ import { Text } from "@/components/atoms/Text/Text"; import { cn } from "@/lib/utils"; +import { Check } from "@phosphor-icons/react"; interface Props { icon: React.ReactNode; @@ -24,13 +25,18 @@ export function SelectableCard({ onClick={onClick} aria-pressed={selected} className={cn( - "flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8", + "relative flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8", className, selected ? "border-purple-500 bg-purple-50 shadow-sm" : "border-transparent", )} > + {selected && ( + + + + )} - Pick the tasks you'd love to hand off to Autopilot + Pick the tasks you'd love to hand off to AutoPilot
@@ -107,11 +110,22 @@ export function PainPointsStep() { /> ))}
- {!hasSomethingElse ? ( - - Pick as many as you want — you can always change later - - ) : null} + + {shaking + ? "You've picked 3 — tap one to swap it out" + : atLimit && canContinue + ? "3 selected — you're all set!" + : atLimit && hasSomethingElse + ? "Tell us what else takes up your time" + : "Pick up to 3 to start — AutoPilot can help with anything else later"} + {hasSomethingElse && ( @@ -133,7 +147,7 @@ export function PainPointsStep() { disabled={!canContinue} className="w-full max-w-xs" > - Launch Autopilot + Launch AutoPilot diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx index 79704e3e31..9bb6af42cd 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/RoleStep.tsx @@ -8,6 +8,7 @@ import { FadeIn } from "@/components/atoms/FadeIn/FadeIn"; import { SelectableCard } from "../components/SelectableCard"; import { useOnboardingWizardStore } from "../store"; import { Emoji } from "@/components/atoms/Emoji/Emoji"; +import { useEffect, useRef } from "react"; const IMG_SIZE = 42; @@ -57,12 +58,26 @@ export function RoleStep() { const setRole = useOnboardingWizardStore((s) => s.setRole); const setOtherRole = useOnboardingWizardStore((s) => s.setOtherRole); const nextStep = useOnboardingWizardStore((s) => s.nextStep); + const autoAdvanceTimer = useRef | null>(null); const isOther = role === "Other"; - const canContinue = role && (!isOther || otherRole.trim()); - function handleContinue() { - if (canContinue) { + useEffect(() => { + return () => { + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + }; + }, []); + + function handleRoleSelect(id: string) { + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + setRole(id); + if (id !== "Other") { + autoAdvanceTimer.current = setTimeout(nextStep, 350); + } + } + + function handleOtherContinue() { + if (otherRole.trim()) { nextStep(); } } @@ -78,7 +93,7 @@ export function RoleStep() { What best describes you, {name}? - Autopilot will tailor automations to your world + So AutoPilot knows how to help you best @@ -89,33 +104,35 @@ export function RoleStep() { icon={r.icon} label={r.label} selected={role === r.id} - onClick={() => setRole(r.id)} + onClick={() => handleRoleSelect(r.id)} className="p-8" /> ))} {isOther && ( -
- setOtherRole(e.target.value)} - autoFocus - /> -
- )} + <> +
+ setOtherRole(e.target.value)} + autoFocus + /> +
- + + + )} ); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx index fa054161cc..06ce9b57b7 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/WelcomeStep.tsx @@ -4,13 +4,6 @@ import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo"; import { Button } from "@/components/atoms/Button/Button"; import { Input } from "@/components/atoms/Input/Input"; import { Text } from "@/components/atoms/Text/Text"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/atoms/Tooltip/BaseTooltip"; -import { Question } from "@phosphor-icons/react"; import { FadeIn } from "@/components/atoms/FadeIn/FadeIn"; import { useOnboardingWizardStore } from "../store"; @@ -40,36 +33,16 @@ export function WelcomeStep() { Welcome to AutoGPT Let's personalize your experience so{" "} - - Autopilot - - - - - - - - Autopilot is AutoGPT's AI assistant that watches your - connected apps, spots repetitive tasks you do every day - and runs them for you automatically. - - - - + + AutoPilot {" "} - can start saving you time right away + can start saving you time setName(e.target.value)} diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx new file mode 100644 index 0000000000..f6843f7998 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/PainPointsStep.test.tsx @@ -0,0 +1,154 @@ +import { + render, + screen, + fireEvent, + cleanup, +} from "@/tests/integrations/test-utils"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useOnboardingWizardStore } from "../../store"; +import { PainPointsStep } from "../PainPointsStep"; + +vi.mock("@/components/atoms/Emoji/Emoji", () => ({ + Emoji: ({ text }: { text: string }) => {text}, +})); + +vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({ + FadeIn: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +function getCard(name: RegExp) { + return screen.getByRole("button", { name }); +} + +function clickCard(name: RegExp) { + fireEvent.click(getCard(name)); +} + +function getLaunchButton() { + return screen.getByRole("button", { name: /launch autopilot/i }); +} + +afterEach(cleanup); + +beforeEach(() => { + useOnboardingWizardStore.getState().reset(); + useOnboardingWizardStore.getState().setName("Alice"); + useOnboardingWizardStore.getState().setRole("Founder/CEO"); + useOnboardingWizardStore.getState().goToStep(3); +}); + +describe("PainPointsStep", () => { + test("renders all pain point cards", () => { + render(); + + expect(getCard(/finding leads/i)).toBeDefined(); + expect(getCard(/email & outreach/i)).toBeDefined(); + expect(getCard(/reports & data/i)).toBeDefined(); + expect(getCard(/customer support/i)).toBeDefined(); + expect(getCard(/social media/i)).toBeDefined(); + expect(getCard(/something else/i)).toBeDefined(); + }); + + test("shows default helper text", () => { + render(); + + expect( + screen.getAllByText(/pick up to 3 to start/i).length, + ).toBeGreaterThan(0); + }); + + test("selecting a card marks it as pressed", () => { + render(); + + clickCard(/finding leads/i); + + expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe("true"); + }); + + test("launch button is disabled when nothing is selected", () => { + render(); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(true); + }); + + test("launch button is enabled after selecting a pain point", () => { + render(); + + clickCard(/finding leads/i); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(false); + }); + + test("shows success text when 3 items are selected", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + + expect(screen.getAllByText(/3 selected/i).length).toBeGreaterThan(0); + }); + + test("does not select a 4th item when at the limit", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + clickCard(/customer support/i); + + expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe( + "false", + ); + }); + + test("can deselect when at the limit and select a different one", () => { + render(); + + clickCard(/finding leads/i); + clickCard(/email & outreach/i); + clickCard(/reports & data/i); + + clickCard(/finding leads/i); + expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe( + "false", + ); + + clickCard(/customer support/i); + expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe( + "true", + ); + }); + + test("shows input when 'Something else' is selected", () => { + render(); + + clickCard(/something else/i); + + expect( + screen.getByPlaceholderText(/what else takes up your time/i), + ).toBeDefined(); + }); + + test("launch button is disabled when 'Something else' selected but input empty", () => { + render(); + + clickCard(/something else/i); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(true); + }); + + test("launch button is enabled when 'Something else' selected and input filled", () => { + render(); + + clickCard(/something else/i); + fireEvent.change( + screen.getByPlaceholderText(/what else takes up your time/i), + { target: { value: "Manual invoicing" } }, + ); + + expect(getLaunchButton().hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx new file mode 100644 index 0000000000..0cafccab98 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/__tests__/RoleStep.test.tsx @@ -0,0 +1,123 @@ +import { + render, + screen, + fireEvent, + cleanup, +} from "@/tests/integrations/test-utils"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useOnboardingWizardStore } from "../../store"; +import { RoleStep } from "../RoleStep"; + +vi.mock("@/components/atoms/Emoji/Emoji", () => ({ + Emoji: ({ text }: { text: string }) => {text}, +})); + +vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({ + FadeIn: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +beforeEach(() => { + vi.useFakeTimers(); + useOnboardingWizardStore.getState().reset(); + useOnboardingWizardStore.getState().setName("Alice"); + useOnboardingWizardStore.getState().goToStep(2); +}); + +describe("RoleStep", () => { + test("renders all role cards", () => { + render(); + + expect(screen.getByText("Founder / CEO")).toBeDefined(); + expect(screen.getByText("Operations")).toBeDefined(); + expect(screen.getByText("Sales / BD")).toBeDefined(); + expect(screen.getByText("Marketing")).toBeDefined(); + expect(screen.getByText("Product / PM")).toBeDefined(); + expect(screen.getByText("Engineering")).toBeDefined(); + expect(screen.getByText("HR / People")).toBeDefined(); + expect(screen.getByText("Other")).toBeDefined(); + }); + + test("displays the user name in the heading", () => { + render(); + + expect( + screen.getAllByText(/what best describes you, alice/i).length, + ).toBeGreaterThan(0); + }); + + test("selecting a non-Other role auto-advances after delay", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /engineering/i })); + + expect(useOnboardingWizardStore.getState().role).toBe("Engineering"); + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + + vi.advanceTimersByTime(350); + + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); + + test("selecting 'Other' does not auto-advance", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + vi.advanceTimersByTime(500); + + expect(useOnboardingWizardStore.getState().currentStep).toBe(2); + }); + + test("selecting 'Other' shows text input and Continue button", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined(); + expect(screen.getByRole("button", { name: /continue/i })).toBeDefined(); + }); + + test("Continue button is disabled when Other input is empty", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + + const continueBtn = screen.getByRole("button", { name: /continue/i }); + expect(continueBtn.hasAttribute("disabled")).toBe(true); + }); + + test("Continue button advances when Other role text is filled", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + fireEvent.change(screen.getByPlaceholderText(/describe your role/i), { + target: { value: "Designer" }, + }); + + const continueBtn = screen.getByRole("button", { name: /continue/i }); + expect(continueBtn.hasAttribute("disabled")).toBe(false); + + fireEvent.click(continueBtn); + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); + + test("switching from Other to a regular role cancels Other and auto-advances", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /\bother\b/i })); + expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: /marketing/i })); + + expect(useOnboardingWizardStore.getState().role).toBe("Marketing"); + vi.advanceTimersByTime(350); + expect(useOnboardingWizardStore.getState().currentStep).toBe(3); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts index bf8f5e59cc..384a43e80c 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/steps/usePainPointsStep.ts @@ -1,4 +1,5 @@ -import { useOnboardingWizardStore } from "../store"; +import { useEffect, useRef, useState } from "react"; +import { MAX_PAIN_POINT_SELECTIONS, useOnboardingWizardStore } from "../store"; const ROLE_TOP_PICKS: Record = { "Founder/CEO": [ @@ -23,18 +24,38 @@ export function usePainPointsStep() { const role = useOnboardingWizardStore((s) => s.role); const painPoints = useOnboardingWizardStore((s) => s.painPoints); const otherPainPoint = useOnboardingWizardStore((s) => s.otherPainPoint); - const togglePainPoint = useOnboardingWizardStore((s) => s.togglePainPoint); + const storeToggle = useOnboardingWizardStore((s) => s.togglePainPoint); const setOtherPainPoint = useOnboardingWizardStore( (s) => s.setOtherPainPoint, ); const nextStep = useOnboardingWizardStore((s) => s.nextStep); + const [shaking, setShaking] = useState(false); + const shakeTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (shakeTimer.current) clearTimeout(shakeTimer.current); + }; + }, []); const topIDs = getTopPickIDs(role); const hasSomethingElse = painPoints.includes("Something else"); + const atLimit = painPoints.length >= MAX_PAIN_POINT_SELECTIONS; const canContinue = painPoints.length > 0 && (!hasSomethingElse || Boolean(otherPainPoint.trim())); + function togglePainPoint(id: string) { + const alreadySelected = painPoints.includes(id); + if (!alreadySelected && atLimit) { + if (shakeTimer.current) clearTimeout(shakeTimer.current); + setShaking(true); + shakeTimer.current = setTimeout(() => setShaking(false), 600); + return; + } + storeToggle(id); + } + function handleLaunch() { if (canContinue) { nextStep(); @@ -48,6 +69,8 @@ export function usePainPointsStep() { togglePainPoint, setOtherPainPoint, hasSomethingElse, + atLimit, + shaking, canContinue, handleLaunch, }; diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts index edc5ffa020..fe5e52b8c1 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; +export const MAX_PAIN_POINT_SELECTIONS = 3; export type Step = 1 | 2 | 3 | 4; interface OnboardingWizardState { @@ -40,6 +41,8 @@ export const useOnboardingWizardStore = create( togglePainPoint(painPoint) { set((state) => { const exists = state.painPoints.includes(painPoint); + if (!exists && state.painPoints.length >= MAX_PAIN_POINT_SELECTIONS) + return state; return { painPoints: exists ? state.painPoints.filter((p) => p !== painPoint) diff --git a/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx index 2591a14cf4..ee2caa39af 100644 --- a/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx +++ b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx @@ -78,7 +78,7 @@ export function Input({ "font-normal text-black", "placeholder:font-normal placeholder:text-zinc-400", // Focus and hover states - "focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0", + "focus:border-purple-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-purple-400 focus:ring-offset-0", className, ); diff --git a/autogpt_platform/frontend/src/tests/onboarding.spec.ts b/autogpt_platform/frontend/src/tests/onboarding.spec.ts index d1916fff2e..321469c268 100644 --- a/autogpt_platform/frontend/src/tests/onboarding.spec.ts +++ b/autogpt_platform/frontend/src/tests/onboarding.spec.ts @@ -32,7 +32,7 @@ test("onboarding wizard step navigation works", async ({ page }) => { // Step 1: Welcome await expect(page.getByText("Welcome to AutoGPT")).toBeVisible(); - await page.getByLabel("Your first name").fill("Bob"); + await page.getByLabel("What should I call you?").fill("Bob"); await page.getByRole("button", { name: "Continue" }).click(); // Step 2: Role — verify we're here, then go back @@ -41,7 +41,7 @@ test("onboarding wizard step navigation works", async ({ page }) => { // Should be back on step 1 with name preserved await expect(page.getByText("Welcome to AutoGPT")).toBeVisible(); - await expect(page.getByLabel("Your first name")).toHaveValue("Bob"); + await expect(page.getByLabel("What should I call you?")).toHaveValue("Bob"); }); test("onboarding wizard validates required fields", async ({ page }) => { @@ -53,18 +53,13 @@ test("onboarding wizard validates required fields", async ({ page }) => { await expect(continueButton).toBeDisabled(); // Fill name — continue should become enabled - await page.getByLabel("Your first name").fill("Charlie"); + await page.getByLabel("What should I call you?").fill("Charlie"); await expect(continueButton).toBeEnabled(); await continueButton.click(); - // Step 2: Continue should be disabled without a role - const step2Continue = page.getByRole("button", { name: "Continue" }); - await expect(step2Continue).toBeDisabled(); - - // Select role — continue should become enabled + // Step 2: Role — selecting auto-advances to step 3 + await expect(page.getByText("What best describes you")).toBeVisible(); await page.getByText("Engineering").click(); - await expect(step2Continue).toBeEnabled(); - await step2Continue.click(); // Step 3: Launch Autopilot should be disabled without any pain points const launchButton = page.getByRole("button", { name: "Launch Autopilot" }); @@ -95,7 +90,7 @@ test("onboarding URL params sync with steps", async ({ page }) => { await expect(page.getByText("Welcome to AutoGPT")).toBeVisible(); // Fill name and go to step 2 - await page.getByLabel("Your first name").fill("Test"); + await page.getByLabel("What should I call you?").fill("Test"); await page.getByRole("button", { name: "Continue" }).click(); // URL should show step=2 @@ -106,12 +101,11 @@ test("role-based pain point ordering works", async ({ page }) => { await signupTestUser(page, undefined, undefined, false); // Complete step 1 - await page.getByLabel("Your first name").fill("Test"); + await page.getByLabel("What should I call you?").fill("Test"); await page.getByRole("button", { name: "Continue" }).click(); - // Select Sales/BD role + // Select Sales/BD role (auto-advances to step 3) await page.getByText("Sales / BD").click(); - await page.getByRole("button", { name: "Continue" }).click(); // On pain points step, "Finding leads" should be visible (top pick for Sales) await expect(page.getByText("What's eating your time?")).toBeVisible(); diff --git a/autogpt_platform/frontend/src/tests/utils/onboarding.ts b/autogpt_platform/frontend/src/tests/utils/onboarding.ts index 22dd7330b4..375babc743 100644 --- a/autogpt_platform/frontend/src/tests/utils/onboarding.ts +++ b/autogpt_platform/frontend/src/tests/utils/onboarding.ts @@ -52,15 +52,14 @@ export async function completeOnboardingWizard( await expect(page.getByText("Welcome to AutoGPT")).toBeVisible({ timeout: 10000, }); - await page.getByLabel("Your first name").fill(name); + await page.getByLabel("What should I call you?").fill(name); await page.getByRole("button", { name: "Continue" }).click(); - // Step 2: Role — select a role + // Step 2: Role — select a role (auto-advances after selection) await expect(page.getByText("What best describes you")).toBeVisible({ timeout: 5000, }); await page.getByText(role, { exact: false }).click(); - await page.getByRole("button", { name: "Continue" }).click(); // Step 3: Pain points — select tasks await expect(page.getByText("What's eating your time?")).toBeVisible({ @@ -72,9 +71,6 @@ export async function completeOnboardingWizard( await page.getByRole("button", { name: "Launch Autopilot" }).click(); // Step 4: Preparing — wait for animation to complete and redirect to /copilot - await expect(page.getByText("Preparing your workspace")).toBeVisible({ - timeout: 5000, - }); await page.waitForURL(/\/copilot/, { timeout: 15000 }); return { name, role, painPoints }; diff --git a/autogpt_platform/frontend/tailwind.config.ts b/autogpt_platform/frontend/tailwind.config.ts index 754521de4c..6b6fff3f41 100644 --- a/autogpt_platform/frontend/tailwind.config.ts +++ b/autogpt_platform/frontend/tailwind.config.ts @@ -175,6 +175,13 @@ const config = { boxShadow: "0 0 0 30px rgba(0, 0, 0, 0)", }, }, + shake: { + "0%, 100%": { transform: "translateX(0)" }, + "20%": { transform: "translateX(-4px)" }, + "40%": { transform: "translateX(4px)" }, + "60%": { transform: "translateX(-3px)" }, + "80%": { transform: "translateX(3px)" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", @@ -182,6 +189,7 @@ const config = { "fade-in": "fade-in 0.2s ease-out", shimmer: "shimmer 4s ease-in-out infinite", loader: "loader 1s infinite", + shake: "shake 0.5s ease-in-out", }, transitionDuration: { "2000": "2000ms", From 41c2ee9f83fe65303b55443cd85cc749570b7ce5 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Tue, 7 Apr 2026 06:24:22 -0500 Subject: [PATCH 05/18] feat(platform): add copilot artifact preview panel (#12629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Why / What / How Copilot artifacts were not previewing reliably: PDFs downloaded instead of rendering, Python code could still render like markdown, JSX/TSX artifacts were brittle, HTML dashboards/charts could fail to execute, and users had to manually open artifact panes after generation. The pane also got stuck at maximized width when trying to drag it smaller. This PR adds a dedicated copilot artifact panel and preview pipeline across the backend/frontend boundary. It preserves artifact metadata needed for classification, adds extension-first preview routing, introduces dedicated preview/rendering paths for HTML/CSV/code/PDF/React artifacts, auto-opens new or edited assistant artifacts, and fixes the maximized-pane resize path so dragging exits maximized mode immediately. ### Changes 🏗️ - add artifact card and artifact panel UI in copilot, including persisted panel state and resize/maximize/minimize behavior - add shared artifact extraction/classification helpers and auto-open behavior for new or edited assistant messages with artifacts - add preview/rendering support for HTML, CSV, PDF, code, and React artifact files - fix code artifacts such as Python to render through the code renderer with a dark code surface instead of markdown-style output - improve JSX/TSX preview behavior with provider wrapping, fallback export selection, and explicit runtime error surfaces - allow script execution inside HTML previews so embedded chart dashboards can render - update workspace artifact/backend API handling and regenerate the frontend OpenAPI client - add regression coverage for artifact helpers, React preview runtime, auto-open behavior, code rendering, and panel store behavior - post-review hardening: correct download path for cross-origin URLs, defer scroll restore until content mounts, gate auto-open behind the ARTIFACTS flag, parse CSVs with RFC 4180-compliant quoted newlines + BOM handling, distinguish 413 vs 409 on upload, normalize empty session_id, and keep AnimatePresence mounted so the panel exit animation plays ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] `pnpm format` - [x] `pnpm lint` - [x] `pnpm types` - [x] `pnpm test:unit` #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) --- > [!NOTE] > **Medium Risk** > Adds a new Copilot artifact preview surface that executes user/AI-generated HTML/React in sandboxed iframes and changes workspace file upload/listing behavior, so regressions could affect file handling and client security assumptions despite sandboxing safeguards. > > **Overview** > Adds an **Artifacts** feature (flagged by `Flag.ARTIFACTS`) to Copilot: workspace file links/attachments now render as `ArtifactCard`s and can open a new resizable/minimizable `ArtifactPanel` with history, auto-open behavior, copy/download actions, and persisted panel width. > > Introduces a richer artifact preview pipeline with type classification and dedicated renderers for **HTML**, **CSV**, **PDF**, **code (Shiki-highlighted)**, and **React/TSX** (transpiled and executed in a sandboxed iframe), plus safer download filename handling and content caching/scroll restore. > > Extends the workspace backend API by adding `GET /workspace/files` pagination, standardizing operation IDs in OpenAPI, attaching `metadata.origin` on uploads/agent-created files, normalizing empty `session_id`, improving upload error mapping (409 vs 413), and hardening post-quota soft-delete error handling; updates and expands test coverage accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b732d10eca678e32f944ba9f618bc9caeb1fce16. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Zamil Majdy Co-authored-by: Claude Opus 4.6 (1M context) --- .../backend/api/features/workspace/routes.py | 96 +++- .../api/features/workspace/routes_test.py | 425 ++++++++++++++---- .../backend/copilot/tools/workspace_files.py | 1 + .../backend/backend/util/workspace.py | 3 + .../app/(platform)/copilot/CopilotPage.tsx | 90 ++-- .../components/ArtifactCard/ArtifactCard.tsx | 114 +++++ .../ArtifactPanel/ArtifactPanel.tsx | 125 ++++++ .../components/ArtifactContent.tsx | 198 ++++++++ .../components/ArtifactDragHandle.tsx | 93 ++++ .../components/ArtifactMinimizedStrip.tsx | 47 ++ .../components/ArtifactPanelHeader.tsx | 138 ++++++ .../components/ArtifactReactPreview.tsx | 72 +++ .../components/ArtifactSkeleton.tsx | 17 + .../ArtifactPanel/components/SourceToggle.tsx | 41 ++ .../__tests__/useArtifactContent.test.ts | 167 +++++++ .../components/reactArtifactPreview.test.ts | 88 ++++ .../components/reactArtifactPreview.ts | 318 +++++++++++++ .../components/transpileReactArtifact.test.ts | 51 +++ .../components/transpileReactArtifact.ts | 43 ++ .../components/useArtifactContent.ts | 154 +++++++ .../ArtifactPanel/downloadArtifact.test.ts | 121 +++++ .../ArtifactPanel/downloadArtifact.ts | 35 ++ .../components/ArtifactPanel/helpers.test.ts | 79 ++++ .../components/ArtifactPanel/helpers.ts | 229 ++++++++++ .../ArtifactPanel/useArtifactPanel.ts | 148 ++++++ .../ChatContainer/ChatContainer.tsx | 21 +- .../useAutoOpenArtifacts.test.ts | 140 ++++++ .../ChatContainer/useAutoOpenArtifacts.ts | 91 ++++ .../components/MessageAttachments.tsx | 18 + .../components/MessagePartRenderer.tsx | 35 +- .../ChatMessagesContainer/helpers.test.ts | 103 +++++ .../ChatMessagesContainer/helpers.ts | 86 +++- .../src/app/(platform)/copilot/store.test.ts | 141 ++++++ .../src/app/(platform)/copilot/store.ts | 170 +++++++ .../frontend/src/app/api/openapi.json | 121 ++++- .../contextual/OutputRenderers/index.ts | 4 + .../renderers/CSVRenderer.test.ts | 67 +++ .../OutputRenderers/renderers/CSVRenderer.tsx | 177 ++++++++ .../renderers/CodeRenderer.tsx | 174 ++++++- .../renderers/HTMLRenderer.tsx | 75 ++++ .../widgets/FileInput/useWorkspaceUpload.ts | 4 +- .../lib/__tests__/iframe-sandbox-csp.test.ts | 58 +++ .../frontend/src/lib/iframe-sandbox-csp.ts | 48 ++ .../services/feature-flags/use-get-flag.ts | 2 + .../src/services/storage/local-storage.ts | 1 + 45 files changed, 4267 insertions(+), 162 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactCard/ArtifactCard.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactMinimizedStrip.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactPanelHeader.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactReactPreview.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactSkeleton.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/SourceToggle.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/__tests__/useArtifactContent.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/reactArtifactPreview.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/reactArtifactPreview.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/transpileReactArtifact.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/transpileReactArtifact.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/useArtifactContent.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/downloadArtifact.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/helpers.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/useArtifactPanel.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/useAutoOpenArtifacts.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/useAutoOpenArtifacts.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/helpers.test.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/store.test.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CSVRenderer.test.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/CSVRenderer.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/OutputRenderers/renderers/HTMLRenderer.tsx create mode 100644 autogpt_platform/frontend/src/lib/__tests__/iframe-sandbox-csp.test.ts create mode 100644 autogpt_platform/frontend/src/lib/iframe-sandbox-csp.ts diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py index 8ca339edbd..39bcc6c7c4 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -12,7 +12,7 @@ import fastapi from autogpt_libs.auth.dependencies import get_user_id, requires_user from fastapi import Query, UploadFile from fastapi.responses import Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from backend.data.workspace import ( WorkspaceFile, @@ -131,9 +131,26 @@ class StorageUsageResponse(BaseModel): file_count: int +class WorkspaceFileItem(BaseModel): + id: str + name: str + path: str + mime_type: str + size_bytes: int + metadata: dict = Field(default_factory=dict) + created_at: str + + +class ListFilesResponse(BaseModel): + files: list[WorkspaceFileItem] + offset: int = 0 + has_more: bool = False + + @router.get( "/files/{file_id}/download", summary="Download file by ID", + operation_id="getWorkspaceDownloadFileById", ) async def download_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -158,6 +175,7 @@ async def download_file( @router.delete( "/files/{file_id}", summary="Delete a workspace file", + operation_id="deleteWorkspaceFile", ) async def delete_workspace_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -183,6 +201,7 @@ async def delete_workspace_file( @router.post( "/files/upload", summary="Upload file to workspace", + operation_id="uploadWorkspaceFile", ) async def upload_file( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -196,6 +215,9 @@ async def upload_file( Files are stored in session-scoped paths when session_id is provided, so the agent's session-scoped tools can discover them automatically. """ + # Empty-string session_id drops session scoping; normalize to None. + session_id = session_id or None + config = Config() # Sanitize filename — strip any directory components @@ -250,16 +272,27 @@ async def upload_file( manager = WorkspaceManager(user_id, workspace.id, session_id) try: workspace_file = await manager.write_file( - content, filename, overwrite=overwrite + content, filename, overwrite=overwrite, metadata={"origin": "user-upload"} ) except ValueError as e: - raise fastapi.HTTPException(status_code=409, detail=str(e)) from e + # write_file raises ValueError for both path-conflict and size-limit + # cases; map each to its correct HTTP status. + message = str(e) + if message.startswith("File too large"): + raise fastapi.HTTPException(status_code=413, detail=message) from e + raise fastapi.HTTPException(status_code=409, detail=message) from e # Post-write storage check — eliminates TOCTOU race on the quota. # If a concurrent upload pushed us over the limit, undo this write. new_total = await get_workspace_total_size(workspace.id) if storage_limit_bytes and new_total > storage_limit_bytes: - await soft_delete_workspace_file(workspace_file.id, workspace.id) + try: + await soft_delete_workspace_file(workspace_file.id, workspace.id) + except Exception as e: + logger.warning( + f"Failed to soft-delete over-quota file {workspace_file.id} " + f"in workspace {workspace.id}: {e}" + ) raise fastapi.HTTPException( status_code=413, detail={ @@ -281,6 +314,7 @@ async def upload_file( @router.get( "/storage/usage", summary="Get workspace storage usage", + operation_id="getWorkspaceStorageUsage", ) async def get_storage_usage( user_id: Annotated[str, fastapi.Security(get_user_id)], @@ -301,3 +335,57 @@ async def get_storage_usage( used_percent=round((used_bytes / limit_bytes) * 100, 1) if limit_bytes else 0, file_count=file_count, ) + + +@router.get( + "/files", + summary="List workspace files", + operation_id="listWorkspaceFiles", +) +async def list_workspace_files( + user_id: Annotated[str, fastapi.Security(get_user_id)], + session_id: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=1000), + offset: int = Query(default=0, ge=0), +) -> ListFilesResponse: + """ + List files in the user's workspace. + + When session_id is provided, only files for that session are returned. + Otherwise, all files across sessions are listed. Results are paginated + via `limit`/`offset`; `has_more` indicates whether additional pages exist. + """ + workspace = await get_or_create_workspace(user_id) + + # Treat empty-string session_id the same as omitted — an empty value + # would otherwise silently list files across every session instead of + # scoping to one. + session_id = session_id or None + + manager = WorkspaceManager(user_id, workspace.id, session_id) + include_all = session_id is None + # Fetch one extra to compute has_more without a separate count query. + files = await manager.list_files( + limit=limit + 1, + offset=offset, + include_all_sessions=include_all, + ) + has_more = len(files) > limit + page = files[:limit] + + return ListFilesResponse( + files=[ + WorkspaceFileItem( + id=f.id, + name=f.name, + path=f.path, + mime_type=f.mime_type, + size_bytes=f.size_bytes, + metadata=f.metadata or {}, + created_at=f.created_at.isoformat(), + ) + for f in page + ], + offset=offset, + has_more=has_more, + ) diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes_test.py b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py index 76da67aaa1..42726ba051 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes_test.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py @@ -1,48 +1,28 @@ -"""Tests for workspace file upload and download routes.""" - import io from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch import fastapi import fastapi.testclient import pytest -import pytest_mock -from backend.api.features.workspace import routes as workspace_routes -from backend.data.workspace import WorkspaceFile +from backend.api.features.workspace.routes import router +from backend.data.workspace import Workspace, WorkspaceFile app = fastapi.FastAPI() -app.include_router(workspace_routes.router) +app.include_router(router) @app.exception_handler(ValueError) async def _value_error_handler( request: fastapi.Request, exc: ValueError ) -> fastapi.responses.JSONResponse: - """Mirror the production ValueError → 400 mapping from rest_api.py.""" + """Mirror the production ValueError → 400 mapping from the REST app.""" return fastapi.responses.JSONResponse(status_code=400, content={"detail": str(exc)}) client = fastapi.testclient.TestClient(app) -TEST_USER_ID = "3e53486c-cf57-477e-ba2a-cb02dc828e1a" - -MOCK_WORKSPACE = type("W", (), {"id": "ws-1"})() - -_NOW = datetime(2023, 1, 1, tzinfo=timezone.utc) - -MOCK_FILE = WorkspaceFile( - id="file-aaa-bbb", - workspace_id="ws-1", - created_at=_NOW, - updated_at=_NOW, - name="hello.txt", - path="/session/hello.txt", - mime_type="text/plain", - size_bytes=13, - storage_path="local://hello.txt", -) - @pytest.fixture(autouse=True) def setup_app_auth(mock_jwt_user): @@ -53,25 +33,201 @@ def setup_app_auth(mock_jwt_user): app.dependency_overrides.clear() +def _make_workspace(user_id: str = "test-user-id") -> Workspace: + return Workspace( + id="ws-001", + user_id=user_id, + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + +def _make_file(**overrides) -> WorkspaceFile: + defaults = { + "id": "file-001", + "workspace_id": "ws-001", + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "updated_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "name": "test.txt", + "path": "/test.txt", + "storage_path": "local://test.txt", + "mime_type": "text/plain", + "size_bytes": 100, + "checksum": None, + "is_deleted": False, + "deleted_at": None, + "metadata": {}, + } + defaults.update(overrides) + return WorkspaceFile(**defaults) + + +def _make_file_mock(**overrides) -> MagicMock: + """Create a mock WorkspaceFile to simulate DB records with null fields.""" + defaults = { + "id": "file-001", + "name": "test.txt", + "path": "/test.txt", + "mime_type": "text/plain", + "size_bytes": 100, + "metadata": {}, + "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + } + defaults.update(overrides) + mock = MagicMock(spec=WorkspaceFile) + for k, v in defaults.items(): + setattr(mock, k, v) + return mock + + +# -- list_workspace_files tests -- + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_returns_all_when_no_session(mock_manager_cls, mock_get_workspace): + mock_get_workspace.return_value = _make_workspace() + files = [ + _make_file(id="f1", name="a.txt", metadata={"origin": "user-upload"}), + _make_file(id="f2", name="b.csv", metadata={"origin": "agent-created"}), + ] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files") + assert response.status_code == 200 + + data = response.json() + assert len(data["files"]) == 2 + assert data["has_more"] is False + assert data["offset"] == 0 + assert data["files"][0]["id"] == "f1" + assert data["files"][0]["metadata"] == {"origin": "user-upload"} + assert data["files"][1]["id"] == "f2" + mock_instance.list_files.assert_called_once_with( + limit=201, offset=0, include_all_sessions=True + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_scopes_to_session_when_provided( + mock_manager_cls, mock_get_workspace, test_user_id +): + mock_get_workspace.return_value = _make_workspace(user_id=test_user_id) + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?session_id=sess-123") + assert response.status_code == 200 + + data = response.json() + assert data["files"] == [] + assert data["has_more"] is False + mock_manager_cls.assert_called_once_with(test_user_id, "ws-001", "sess-123") + mock_instance.list_files.assert_called_once_with( + limit=201, offset=0, include_all_sessions=False + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_null_metadata_coerced_to_empty_dict( + mock_manager_cls, mock_get_workspace +): + """Route uses `f.metadata or {}` for pre-existing files with null metadata.""" + mock_get_workspace.return_value = _make_workspace() + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [_make_file_mock(metadata=None)] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files") + assert response.status_code == 200 + assert response.json()["files"][0]["metadata"] == {} + + +# -- upload_file metadata tests -- + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.get_workspace_total_size") +@patch("backend.api.features.workspace.routes.scan_content_safe") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_upload_passes_user_upload_origin_metadata( + mock_manager_cls, mock_scan, mock_total_size, mock_get_workspace +): + mock_get_workspace.return_value = _make_workspace() + mock_total_size.return_value = 100 + written = _make_file(id="new-file", name="doc.pdf") + mock_instance = AsyncMock() + mock_instance.write_file.return_value = written + mock_manager_cls.return_value = mock_instance + + response = client.post( + "/files/upload", + files={"file": ("doc.pdf", b"fake-pdf-content", "application/pdf")}, + ) + assert response.status_code == 200 + + mock_instance.write_file.assert_called_once() + call_kwargs = mock_instance.write_file.call_args + assert call_kwargs.kwargs.get("metadata") == {"origin": "user-upload"} + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.get_workspace_total_size") +@patch("backend.api.features.workspace.routes.scan_content_safe") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_upload_returns_409_on_file_conflict( + mock_manager_cls, mock_scan, mock_total_size, mock_get_workspace +): + mock_get_workspace.return_value = _make_workspace() + mock_total_size.return_value = 100 + mock_instance = AsyncMock() + mock_instance.write_file.side_effect = ValueError("File already exists at path") + mock_manager_cls.return_value = mock_instance + + response = client.post( + "/files/upload", + files={"file": ("dup.txt", b"content", "text/plain")}, + ) + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + +# -- Restored upload/download/delete security + invariant tests -- + + def _upload( filename: str = "hello.txt", content: bytes = b"Hello, world!", content_type: str = "text/plain", ): - """Helper to POST a file upload.""" return client.post( "/files/upload?session_id=sess-1", files={"file": (filename, io.BytesIO(content), content_type)}, ) -# ---- Happy path ---- +_MOCK_FILE = WorkspaceFile( + id="file-aaa-bbb", + workspace_id="ws-001", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + name="hello.txt", + path="/sessions/sess-1/hello.txt", + mime_type="text/plain", + size_bytes=13, + storage_path="local://hello.txt", +) -def test_upload_happy_path(mocker: pytest_mock.MockFixture): +def test_upload_happy_path(mocker): mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -82,7 +238,7 @@ def test_upload_happy_path(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -96,10 +252,7 @@ def test_upload_happy_path(mocker: pytest_mock.MockFixture): assert data["size_bytes"] == 13 -# ---- Per-file size limit ---- - - -def test_upload_exceeds_max_file_size(mocker: pytest_mock.MockFixture): +def test_upload_exceeds_max_file_size(mocker): """Files larger than max_file_size_mb should be rejected with 413.""" cfg = mocker.patch("backend.api.features.workspace.routes.Config") cfg.return_value.max_file_size_mb = 0 # 0 MB → any content is too big @@ -109,15 +262,11 @@ def test_upload_exceeds_max_file_size(mocker: pytest_mock.MockFixture): assert response.status_code == 413 -# ---- Storage quota exceeded ---- - - -def test_upload_storage_quota_exceeded(mocker: pytest_mock.MockFixture): +def test_upload_storage_quota_exceeded(mocker): mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) - # Current usage already at limit mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", return_value=500 * 1024 * 1024, @@ -128,27 +277,22 @@ def test_upload_storage_quota_exceeded(mocker: pytest_mock.MockFixture): assert "Storage limit exceeded" in response.text -# ---- Post-write quota race (B2) ---- - - -def test_upload_post_write_quota_race(mocker: pytest_mock.MockFixture): - """If a concurrent upload tips the total over the limit after write, - the file should be soft-deleted and 413 returned.""" +def test_upload_post_write_quota_race(mocker): + """Concurrent upload tipping over limit after write should soft-delete + 413.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) - # Pre-write check passes (under limit), but post-write check fails mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", - side_effect=[0, 600 * 1024 * 1024], # first call OK, second over limit + side_effect=[0, 600 * 1024 * 1024], ) mocker.patch( "backend.api.features.workspace.routes.scan_content_safe", return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -160,17 +304,14 @@ def test_upload_post_write_quota_race(mocker: pytest_mock.MockFixture): response = _upload() assert response.status_code == 413 - mock_delete.assert_called_once_with("file-aaa-bbb", "ws-1") + mock_delete.assert_called_once_with("file-aaa-bbb", "ws-001") -# ---- Any extension accepted (no allowlist) ---- - - -def test_upload_any_extension(mocker: pytest_mock.MockFixture): +def test_upload_any_extension(mocker): """Any file extension should be accepted — ClamAV is the security layer.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -181,7 +322,7 @@ def test_upload_any_extension(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -191,16 +332,13 @@ def test_upload_any_extension(mocker: pytest_mock.MockFixture): assert response.status_code == 200 -# ---- Virus scan rejection ---- - - -def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): +def test_upload_blocked_by_virus_scan(mocker): """Files flagged by ClamAV should be rejected and never written to storage.""" from backend.api.features.store.exceptions import VirusDetectedError mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -211,7 +349,7 @@ def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): side_effect=VirusDetectedError("Eicar-Test-Signature"), ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -219,18 +357,14 @@ def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): response = _upload(filename="evil.exe", content=b"X5O!P%@AP...") assert response.status_code == 400 - assert "Virus detected" in response.text mock_manager.write_file.assert_not_called() -# ---- No file extension ---- - - -def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): +def test_upload_file_without_extension(mocker): """Files without an extension should be accepted and stored as-is.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -241,7 +375,7 @@ def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, @@ -257,14 +391,11 @@ def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): assert mock_manager.write_file.call_args[0][1] == "Makefile" -# ---- Filename sanitization (SF5) ---- - - -def test_upload_strips_path_components(mocker: pytest_mock.MockFixture): +def test_upload_strips_path_components(mocker): """Path-traversal filenames should be reduced to their basename.""" mocker.patch( "backend.api.features.workspace.routes.get_or_create_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_total_size", @@ -275,28 +406,23 @@ def test_upload_strips_path_components(mocker: pytest_mock.MockFixture): return_value=None, ) mock_manager = mocker.MagicMock() - mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mock_manager.write_file = mocker.AsyncMock(return_value=_MOCK_FILE) mocker.patch( "backend.api.features.workspace.routes.WorkspaceManager", return_value=mock_manager, ) - # Filename with traversal _upload(filename="../../etc/passwd.txt") - # write_file should have been called with just the basename mock_manager.write_file.assert_called_once() call_args = mock_manager.write_file.call_args assert call_args[0][1] == "passwd.txt" -# ---- Download ---- - - -def test_download_file_not_found(mocker: pytest_mock.MockFixture): +def test_download_file_not_found(mocker): mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mocker.patch( "backend.api.features.workspace.routes.get_workspace_file", @@ -307,14 +433,11 @@ def test_download_file_not_found(mocker: pytest_mock.MockFixture): assert response.status_code == 404 -# ---- Delete ---- - - -def test_delete_file_success(mocker: pytest_mock.MockFixture): +def test_delete_file_success(mocker): """Deleting an existing file should return {"deleted": true}.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mock_manager = mocker.MagicMock() mock_manager.delete_file = mocker.AsyncMock(return_value=True) @@ -329,11 +452,11 @@ def test_delete_file_success(mocker: pytest_mock.MockFixture): mock_manager.delete_file.assert_called_once_with("file-aaa-bbb") -def test_delete_file_not_found(mocker: pytest_mock.MockFixture): +def test_delete_file_not_found(mocker): """Deleting a non-existent file should return 404.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", - return_value=MOCK_WORKSPACE, + return_value=_make_workspace(), ) mock_manager = mocker.MagicMock() mock_manager.delete_file = mocker.AsyncMock(return_value=False) @@ -347,7 +470,7 @@ def test_delete_file_not_found(mocker: pytest_mock.MockFixture): assert "File not found" in response.text -def test_delete_file_no_workspace(mocker: pytest_mock.MockFixture): +def test_delete_file_no_workspace(mocker): """Deleting when user has no workspace should return 404.""" mocker.patch( "backend.api.features.workspace.routes.get_workspace", @@ -357,3 +480,123 @@ def test_delete_file_no_workspace(mocker: pytest_mock.MockFixture): response = client.delete("/files/file-aaa-bbb") assert response.status_code == 404 assert "Workspace not found" in response.text + + +def test_upload_write_file_too_large_returns_413(mocker): + """write_file raises ValueError("File too large: …") → must map to 413.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=_make_workspace(), + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock( + side_effect=ValueError("File too large: 900 bytes exceeds 1MB limit") + ) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload() + assert response.status_code == 413 + assert "File too large" in response.text + + +def test_upload_write_file_conflict_returns_409(mocker): + """Non-'File too large' ValueErrors from write_file stay as 409.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=_make_workspace(), + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock( + side_effect=ValueError("File already exists at path: /sessions/x/a.txt") + ) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload() + assert response.status_code == 409 + assert "already exists" in response.text + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_has_more_true_when_limit_exceeded( + mock_manager_cls, mock_get_workspace +): + """The limit+1 fetch trick must flip has_more=True and trim the page.""" + mock_get_workspace.return_value = _make_workspace() + # Backend was asked for limit+1=3, and returned exactly 3 items. + files = [ + _make_file(id="f1", name="a.txt"), + _make_file(id="f2", name="b.txt"), + _make_file(id="f3", name="c.txt"), + ] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?limit=2") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is True + assert len(data["files"]) == 2 + assert data["files"][0]["id"] == "f1" + assert data["files"][1]["id"] == "f2" + mock_instance.list_files.assert_called_once_with( + limit=3, offset=0, include_all_sessions=True + ) + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_has_more_false_when_exactly_page_size( + mock_manager_cls, mock_get_workspace +): + """Exactly `limit` rows means we're on the last page — has_more=False.""" + mock_get_workspace.return_value = _make_workspace() + files = [_make_file(id="f1", name="a.txt"), _make_file(id="f2", name="b.txt")] + mock_instance = AsyncMock() + mock_instance.list_files.return_value = files + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?limit=2") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is False + assert len(data["files"]) == 2 + + +@patch("backend.api.features.workspace.routes.get_or_create_workspace") +@patch("backend.api.features.workspace.routes.WorkspaceManager") +def test_list_files_offset_is_echoed_back(mock_manager_cls, mock_get_workspace): + mock_get_workspace.return_value = _make_workspace() + mock_instance = AsyncMock() + mock_instance.list_files.return_value = [] + mock_manager_cls.return_value = mock_instance + + response = client.get("/files?offset=50&limit=10") + assert response.status_code == 200 + assert response.json()["offset"] == 50 + mock_instance.list_files.assert_called_once_with( + limit=11, offset=50, include_all_sessions=True + ) diff --git a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py index def2d4772a..a5fe549923 100644 --- a/autogpt_platform/backend/backend/copilot/tools/workspace_files.py +++ b/autogpt_platform/backend/backend/copilot/tools/workspace_files.py @@ -845,6 +845,7 @@ class WriteWorkspaceFileTool(BaseTool): path=path, mime_type=mime_type, overwrite=overwrite, + metadata={"origin": "agent-created"}, ) # Build informative source label and message. diff --git a/autogpt_platform/backend/backend/util/workspace.py b/autogpt_platform/backend/backend/util/workspace.py index 34ab1e3582..5ec4a5b336 100644 --- a/autogpt_platform/backend/backend/util/workspace.py +++ b/autogpt_platform/backend/backend/util/workspace.py @@ -155,6 +155,7 @@ class WorkspaceManager: path: Optional[str] = None, mime_type: Optional[str] = None, overwrite: bool = False, + metadata: Optional[dict] = None, ) -> WorkspaceFile: """ Write file to workspace. @@ -168,6 +169,7 @@ class WorkspaceManager: path: Virtual path (defaults to "/{filename}", session-scoped if session_id set) mime_type: MIME type (auto-detected if not provided) overwrite: Whether to overwrite existing file at path + metadata: Optional metadata dict (e.g., origin tracking) Returns: Created WorkspaceFile instance @@ -246,6 +248,7 @@ class WorkspaceManager: mime_type=mime_type, size_bytes=len(content), checksum=checksum, + metadata=metadata, ) except UniqueViolationError: if retries > 0: diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index 90084bc535..c4cc08a501 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -8,6 +8,7 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; import { SidebarProvider } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import { UploadSimple } from "@phosphor-icons/react"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; @@ -20,6 +21,14 @@ import { RateLimitResetDialog } from "./components/RateLimitResetDialog/RateLimi import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader"; import { useCopilotPage } from "./useCopilotPage"; +const ArtifactPanel = dynamic( + () => + import("./components/ArtifactPanel/ArtifactPanel").then( + (m) => m.ArtifactPanel, + ), + { ssr: false }, +); + export function CopilotPage() { const [isDragging, setIsDragging] = useState(false); const [droppedFiles, setDroppedFiles] = useState([]); @@ -116,6 +125,7 @@ export function CopilotPage() { const resetCost = usage?.reset_cost; const isBillingEnabled = useGetFlag(Flag.ENABLE_PLATFORM_PAYMENT); + const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS); const { credits, fetchCredits } = useCredits({ fetchInitialCredits: true }); const hasInsufficientCredits = credits !== null && resetCost != null && credits < resetCost; @@ -150,48 +160,52 @@ export function CopilotPage() { className="h-[calc(100vh-72px)] min-h-0" > {!isMobile && } -
- {isMobile && } - - {/* Drop overlay */} +
- - - Drop files here - -
-
- + {isMobile && } + + {/* Drop overlay */} +
+ + + Drop files here + +
+
+ +
+ {!isMobile && isArtifactsEnabled && }
+ {isMobile && isArtifactsEnabled && } {isMobile && ( s.artifactPanel.activeArtifact?.id); + const isOpen = useCopilotUIStore((s) => s.artifactPanel.isOpen); + const openArtifact = useCopilotUIStore((s) => s.openArtifact); + + const isActive = isOpen && activeID === artifact.id; + const classification = classifyArtifact( + artifact.mimeType, + artifact.title, + artifact.sizeBytes, + ); + const Icon = classification.icon; + + function handleDownloadOnly() { + downloadArtifact(artifact).catch(() => { + toast({ + title: "Download failed", + description: "Couldn't fetch the file.", + variant: "destructive", + }); + }); + } + + if (!classification.openable) { + return ( + + ); + } + + return ( + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx new file mode 100644 index 0000000000..78e79e50e8 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/ArtifactPanel.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArtifactContent } from "./components/ArtifactContent"; +import { ArtifactDragHandle } from "./components/ArtifactDragHandle"; +import { ArtifactMinimizedStrip } from "./components/ArtifactMinimizedStrip"; +import { ArtifactPanelHeader } from "./components/ArtifactPanelHeader"; +import { useArtifactPanel } from "./useArtifactPanel"; + +interface Props { + mobile?: boolean; +} + +export function ArtifactPanel({ mobile }: Props) { + const { + isOpen, + isMinimized, + isMaximized, + activeArtifact, + history, + effectiveWidth, + isSourceView, + classification, + setIsSourceView, + closeArtifactPanel, + minimizeArtifactPanel, + maximizeArtifactPanel, + restoreArtifactPanel, + setArtifactPanelWidth, + goBackArtifact, + canCopy, + handleCopy, + handleDownload, + } = useArtifactPanel(); + + if (!activeArtifact || !classification) return null; + + const headerProps = { + artifact: activeArtifact, + classification, + canGoBack: history.length > 0, + isMaximized, + isSourceView, + hasSourceToggle: classification.hasSourceToggle, + mobile: !!mobile, + canCopy, + onBack: goBackArtifact, + onClose: closeArtifactPanel, + onMinimize: minimizeArtifactPanel, + onMaximize: maximizeArtifactPanel, + onRestore: restoreArtifactPanel, + onCopy: handleCopy, + onDownload: handleDownload, + onSourceToggle: setIsSourceView, + }; + + // Mobile: fullscreen Sheet overlay + if (mobile) { + return ( + !open && closeArtifactPanel()} + > + + + {activeArtifact.title} + + + + + + ); + } + + // Minimized strip + if (isOpen && isMinimized) { + return ( + + ); + } + + // Keep AnimatePresence mounted across the open→closed transition so the + // exit animation on the motion.div has a chance to run. + return ( + + {isOpen && ( + + + + + + )} + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx new file mode 100644 index 0000000000..6e057293b5 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactContent.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { globalRegistry } from "@/components/contextual/OutputRenderers"; +import { codeRenderer } from "@/components/contextual/OutputRenderers/renderers/CodeRenderer"; +import { Suspense } from "react"; +import type { ArtifactRef } from "../../../store"; +import type { ArtifactClassification } from "../helpers"; +import { ArtifactReactPreview } from "./ArtifactReactPreview"; +import { ArtifactSkeleton } from "./ArtifactSkeleton"; +import { + TAILWIND_CDN_URL, + wrapWithHeadInjection, +} from "@/lib/iframe-sandbox-csp"; +import { useArtifactContent } from "./useArtifactContent"; + +interface Props { + artifact: ArtifactRef; + isSourceView: boolean; + classification: ArtifactClassification; +} + +function ArtifactContentLoader({ + artifact, + isSourceView, + classification, +}: Props) { + const { content, pdfUrl, isLoading, error, scrollRef, retry } = + useArtifactContent(artifact, classification); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+

Failed to load content

+

{error}

+ +
+ ); + } + + return ( +
+ +
+ ); +} + +function ArtifactRenderer({ + artifact, + content, + pdfUrl, + isSourceView, + classification, +}: { + artifact: ArtifactRef; + content: string | null; + pdfUrl: string | null; + isSourceView: boolean; + classification: ArtifactClassification; +}) { + // Image: render directly from URL (no content fetch) + if (classification.type === "image") { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {artifact.title} +
+ ); + } + + if (classification.type === "pdf" && pdfUrl) { + // No sandbox — Chrome/Edge block PDF rendering in sandboxed iframes + // (Chromium bug #413851). The blob URL has a null origin so it can't + // access the parent page regardless. + return ( +