Compare commits

...

3 Commits

Author SHA1 Message Date
rohoswagger
aa14ee3059 fix(craft): simplify offline page and remove dead /_next/ route
- Offline HTML: drop typing animation, show single static message
  "Sandbox is asleep..." with the terminal chrome from page.tsx

- Remove nextjs_assets_router and _extract_session_from_referer:
  _rewrite_asset_paths() already rewrites all /_next/ references to
  /build/sessions/{session_id}/webapp/_next/ in proxied responses, so
  the root-level /_next/ route was never hit. Removing it eliminates
  Referer-based session extraction (broken when header is absent) and
  an unnecessary public endpoint bypass in auth_check.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 16:11:00 -08:00
rohoswagger
ce69c91ce5 feat(craft): terminal UI for offline sandbox page
Replace the generic offline page with a terminal UI matching the default
Craft web template (page.tsx). Uses the crafting_table title bar with
square traffic-light buttons, emerald /> prompt, dark neutral gradient
background, and a typing animation with messages indicating the sandbox
is asleep and the owner needs to wake it up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 15:56:18 -08:00
rohoswagger
10f731c2f4 feat(craft): shareable webapp URLs
- Add is_public field to BuildSession with migration
- PATCH /sessions/{id}/public endpoint (owner-only)
- Public proxy router: unauthenticated access when is_public=True
- Auto-restart Next.js in background when port is dead (local backend)
- Strip Set-Cookie from proxied responses to prevent cookie pollution
- ShareButton component with popover, link copy, and SWR cache invalidation
- Offline page auto-refreshes every 15s while sandbox warms up
2026-02-17 15:33:47 -08:00
16 changed files with 576 additions and 62 deletions

View File

@@ -0,0 +1,32 @@
"""add is_public to build_session
Revision ID: c7f2e1b4a9d3
Revises: 19c0ccb01687
Create Date: 2026-02-17 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c7f2e1b4a9d3"
down_revision = "19c0ccb01687"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"build_session",
sa.Column(
"is_public",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("build_session", "is_public")

View File

@@ -4712,6 +4712,9 @@ class BuildSession(Base):
demo_data_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("true")
)
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
# Relationships
user: Mapped[User | None] = relationship("User", foreign_keys=[user_id])

View File

@@ -64,7 +64,7 @@ from onyx.server.documents.connector import router as connector_router
from onyx.server.documents.credential import router as credential_router
from onyx.server.documents.document import router as document_router
from onyx.server.documents.standard_oauth import router as standard_oauth_router
from onyx.server.features.build.api.api import nextjs_assets_router
from onyx.server.features.build.api.api import public_build_router
from onyx.server.features.build.api.api import router as build_router
from onyx.server.features.default_assistant.api import (
router as default_assistant_router,
@@ -378,8 +378,8 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
include_router_with_global_prefix_prepended(application, admin_input_prompt_router)
include_router_with_global_prefix_prepended(application, cc_pair_router)
include_router_with_global_prefix_prepended(application, projects_router)
include_router_with_global_prefix_prepended(application, public_build_router)
include_router_with_global_prefix_prepended(application, build_router)
include_router_with_global_prefix_prepended(application, nextjs_assets_router)
include_router_with_global_prefix_prepended(application, document_set_router)
include_router_with_global_prefix_prepended(application, hierarchy_router)
include_router_with_global_prefix_prepended(application, search_settings_router)

View File

@@ -59,6 +59,9 @@ PUBLIC_ENDPOINT_SPECS = [
# anonymous user on cloud
("/tenants/anonymous-user", {"POST"}),
("/metrics", {"GET"}), # added by prometheus_fastapi_instrumentator
# craft webapp proxy — public when session.is_public=True (access enforced in handler)
("/build/sessions/{session_id}/webapp", {"GET"}),
("/build/sessions/{session_id}/webapp/{path:path}", {"GET"}),
]

View File

@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.auth.users import optional_user
from onyx.configs.constants import DocumentSource
from onyx.db.connector_credential_pair import get_connector_credential_pairs_for_user
from onyx.db.engine.sql_engine import get_session
@@ -217,12 +218,15 @@ def get_build_connectors(
return BuildConnectorListResponse(connectors=connectors)
# Headers to skip when proxying (hop-by-hop headers)
# Headers to skip when proxying.
# Hop-by-hop headers must not be forwarded, and set-cookie is stripped to
# prevent LLM-generated apps from setting cookies on the parent Onyx domain.
EXCLUDED_HEADERS = {
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
"set-cookie",
}
@@ -365,71 +369,173 @@ def _proxy_request(
raise HTTPException(status_code=502, detail="Bad gateway")
@router.get("/sessions/{session_id}/webapp", response_model=None)
def _check_webapp_access(
session_id: UUID, user: User | None, db_session: Session
) -> BuildSession:
"""Check if user can access a session's webapp.
- Public sessions: accessible by anyone (no auth required)
- Private sessions: only accessible by the session owner
Returns the session if access is granted, raises HTTPException otherwise.
"""
session = db_session.get(BuildSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.is_public:
return session
# Private session: require authenticated owner
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
if session.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
return session
def _offline_html_response() -> Response:
"""Return a branded Craft HTML page when the sandbox is not reachable.
Design mirrors the default Craft web template (outputs/web/app/page.tsx):
terminal window aesthetic with Minecraft-themed typing animation.
"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="15" />
<title>Craft — Starting up</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
background: linear-gradient(to bottom right, #030712, #111827, #030712);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
.terminal {
width: 100%;
max-width: 580px;
border: 2px solid #374151;
border-radius: 2px;
}
.titlebar {
background: #1f2937;
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #374151;
}
.btn { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.btn-red { background: #ef4444; }
.btn-yellow { background: #eab308; }
.btn-green { background: #22c55e; }
.title-label {
flex: 1;
text-align: center;
font-size: 0.75rem;
color: #6b7280;
margin-right: 36px;
}
.body {
background: #111827;
padding: 1.5rem;
min-height: 200px;
font-size: 0.875rem;
color: #d1d5db;
display: flex;
align-items: flex-start;
gap: 0.375rem;
}
.prompt { color: #10b981; user-select: none; }
.tagline {
font-size: 0.8125rem;
color: #4b5563;
text-align: center;
}
</style>
</head>
<body>
<div class="terminal">
<div class="titlebar">
<div class="btn btn-red"></div>
<div class="btn btn-yellow"></div>
<div class="btn btn-green"></div>
<span class="title-label">crafting_table</span>
</div>
<div class="body">
<span class="prompt">/&gt;</span>
<span>Sandbox is asleep...</span>
</div>
</div>
<p class="tagline">Ask the owner to open their Craft session to wake it up.</p>
</body>
</html>"""
return Response(content=html, status_code=503, media_type="text/html")
# Public router for webapp proxy — no authentication required
# (access controlled per-session via is_public flag)
public_build_router = APIRouter(prefix="/build")
@public_build_router.get("/sessions/{session_id}/webapp", response_model=None)
def get_webapp_root(
session_id: UUID,
request: Request,
_: User = Depends(current_user),
user: User | None = Depends(optional_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy the root path of the webapp for a specific session."""
return _proxy_request("", request, session_id, db_session)
"""Proxy the root path of the webapp for a specific session.
Accessible without authentication when session.is_public=True.
Returns a friendly offline page when the sandbox is not running.
"""
_check_webapp_access(session_id, user, db_session)
try:
return _proxy_request("", request, session_id, db_session)
except HTTPException as e:
if e.status_code in (502, 503, 504):
return _offline_html_response()
raise
@router.get("/sessions/{session_id}/webapp/{path:path}", response_model=None)
@public_build_router.get(
"/sessions/{session_id}/webapp/{path:path}", response_model=None
)
def get_webapp_path(
session_id: UUID,
path: str,
request: Request,
_: User = Depends(current_user),
user: User | None = Depends(optional_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy any subpath of the webapp (static assets, etc.) for a specific session."""
return _proxy_request(path, request, session_id, db_session)
"""Proxy any subpath of the webapp for a specific session.
# Separate router for Next.js static assets at /_next/*
# This is needed because Next.js apps may reference assets with root-relative paths
# that don't get rewritten. The session_id is extracted from the Referer header.
nextjs_assets_router = APIRouter()
def _extract_session_from_referer(request: Request) -> UUID | None:
"""Extract session_id from the Referer header.
Expects Referer to contain /api/build/sessions/{session_id}/webapp
Accessible without authentication when session.is_public=True.
Returns a friendly offline page when the sandbox is not running.
"""
import re
referer = request.headers.get("referer", "")
match = re.search(r"/api/build/sessions/([a-f0-9-]+)/webapp", referer)
if match:
try:
return UUID(match.group(1))
except ValueError:
return None
return None
@nextjs_assets_router.get("/_next/{path:path}", response_model=None)
def get_nextjs_assets(
path: str,
request: Request,
_: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> StreamingResponse | Response:
"""Proxy Next.js static assets requested at root /_next/ path.
The session_id is extracted from the Referer header since these requests
come from within the iframe context.
"""
session_id = _extract_session_from_referer(request)
if not session_id:
raise HTTPException(
status_code=400,
detail="Could not determine session from request context",
)
return _proxy_request(f"_next/{path}", request, session_id, db_session)
_check_webapp_access(session_id, user, db_session)
try:
return _proxy_request(path, request, session_id, db_session)
except HTTPException as e:
if e.status_code in (502, 503, 504):
return _offline_html_response()
raise
# =============================================================================

View File

@@ -107,6 +107,7 @@ class SessionResponse(BaseModel):
nextjs_port: int | None
sandbox: SandboxResponse | None
artifacts: list[ArtifactResponse]
is_public: bool
@classmethod
def from_model(
@@ -129,6 +130,7 @@ class SessionResponse(BaseModel):
nextjs_port=session.nextjs_port,
sandbox=(SandboxResponse.from_model(sandbox) if sandbox else None),
artifacts=[ArtifactResponse.from_model(a) for a in session.artifacts],
is_public=session.is_public,
)
@@ -159,6 +161,19 @@ class SessionListResponse(BaseModel):
sessions: list[SessionResponse]
class SetSessionPublicRequest(BaseModel):
"""Request to set the public status of a session."""
is_public: bool
class SetSessionPublicResponse(BaseModel):
"""Response after setting session public status."""
session_id: str
is_public: bool
# ===== Message Models =====
class MessageRequest(BaseModel):
"""Request to send a message to the CLI agent."""
@@ -244,6 +259,7 @@ class WebappInfo(BaseModel):
webapp_url: str | None # URL to access the webapp (e.g., http://localhost:3015)
status: str # Sandbox status (running, terminated, etc.)
ready: bool # Whether the NextJS dev server is actually responding
is_public: bool # Whether the webapp is publicly accessible without auth
# ===== File Upload Models =====

View File

@@ -30,6 +30,8 @@ from onyx.server.features.build.api.models import SessionListResponse
from onyx.server.features.build.api.models import SessionNameGenerateResponse
from onyx.server.features.build.api.models import SessionResponse
from onyx.server.features.build.api.models import SessionUpdateRequest
from onyx.server.features.build.api.models import SetSessionPublicRequest
from onyx.server.features.build.api.models import SetSessionPublicResponse
from onyx.server.features.build.api.models import SuggestionBubble
from onyx.server.features.build.api.models import SuggestionTheme
from onyx.server.features.build.api.models import UploadResponse
@@ -38,6 +40,7 @@ from onyx.server.features.build.configs import SANDBOX_BACKEND
from onyx.server.features.build.configs import SandboxBackend
from onyx.server.features.build.db.build_session import allocate_nextjs_port
from onyx.server.features.build.db.build_session import get_build_session
from onyx.server.features.build.db.build_session import set_build_session_public_status
from onyx.server.features.build.db.sandbox import get_latest_snapshot_for_session
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
@@ -294,6 +297,29 @@ def update_session_name(
return SessionResponse.from_model(session, sandbox)
@router.patch("/{session_id}/public")
def set_session_public(
session_id: UUID,
request: SetSessionPublicRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> SetSessionPublicResponse:
"""Set whether a build session webapp is publicly accessible.
When is_public=True, anyone with the session URL can view the webapp
without authentication.
"""
updated = set_build_session_public_status(
session_id, user.id, request.is_public, db_session
)
if not updated:
raise HTTPException(status_code=404, detail="Session not found")
return SetSessionPublicResponse(
session_id=str(session_id),
is_public=updated.is_public,
)
@router.delete("/{session_id}", response_model=None)
def delete_session(
session_id: UUID,

View File

@@ -159,6 +159,26 @@ def update_session_status(
logger.info(f"Updated build session {session_id} status to {status}")
def set_build_session_public_status(
session_id: UUID,
user_id: UUID,
is_public: bool,
db_session: Session,
) -> BuildSession | None:
"""Set the public/private status of a build session.
Only the session owner can change this setting.
Returns the updated session, or None if not found/unauthorized.
"""
session = get_build_session(session_id, user_id, db_session)
if not session:
return None
session.is_public = is_public
db_session.commit()
logger.info(f"Set build session {session_id} is_public={is_public}")
return session
def delete_build_session__no_commit(
session_id: UUID,
user_id: UUID,

View File

@@ -474,6 +474,23 @@ class SandboxManager(ABC):
"""
...
def ensure_nextjs_running(
self,
sandbox_id: UUID,
session_id: UUID,
nextjs_port: int,
) -> None:
"""Ensure the Next.js server is running for a session.
Default is a no-op — only meaningful for local backends that manage
process lifecycles directly (e.g., LocalSandboxManager).
Args:
sandbox_id: The sandbox ID
session_id: The session ID
nextjs_port: The port the Next.js server should be listening on
"""
# Singleton instance cache for the factory
_sandbox_manager_instance: SandboxManager | None = None

View File

@@ -89,9 +89,17 @@ class LocalSandboxManager(SandboxManager):
self._acp_clients: dict[tuple[UUID, UUID], ACPAgentClient] = {}
# Track Next.js processes - keyed by (sandbox_id, session_id) tuple
# Used for clean shutdown when sessions are deleted
# Used for clean shutdown when sessions are deleted.
# Mutated from background threads; all access must hold _nextjs_lock.
self._nextjs_processes: dict[tuple[UUID, UUID], subprocess.Popen[bytes]] = {}
# Track sessions currently being (re)started - prevents concurrent restarts.
# Mutated from background threads; all access must hold _nextjs_lock.
self._nextjs_starting: set[tuple[UUID, UUID]] = set()
# Lock guarding both _nextjs_processes and _nextjs_starting
self._nextjs_lock = threading.Lock()
# Validate templates exist (raises RuntimeError if missing)
self._validate_templates()
@@ -326,16 +334,18 @@ class LocalSandboxManager(SandboxManager):
RuntimeError: If termination fails
"""
# Stop all Next.js processes for this sandbox (keyed by (sandbox_id, session_id))
processes_to_stop = [
(key, process)
for key, process in self._nextjs_processes.items()
if key[0] == sandbox_id
]
with self._nextjs_lock:
processes_to_stop = [
(key, process)
for key, process in self._nextjs_processes.items()
if key[0] == sandbox_id
]
for key, process in processes_to_stop:
session_id = key[1]
try:
self._stop_nextjs_process(process, session_id)
del self._nextjs_processes[key]
with self._nextjs_lock:
self._nextjs_processes.pop(key, None)
except Exception as e:
logger.warning(
f"Failed to stop Next.js for sandbox {sandbox_id}, "
@@ -516,7 +526,8 @@ class LocalSandboxManager(SandboxManager):
web_dir, nextjs_port
)
# Store process for clean shutdown on session delete
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
with self._nextjs_lock:
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
logger.info("Next.js server started successfully")
# Setup venv and AGENTS.md
@@ -575,7 +586,8 @@ class LocalSandboxManager(SandboxManager):
"""
# Stop Next.js dev server - try stored process first, then fallback to port lookup
process_key = (sandbox_id, session_id)
nextjs_process = self._nextjs_processes.pop(process_key, None)
with self._nextjs_lock:
nextjs_process = self._nextjs_processes.pop(process_key, None)
if nextjs_process is not None:
self._stop_nextjs_process(nextjs_process, session_id)
elif nextjs_port is not None:
@@ -766,6 +778,87 @@ class LocalSandboxManager(SandboxManager):
outputs_path = session_path / "outputs"
return outputs_path.exists()
def ensure_nextjs_running(
self,
sandbox_id: UUID,
session_id: UUID,
nextjs_port: int,
) -> None:
"""Start Next.js server for a session if not already running.
Called when the server is detected as unreachable (e.g., after API server restart).
Returns immediately — the actual startup runs in a background daemon thread.
A per-session guard prevents concurrent restarts from racing.
Args:
sandbox_id: The sandbox ID
session_id: The session ID
nextjs_port: The port number for the Next.js server
"""
process_key = (sandbox_id, session_id)
with self._nextjs_lock:
# Already have a live tracked process
existing = self._nextjs_processes.get(process_key)
if existing is not None and existing.poll() is None:
return
# Already being started by another thread
if process_key in self._nextjs_starting:
return
# Check if the port is already responding (orphan process that survived restart)
try:
import httpx
with httpx.Client(timeout=1.0) as client:
client.get(f"http://localhost:{nextjs_port}")
logger.info(
f"Port {nextjs_port} already alive for session {session_id} "
"(orphan process) — skipping restart"
)
return
except Exception:
pass # Port is dead; proceed to restart
self._nextjs_starting.add(process_key)
logger.info(
f"Scheduling background Next.js restart for session {session_id} "
f"on port {nextjs_port}"
)
def _start_in_background() -> None:
try:
sandbox_path = self._get_sandbox_path(sandbox_id)
web_dir = self._directory_manager.get_web_path(
sandbox_path, str(session_id)
)
if not web_dir.exists():
logger.warning(
f"Web dir missing for session {session_id}: {web_dir}"
"cannot restart Next.js"
)
return
process = self._process_manager.start_nextjs_server(
web_dir, nextjs_port
)
with self._nextjs_lock:
self._nextjs_processes[process_key] = process
logger.info(
f"Auto-restarted Next.js for session {session_id} "
f"on port {nextjs_port}"
)
except Exception as e:
logger.error(
f"Failed to auto-restart Next.js for session {session_id}: {e}"
)
finally:
with self._nextjs_lock:
self._nextjs_starting.discard(process_key)
threading.Thread(target=_start_in_background, daemon=True).start()
def restore_snapshot(
self,
sandbox_id: UUID,

View File

@@ -1765,6 +1765,7 @@ class SessionManager:
"webapp_url": None,
"status": "no_sandbox",
"ready": False,
"is_public": session.is_public,
}
# Return the proxy URL - the proxy handles routing to the correct sandbox
@@ -1777,11 +1778,21 @@ class SessionManager:
# Quick health check: can the API server reach the NextJS dev server?
ready = self._check_nextjs_ready(sandbox.id, session.nextjs_port)
# If not ready, ask the sandbox manager to ensure Next.js is running.
# For the local backend this triggers a background restart so that the
# frontend poll loop eventually sees ready=True without the user having
# to manually recreate the session.
if not ready:
self._sandbox_manager.ensure_nextjs_running(
sandbox.id, session_id, session.nextjs_port
)
return {
"has_webapp": session.nextjs_port is not None,
"webapp_url": webapp_url,
"status": sandbox.status.value,
"ready": ready,
"is_public": session.is_public,
}
def _check_nextjs_ready(self, sandbox_id: UUID, port: int) -> bool:

View File

@@ -31,6 +31,7 @@ import {
} from "@opal/icons";
import { IconProps } from "@opal/types";
import CraftingLoader from "@/app/craft/components/CraftingLoader";
import ShareButton from "@/app/craft/components/ShareButton";
// Output panel sub-components
import UrlBar from "@/app/craft/components/output-panel/UrlBar";
@@ -615,6 +616,20 @@ const BuildOutputPanel = memo(({ onClose, isOpen }: BuildOutputPanelProps) => {
onDownload={isMarkdownPreview ? handleDocxDownload : undefined}
isDownloading={isExportingDocx}
onRefresh={handleRefresh}
shareButton={
!isFilePreviewActive &&
activeOutputTab === "preview" &&
session?.id &&
displayUrl?.startsWith("http") ? (
<ShareButton
key={session.id}
sessionId={session.id}
webappUrl={displayUrl}
isPublic={webappInfo?.is_public ?? false}
onPublicChange={mutate}
/>
) : undefined
}
/>
{/* Tab Content */}

View File

@@ -0,0 +1,148 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import Switch from "@/refresh-components/inputs/Switch";
import { SvgLink, SvgCopy, SvgCheck, SvgX } from "@opal/icons";
import { setSessionPublic } from "@/app/craft/services/apiServices";
interface ShareButtonProps {
sessionId: string;
webappUrl: string;
isPublic: boolean;
onPublicChange?: () => void;
}
export default function ShareButton({
sessionId,
webappUrl,
isPublic: initialIsPublic,
onPublicChange,
}: ShareButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [isPublic, setIsPublic] = useState(initialIsPublic);
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
"idle"
);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
// Build the full share URL from the webapp URL path
const shareUrl = webappUrl.startsWith("http")
? webappUrl
: `${window.location.origin}${webappUrl}`;
// Close popover on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
const handleToggle = async (newValue: boolean) => {
setIsLoading(true);
try {
await setSessionPublic(sessionId, newValue);
setIsPublic(newValue);
onPublicChange?.();
} catch (err) {
console.error("Failed to update sharing:", err);
} finally {
setIsLoading(false);
}
};
const handleCopy = async () => {
let success = false;
try {
await navigator.clipboard.writeText(shareUrl);
success = true;
} catch {
// Clipboard API unavailable (HTTP context or permission denied) — try execCommand
try {
const el = document.createElement("textarea");
el.value = shareUrl;
el.style.cssText = "position:fixed;opacity:0";
document.body.appendChild(el);
el.focus();
el.select();
success = document.execCommand("copy");
document.body.removeChild(el);
} catch {
// Both methods failed
}
}
setCopyState(success ? "copied" : "error");
setTimeout(() => setCopyState("idle"), 2000);
};
return (
<div className="relative flex-shrink-0" ref={popoverRef}>
<Button
action
primary={isPublic}
tertiary={!isPublic}
leftIcon={SvgLink}
onClick={() => setIsOpen((v) => !v)}
aria-label="Share webapp"
>
{isPublic ? "Shared" : "Share"}
</Button>
{isOpen && (
<div className="absolute top-full right-0 mt-2 z-50 w-80 rounded-12 border border-border-01 bg-background-neutral-00 shadow-lg p-4 flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between">
<Text mainUiAction text04>
Share app
</Text>
<Switch
checked={isPublic}
disabled={isLoading}
onCheckedChange={handleToggle}
/>
</div>
{/* Description */}
<Text secondaryBody text03>
{isPublic
? "Anyone with the link can view this app."
: "Enable sharing to let anyone view this app with a link."}
</Text>
{/* URL row - only shown when public */}
{isPublic && (
<div className="flex items-center gap-2 p-2 rounded-08 bg-background-tint-02">
<Text secondaryBody text03 className="flex-1 truncate">
{shareUrl}
</Text>
<Button
action
tertiary
size="md"
leftIcon={
copyState === "copied"
? SvgCheck
: copyState === "error"
? SvgX
: SvgCopy
}
onClick={handleCopy}
aria-label="Copy link"
/>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -38,6 +38,8 @@ export interface UrlBarProps {
isDownloading?: boolean;
/** Optional refresh callback — shows a refresh icon at the right edge of the URL pill */
onRefresh?: () => void;
/** Optional share button node — shown on the far right of the URL bar */
shareButton?: React.ReactNode;
}
/**
@@ -60,6 +62,7 @@ export default function UrlBar({
onDownload,
isDownloading = false,
onRefresh,
shareButton,
}: UrlBarProps) {
const handleOpenInNewTab = () => {
if (previewUrl) {
@@ -152,6 +155,8 @@ export default function UrlBar({
{isDownloading ? "Exporting..." : "Export to .docx"}
</Button>
)}
{/* Share button — shown on the far right when a webapp is active */}
{shareButton}
</div>
</div>
);

View File

@@ -198,6 +198,23 @@ export async function updateSessionName(
}
}
export async function setSessionPublic(
sessionId: string,
isPublic: boolean
): Promise<{ session_id: string; is_public: boolean }> {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/public`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_public: isPublic }),
});
if (!res.ok) {
throw new Error(`Failed to update session visibility: ${res.status}`);
}
return res.json();
}
export async function deleteSession(sessionId: string): Promise<void> {
const res = await fetch(`${API_BASE}/sessions/${sessionId}`, {
method: "DELETE",

View File

@@ -165,6 +165,7 @@ export interface ApiSessionResponse {
last_activity_at: string;
sandbox: ApiSandboxResponse | null;
artifacts: ApiArtifactResponse[];
is_public: boolean;
}
export interface ApiDetailedSessionResponse extends ApiSessionResponse {
@@ -196,6 +197,7 @@ export interface ApiWebappInfoResponse {
webapp_url: string | null;
status: string;
ready: boolean;
is_public: boolean;
}
export interface FileSystemEntry {