feat(platform): add copilot artifact preview panel (#12629)

### 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**)

<!-- CURSOR_SUMMARY -->
---

> [!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.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b732d10eca. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-04-07 06:24:22 -05:00
committed by GitHub
parent ca748ee12a
commit 41c2ee9f83
45 changed files with 4267 additions and 162 deletions

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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:

View File

@@ -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<File[]>([]);
@@ -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 && <ChatSidebar />}
<div
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div className="flex h-full w-full flex-row overflow-hidden">
<div
className={cn(
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
isDragging ? "opacity-100" : "opacity-0",
)}
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-[#f8f8f9] px-0"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
<span className="text-lg font-medium text-violet-600">
Drop files here
</span>
</div>
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
status={status}
error={error}
sessionId={sessionId}
isLoadingSession={isLoadingSession}
isSessionError={isSessionError}
isCreatingSession={isCreatingSession}
isReconnecting={isReconnecting}
isSyncing={isSyncing}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
historicalDurations={historicalDurations}
/>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div
className={cn(
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
isDragging ? "opacity-100" : "opacity-0",
)}
>
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
<span className="text-lg font-medium text-violet-600">
Drop files here
</span>
</div>
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
status={status}
error={error}
sessionId={sessionId}
isLoadingSession={isLoadingSession}
isSessionError={isSessionError}
isCreatingSession={isCreatingSession}
isReconnecting={isReconnecting}
isSyncing={isSyncing}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
historicalDurations={historicalDurations}
/>
</div>
</div>
{!isMobile && isArtifactsEnabled && <ArtifactPanel />}
</div>
{isMobile && isArtifactsEnabled && <ArtifactPanel mobile />}
{isMobile && (
<MobileDrawer
isOpen={isDrawerOpen}

View File

@@ -0,0 +1,114 @@
"use client";
import { toast } from "@/components/molecules/Toast/use-toast";
import { cn } from "@/lib/utils";
import { CaretRight, DownloadSimple } from "@phosphor-icons/react";
import type { ArtifactRef } from "../../store";
import { useCopilotUIStore } from "../../store";
import { downloadArtifact } from "../ArtifactPanel/downloadArtifact";
import { classifyArtifact } from "../ArtifactPanel/helpers";
interface Props {
artifact: ArtifactRef;
}
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ArtifactCard({ artifact }: Props) {
const activeID = useCopilotUIStore((s) => 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 (
<button
type="button"
onClick={handleDownloadOnly}
className="my-1 flex w-full items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2.5 text-left transition-colors hover:bg-zinc-50"
>
<Icon size={20} className="shrink-0 text-zinc-400" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-900">
{artifact.title}
</p>
<p className="text-xs text-zinc-400">
{classification.label}
{artifact.sizeBytes
? ` \u2022 ${formatSize(artifact.sizeBytes)}`
: ""}
</p>
</div>
<DownloadSimple size={16} className="shrink-0 text-zinc-400" />
</button>
);
}
return (
<button
type="button"
onClick={() => openArtifact(artifact)}
className={cn(
"my-1 flex w-full items-center gap-3 rounded-lg border bg-white px-3 py-2.5 text-left transition-colors hover:bg-zinc-50",
isActive ? "border-violet-300 bg-violet-50/50" : "border-zinc-200",
)}
>
<Icon
size={20}
className={cn(
"shrink-0",
isActive ? "text-violet-500" : "text-zinc-400",
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-900">
{artifact.title}
</p>
<p className="text-xs text-zinc-400">
<span
className={cn(
"inline-block rounded-full px-1.5 py-0.5 text-xs font-medium",
artifact.origin === "user-upload"
? "bg-blue-50 text-blue-500"
: "bg-violet-50 text-violet-500",
)}
>
{classification.label}
</span>
{artifact.sizeBytes
? ` \u2022 ${formatSize(artifact.sizeBytes)}`
: ""}
</p>
</div>
<CaretRight
size={16}
className={cn(
"shrink-0",
isActive ? "text-violet-400" : "text-zinc-300",
)}
/>
</button>
);
}

View File

@@ -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 (
<Sheet
open={isOpen}
onOpenChange={(open) => !open && closeArtifactPanel()}
>
<SheetContent
side="right"
className="flex w-full flex-col p-0 sm:max-w-full"
>
<SheetHeader className="sr-only">
<SheetTitle>{activeArtifact.title}</SheetTitle>
</SheetHeader>
<ArtifactPanelHeader {...headerProps} />
<ArtifactContent
artifact={activeArtifact}
isSourceView={isSourceView}
classification={classification}
/>
</SheetContent>
</Sheet>
);
}
// Minimized strip
if (isOpen && isMinimized) {
return (
<ArtifactMinimizedStrip
artifact={activeArtifact}
classification={classification}
onExpand={restoreArtifactPanel}
/>
);
}
// Keep AnimatePresence mounted across the open→closed transition so the
// exit animation on the motion.div has a chance to run.
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="artifact-panel"
data-artifact-panel
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="relative flex h-full flex-col overflow-hidden border-l border-zinc-200 bg-white"
style={{ width: effectiveWidth }}
>
<ArtifactDragHandle onWidthChange={setArtifactPanelWidth} />
<ArtifactPanelHeader {...headerProps} />
<ArtifactContent
artifact={activeArtifact}
isSourceView={isSourceView}
classification={classification}
/>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -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 <ArtifactSkeleton extraLine />;
}
if (error) {
return (
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 p-8 text-center"
>
<p className="text-sm text-zinc-500">Failed to load content</p>
<p className="text-xs text-zinc-400">{error}</p>
<button
type="button"
onClick={retry}
className="rounded-md border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-sm transition-colors hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
>
Try again
</button>
</div>
);
}
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<ArtifactRenderer
artifact={artifact}
content={content}
pdfUrl={pdfUrl}
isSourceView={isSourceView}
classification={classification}
/>
</div>
);
}
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 (
<div className="flex items-center justify-center p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={artifact.sourceUrl}
alt={artifact.title}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
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 (
<iframe src={pdfUrl} className="h-full w-full" title={artifact.title} />
);
}
if (content === null) return null;
// Source view: always show raw text
if (isSourceView) {
return (
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-sm text-zinc-800">
{content}
</pre>
);
}
if (classification.type === "html") {
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
const wrapped = wrapWithHeadInjection(content, tailwindScript);
return (
<iframe
sandbox="allow-scripts"
srcDoc={wrapped}
className="h-full w-full border-0"
title={artifact.title}
/>
);
}
if (classification.type === "react") {
return <ArtifactReactPreview source={content} title={artifact.title} />;
}
// Code: pass with explicit type metadata so CodeRenderer matches
// (prevents higher-priority MarkdownRenderer from claiming it)
if (classification.type === "code") {
const ext = artifact.title.split(".").pop() ?? "";
const codeMeta = {
mimeType: artifact.mimeType ?? undefined,
filename: artifact.title,
type: "code",
language: ext,
};
return <div className="p-4">{codeRenderer.render(content, codeMeta)}</div>;
}
// JSON: parse first so the JSONRenderer gets an object, not a string
// (prevents higher-priority MarkdownRenderer from claiming it)
if (classification.type === "json") {
try {
const parsed = JSON.parse(content);
const jsonMeta = {
mimeType: "application/json",
type: "json",
filename: artifact.title,
};
const jsonRenderer = globalRegistry.getRenderer(parsed, jsonMeta);
if (jsonRenderer) {
return (
<div className="p-4">{jsonRenderer.render(parsed, jsonMeta)}</div>
);
}
} catch {
// invalid JSON — fall through to plain text
}
}
// CSV: pass with explicit metadata so CSVRenderer matches
if (classification.type === "csv") {
const csvMeta = { mimeType: "text/csv", filename: artifact.title };
const csvRenderer = globalRegistry.getRenderer(content, csvMeta);
if (csvRenderer) {
return <div className="p-4">{csvRenderer.render(content, csvMeta)}</div>;
}
}
// Try the global renderer registry
const metadata = {
mimeType: artifact.mimeType ?? undefined,
filename: artifact.title,
};
const renderer = globalRegistry.getRenderer(content, metadata);
if (renderer) {
return <div className="p-4">{renderer.render(content, metadata)}</div>;
}
// Fallback: plain text
return (
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-sm text-zinc-800">
{content}
</pre>
);
}
export function ArtifactContent(props: Props) {
return (
<Suspense fallback={<ArtifactSkeleton />}>
<ArtifactContentLoader {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
import { DEFAULT_PANEL_WIDTH } from "../../../store";
interface Props {
onWidthChange: (width: number) => void;
minWidth?: number;
maxWidthPercent?: number;
}
export function ArtifactDragHandle({
onWidthChange,
minWidth = 320,
maxWidthPercent = 85,
}: Props) {
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
// Use refs for the callback + bounds so the drag listeners can read the
// latest values without having to detach/reattach between re-renders.
const onWidthChangeRef = useRef(onWidthChange);
const minWidthRef = useRef(minWidth);
const maxWidthPercentRef = useRef(maxWidthPercent);
onWidthChangeRef.current = onWidthChange;
minWidthRef.current = minWidth;
maxWidthPercentRef.current = maxWidthPercent;
// Attach document listeners only while dragging, and always tear them down
// on unmount — otherwise closing the panel mid-drag leaves listeners bound
// to a handler that calls setState on the unmounted component.
useEffect(() => {
if (!isDragging) return;
function handlePointerMove(moveEvent: PointerEvent) {
const delta = startXRef.current - moveEvent.clientX;
const maxWidth = window.innerWidth * (maxWidthPercentRef.current / 100);
const newWidth = Math.min(
maxWidth,
Math.max(minWidthRef.current, startWidthRef.current + delta),
);
onWidthChangeRef.current(newWidth);
}
function handlePointerUp() {
setIsDragging(false);
}
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
return () => {
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
};
}, [isDragging]);
function handlePointerDown(e: React.PointerEvent) {
e.preventDefault();
startXRef.current = e.clientX;
// Get the panel's current width from its parent
const panel = (e.target as HTMLElement).closest(
"[data-artifact-panel]",
) as HTMLElement | null;
startWidthRef.current = panel?.offsetWidth ?? DEFAULT_PANEL_WIDTH;
setIsDragging(true);
}
return (
// 12px transparent hit target with the visible 1px line centered inside
// (WCAG-compliant, matches ~8-12px conventions of other resizable panels).
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
className={cn(
"group absolute -left-1.5 top-0 z-10 flex h-full w-3 cursor-col-resize items-stretch justify-center",
)}
onPointerDown={handlePointerDown}
>
<div
className={cn(
"h-full w-px bg-transparent transition-colors group-hover:w-0.5 group-hover:bg-violet-400",
isDragging && "w-0.5 bg-violet-500",
)}
/>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { ArrowsOutSimple } from "@phosphor-icons/react";
import type { ArtifactRef } from "../../../store";
import type { ArtifactClassification } from "../helpers";
interface Props {
artifact: ArtifactRef;
classification: ArtifactClassification;
onExpand: () => void;
}
export function ArtifactMinimizedStrip({
artifact,
classification,
onExpand,
}: Props) {
const Icon = classification.icon;
return (
<div className="flex h-full w-10 flex-col items-center border-l border-zinc-200 bg-white pt-3">
<button
type="button"
onClick={onExpand}
className="rounded p-1.5 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
title="Expand panel"
>
<ArrowsOutSimple size={16} />
</button>
<div className="mt-3 text-zinc-400">
<Icon size={16} />
</div>
<span
className="mt-2 text-xs text-zinc-400"
style={{
writingMode: "vertical-rl",
textOrientation: "mixed",
maxHeight: "120px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{artifact.title}
</span>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
ArrowsIn,
ArrowsOut,
Copy,
DownloadSimple,
Minus,
X,
} from "@phosphor-icons/react";
import type { ArtifactRef } from "../../../store";
import type { ArtifactClassification } from "../helpers";
import { SourceToggle } from "./SourceToggle";
interface Props {
artifact: ArtifactRef;
classification: ArtifactClassification;
canGoBack: boolean;
isMaximized: boolean;
isSourceView: boolean;
hasSourceToggle: boolean;
mobile?: boolean;
canCopy?: boolean;
onBack: () => void;
onClose: () => void;
onMinimize: () => void;
onMaximize: () => void;
onRestore: () => void;
onCopy: () => void;
onDownload: () => void;
onSourceToggle: (isSource: boolean) => void;
}
function HeaderButton({
onClick,
title,
children,
}: {
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="rounded p-1.5 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
>
{children}
</button>
);
}
export function ArtifactPanelHeader({
artifact,
classification,
canGoBack,
isMaximized,
isSourceView,
hasSourceToggle,
mobile,
canCopy = true,
onBack,
onClose,
onMinimize,
onMaximize,
onRestore,
onCopy,
onDownload,
onSourceToggle,
}: Props) {
const Icon = classification.icon;
return (
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-zinc-200 bg-white px-3 py-2">
{/* Left section */}
<div className="flex min-w-0 flex-1 items-center gap-2">
{canGoBack && (
<HeaderButton onClick={onBack} title="Back">
<ArrowLeft size={16} />
</HeaderButton>
)}
<Icon size={16} className="shrink-0 text-zinc-400" />
<span className="truncate text-sm font-medium text-zinc-900">
{artifact.title}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
artifact.origin === "user-upload"
? "bg-blue-50 text-blue-600"
: "bg-violet-50 text-violet-600",
)}
>
{classification.label}
</span>
</div>
{/* Right section */}
<div className="flex items-center gap-1">
{hasSourceToggle && (
<SourceToggle isSourceView={isSourceView} onToggle={onSourceToggle} />
)}
{canCopy && (
<HeaderButton onClick={onCopy} title="Copy">
<Copy size={16} />
</HeaderButton>
)}
<HeaderButton onClick={onDownload} title="Download">
<DownloadSimple size={16} />
</HeaderButton>
{!mobile && (
<>
<HeaderButton onClick={onMinimize} title="Minimize">
<Minus size={16} />
</HeaderButton>
{isMaximized ? (
<HeaderButton onClick={onRestore} title="Restore">
<ArrowsIn size={16} />
</HeaderButton>
) : (
<HeaderButton onClick={onMaximize} title="Maximize">
<ArrowsOut size={16} />
</HeaderButton>
)}
</>
)}
<HeaderButton onClick={onClose} title="Close">
<X size={16} />
</HeaderButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { ArtifactSkeleton } from "./ArtifactSkeleton";
import {
buildReactArtifactSrcDoc,
collectPreviewStyles,
transpileReactArtifactSource,
} from "./reactArtifactPreview";
interface Props {
source: string;
title: string;
}
export function ArtifactReactPreview({ source, title }: Props) {
const [srcDoc, setSrcDoc] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setSrcDoc(null);
setError(null);
transpileReactArtifactSource(source, title)
.then((compiledCode) => {
if (cancelled) return;
setSrcDoc(
buildReactArtifactSrcDoc(compiledCode, title, collectPreviewStyles()),
);
})
.catch((nextError: unknown) => {
if (cancelled) return;
setError(
nextError instanceof Error
? nextError.message
: "Failed to build artifact preview",
);
});
return () => {
cancelled = true;
};
}, [source, title]);
if (error) {
return (
<div className="flex flex-col gap-2 p-4">
<p className="text-sm font-medium text-red-600">
Failed to render React preview
</p>
<pre className="whitespace-pre-wrap break-words rounded-md bg-red-50 p-3 font-mono text-xs text-red-900">
{error}
</pre>
</div>
);
}
if (!srcDoc) {
return <ArtifactSkeleton />;
}
return (
<iframe
sandbox="allow-scripts"
srcDoc={srcDoc}
className="h-full w-full border-0"
title={`${title} preview`}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { Skeleton } from "@/components/ui/skeleton";
interface Props {
/** Extra line before the 32h block (the variant used while fetching text). */
extraLine?: boolean;
}
export function ArtifactSkeleton({ extraLine }: Props) {
return (
<div className="space-y-3 p-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
{extraLine && <Skeleton className="h-4 w-5/6" />}
<Skeleton className="h-32 w-full" />
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { cn } from "@/lib/utils";
interface Props {
isSourceView: boolean;
onToggle: (isSource: boolean) => void;
}
export function SourceToggle({ isSourceView, onToggle }: Props) {
return (
<div className="flex items-center rounded-md border border-zinc-200 bg-zinc-50 p-0.5 text-xs font-medium">
<button
type="button"
aria-pressed={!isSourceView}
className={cn(
"rounded px-2 py-1 transition-colors",
!isSourceView
? "bg-white text-zinc-900 shadow-sm"
: "text-zinc-500 hover:text-zinc-700",
)}
onClick={() => onToggle(false)}
>
Preview
</button>
<button
type="button"
aria-pressed={isSourceView}
className={cn(
"rounded px-2 py-1 transition-colors",
isSourceView
? "bg-white text-zinc-900 shadow-sm"
: "text-zinc-500 hover:text-zinc-700",
)}
onClick={() => onToggle(true)}
>
Source
</button>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import {
useArtifactContent,
getCachedArtifactContent,
} from "../useArtifactContent";
import type { ArtifactRef } from "../../../../store";
import type { ArtifactClassification } from "../../helpers";
function makeArtifact(overrides?: Partial<ArtifactRef>): ArtifactRef {
return {
id: "file-001",
title: "test.txt",
mimeType: "text/plain",
sourceUrl: "/api/proxy/api/workspace/files/file-001/download",
origin: "agent",
...overrides,
};
}
function makeClassification(
overrides?: Partial<ArtifactClassification>,
): ArtifactClassification {
return {
type: "text",
icon: vi.fn() as any,
label: "Text",
openable: true,
hasSourceToggle: false,
...overrides,
};
}
describe("useArtifactContent", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve("file content here"),
blob: () => Promise.resolve(new Blob(["pdf bytes"])),
}),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches text content for text artifacts", async () => {
const artifact = makeArtifact();
const classification = makeClassification({ type: "text" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.content).toBe("file content here");
expect(result.current.error).toBeNull();
});
it("skips fetch for image artifacts", async () => {
const artifact = makeArtifact({ mimeType: "image/png" });
const classification = makeClassification({ type: "image" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.content).toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
it("creates blob URL for PDF artifacts", async () => {
const artifact = makeArtifact({ mimeType: "application/pdf" });
const classification = makeClassification({ type: "pdf" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.pdfUrl).toMatch(/^blob:/);
});
it("sets error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 404,
text: () => Promise.resolve("Not found"),
}),
);
// Use a unique ID to avoid hitting the module-level content cache
const artifact = makeArtifact({ id: "error-test-unique" });
const classification = makeClassification({ type: "text" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
await waitFor(() => {
expect(result.current.error).toBeTruthy();
});
expect(result.current.error).toContain("404");
expect(result.current.content).toBeNull();
});
it("caches fetched content and exposes via getCachedArtifactContent", async () => {
const artifact = makeArtifact({ id: "cache-test" });
const classification = makeClassification({ type: "text" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
await waitFor(() => {
expect(result.current.content).toBe("file content here");
});
expect(getCachedArtifactContent("cache-test")).toBe("file content here");
});
it("retry clears cache and re-fetches", async () => {
let callCount = 0;
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() => {
callCount++;
return Promise.resolve({
ok: true,
text: () => Promise.resolve(`response ${callCount}`),
});
}),
);
const artifact = makeArtifact({ id: "retry-test" });
const classification = makeClassification({ type: "text" });
const { result } = renderHook(() =>
useArtifactContent(artifact, classification),
);
await waitFor(() => {
expect(result.current.content).toBe("response 1");
});
act(() => {
result.current.retry();
});
await waitFor(() => {
expect(result.current.content).toBe("response 2");
});
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
buildReactArtifactSrcDoc,
collectPreviewStyles,
escapeHtml,
} from "./reactArtifactPreview";
describe("escapeHtml", () => {
it("escapes &, <, >, \", '", () => {
expect(escapeHtml("a & b")).toBe("a &amp; b");
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
expect(escapeHtml('hello "world"')).toBe("hello &quot;world&quot;");
expect(escapeHtml("it's")).toBe("it&#39;s");
});
it("neutralizes a </title> escape attempt", () => {
// Used to escape a title that lands inside <title>${safeTitle}</title>
const out = escapeHtml("</title><script>alert(1)</script>");
expect(out).not.toContain("</title>");
expect(out).not.toContain("<script>");
expect(out).toContain("&lt;/title&gt;");
expect(out).toContain("&lt;script&gt;");
});
it("escapes ampersand first so entities aren't double-escaped in the wrong order", () => {
// If & were escaped AFTER <, the < → &lt; output would become &amp;lt;.
// Verify the & substitution ran on the raw input only.
expect(escapeHtml("A&B<C")).toBe("A&amp;B&lt;C");
});
it("is safe on empty / plain strings", () => {
expect(escapeHtml("")).toBe("");
expect(escapeHtml("plain text 123")).toBe("plain text 123");
});
});
describe("buildReactArtifactSrcDoc", () => {
const STYLES = collectPreviewStyles();
it("does not contain a CSP meta tag (see iframe-sandbox-csp.ts)", () => {
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
expect(doc).not.toContain("Content-Security-Policy");
});
it("includes SRI-pinned React and ReactDOM bundles", () => {
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
expect(doc).toContain(
'src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"',
);
expect(doc).toContain('integrity="sha384-');
expect(doc).toContain(
'src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"',
);
});
it("escapes the title into the <title> tag", () => {
const doc = buildReactArtifactSrcDoc(
"module.exports = {};",
"</title><script>alert(1)</script>",
STYLES,
);
expect(doc).not.toMatch(/<title><\/title><script>/);
expect(doc).toContain("&lt;/title&gt;");
});
it("escapes </script> sequences in compiled code so the inline script can't be broken out of", () => {
// A legitimate artifact may contain the literal string "</script>" inside
// a JSX template or string; it must be \u003c-escaped before embedding.
const compiled = 'const x = "</script><script>alert(1)</script>";';
const doc = buildReactArtifactSrcDoc(compiled, "A", STYLES);
// The raw compiled string should NOT appear verbatim inside the srcDoc
// (that would break out of the runtime <script>).
expect(doc).not.toContain('"</script><script>alert(1)</script>"');
// Instead, the escaped \u003c/script> form is what we expect.
expect(doc).toContain("\\u003c/script>");
});
it("wires up #root and #error containers", () => {
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
expect(doc).toContain('<div id="root">');
expect(doc).toContain('<div id="error">');
});
it("injects the styles markup supplied by collectPreviewStyles", () => {
const doc = buildReactArtifactSrcDoc("module.exports = {};", "A", STYLES);
expect(doc).toContain("box-sizing: border-box");
});
});

View File

@@ -0,0 +1,318 @@
/**
* React artifact preview — security model
*
* AI-generated TSX source is transpiled (TypeScript) and executed inside a
* sandboxed iframe (`sandbox="allow-scripts"` without `allow-same-origin`).
*
* What's isolated:
* - No access to parent page cookies, localStorage, or sessionStorage
* - No form submissions or popups (no allow-forms / allow-popups)
* - Treated as a unique opaque origin by the browser
*
* What's allowed inside the iframe:
* - Inline script execution (needed to render React components)
* - `new Function()` is used to evaluate the compiled code (eval-equivalent)
* - Full DOM access within the iframe
* - Network requests via fetch/XHR (allowed — only artifact content is
* visible inside the sandbox, no secret data to exfiltrate)
*
* React is loaded from unpkg with pinned version and SRI integrity hashes.
*/
import { TAILWIND_CDN_URL } from "@/lib/iframe-sandbox-csp";
export { transpileReactArtifactSource } from "./transpileReactArtifact";
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/** Minimal CSS reset for React artifact previews.
*
* Previously this copied ALL host stylesheets (200KB+ Tailwind) into every
* preview iframe. Now we provide a self-contained reset and let artifacts
* declare their own styles. This avoids tight coupling between the app's CSS
* and artifact rendering, and keeps the srcdoc size small.
*/
export function collectPreviewStyles() {
return `<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; }
</style>`;
}
export function buildReactArtifactSrcDoc(
compiledCode: string,
title: string,
stylesMarkup: string,
) {
const safeTitle = escapeHtml(title);
const runtime = JSON.stringify(compiledCode).replace(/</g, "\\u003c");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
${stylesMarkup}
<style>
html, body, #root {
height: 100%;
margin: 0;
}
body {
background:
radial-gradient(circle at top, rgba(148, 163, 184, 0.18), transparent 35%),
#f8fafc;
color: #18181b;
font-family: ui-sans-serif, system-ui, sans-serif;
}
#root {
box-sizing: border-box;
min-height: 100%;
isolation: isolate;
}
#error {
display: none;
box-sizing: border-box;
margin: 24px;
padding: 16px;
border: 1px solid #fecaca;
border-radius: 16px;
background: #fff1f2;
color: #991b1b;
font-family: ui-monospace, SFMono-Regular, monospace;
white-space: pre-wrap;
}
</style>
<script src="${TAILWIND_CDN_URL}"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"></script>
</head>
<body>
<div id="root"></div>
<div id="error"></div>
<script>
(function () {
const compiledCode = ${runtime};
const rootElement = document.getElementById("root");
const errorElement = document.getElementById("error");
function showError(error) {
rootElement.style.display = "none";
errorElement.style.display = "block";
errorElement.textContent =
error instanceof Error && error.stack
? error.stack
: error instanceof Error
? error.message
: String(error);
}
function getModuleExports(module, exports) {
return {
...exports,
...(typeof module.exports === "object" ? module.exports : {}),
};
}
function getRenderableCandidate(moduleExports) {
if (typeof moduleExports.default === "function") {
return moduleExports.default;
}
if (typeof moduleExports.App === "function") {
return moduleExports.App;
}
const namedCandidate = Object.entries(moduleExports).find(
([name, value]) =>
name !== "default" &&
!name.endsWith("Provider") &&
/^[A-Z]/.test(name) &&
typeof value === "function",
);
if (namedCandidate) {
return namedCandidate[1];
}
if (typeof App !== "undefined" && typeof App === "function") {
return App;
}
throw new Error(
"No renderable component found. Export a default component, export App, or export a named component.",
);
}
function wrapWithProviders(Component, moduleExports) {
const providers = Object.entries(moduleExports)
.filter(
([name, value]) =>
name !== "default" &&
name.endsWith("Provider") &&
typeof value === "function",
)
.map(([, value]) => value);
if (providers.length === 0) {
return Component;
}
return function WrappedArtifactPreview() {
let tree = React.createElement(Component);
for (let i = providers.length - 1; i >= 0; i -= 1) {
tree = React.createElement(providers[i], null, tree);
}
return tree;
};
}
function require(name) {
if (name === "react") {
return React;
}
if (name === "react-dom") {
return ReactDOM;
}
if (name === "react-dom/client") {
return { createRoot: ReactDOM.createRoot };
}
if (name === "react/jsx-runtime" || name === "react/jsx-dev-runtime") {
// jsx/jsxs signature: (type, config, key) where config.children is
// the children (single value for jsx, array for jsxs). createElement
// wants variadic children, so we have to unpack config.children.
function jsx(type, config, key) {
var props = {};
if (config != null) {
for (var k in config) {
if (k !== "children") props[k] = config[k];
}
}
if (key !== undefined) props.key = key;
var children =
config != null && "children" in config ? config.children : undefined;
if (Array.isArray(children)) {
return React.createElement.apply(
null,
[type, props].concat(children),
);
}
return children === undefined
? React.createElement(type, props)
: React.createElement(type, props, children);
}
return { Fragment: React.Fragment, jsx: jsx, jsxs: jsx };
}
throw new Error("Unsupported import in artifact preview: " + name);
}
class PreviewErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return React.createElement(
"div",
{
style: {
margin: "24px",
padding: "16px",
border: "1px solid #fecaca",
borderRadius: "16px",
background: "#fff1f2",
color: "#991b1b",
fontFamily: "ui-monospace, SFMono-Regular, monospace",
whiteSpace: "pre-wrap",
},
},
this.state.error.stack || this.state.error.message || String(this.state.error),
);
}
return this.props.children;
}
}
try {
const exports = {};
const module = { exports };
const factory = new Function(
"React",
"ReactDOM",
"module",
"exports",
"require",
\`
"use strict";
\${compiledCode}
return {
module,
exports,
app: typeof App !== "undefined" ? App : undefined,
};
\`,
);
const executionResult = factory(
React,
ReactDOM,
module,
exports,
require,
);
const moduleExports = getModuleExports(
executionResult.module,
executionResult.exports,
);
if (
executionResult.app &&
typeof moduleExports.App !== "function"
) {
moduleExports.App = executionResult.app;
}
const Component = wrapWithProviders(
getRenderableCandidate(moduleExports),
moduleExports,
);
ReactDOM.createRoot(rootElement).render(
React.createElement(
PreviewErrorBoundary,
null,
React.createElement(Component),
),
);
} catch (error) {
showError(error);
}
})();
</script>
</body>
</html>`;
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { transpileReactArtifactSource } from "./transpileReactArtifact";
describe("transpileReactArtifactSource", () => {
it("transpiles a simple TSX function component", async () => {
const src =
'import React from "react";\nexport default function App() { return <div>hi</div>; }';
const out = await transpileReactArtifactSource(src, "App.tsx");
// Classic-transform emits React.createElement calls.
// esModuleInterop emits `react_1.default.createElement(...)` — match either form.
expect(out).toMatch(/\.createElement\(/);
expect(out).not.toContain("<div>");
});
it("still transpiles when the filename lacks an extension (ensureJsxExtension)", async () => {
const src = "export default function A() { return <span>x</span>; }";
// Previously: filename without .tsx caused a JSX syntax error.
const out = await transpileReactArtifactSource(src, "A");
// esModuleInterop emits `react_1.default.createElement(...)` — match either form.
expect(out).toMatch(/\.createElement\(/);
});
it("still transpiles when the filename ends in .ts (not jsx-aware)", async () => {
const src = "export default function A() { return <b>x</b>; }";
const out = await transpileReactArtifactSource(src, "A.ts");
// esModuleInterop emits `react_1.default.createElement(...)` — match either form.
expect(out).toMatch(/\.createElement\(/);
});
it("keeps .tsx extension as-is", async () => {
const src = "export default function A() { return <i>x</i>; }";
const out = await transpileReactArtifactSource(src, "Comp.tsx");
// esModuleInterop emits `react_1.default.createElement(...)` — match either form.
expect(out).toMatch(/\.createElement\(/);
});
it("throws with a useful diagnostic on syntax errors", async () => {
const broken = "export default function A() { return <div><b></div>; }"; // unclosed <b>
await expect(
transpileReactArtifactSource(broken, "broken.tsx"),
).rejects.toThrow();
});
it("transpiles TypeScript type annotations away", async () => {
const src =
"function greet(name: string): string { return 'hi ' + name; }\nexport default () => greet('a');";
const out = await transpileReactArtifactSource(src, "g.tsx");
expect(out).not.toContain(": string");
expect(out).toContain("function greet(name)");
});
});

View File

@@ -0,0 +1,43 @@
function ensureJsxExtension(filename: string): string {
// TypeScript infers JSX parsing from the file extension; if the artifact
// title is "component" or "foo.ts", TSX syntax in the source will be
// treated as a syntax error. Force a .tsx extension for transpilation.
const lower = filename.toLowerCase();
if (lower.endsWith(".tsx") || lower.endsWith(".jsx")) return filename;
return `${filename || "artifact"}.tsx`;
}
export async function transpileReactArtifactSource(
source: string,
filename: string,
) {
const ts = await import("typescript");
const result = ts.transpileModule(source, {
compilerOptions: {
allowJs: true,
esModuleInterop: true,
jsx: ts.JsxEmit.React,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
fileName: ensureJsxExtension(filename),
reportDiagnostics: true,
});
const diagnostics =
result.diagnostics?.filter(
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
) ?? [];
if (diagnostics.length > 0) {
const message = diagnostics
.slice(0, 3)
.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
)
.join("\n\n");
throw new Error(message);
}
return result.outputText;
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { ArtifactRef } from "../../../store";
import type { ArtifactClassification } from "../helpers";
// Cap on cached text artifacts. Long sessions with many large artifacts
// would otherwise hold every opened one in memory.
const CONTENT_CACHE_MAX = 12;
// Module-level LRU keyed by artifact id so a sibling action (e.g. Copy
// in ArtifactPanelHeader) can read what the panel already fetched without
// re-hitting the network.
const contentCache = new Map<string, string>();
export function getCachedArtifactContent(id: string): string | undefined {
return contentCache.get(id);
}
export function clearContentCache() {
contentCache.clear();
}
export function useArtifactContent(
artifact: ArtifactRef,
classification: ArtifactClassification,
) {
const [content, setContent] = useState<string | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Bumped by `retry()` to force the fetch effect to re-run.
const [retryNonce, setRetryNonce] = useState(0);
const scrollPositions = useRef(new Map<string, number>());
const scrollRef = useRef<HTMLDivElement>(null);
function retry() {
// Drop any cached failure/content for this id so we actually re-fetch.
contentCache.delete(artifact.id);
setRetryNonce((n) => n + 1);
}
// Save scroll position when switching artifacts. Only save when the
// content div has actually been mounted with a nonzero scrollTop, so we
// don't overwrite a previously-saved position with 0 from a skeleton render.
useEffect(() => {
return () => {
const node = scrollRef.current;
if (node && node.scrollTop > 0) {
scrollPositions.current.set(artifact.id, node.scrollTop);
}
};
}, [artifact.id]);
// Restore scroll position — wait until isLoading flips to false, since
// the scroll container is replaced by a Skeleton during loading and the
// real content div would otherwise mount with scrollTop=0.
useEffect(() => {
if (isLoading) return;
const saved = scrollPositions.current.get(artifact.id);
if (saved != null && scrollRef.current) {
scrollRef.current.scrollTop = saved;
}
}, [artifact.id, isLoading]);
useEffect(() => {
if (classification.type === "image") {
setContent(null);
setPdfUrl(null);
setError(null);
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setError(null);
if (classification.type === "pdf") {
let objectUrl: string | null = null;
setContent(null);
setPdfUrl(null);
fetch(artifact.sourceUrl)
.then((res) => {
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
return res.blob();
})
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
return;
}
setPdfUrl(objectUrl);
setIsLoading(false);
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setIsLoading(false);
}
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}
setPdfUrl(null);
// LRU touch — re-insert so the most-recently-used entry sits at the
// tail and the oldest entry falls off the head first.
const cache = contentCache;
const cached = cache.get(artifact.id);
if (cached !== undefined) {
cache.delete(artifact.id);
cache.set(artifact.id, cached);
setContent(cached);
setIsLoading(false);
return () => {
cancelled = true;
};
}
fetch(artifact.sourceUrl)
.then((res) => {
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
return res.text();
})
.then((text) => {
if (!cancelled) {
if (cache.size >= CONTENT_CACHE_MAX) {
// Map preserves insertion order — first key is the oldest.
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(artifact.id, text);
setContent(text);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [artifact.id, artifact.sourceUrl, classification.type, retryNonce]);
return { content, pdfUrl, isLoading, error, scrollRef, retry };
}

View File

@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ArtifactRef } from "../../store";
import { downloadArtifact } from "./downloadArtifact";
function makeArtifact(title: string): ArtifactRef {
return {
id: "abc",
title,
mimeType: "text/plain",
sourceUrl: "/api/proxy/api/workspace/files/abc/download",
origin: "agent",
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("downloadArtifact filename sanitization", () => {
it("strips path separators and control characters", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(["x"])),
});
const clicks: HTMLAnchorElement[] = [];
const originalCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreate(tag);
if (tag === "a") {
clicks.push(el as HTMLAnchorElement);
// Prevent actual navigation in test env.
(el as HTMLAnchorElement).click = () => {};
}
return el;
});
global.URL.createObjectURL = vi.fn(() => "blob:mock");
global.URL.revokeObjectURL = vi.fn();
await downloadArtifact(makeArtifact("../../etc/passwd"));
// ..→_ then /→_ gives ____etc_passwd (no leading ..)
expect(clicks[0]?.download).toBe("____etc_passwd");
});
it("replaces Windows-reserved characters", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(["x"])),
});
const clicks: HTMLAnchorElement[] = [];
const originalCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreate(tag);
if (tag === "a") {
clicks.push(el as HTMLAnchorElement);
(el as HTMLAnchorElement).click = () => {};
}
return el;
});
global.URL.createObjectURL = vi.fn(() => "blob:mock");
global.URL.revokeObjectURL = vi.fn();
await downloadArtifact(makeArtifact('a<b>c:"d*e?f|g'));
expect(clicks[0]?.download).toBe("a_b_c__d_e_f_g");
});
it("falls back to 'download' when title is empty after sanitization", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(["x"])),
});
const clicks: HTMLAnchorElement[] = [];
const originalCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreate(tag);
if (tag === "a") {
clicks.push(el as HTMLAnchorElement);
(el as HTMLAnchorElement).click = () => {};
}
return el;
});
global.URL.createObjectURL = vi.fn(() => "blob:mock");
global.URL.revokeObjectURL = vi.fn();
await downloadArtifact(makeArtifact(""));
expect(clicks[0]?.download).toBe("download");
});
it("keeps normal filenames intact", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(["x"])),
});
const clicks: HTMLAnchorElement[] = [];
const originalCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreate(tag);
if (tag === "a") {
clicks.push(el as HTMLAnchorElement);
(el as HTMLAnchorElement).click = () => {};
}
return el;
});
global.URL.createObjectURL = vi.fn(() => "blob:mock");
global.URL.revokeObjectURL = vi.fn();
await downloadArtifact(makeArtifact("report-2024 (final).pdf"));
expect(clicks[0]?.download).toBe("report-2024 (final).pdf");
});
it("rejects when fetch returns non-ok status", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
await expect(downloadArtifact(makeArtifact("x.txt"))).rejects.toThrow(
/Download failed: 404/,
);
});
it("rejects when fetch itself throws", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
await expect(downloadArtifact(makeArtifact("x.txt"))).rejects.toThrow();
});
});

View File

@@ -0,0 +1,35 @@
import type { ArtifactRef } from "../../store";
/**
* Trigger a file download from an artifact URL.
*
* Uses fetch+blob instead of a bare `<a download>` because the browser
* ignores the `download` attribute on cross-origin responses (GCS signed
* URLs), and some browsers require the anchor to be attached to the DOM
* before `.click()` fires the download.
*/
export function downloadArtifact(artifact: ArtifactRef): Promise<void> {
// Replace path separators, Windows-reserved chars, control chars, and
// parent-dir sequences so the browser-assigned filename is safe to write
// anywhere on the user's filesystem.
const safeName =
artifact.title
.replace(/\.\./g, "_")
.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
.replace(/^\.+/, "") || "download";
return fetch(artifact.sourceUrl)
.then((res) => {
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { classifyArtifact } from "./helpers";
describe("classifyArtifact", () => {
it("routes PDF by extension", () => {
const c = classifyArtifact(null, "report.pdf");
expect(c.type).toBe("pdf");
expect(c.openable).toBe(true);
});
it("routes PDF by MIME when no extension matches", () => {
const c = classifyArtifact("application/pdf", "noextension");
expect(c.type).toBe("pdf");
});
it("routes JSX/TSX as react", () => {
expect(classifyArtifact(null, "App.tsx").type).toBe("react");
expect(classifyArtifact(null, "Comp.jsx").type).toBe("react");
});
it("routes code extensions to code", () => {
expect(classifyArtifact(null, "script.py").type).toBe("code");
expect(classifyArtifact(null, "main.go").type).toBe("code");
expect(classifyArtifact(null, "Dockerfile.yml").type).toBe("code");
});
it("treats images as image (inline rendered)", () => {
expect(classifyArtifact(null, "photo.png").type).toBe("image");
expect(classifyArtifact("image/svg+xml", "unknown").type).toBe("image");
});
it("treats CSVs as csv with source toggle", () => {
const c = classifyArtifact(null, "data.csv");
expect(c.type).toBe("csv");
expect(c.hasSourceToggle).toBe(true);
});
it("treats HTML as html with source toggle", () => {
expect(classifyArtifact(null, "page.html").type).toBe("html");
expect(classifyArtifact("text/html", "noext").type).toBe("html");
});
it("treats markdown as markdown", () => {
expect(classifyArtifact(null, "README.md").type).toBe("markdown");
expect(classifyArtifact("text/markdown", "x").type).toBe("markdown");
});
it("gates files > 10MB to download-only", () => {
const c = classifyArtifact("text/plain", "big.txt", 20 * 1024 * 1024);
expect(c.openable).toBe(false);
expect(c.type).toBe("download-only");
});
it("treats binary/octet-stream MIME as download-only", () => {
expect(classifyArtifact("application/zip", "a.zip").openable).toBe(false);
expect(classifyArtifact("application/octet-stream", "x").openable).toBe(
false,
);
expect(classifyArtifact("video/mp4", "clip.mp4").openable).toBe(false);
});
it("defaults unknown extension+MIME to download-only (not text)", () => {
// Regression: previously dumped binary as <pre>; now refuses to open.
const c = classifyArtifact(null, "data.bin");
expect(c.openable).toBe(false);
expect(c.type).toBe("download-only");
});
it("is case-insensitive on extension", () => {
expect(classifyArtifact(null, "image.PNG").type).toBe("image");
expect(classifyArtifact(null, "Notes.MD").type).toBe("markdown");
});
it("prioritizes extension over MIME", () => {
// Extension says CSV, MIME says plain text → extension wins.
const c = classifyArtifact("text/plain", "data.csv");
expect(c.type).toBe("csv");
});
});

View File

@@ -0,0 +1,229 @@
import {
Code,
File,
FileHtml,
FileText,
Image,
Table,
} from "@phosphor-icons/react";
import type { Icon } from "@phosphor-icons/react";
export interface ArtifactClassification {
type:
| "markdown"
| "code"
| "react"
| "html"
| "csv"
| "json"
| "image"
| "pdf"
| "text"
| "download-only";
icon: Icon;
label: string;
openable: boolean;
hasSourceToggle: boolean;
}
const TEN_MB = 10 * 1024 * 1024;
// Catalog of classification kinds. Each entry defines the shared output
// shape; extension/MIME → kind mapping is handled by the lookup tables below.
const KIND: Record<string, ArtifactClassification> = {
image: {
type: "image",
icon: Image,
label: "Image",
openable: true,
hasSourceToggle: false,
},
pdf: {
type: "pdf",
icon: FileText,
label: "PDF",
openable: true,
hasSourceToggle: false,
},
csv: {
type: "csv",
icon: Table,
label: "Spreadsheet",
openable: true,
hasSourceToggle: true,
},
html: {
type: "html",
icon: FileHtml,
label: "HTML",
openable: true,
hasSourceToggle: true,
},
react: {
type: "react",
icon: FileHtml,
label: "React",
openable: true,
hasSourceToggle: true,
},
markdown: {
type: "markdown",
icon: FileText,
label: "Document",
openable: true,
hasSourceToggle: true,
},
json: {
type: "json",
icon: Code,
label: "Data",
openable: true,
hasSourceToggle: true,
},
code: {
type: "code",
icon: Code,
label: "Code",
openable: true,
hasSourceToggle: false,
},
text: {
type: "text",
icon: FileText,
label: "Text",
openable: true,
hasSourceToggle: false,
},
"download-only": {
type: "download-only",
icon: File,
label: "File",
openable: false,
hasSourceToggle: false,
},
};
// Extension → kind. First match wins.
const EXT_KIND: Record<string, string> = {
".png": "image",
".jpg": "image",
".jpeg": "image",
".gif": "image",
".webp": "image",
".svg": "image",
".bmp": "image",
".ico": "image",
".pdf": "pdf",
".csv": "csv",
".html": "html",
".htm": "html",
".jsx": "react",
".tsx": "react",
".md": "markdown",
".mdx": "markdown",
".json": "json",
".txt": "text",
".log": "text",
// code extensions
".js": "code",
".ts": "code",
".py": "code",
".rb": "code",
".go": "code",
".rs": "code",
".java": "code",
".c": "code",
".cpp": "code",
".h": "code",
".cs": "code",
".php": "code",
".swift": "code",
".kt": "code",
".sh": "code",
".bash": "code",
".zsh": "code",
".yml": "code",
".yaml": "code",
".toml": "code",
".ini": "code",
".cfg": "code",
".sql": "code",
".r": "code",
".lua": "code",
".pl": "code",
".scala": "code",
};
// Exact-match MIME → kind (fallback when extension doesn't match).
const MIME_KIND: Record<string, string> = {
"application/pdf": "pdf",
"text/csv": "csv",
"text/html": "html",
"text/jsx": "react",
"text/tsx": "react",
"application/jsx": "react",
"application/x-typescript-jsx": "react",
"text/markdown": "markdown",
"text/x-markdown": "markdown",
"application/json": "json",
"application/javascript": "code",
"text/javascript": "code",
"application/typescript": "code",
"text/typescript": "code",
"application/xml": "code",
"text/xml": "code",
};
const BINARY_MIMES = new Set([
"application/zip",
"application/x-zip-compressed",
"application/gzip",
"application/x-tar",
"application/x-rar-compressed",
"application/x-7z-compressed",
"application/octet-stream",
"application/x-executable",
"application/x-msdos-program",
"application/vnd.microsoft.portable-executable",
]);
function getExtension(filename?: string): string {
if (!filename) return "";
const lastDot = filename.lastIndexOf(".");
if (lastDot === -1) return "";
return filename.slice(lastDot).toLowerCase();
}
export function classifyArtifact(
mimeType: string | null,
filename?: string,
sizeBytes?: number,
): ArtifactClassification {
// Size gate: >10MB is download-only regardless of type.
if (sizeBytes && sizeBytes > TEN_MB) return KIND["download-only"];
// Extension first (more reliable than MIME for AI-generated files).
const ext = getExtension(filename);
const extKind = EXT_KIND[ext];
if (extKind) return KIND[extKind];
// MIME fallbacks.
const mime = (mimeType ?? "").toLowerCase();
if (mime.startsWith("image/")) return KIND.image;
const mimeKind = MIME_KIND[mime];
if (mimeKind) return KIND[mimeKind];
if (mime.startsWith("text/x-")) return KIND.code;
if (
BINARY_MIMES.has(mime) ||
mime.startsWith("audio/") ||
mime.startsWith("video/")
) {
return KIND["download-only"];
}
if (mime.startsWith("text/")) return KIND.text;
// Unknown extension + unknown MIME: don't open — we can't safely assume
// this is text, and fetching a binary to dump it into a <pre> wastes
// bandwidth and shows garbage.
return KIND["download-only"];
}

View File

@@ -0,0 +1,148 @@
"use client";
import { toast } from "@/components/molecules/Toast/use-toast";
import { useEffect, useState } from "react";
import { useCopilotUIStore } from "../../store";
import { getCachedArtifactContent } from "./components/useArtifactContent";
import { downloadArtifact } from "./downloadArtifact";
import { classifyArtifact } from "./helpers";
// SSR fallback for viewport width before window is available.
const DEFAULT_VIEWPORT_WIDTH = 1280;
export function useArtifactPanel() {
const artifactPanel = useCopilotUIStore((s) => s.artifactPanel);
const closeArtifactPanel = useCopilotUIStore((s) => s.closeArtifactPanel);
const minimizeArtifactPanel = useCopilotUIStore(
(s) => s.minimizeArtifactPanel,
);
const maximizeArtifactPanel = useCopilotUIStore(
(s) => s.maximizeArtifactPanel,
);
const restoreArtifactPanel = useCopilotUIStore((s) => s.restoreArtifactPanel);
const setArtifactPanelWidth = useCopilotUIStore(
(s) => s.setArtifactPanelWidth,
);
const goBackArtifact = useCopilotUIStore((s) => s.goBackArtifact);
const [isSourceView, setIsSourceView] = useState(false);
const { activeArtifact } = artifactPanel;
const classification = activeArtifact
? classifyArtifact(
activeArtifact.mimeType,
activeArtifact.title,
activeArtifact.sizeBytes,
)
: null;
// Reset source view when switching artifacts
useEffect(() => {
setIsSourceView(false);
}, [activeArtifact?.id]);
// Keyboard: Escape to close
useEffect(() => {
if (!artifactPanel.isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (document.querySelector('[role="dialog"], [data-state="open"]'))
return;
closeArtifactPanel();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [artifactPanel.isOpen, closeArtifactPanel]);
// Track viewport width reactively for maximize mode.
const [viewportWidth, setViewportWidth] = useState(
typeof window !== "undefined" ? window.innerWidth : DEFAULT_VIEWPORT_WIDTH,
);
useEffect(() => {
// Throttle to ~10Hz: resize fires continuously during drag, but we only
// need the panel width to follow the viewport within a frame or two.
let timer: ReturnType<typeof setTimeout> | null = null;
function handleResize() {
if (timer) return;
timer = setTimeout(() => {
setViewportWidth(window.innerWidth);
timer = null;
}, 100);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (timer) clearTimeout(timer);
};
}, []);
const canCopy =
classification != null &&
classification.type !== "image" &&
classification.type !== "download-only" &&
classification.type !== "pdf";
function handleCopy() {
if (!activeArtifact || !canCopy) return;
// Reuse content already fetched by the preview pane when available —
// Copy should feel instant, not trigger a second network round-trip.
const cached = getCachedArtifactContent(activeArtifact.id);
const textPromise = cached
? Promise.resolve(cached)
: fetch(activeArtifact.sourceUrl).then((res) => {
if (!res.ok) throw new Error(`Copy failed: ${res.status}`);
return res.text();
});
textPromise
.then((text) => navigator.clipboard.writeText(text))
.then(() => {
toast({ title: "Copied to clipboard" });
})
.catch(() => {
toast({
title: "Copy failed",
description: "Couldn't read the file or access the clipboard.",
variant: "destructive",
});
});
}
function handleDownload() {
if (!activeArtifact) return;
downloadArtifact(activeArtifact).catch(() => {
toast({
title: "Download failed",
description: "Couldn't fetch the file.",
variant: "destructive",
});
});
}
// Always clamp against the current viewport so a previously-dragged-wide
// panel doesn't spill offscreen after the user resizes their window.
const maxWidth = viewportWidth * 0.85;
const effectiveWidth = artifactPanel.isMaximized
? maxWidth
: Math.min(artifactPanel.width, maxWidth);
return {
...artifactPanel,
effectiveWidth,
isSourceView,
classification,
setIsSourceView,
closeArtifactPanel,
minimizeArtifactPanel,
maximizeArtifactPanel,
restoreArtifactPanel,
setArtifactPanelWidth,
goBackArtifact,
canCopy,
handleCopy,
handleDownload,
};
}

View File

@@ -1,11 +1,15 @@
"use client";
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
import { cn } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { UIDataTypes, UIMessage, UITools } from "ai";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback } from "react";
import { useCopilotUIStore } from "../../store";
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
import { EmptySession } from "../EmptySession/EmptySession";
import { useAutoOpenArtifacts } from "./useAutoOpenArtifacts";
export interface ChatContainerProps {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
@@ -48,6 +52,16 @@ export const ChatContainer = ({
onDroppedFilesConsumed,
historicalDurations,
}: ChatContainerProps) => {
const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS);
const isArtifactPanelOpen = useCopilotUIStore((s) => s.artifactPanel.isOpen);
// When the flag is off we must not auto-open artifacts or let the panel's
// open state drive layout width; an artifact generated in a stale session
// state would otherwise shrink the chat column with no panel rendered.
const isArtifactOpen = isArtifactsEnabled && isArtifactPanelOpen;
useAutoOpenArtifacts({
messages: isArtifactsEnabled ? messages : [],
sessionId,
});
const isBusy =
status === "streaming" ||
status === "submitted" ||
@@ -76,7 +90,12 @@ export const ChatContainer = ({
<LayoutGroup id="copilot-2-chat-layout">
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
{sessionId ? (
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
<div
className={cn(
"mx-auto flex h-full min-h-0 w-full flex-col",
!isArtifactOpen && "max-w-3xl",
)}
>
<ChatMessagesContainer
messages={messages}
status={status}

View File

@@ -0,0 +1,140 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { useCopilotUIStore } from "../../store";
import { useAutoOpenArtifacts } from "./useAutoOpenArtifacts";
function assistantMessageWithText(id: string, text: string) {
return {
id,
role: "assistant" as const,
parts: [{ type: "text" as const, text }],
};
}
const A_ID = "11111111-0000-0000-0000-000000000000";
const B_ID = "22222222-0000-0000-0000-000000000000";
function resetStore() {
useCopilotUIStore.setState({
artifactPanel: {
isOpen: false,
isMinimized: false,
isMaximized: false,
width: 600,
activeArtifact: null,
history: [],
},
});
}
describe("useAutoOpenArtifacts", () => {
beforeEach(resetStore);
it("does NOT auto-open on the initial hydration of message list (baseline pass)", () => {
const messages = [
assistantMessageWithText("m1", `[a](workspace://${A_ID})`),
];
renderHook(() =>
useAutoOpenArtifacts({ messages: messages as any, sessionId: "s1" }),
);
// Initial run just records the baseline fingerprint; nothing opens.
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
});
it("auto-opens when an existing assistant message adds a new artifact", () => {
// 1st render: baseline with no artifact.
const initial = [assistantMessageWithText("m1", "thinking...")];
const { rerender } = renderHook(
({ messages, sessionId }) =>
useAutoOpenArtifacts({ messages: messages as any, sessionId }),
{ initialProps: { messages: initial, sessionId: "s1" } },
);
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
// 2nd render: same message id now contains an artifact link.
act(() => {
rerender({
messages: [
assistantMessageWithText("m1", `here: [A](workspace://${A_ID})`),
],
sessionId: "s1",
});
});
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isOpen).toBe(true);
expect(s.activeArtifact?.id).toBe(A_ID);
});
it("does not re-open when the fingerprint hasn't changed", () => {
const msg = assistantMessageWithText("m1", `[A](workspace://${A_ID})`);
const { rerender } = renderHook(
({ messages, sessionId }) =>
useAutoOpenArtifacts({ messages: messages as any, sessionId }),
{ initialProps: { messages: [msg], sessionId: "s1" } },
);
// Baseline captured; no open.
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
// Rerender identical content: no change in fingerprint → no open.
act(() => {
rerender({ messages: [msg], sessionId: "s1" });
});
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
});
it("auto-opens when a brand-new assistant message arrives after the baseline is established", () => {
// First render: one message without artifacts → establishes baseline.
const { rerender } = renderHook(
({ messages, sessionId }) =>
useAutoOpenArtifacts({ messages: messages as any, sessionId }),
{
initialProps: {
messages: [assistantMessageWithText("m1", "plain")] as any,
sessionId: "s1",
},
},
);
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
// Second render: a *new* assistant message with an artifact. Baseline
// is already set, so this should auto-open.
act(() => {
rerender({
messages: [
assistantMessageWithText("m1", "plain"),
assistantMessageWithText("m2", `[B](workspace://${B_ID})`),
] as any,
sessionId: "s1",
});
});
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isOpen).toBe(true);
expect(s.activeArtifact?.id).toBe(B_ID);
});
it("resets hydration baseline when sessionId changes", () => {
const { rerender } = renderHook(
({ messages, sessionId }) =>
useAutoOpenArtifacts({ messages: messages as any, sessionId }),
{
initialProps: {
messages: [
assistantMessageWithText("m1", `[A](workspace://${A_ID})`),
] as any,
sessionId: "s1",
},
},
);
// Switch to a new session — the first pass on the new session should
// NOT auto-open (it's a fresh hydration).
act(() => {
rerender({
messages: [
assistantMessageWithText("m2", `[B](workspace://${B_ID})`),
] as any,
sessionId: "s2",
});
});
expect(useCopilotUIStore.getState().artifactPanel.isOpen).toBe(false);
});
});

View File

@@ -0,0 +1,91 @@
"use client";
import { UIDataTypes, UIMessage, UITools } from "ai";
import { useEffect, useRef } from "react";
import type { ArtifactRef } from "../../store";
import { useCopilotUIStore } from "../../store";
import { getMessageArtifacts } from "../ChatMessagesContainer/helpers";
function fingerprintArtifacts(artifacts: ArtifactRef[]): string {
return artifacts
.map((a) => `${a.id}:${a.title}:${a.mimeType ?? ""}:${a.sourceUrl}`)
.join("|");
}
interface UseAutoOpenArtifactsOptions {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
sessionId: string | null;
}
export function useAutoOpenArtifacts({
messages,
sessionId,
}: UseAutoOpenArtifactsOptions) {
const openArtifact = useCopilotUIStore((state) => state.openArtifact);
const messageFingerprintsRef = useRef<Map<string, string>>(new Map());
const hasInitializedRef = useRef(false);
useEffect(() => {
messageFingerprintsRef.current = new Map();
hasInitializedRef.current = false;
}, [sessionId]);
useEffect(() => {
if (messages.length === 0) {
messageFingerprintsRef.current = new Map();
return;
}
// Only scan messages whose fingerprint might have changed since the
// last pass: that's the last assistant message (currently streaming)
// plus any assistant message whose id isn't in the baseline yet.
// This keeps the cost O(new+tail), not O(all messages), on every chunk.
const previous = messageFingerprintsRef.current;
const nextFingerprints = new Map<string, string>(previous);
let nextArtifact: ArtifactRef | null = null;
const lastAssistantIdx = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") return i;
}
return -1;
})();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (message.role !== "assistant") continue;
const isTailAssistant = i === lastAssistantIdx;
const isNewMessage = !previous.has(message.id);
if (!isTailAssistant && !isNewMessage) continue;
const artifacts = getMessageArtifacts(message);
const fingerprint = fingerprintArtifacts(artifacts);
nextFingerprints.set(message.id, fingerprint);
if (!hasInitializedRef.current || fingerprint.length === 0) {
continue;
}
const previousFingerprint = previous.get(message.id) ?? "";
if (previousFingerprint === fingerprint) continue;
nextArtifact = artifacts[artifacts.length - 1] ?? nextArtifact;
}
// Drop entries for messages that no longer exist (e.g. history truncated).
const liveIds = new Set(messages.map((m) => m.id));
for (const id of nextFingerprints.keys()) {
if (!liveIds.has(id)) nextFingerprints.delete(id);
}
messageFingerprintsRef.current = nextFingerprints;
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
return;
}
if (nextArtifact) {
openArtifact(nextArtifact);
}
}, [messages, openArtifact]);
}

View File

@@ -2,6 +2,7 @@ import {
FileText as FileTextIcon,
DownloadSimple as DownloadIcon,
} from "@phosphor-icons/react";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import type { FileUIPart } from "ai";
import {
globalRegistry,
@@ -14,6 +15,8 @@ import {
ContentCardTitle,
ContentCardSubtitle,
} from "../../ToolAccordion/AccordionContent";
import { ArtifactCard } from "../../ArtifactCard/ArtifactCard";
import { filePartToArtifactRef } from "../helpers";
interface Props {
files: FileUIPart[];
@@ -39,11 +42,26 @@ function renderFileContent(file: FileUIPart): React.ReactNode | null {
}
export function MessageAttachments({ files, isUser }: Props) {
const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS);
if (files.length === 0) return null;
return (
<div className="mt-2 flex flex-col gap-2">
{files.map((file, i) => {
if (isArtifactsEnabled) {
const artifactRef = filePartToArtifactRef(
file,
isUser ? "user-upload" : "agent",
);
if (artifactRef) {
return (
<ArtifactCard
key={`artifact-${artifactRef.id}-${i}`}
artifact={artifactRef}
/>
);
}
}
const rendered = renderFileContent(file);
return rendered ? (
<div

View File

@@ -1,7 +1,9 @@
import { MessageResponse } from "@/components/ai-elements/message";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { ExclamationMarkIcon } from "@phosphor-icons/react";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { ArtifactCard } from "../../ArtifactCard/ArtifactCard";
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
@@ -19,7 +21,11 @@ import { RunBlockTool } from "../../../tools/RunBlock/RunBlock";
import { RunMCPToolComponent } from "../../../tools/RunMCPTool/RunMCPTool";
import { SearchDocsTool } from "../../../tools/SearchDocs/SearchDocs";
import { ViewAgentOutputTool } from "../../../tools/ViewAgentOutput/ViewAgentOutput";
import { parseSpecialMarkers, resolveWorkspaceUrls } from "../helpers";
import {
extractWorkspaceArtifacts,
parseSpecialMarkers,
resolveWorkspaceUrls,
} from "../helpers";
/**
* Custom img component for Streamdown that renders <video> elements
@@ -61,6 +67,27 @@ function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
/** Stable components override for Streamdown (avoids re-creating on every render). */
const STREAMDOWN_COMPONENTS = { img: WorkspaceMediaImage };
function TextWithArtifactCards({ text }: { text: string }) {
const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS);
const artifacts = extractWorkspaceArtifacts(text);
const resolved = resolveWorkspaceUrls(text);
return (
<>
{isArtifactsEnabled && artifacts.length > 0 && (
<div className="mb-2 flex flex-col gap-1">
{artifacts.map((artifact) => (
<ArtifactCard key={artifact.id} artifact={artifact} />
))}
</div>
)}
<MessageResponse components={STREAMDOWN_COMPONENTS}>
{resolved}
</MessageResponse>
</>
);
}
interface Props {
part: UIMessage<unknown, UIDataTypes, UITools>["parts"][number];
messageID: string;
@@ -118,11 +145,7 @@ export function MessagePartRenderer({
);
}
return (
<MessageResponse key={key} components={STREAMDOWN_COMPONENTS}>
{resolveWorkspaceUrls(cleanText)}
</MessageResponse>
);
return <TextWithArtifactCards key={key} text={cleanText} />;
}
case "tool-ask_question":
return <AskQuestionTool key={key} part={part as ToolUIPart} />;

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { extractWorkspaceArtifacts, filePartToArtifactRef } from "./helpers";
describe("extractWorkspaceArtifacts", () => {
it("extracts a single workspace:// link with its markdown title", () => {
const text =
"See [the report](workspace://550e8400-e29b-41d4-a716-446655440000) for details.";
const out = extractWorkspaceArtifacts(text);
expect(out).toHaveLength(1);
expect(out[0].id).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(out[0].title).toBe("the report");
expect(out[0].origin).toBe("agent");
});
it("falls back to a synthetic title when the URI isn't wrapped in link markdown", () => {
const text = "raw workspace://abc12345-0000-0000-0000-000000000000 link";
const out = extractWorkspaceArtifacts(text);
expect(out).toHaveLength(1);
expect(out[0].title).toBe("File abc12345");
});
it("skips URIs inside image markdown so images don't double-render", () => {
const text =
"![chart](workspace://abc12345-0000-0000-0000-000000000000#image/png)";
expect(extractWorkspaceArtifacts(text)).toEqual([]);
});
it("still extracts non-image links when image links are also present", () => {
const text =
"![chart](workspace://aaaaaaaa-0000-0000-0000-000000000000#image/png) " +
"and [doc](workspace://bbbbbbbb-0000-0000-0000-000000000000)";
const out = extractWorkspaceArtifacts(text);
expect(out).toHaveLength(1);
expect(out[0].id).toBe("bbbbbbbb-0000-0000-0000-000000000000");
});
it("deduplicates repeated references to the same artifact id", () => {
const text =
"[A](workspace://11111111-0000-0000-0000-000000000000) and " +
"[A again](workspace://11111111-0000-0000-0000-000000000000)";
const out = extractWorkspaceArtifacts(text);
expect(out).toHaveLength(1);
});
it("returns empty when no workspace URIs are present", () => {
expect(extractWorkspaceArtifacts("plain text, no links")).toEqual([]);
});
it("picks up the mime hint from the URI fragment", () => {
const text =
"![v](workspace://cccccccc-0000-0000-0000-000000000000#video/mp4) " +
"[d](workspace://dddddddd-0000-0000-0000-000000000000#application/pdf)";
const out = extractWorkspaceArtifacts(text);
expect(out).toHaveLength(1);
expect(out[0].mimeType).toBe("application/pdf");
});
});
describe("filePartToArtifactRef", () => {
it("returns null without a url", () => {
expect(
filePartToArtifactRef({ type: "file", url: "", filename: "x" } as any),
).toBeNull();
});
it("returns null for URLs that don't match the workspace file pattern", () => {
expect(
filePartToArtifactRef({
type: "file",
url: "https://example.com/file.txt",
filename: "file.txt",
} as any),
).toBeNull();
});
it("extracts id from the workspace proxy URL", () => {
const ref = filePartToArtifactRef({
type: "file",
url: "/api/proxy/api/workspace/files/550e8400-e29b-41d4-a716-446655440000/download",
filename: "report.pdf",
mediaType: "application/pdf",
} as any);
expect(ref?.id).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(ref?.title).toBe("report.pdf");
expect(ref?.mimeType).toBe("application/pdf");
});
it("defaults origin to user-upload but accepts an override", () => {
const url =
"/api/proxy/api/workspace/files/550e8400-e29b-41d4-a716-446655440000/download";
const defaulted = filePartToArtifactRef({
type: "file",
url,
filename: "a.txt",
} as any);
expect(defaulted?.origin).toBe("user-upload");
const overridden = filePartToArtifactRef(
{ type: "file", url, filename: "a.txt" } as any,
"agent",
);
expect(overridden?.origin).toBe("agent");
});
});

View File

@@ -1,6 +1,8 @@
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { parseWorkspaceURI } from "@/lib/workspace-uri";
import { FileUIPart, ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import type { ArtifactRef } from "../../store";
export type MessagePart = UIMessage<
unknown,
@@ -31,6 +33,10 @@ const CUSTOM_TOOL_TYPES = new Set([
"tool-create_feature_request",
]);
const WORKSPACE_FILE_PATTERN =
/\/api\/proxy\/api\/workspace\/files\/([a-f0-9-]+)\/download/;
const WORKSPACE_URI_PATTERN = /workspace:\/\/([a-f0-9-]+)(?:#([^\s)\]]+))?/g;
const INTERACTIVE_RESPONSE_TYPES: ReadonlySet<string> = new Set([
ResponseType.setup_requirements,
ResponseType.agent_details,
@@ -233,6 +239,84 @@ export function parseSpecialMarkers(text: string): {
return { markerType: null, markerText: "", cleanText: text };
}
export function filePartToArtifactRef(
file: FileUIPart,
origin: ArtifactRef["origin"] = "user-upload",
): ArtifactRef | null {
if (!file.url) return null;
const match = file.url.match(WORKSPACE_FILE_PATTERN);
if (!match) return null;
return {
id: match[1],
title: file.filename || "File",
mimeType: file.mediaType || null,
sourceUrl: file.url,
origin,
};
}
export function extractWorkspaceArtifacts(text: string): ArtifactRef[] {
const seen = new Set<string>();
const artifacts: ArtifactRef[] = [];
for (const match of text.matchAll(WORKSPACE_URI_PATTERN)) {
const fullUri = match[0];
const parsed = parseWorkspaceURI(fullUri);
if (!parsed || seen.has(parsed.fileID)) continue;
// Skip URIs inside image markdown (`![alt](workspace://...)`). Images are
// rendered inline via resolveWorkspaceUrls — surfacing them as cards too
// would double-render the same asset.
const escapedUri = escapeRegExp(fullUri);
const imagePattern = new RegExp(`!\\[[^\\]]*\\]\\(${escapedUri}\\)`);
if (imagePattern.test(text)) continue;
seen.add(parsed.fileID);
const linkPattern = new RegExp(`\\[([^\\]]+)\\]\\(${escapedUri}\\)`);
const linkMatch = text.match(linkPattern);
const title = linkMatch?.[1] ?? `File ${parsed.fileID.slice(0, 8)}`;
artifacts.push({
id: parsed.fileID,
title,
mimeType: parsed.mimeType,
sourceUrl: `/api/proxy${getGetWorkspaceDownloadFileByIdUrl(parsed.fileID)}`,
origin: "agent",
});
}
return artifacts;
}
export function getMessageArtifacts(
message: UIMessage<unknown, UIDataTypes, UITools>,
): ArtifactRef[] {
const seen = new Set<string>();
const artifacts: ArtifactRef[] = [];
for (const part of message.parts) {
if (part.type === "text") {
for (const artifact of extractWorkspaceArtifacts(part.text)) {
if (seen.has(artifact.id)) continue;
seen.add(artifact.id);
artifacts.push(artifact);
}
}
if (part.type === "file") {
const origin = message.role === "user" ? "user-upload" : "agent";
const artifact = filePartToArtifactRef(part, origin);
if (!artifact || seen.has(artifact.id)) continue;
seen.add(artifact.id);
artifacts.push(artifact);
}
}
return artifacts;
}
/**
* Resolve workspace:// URLs in markdown text to proxy download URLs.
*

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ArtifactRef } from "./store";
import { useCopilotUIStore } from "./store";
function makeArtifact(id: string, title = `file-${id}`): ArtifactRef {
return {
id,
title,
mimeType: "text/plain",
sourceUrl: `/api/proxy/api/workspace/files/${id}/download`,
origin: "agent",
};
}
function resetStore() {
useCopilotUIStore.setState({
artifactPanel: {
isOpen: false,
isMinimized: false,
isMaximized: false,
width: 600,
activeArtifact: null,
history: [],
},
});
}
describe("artifactPanel store actions", () => {
beforeEach(resetStore);
it("openArtifact opens the panel and sets the active artifact", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isOpen).toBe(true);
expect(s.isMinimized).toBe(false);
expect(s.activeArtifact?.id).toBe("a");
expect(s.history).toEqual([]);
});
it("openArtifact pushes the previous artifact onto history", () => {
const a = makeArtifact("a");
const b = makeArtifact("b");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().openArtifact(b);
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.activeArtifact?.id).toBe("b");
expect(s.history.map((h) => h.id)).toEqual(["a"]);
});
it("openArtifact does NOT push history when re-opening the same artifact", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().openArtifact(a);
expect(useCopilotUIStore.getState().artifactPanel.history).toEqual([]);
});
it("openArtifact pops the top of history when returning to it (A→B→A)", () => {
const a = makeArtifact("a");
const b = makeArtifact("b");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().openArtifact(b);
useCopilotUIStore.getState().openArtifact(a); // ping-pong
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.activeArtifact?.id).toBe("a");
// History was [a]; returning to a should pop, not push.
expect(s.history).toEqual([]);
});
it("goBackArtifact pops the last entry and becomes active", () => {
const a = makeArtifact("a");
const b = makeArtifact("b");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().openArtifact(b);
useCopilotUIStore.getState().goBackArtifact();
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.activeArtifact?.id).toBe("a");
expect(s.history).toEqual([]);
});
it("goBackArtifact is a no-op when history is empty", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().goBackArtifact();
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.activeArtifact?.id).toBe("a");
});
it("closeArtifactPanel keeps activeArtifact (for exit animation) and clears history", () => {
const a = makeArtifact("a");
const b = makeArtifact("b");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().openArtifact(b);
useCopilotUIStore.getState().closeArtifactPanel();
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isOpen).toBe(false);
expect(s.isMinimized).toBe(false);
expect(s.activeArtifact?.id).toBe("b");
expect(s.history).toEqual([]);
});
it("minimize/restore toggles isMinimized without touching activeArtifact", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().minimizeArtifactPanel();
expect(useCopilotUIStore.getState().artifactPanel.isMinimized).toBe(true);
useCopilotUIStore.getState().restoreArtifactPanel();
expect(useCopilotUIStore.getState().artifactPanel.isMinimized).toBe(false);
expect(useCopilotUIStore.getState().artifactPanel.activeArtifact?.id).toBe(
"a",
);
});
it("maximize sets isMaximized and clears isMinimized", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().minimizeArtifactPanel();
useCopilotUIStore.getState().maximizeArtifactPanel();
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isMaximized).toBe(true);
expect(s.isMinimized).toBe(false);
});
it("restoreArtifactPanel clears both isMinimized and isMaximized", () => {
const a = makeArtifact("a");
useCopilotUIStore.getState().openArtifact(a);
useCopilotUIStore.getState().maximizeArtifactPanel();
useCopilotUIStore.getState().restoreArtifactPanel();
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.isMaximized).toBe(false);
expect(s.isMinimized).toBe(false);
});
it("setArtifactPanelWidth updates width and clears isMaximized", () => {
useCopilotUIStore.getState().maximizeArtifactPanel();
useCopilotUIStore.getState().setArtifactPanelWidth(720);
const s = useCopilotUIStore.getState().artifactPanel;
expect(s.width).toBe(720);
expect(s.isMaximized).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { Key, storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { clearContentCache } from "./components/ArtifactPanel/components/useArtifactContent";
import { ORIGINAL_TITLE, parseSessionIDs } from "./helpers";
export interface DeleteTarget {
@@ -7,11 +8,77 @@ export interface DeleteTarget {
title: string | null | undefined;
}
/**
* A single workspace artifact surfaced in the copilot chat.
*
* Rendered by `ArtifactCard` (inline) and `ArtifactPanel` (preview pane).
* Typically extracted from `workspace://<id>` URIs in assistant text parts
* or from `FileUIPart` attachments; see `getMessageArtifacts` in
* `ChatMessagesContainer/helpers.ts`.
*/
export interface ArtifactRef {
/** Workspace file ID (matches the backend `WorkspaceFile.id`). */
id: string;
/** Human-visible filename, used as both title and download filename. */
title: string;
/** MIME type if known (from backend metadata or `workspace://id#mime`). */
mimeType: string | null;
/**
* Fully-qualified URL the preview/download code will fetch from. Today
* this is always the same-origin proxy path
* `/api/proxy/api/workspace/files/{id}/download`.
*/
sourceUrl: string;
/**
* Who produced the artifact — drives the origin badge color in
* `ArtifactPanelHeader`. Derived from the emitting message's role.
*/
origin: "agent" | "user-upload";
/** Size in bytes if known — used by `classifyArtifact` for size gating. */
sizeBytes?: number;
}
interface ArtifactPanelState {
isOpen: boolean;
isMinimized: boolean;
isMaximized: boolean;
width: number;
activeArtifact: ArtifactRef | null;
history: ArtifactRef[];
}
export const DEFAULT_PANEL_WIDTH = 600;
/** Autopilot response mode. */
export type CopilotMode = "extended_thinking" | "fast";
const isClient = typeof window !== "undefined";
function getPersistedWidth(): number {
if (!isClient) return DEFAULT_PANEL_WIDTH;
const saved = storage.get(Key.COPILOT_ARTIFACT_PANEL_WIDTH);
if (saved) {
const parsed = parseInt(saved, 10);
// Match the drag-handle clamp so a stale/corrupt value can't open the
// panel wider than 85% of the viewport.
const maxWidth = window.innerWidth * 0.85;
if (!isNaN(parsed) && parsed >= 320) {
return Math.min(parsed, maxWidth);
}
}
return DEFAULT_PANEL_WIDTH;
}
let panelWidthPersistTimer: ReturnType<typeof setTimeout> | null = null;
function schedulePanelWidthPersist(width: number) {
if (!isClient) return;
if (panelWidthPersistTimer) clearTimeout(panelWidthPersistTimer);
panelWidthPersistTimer = setTimeout(() => {
storage.set(Key.COPILOT_ARTIFACT_PANEL_WIDTH, String(width));
panelWidthPersistTimer = null;
}, 200);
}
function persistCompletedSessions(ids: Set<string>) {
if (!isClient) return;
try {
@@ -50,6 +117,16 @@ interface CopilotUIState {
showNotificationDialog: boolean;
setShowNotificationDialog: (show: boolean) => void;
// Artifact panel
artifactPanel: ArtifactPanelState;
openArtifact: (ref: ArtifactRef) => void;
closeArtifactPanel: () => void;
minimizeArtifactPanel: () => void;
maximizeArtifactPanel: () => void;
restoreArtifactPanel: () => void;
setArtifactPanelWidth: (width: number) => void;
goBackArtifact: () => void;
/** Autopilot mode: 'extended_thinking' (default) or 'fast'. */
copilotMode: CopilotMode;
setCopilotMode: (mode: CopilotMode) => void;
@@ -111,6 +188,89 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
showNotificationDialog: false,
setShowNotificationDialog: (show) => set({ showNotificationDialog: show }),
// Artifact panel
artifactPanel: {
isOpen: false,
isMinimized: false,
isMaximized: false,
width: getPersistedWidth(),
activeArtifact: null,
history: [],
},
openArtifact: (ref) =>
set((state) => {
const { activeArtifact, history: prevHistory } = state.artifactPanel;
const topOfHistory = prevHistory[prevHistory.length - 1];
const isReturningToTop = topOfHistory?.id === ref.id;
const MAX_HISTORY = 25;
const history = isReturningToTop
? prevHistory.slice(0, -1)
: activeArtifact && activeArtifact.id !== ref.id
? [...prevHistory, activeArtifact].slice(-MAX_HISTORY)
: prevHistory;
return {
artifactPanel: {
...state.artifactPanel,
isOpen: true,
isMinimized: false,
activeArtifact: ref,
history,
},
};
}),
closeArtifactPanel: () =>
set((state) => ({
artifactPanel: {
...state.artifactPanel,
isOpen: false,
isMinimized: false,
history: [],
},
})),
minimizeArtifactPanel: () =>
set((state) => ({
artifactPanel: { ...state.artifactPanel, isMinimized: true },
})),
maximizeArtifactPanel: () =>
set((state) => ({
artifactPanel: {
...state.artifactPanel,
isMaximized: true,
isMinimized: false,
},
})),
restoreArtifactPanel: () =>
set((state) => ({
artifactPanel: {
...state.artifactPanel,
isMaximized: false,
isMinimized: false,
},
})),
setArtifactPanelWidth: (width) => {
schedulePanelWidthPersist(width);
set((state) => ({
artifactPanel: {
...state.artifactPanel,
width,
isMaximized: false,
},
}));
},
goBackArtifact: () =>
set((state) => {
const { history } = state.artifactPanel;
if (history.length === 0) return state;
const previous = history[history.length - 1];
return {
artifactPanel: {
...state.artifactPanel,
activeArtifact: previous,
history: history.slice(0, -1),
},
};
}),
copilotMode:
isClient && storage.get(Key.COPILOT_MODE) === "fast"
? "fast"
@@ -121,16 +281,26 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
},
clearCopilotLocalData: () => {
clearContentCache();
storage.clean(Key.COPILOT_NOTIFICATIONS_ENABLED);
storage.clean(Key.COPILOT_SOUND_ENABLED);
storage.clean(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED);
storage.clean(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED);
storage.clean(Key.COPILOT_ARTIFACT_PANEL_WIDTH);
storage.clean(Key.COPILOT_MODE);
storage.clean(Key.COPILOT_COMPLETED_SESSIONS);
set({
completedSessionIDs: new Set<string>(),
isNotificationsEnabled: false,
isSoundEnabled: true,
artifactPanel: {
isOpen: false,
isMinimized: false,
isMaximized: false,
width: DEFAULT_PANEL_WIDTH,
activeArtifact: null,
history: [],
},
copilotMode: "extended_thinking",
});
if (isClient) {

View File

@@ -7041,12 +7041,76 @@
}
}
},
"/api/workspace/files": {
"get": {
"tags": ["workspace"],
"summary": "List workspace files",
"description": "List files in the user's workspace.\n\nWhen session_id is provided, only files for that session are returned.\nOtherwise, all files across sessions are listed. Results are paginated\nvia `limit`/`offset`; `has_more` indicates whether additional pages exist.",
"operationId": "listWorkspaceFiles",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "session_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Session Id"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 1000,
"minimum": 1,
"default": 200,
"title": "Limit"
}
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0,
"title": "Offset"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ListFilesResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/workspace/files/upload": {
"post": {
"tags": ["workspace"],
"summary": "Upload file to workspace",
"description": "Upload a file to the user's workspace.\n\nFiles are stored in session-scoped paths when session_id is provided,\nso the agent's session-scoped tools can discover them automatically.",
"operationId": "postWorkspaceUpload file to workspace",
"operationId": "uploadWorkspaceFile",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -7074,7 +7138,7 @@
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_postWorkspaceUpload_file_to_workspace"
"$ref": "#/components/schemas/Body_uploadWorkspaceFile"
}
}
}
@@ -7109,7 +7173,7 @@
"tags": ["workspace"],
"summary": "Delete a workspace file",
"description": "Soft-delete a workspace file and attempt to remove it from storage.\n\nUsed when a user clears a file input in the builder.",
"operationId": "deleteWorkspaceDelete a workspace file",
"operationId": "deleteWorkspaceFile",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -7147,7 +7211,7 @@
"tags": ["workspace"],
"summary": "Download file by ID",
"description": "Download a file by its ID.\n\nReturns the file content directly or redirects to a signed URL for GCS.",
"operationId": "getWorkspaceDownload file by id",
"operationId": "getWorkspaceDownloadFileById",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -7181,7 +7245,7 @@
"tags": ["workspace"],
"summary": "Get workspace storage usage",
"description": "Get storage usage information for the user's workspace.",
"operationId": "getWorkspaceGet workspace storage usage",
"operationId": "getWorkspaceStorageUsage",
"responses": {
"200": {
"description": "Successful Response",
@@ -8499,13 +8563,13 @@
"required": ["file"],
"title": "Body_postV2Upload submission media"
},
"Body_postWorkspaceUpload_file_to_workspace": {
"Body_uploadWorkspaceFile": {
"properties": {
"file": { "type": "string", "format": "binary", "title": "File" }
},
"type": "object",
"required": ["file"],
"title": "Body_postWorkspaceUpload file to workspace"
"title": "Body_uploadWorkspaceFile"
},
"BulkMoveAgentsRequest": {
"properties": {
@@ -10692,6 +10756,24 @@
"required": ["source_id", "sink_id", "source_name", "sink_name"],
"title": "Link"
},
"ListFilesResponse": {
"properties": {
"files": {
"items": { "$ref": "#/components/schemas/WorkspaceFileItem" },
"type": "array",
"title": "Files"
},
"offset": { "type": "integer", "title": "Offset", "default": 0 },
"has_more": {
"type": "boolean",
"title": "Has More",
"default": false
}
},
"type": "object",
"required": ["files"],
"title": "ListFilesResponse"
},
"ListSessionsResponse": {
"properties": {
"sessions": {
@@ -15219,6 +15301,31 @@
],
"title": "Webhook"
},
"WorkspaceFileItem": {
"properties": {
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"path": { "type": "string", "title": "Path" },
"mime_type": { "type": "string", "title": "Mime Type" },
"size_bytes": { "type": "integer", "title": "Size Bytes" },
"metadata": {
"additionalProperties": true,
"type": "object",
"title": "Metadata"
},
"created_at": { "type": "string", "title": "Created At" }
},
"type": "object",
"required": [
"id",
"name",
"path",
"mime_type",
"size_bytes",
"created_at"
],
"title": "WorkspaceFileItem"
},
"backend__api__features__workspace__routes__UploadFileResponse": {
"properties": {
"file_id": { "type": "string", "title": "File Id" },

View File

@@ -1,6 +1,8 @@
import { globalRegistry } from "./types";
import { textRenderer } from "./renderers/TextRenderer";
import { codeRenderer } from "./renderers/CodeRenderer";
import { csvRenderer } from "./renderers/CSVRenderer";
import { htmlRenderer } from "./renderers/HTMLRenderer";
import { imageRenderer } from "./renderers/ImageRenderer";
import { videoRenderer } from "./renderers/VideoRenderer";
import { audioRenderer } from "./renderers/AudioRenderer";
@@ -13,7 +15,9 @@ import { linkRenderer } from "./renderers/LinkRenderer";
globalRegistry.register(workspaceFileRenderer);
globalRegistry.register(videoRenderer);
globalRegistry.register(audioRenderer);
globalRegistry.register(htmlRenderer);
globalRegistry.register(imageRenderer);
globalRegistry.register(csvRenderer);
globalRegistry.register(codeRenderer);
globalRegistry.register(markdownRenderer);
globalRegistry.register(jsonRenderer);

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { csvRenderer } from "./CSVRenderer";
function downloadText(value: string, filename = "t.csv"): string {
const dl = csvRenderer.getDownloadContent?.(value, { filename });
if (!dl) throw new Error("no download content");
return dl.filename;
}
describe("csvRenderer.canRender", () => {
it("matches CSV mime type", () => {
expect(csvRenderer.canRender("a,b\n1,2", { mimeType: "text/csv" })).toBe(
true,
);
});
it("matches .csv filename case-insensitively", () => {
expect(csvRenderer.canRender("a,b", { filename: "data.CSV" })).toBe(true);
});
it("rejects non-string values", () => {
expect(csvRenderer.canRender(42, { mimeType: "text/csv" })).toBe(false);
});
it("rejects strings without CSV hint", () => {
expect(csvRenderer.canRender("a,b,c", {})).toBe(false);
});
});
describe("csvRenderer.getDownloadContent", () => {
it("uses filename from metadata", () => {
expect(downloadText("a,b\n1,2", "my.csv")).toBe("my.csv");
});
it("falls back to data.csv", () => {
const dl = csvRenderer.getDownloadContent?.("a,b\n1,2");
expect(dl?.filename).toBe("data.csv");
});
});
describe("csvRenderer.getCopyContent", () => {
it("round-trips content as plain text", () => {
const result = csvRenderer.getCopyContent?.("x,y\n1,2");
expect(result?.mimeType).toBe("text/plain");
expect(result?.data).toBe("x,y\n1,2");
});
});
describe("csvRenderer.render (parse via render output smoke)", () => {
// The parser itself isn't exported, so we exercise it through render.
// These tests ensure render() doesn't throw on edge-case CSVs.
it("handles empty input", () => {
expect(() => csvRenderer.render("")).not.toThrow();
});
it("handles embedded newline inside quoted field", () => {
const csv = 'name,bio\n"Alice","line1\nline2"\n"Bob","x"';
expect(() => csvRenderer.render(csv)).not.toThrow();
});
it("strips BOM from first header cell (smoke)", () => {
const csv = "\ufefftitle,count\nfoo,1";
expect(() => csvRenderer.render(csv)).not.toThrow();
});
it("handles CRLF line endings", () => {
const csv = "a,b\r\n1,2\r\n3,4";
expect(() => csvRenderer.render(csv)).not.toThrow();
});
it("handles escaped double quote inside a quoted field", () => {
const csv = 'name\n"She said ""hi"""';
expect(() => csvRenderer.render(csv)).not.toThrow();
});
});

View File

@@ -0,0 +1,177 @@
import React, { useMemo, useState } from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function parseCSV(text: string): { headers: string[]; rows: string[][] } {
const normalized = text
.replace(/\r\n?/g, "\n")
.replace(/^\ufeff/, "")
.trim();
if (normalized.length === 0) return { headers: [], rows: [] };
// Character-by-character parse so embedded newlines inside "quoted" cells
// (allowed by RFC 4180) don't break the row split.
const rows: string[][] = [];
let current = "";
let row: string[] = [];
let inQuotes = false;
for (let i = 0; i < normalized.length; i++) {
const ch = normalized[i];
if (inQuotes) {
if (ch === '"' && normalized[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
row.push(current);
current = "";
} else if (ch === "\n") {
row.push(current);
rows.push(row);
row = [];
current = "";
} else {
current += ch;
}
}
row.push(current);
rows.push(row);
const headers = rows[0] ?? [];
return { headers, rows: rows.slice(1) };
}
function CSVTable({ value }: { value: string }) {
const { headers, rows } = useMemo(() => parseCSV(value), [value]);
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortAsc, setSortAsc] = useState(true);
const sortedRows = useMemo(() => {
if (sortCol === null) return rows;
return [...rows].sort((a, b) => {
const aVal = a[sortCol] ?? "";
const bVal = b[sortCol] ?? "";
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return sortAsc ? aNum - bNum : bNum - aNum;
}
return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
}, [rows, sortCol, sortAsc]);
function handleSort(col: number) {
if (sortCol === col) {
setSortAsc(!sortAsc);
} else {
setSortCol(col);
setSortAsc(true);
}
}
if (headers.length === 0) {
return <p className="p-4 text-sm text-zinc-500">Empty CSV</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50">
{headers.map((header, i) => (
<th
key={i}
className="px-3 py-2 text-left font-medium text-zinc-700"
>
<button
type="button"
className="flex w-full cursor-pointer select-none items-center gap-1 hover:bg-zinc-100"
onClick={() => handleSort(i)}
>
{header}
{sortCol === i && (
<span className="text-xs">
{sortAsc ? "\u25B2" : "\u25BC"}
</span>
)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-zinc-100 even:bg-zinc-50/50"
style={{
contentVisibility: "auto",
containIntrinsicSize: "0 36px",
}}
>
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-1.5 text-zinc-600">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function canRenderCSV(value: unknown, metadata?: OutputMetadata): boolean {
if (typeof value !== "string") return false;
if (metadata?.mimeType === "text/csv") return true;
if (metadata?.filename?.toLowerCase().endsWith(".csv")) return true;
return false;
}
function renderCSV(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
return <CSVTable value={String(value)} />;
}
function getCopyContentCSV(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const text = String(value);
return { mimeType: "text/plain", data: text, fallbackText: text };
}
function getDownloadContentCSV(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const text = String(value);
return {
data: new Blob([text], { type: "text/csv" }),
filename: metadata?.filename || "data.csv",
mimeType: "text/csv",
};
}
export const csvRenderer: OutputRenderer = {
name: "CSVRenderer",
priority: 38,
canRender: canRenderCSV,
render: renderCSV,
getCopyContent: getCopyContentCSV,
getDownloadContent: getDownloadContentCSV,
isConcatenable: () => false,
};

View File

@@ -1,4 +1,13 @@
import React from "react";
"use client";
import React, { useEffect, useState } from "react";
import {
SHIKI_THEMES,
type BundledLanguage,
getShikiHighlighter,
isLanguageSupported,
resolveLanguage,
} from "@/lib/shiki-highlighter";
import {
OutputRenderer,
OutputMetadata,
@@ -6,6 +15,18 @@ import {
CopyContent,
} from "../types";
interface HighlightToken {
content: string;
color?: string;
htmlStyle?: Record<string, string>;
}
interface HighlightedCodeState {
tokens: HighlightToken[][];
fg?: string;
bg?: string;
}
function getFileExtension(language: string): string {
const extensionMap: Record<string, string> = {
javascript: "js",
@@ -68,24 +89,153 @@ function canRenderCode(value: unknown, metadata?: OutputMetadata): boolean {
return codeIndicators.some((pattern) => pattern.test(value));
}
function EditorLineNumber({ index }: { index: number }) {
return (
<span className="select-none pr-2 text-right font-mono text-xs text-zinc-600">
{index + 1}
</span>
);
}
function PlainCodeLines({ code }: { code: string }) {
return code.split("\n").map((line, index) => (
<div key={`${index}-${line}`} className="grid grid-cols-[3rem_1fr] gap-4">
<EditorLineNumber index={index} />
<span className="whitespace-pre font-mono text-sm text-zinc-100">
{line || " "}
</span>
</div>
));
}
function HighlightedCodeBlock({
code,
filename,
language,
}: {
code: string;
filename?: string;
language?: string;
}) {
const [highlighted, setHighlighted] = useState<HighlightedCodeState | null>(
null,
);
const resolvedLanguage = resolveLanguage(language || "text");
const supportedLanguage = isLanguageSupported(resolvedLanguage)
? resolvedLanguage
: "text";
useEffect(() => {
let cancelled = false;
const shikiLanguage = supportedLanguage as BundledLanguage;
setHighlighted(null);
getShikiHighlighter()
.then(async (highlighter) => {
if (
supportedLanguage !== "text" &&
!highlighter.getLoadedLanguages().includes(supportedLanguage)
) {
await highlighter.loadLanguage(shikiLanguage);
}
const shikiResult = highlighter.codeToTokens(code, {
lang: shikiLanguage,
theme: SHIKI_THEMES[1],
});
if (cancelled) return;
setHighlighted({
tokens: shikiResult.tokens.map((line) =>
line.map((token) => ({
content: token.content,
color: token.color,
htmlStyle: token.htmlStyle,
})),
),
fg: shikiResult.fg,
bg: shikiResult.bg,
});
})
.catch(() => {
if (cancelled) return;
setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [code, supportedLanguage]);
return (
<div className="overflow-hidden rounded-lg border border-zinc-900 bg-[#020617] shadow-sm">
<div className="flex items-center justify-between border-b border-zinc-800 bg-[#111827] px-3 py-2">
<span className="truncate font-mono text-xs text-zinc-400">
{filename || "code"}
</span>
<span className="rounded bg-zinc-800 px-2 py-0.5 font-mono text-[11px] uppercase tracking-wide text-zinc-300">
{supportedLanguage}
</span>
</div>
<div
className="overflow-x-auto"
style={{
backgroundColor: highlighted?.bg || "#020617",
color: highlighted?.fg || "#e2e8f0",
}}
>
<pre className="min-w-full p-4">
{highlighted ? (
highlighted.tokens.map((line, index) => (
<div
key={`${index}-${line.length}`}
className="grid grid-cols-[3rem_1fr] gap-4"
>
<EditorLineNumber index={index} />
<span className="whitespace-pre font-mono text-sm leading-6">
{line.length > 0
? line.map((token, tokenIndex) => (
<span
key={`${index}-${tokenIndex}-${token.content}`}
style={
token.htmlStyle
? (token.htmlStyle as React.CSSProperties)
: token.color
? { color: token.color }
: undefined
}
>
{token.content}
</span>
))
: " "}
</span>
</div>
))
) : (
<PlainCodeLines code={code} />
)}
</pre>
</div>
</div>
);
}
function renderCode(
value: unknown,
metadata?: OutputMetadata,
): React.ReactNode {
const codeValue = String(value);
const language = metadata?.language || "plaintext";
const language = metadata?.language || "text";
return (
<div className="group relative">
{metadata?.language && (
<div className="absolute right-2 top-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground">
{language}
</div>
)}
<pre className="overflow-x-auto rounded-md bg-muted p-3">
<code className="font-mono text-sm">{codeValue}</code>
</pre>
</div>
<HighlightedCodeBlock
code={codeValue}
filename={metadata?.filename}
language={language}
/>
);
}

View File

@@ -0,0 +1,75 @@
import React from "react";
import {
TAILWIND_CDN_URL,
wrapWithHeadInjection,
} from "@/lib/iframe-sandbox-csp";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function HTMLPreview({ value }: { value: string }) {
// Inject Tailwind CDN — no CSP (see iframe-sandbox-csp.ts for why)
const tailwindScript = `<script src="${TAILWIND_CDN_URL}"></script>`;
const srcDoc = wrapWithHeadInjection(value, tailwindScript);
return (
<iframe
sandbox="allow-scripts"
srcDoc={srcDoc}
className="h-96 w-full rounded border border-zinc-200"
title="HTML preview"
/>
);
}
function canRenderHTML(value: unknown, metadata?: OutputMetadata): boolean {
if (typeof value !== "string") return false;
if (metadata?.mimeType === "text/html") return true;
const filename = metadata?.filename?.toLowerCase();
if (filename?.endsWith(".html") || filename?.endsWith(".htm")) return true;
return false;
}
function renderHTML(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
return <HTMLPreview value={String(value)} />;
}
function getCopyContentHTML(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const text = String(value);
return {
mimeType: "text/html",
data: text,
fallbackText: text,
alternativeMimeTypes: ["text/plain"],
};
}
function getDownloadContentHTML(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const text = String(value);
return {
data: new Blob([text], { type: "text/html" }),
filename: metadata?.filename || "page.html",
mimeType: "text/html",
};
}
export const htmlRenderer: OutputRenderer = {
name: "HTMLRenderer",
priority: 42,
canRender: canRenderHTML,
render: renderHTML,
getCopyContent: getCopyContentHTML,
getDownloadContent: getDownloadContentHTML,
isConcatenable: () => false,
};

View File

@@ -1,4 +1,4 @@
import { useDeleteWorkspaceDeleteAWorkspaceFile } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { useDeleteWorkspaceFile } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { uploadFileDirect } from "@/lib/direct-upload";
import { parseWorkspaceFileID, buildWorkspaceURI } from "@/lib/workspace-uri";
@@ -6,7 +6,7 @@ import { parseWorkspaceFileID, buildWorkspaceURI } from "@/lib/workspace-uri";
export function useWorkspaceUpload() {
const { toast } = useToast();
const { mutate: deleteMutation } = useDeleteWorkspaceDeleteAWorkspaceFile({
const { mutate: deleteMutation } = useDeleteWorkspaceFile({
mutation: {
onError: () => {
toast({

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { TAILWIND_CDN_URL, wrapWithHeadInjection } from "../iframe-sandbox-csp";
describe("wrapWithHeadInjection", () => {
const injection = '<script src="https://example.com/lib.js"></script>';
it("injects after <head> when document has a head tag", () => {
const html = "<html><head><title>Test</title></head><body>Hi</body></html>";
const result = wrapWithHeadInjection(html, injection);
expect(result).toContain(`<head>${injection}<title>Test</title>`);
});
it("injects after <head> with attributes", () => {
const html = '<html><head lang="en"><title>Test</title></head></html>';
const result = wrapWithHeadInjection(html, injection);
expect(result).toContain(`<head lang="en">${injection}<title>Test</title>`);
});
it("is case-insensitive for head tag", () => {
const html = "<HTML><HEAD><TITLE>Test</TITLE></HEAD></HTML>";
const result = wrapWithHeadInjection(html, injection);
expect(result).toContain(`<HEAD>${injection}<TITLE>`);
});
it("wraps headless content in a full document skeleton", () => {
const html = "<div>Just a fragment</div>";
const result = wrapWithHeadInjection(html, injection);
expect(result).toBe(
`<!doctype html><html><head>${injection}</head><body>${html}</body></html>`,
);
});
it("wraps empty string in a skeleton", () => {
const result = wrapWithHeadInjection("", injection);
expect(result).toContain("<head>" + injection + "</head>");
expect(result).toContain("<body></body>");
});
});
describe("TAILWIND_CDN_URL", () => {
it("is pinned to a specific version", () => {
expect(TAILWIND_CDN_URL).toMatch(
/^https:\/\/cdn\.tailwindcss\.com\/\d+\.\d+\.\d+$/,
);
});
});
describe("no CSP is exported", () => {
it("does not export ARTIFACT_IFRAME_CSP", async () => {
const mod = await import("../iframe-sandbox-csp");
expect("ARTIFACT_IFRAME_CSP" in mod).toBe(false);
});
it("does not export cspMetaTag", async () => {
const mod = await import("../iframe-sandbox-csp");
expect("cspMetaTag" in mod).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
/**
* Artifact iframe preview utilities.
*
* ===== WHY THERE IS NO CSP =====
*
* We intentionally do NOT inject a Content-Security-Policy meta tag into
* artifact preview iframes. CSP was added and removed multiple times during
* review — here's why it stays out:
*
* 1. `connect-src 'none'` breaks any AI-generated HTML that uses fetch(),
* XMLHttpRequest, or WebSocket — dashboards, API-driven charts, data
* loaders, etc. all silently fail.
*
* 2. The iframe sandbox (`sandbox="allow-scripts"` without `allow-same-origin`)
* already provides strong isolation: the iframe gets a unique opaque origin,
* so it cannot access the parent page's cookies, localStorage, DOM, or
* make same-origin requests to our backend.
*
* 3. The only data a script inside the iframe can exfiltrate is the artifact
* content itself — which the user already sees in the chat. There is no
* secret data available inside the sandbox.
*
* 4. Meta-CSP is unreliable in practice: if AI-generated HTML includes its
* own <meta http-equiv="Content-Security-Policy"> before ours, the browser
* honors the first one and ignores ours.
*
* DO NOT re-add CSP without addressing all four points above.
* ================================================================
*/
// Pinned to a specific version to reduce exposure to unannounced upstream
// changes (SRI is not possible because the JIT runtime is generated on demand).
export const TAILWIND_CDN_URL = "https://cdn.tailwindcss.com/3.4.16";
/**
* Inject content into the <head> of an HTML document string.
* If the content has no <head> tag, wraps it in a full document skeleton.
*/
const HEAD_OPEN_RE = /<head(\s[^>]*)?>/i;
export function wrapWithHeadInjection(
content: string,
headInjection: string,
): string {
if (HEAD_OPEN_RE.test(content)) {
return content.replace(HEAD_OPEN_RE, (match) => `${match}${headInjection}`);
}
return `<!doctype html><html><head>${headInjection}</head><body>${content}</body></html>`;
}

View File

@@ -13,6 +13,7 @@ export enum Flag {
AGENT_FAVORITING = "agent-favoriting",
MARKETPLACE_SEARCH_TERMS = "marketplace-search-terms",
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
ARTIFACTS = "artifacts",
CHAT_MODE_OPTION = "chat-mode-option",
}
@@ -27,6 +28,7 @@ const defaultFlags = {
[Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.ARTIFACTS]: false,
[Flag.CHAT_MODE_OPTION]: false,
};

View File

@@ -15,6 +15,7 @@ export enum Key {
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
COPILOT_ARTIFACT_PANEL_WIDTH = "copilot-artifact-panel-width",
COPILOT_MODE = "copilot-mode",
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",
}