Compare commits

...

6 Commits

Author SHA1 Message Date
rohoswagger
b3b8695716 fix(craft): use String column type for sharing_scope instead of Enum
SQLAlchemy's Enum type validates against member names (PRIVATE) not
values (private). The migration uses plain VARCHAR, so the model should
too — Python-level enum validation comes from the Mapped[SharingScope]
type annotation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 18:01:34 -08:00
rohoswagger
e7ce26a18d feat(craft): replace is_public with three-tier sharing_scope
Replaces the binary is_public flag with a SharingScope enum:
- private: owner-only access (default)
- public_org: any authenticated user can view
- public_global: anyone with the link can view (no auth required)

Backend:
- SharingScope enum added to enums.py
- BuildSession.sharing_scope replaces is_public
- _check_webapp_access branches on scope
- PATCH /{session_id}/public now accepts sharing_scope

Frontend:
- ShareButton replaces toggle with 3-option selector
- SharingScope type added to streamingTypes.ts
- setSessionSharing replaces setSessionPublic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 17:34:39 -08:00
rohoswagger
f97c56ffb9 feat(craft): add sharing_scope column to build_session (migration) 2026-02-17 17:17:54 -08:00
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
17 changed files with 627 additions and 62 deletions

View File

@@ -0,0 +1,31 @@
"""add sharing_scope 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 = "c7f2e1b4a9d3"
down_revision = "19c0ccb01687"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"build_session",
sa.Column(
"sharing_scope",
sa.String(),
nullable=False,
server_default="private",
),
)
def downgrade() -> None:
op.drop_column("build_session", "sharing_scope")

View File

@@ -232,6 +232,12 @@ class BuildSessionStatus(str, PyEnum):
IDLE = "idle"
class SharingScope(str, PyEnum):
PRIVATE = "private"
PUBLIC_ORG = "public_org"
PUBLIC_GLOBAL = "public_global"
class SandboxStatus(str, PyEnum):
PROVISIONING = "provisioning"
RUNNING = "running"

View File

@@ -77,6 +77,7 @@ from onyx.db.enums import (
ThemePreference,
DefaultAppMode,
SwitchoverType,
SharingScope,
)
from onyx.configs.constants import NotificationType
from onyx.configs.constants import SearchFeedbackType
@@ -4712,6 +4713,12 @@ class BuildSession(Base):
demo_data_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("true")
)
sharing_scope: Mapped[SharingScope] = mapped_column(
String,
nullable=False,
default=SharingScope.PRIVATE,
server_default="private",
)
# 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,12 +11,14 @@ 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
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import IndexingStatus
from onyx.db.enums import ProcessingMode
from onyx.db.enums import SharingScope
from onyx.db.index_attempt import get_latest_index_attempt_for_cc_pair_id
from onyx.db.models import BuildSession
from onyx.db.models import User
@@ -217,12 +219,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 +370,176 @@ 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_global: accessible by anyone (no auth required)
- public_org: accessible by any authenticated user
- private: only accessible by the session owner
"""
session = db_session.get(BuildSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.sharing_scope == SharingScope.PUBLIC_GLOBAL:
return session
if session.sharing_scope == SharingScope.PUBLIC_ORG:
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return session
# PRIVATE: require 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 sharing_scope)
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 sharing_scope is public_global.
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 sharing_scope is public_global.
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

@@ -10,6 +10,7 @@ from onyx.configs.constants import MessageType
from onyx.db.enums import ArtifactType
from onyx.db.enums import BuildSessionStatus
from onyx.db.enums import SandboxStatus
from onyx.db.enums import SharingScope
from onyx.server.features.build.sandbox.models import (
FilesystemEntry as FileSystemEntry,
)
@@ -107,6 +108,7 @@ class SessionResponse(BaseModel):
nextjs_port: int | None
sandbox: SandboxResponse | None
artifacts: list[ArtifactResponse]
sharing_scope: SharingScope
@classmethod
def from_model(
@@ -129,6 +131,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],
sharing_scope=session.sharing_scope,
)
@@ -159,6 +162,19 @@ class SessionListResponse(BaseModel):
sessions: list[SessionResponse]
class SetSessionSharingRequest(BaseModel):
"""Request to set the sharing scope of a session."""
sharing_scope: SharingScope
class SetSessionSharingResponse(BaseModel):
"""Response after setting session sharing scope."""
session_id: str
sharing_scope: SharingScope
# ===== Message Models =====
class MessageRequest(BaseModel):
"""Request to send a message to the CLI agent."""
@@ -244,6 +260,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
sharing_scope: SharingScope
# ===== 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 SetSessionSharingRequest
from onyx.server.features.build.api.models import SetSessionSharingResponse
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_sharing_scope
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,25 @@ def update_session_name(
return SessionResponse.from_model(session, sandbox)
@router.patch("/{session_id}/public")
def set_session_public(
session_id: UUID,
request: SetSessionSharingRequest,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> SetSessionSharingResponse:
"""Set the sharing scope of a build session's webapp."""
updated = set_build_session_sharing_scope(
session_id, user.id, request.sharing_scope, db_session
)
if not updated:
raise HTTPException(status_code=404, detail="Session not found")
return SetSessionSharingResponse(
session_id=str(session_id),
sharing_scope=updated.sharing_scope,
)
@router.delete("/{session_id}", response_model=None)
def delete_session(
session_id: UUID,

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from onyx.configs.constants import MessageType
from onyx.db.enums import BuildSessionStatus
from onyx.db.enums import SandboxStatus
from onyx.db.enums import SharingScope
from onyx.db.models import Artifact
from onyx.db.models import BuildMessage
from onyx.db.models import BuildSession
@@ -159,6 +160,26 @@ def update_session_status(
logger.info(f"Updated build session {session_id} status to {status}")
def set_build_session_sharing_scope(
session_id: UUID,
user_id: UUID,
sharing_scope: SharingScope,
db_session: Session,
) -> BuildSession | None:
"""Set the sharing scope 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.sharing_scope = sharing_scope
db_session.commit()
logger.info(f"Set build session {session_id} sharing_scope={sharing_scope}")
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,
"sharing_scope": session.sharing_scope,
}
# 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,
"sharing_scope": session.sharing_scope,
}
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}
sharingScope={webappInfo?.sharing_scope ?? "private"}
onScopeChange={mutate}
/>
) : undefined
}
/>
{/* Tab Content */}

View File

@@ -0,0 +1,181 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { SvgLink, SvgCopy, SvgCheck, SvgX } from "@opal/icons";
import { setSessionSharing } from "@/app/craft/services/apiServices";
import type { SharingScope } from "@/app/craft/types/streamingTypes";
import { cn } from "@/lib/utils";
interface ShareButtonProps {
sessionId: string;
webappUrl: string;
sharingScope: SharingScope;
onScopeChange?: () => void;
}
const SCOPE_OPTIONS: {
value: SharingScope;
label: string;
description: string;
}[] = [
{
value: "private",
label: "Private",
description: "Only you can view this app.",
},
{
value: "public_org",
label: "Org",
description: "Anyone logged into your Onyx can view this app.",
},
{
value: "public_global",
label: "Public",
description: "Anyone with the link can view this app.",
},
];
export default function ShareButton({
sessionId,
webappUrl,
sharingScope: initialScope,
onScopeChange,
}: ShareButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [sharingScope, setSharingScope] = useState<SharingScope>(initialScope);
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
"idle"
);
const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const isShared = sharingScope !== "private";
const shareUrl = webappUrl.startsWith("http")
? webappUrl
: `${window.location.origin}${webappUrl}`;
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 handleSelect = async (scope: SharingScope) => {
if (scope === sharingScope || isLoading) return;
setIsLoading(true);
try {
await setSessionSharing(sessionId, scope);
setSharingScope(scope);
onScopeChange?.();
} 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 {
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 {}
}
setCopyState(success ? "copied" : "error");
setTimeout(() => setCopyState("idle"), 2000);
};
return (
<div className="relative flex-shrink-0" ref={popoverRef}>
<Button
action
primary={isShared}
tertiary={!isShared}
leftIcon={SvgLink}
onClick={() => setIsOpen((v) => !v)}
aria-label="Share webapp"
>
{isShared ? "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">
<Text mainUiAction text04>
Share app
</Text>
{/* Scope selector */}
<div className="flex flex-col gap-1">
{SCOPE_OPTIONS.map((opt) => (
<div
key={opt.value}
role="button"
tabIndex={0}
onClick={() => handleSelect(opt.value)}
onKeyDown={(e) => e.key === "Enter" && handleSelect(opt.value)}
aria-disabled={isLoading}
className={cn(
"flex flex-col items-start gap-0.5 px-3 py-2 rounded-08 text-left transition-colors cursor-pointer",
sharingScope === opt.value
? "bg-background-tint-03"
: "hover:bg-background-tint-02"
)}
>
<Text mainUiAction text04>
{opt.label}
</Text>
<Text secondaryBody text03>
{opt.description}
</Text>
</div>
))}
</div>
{/* Copy link row — shown when not private */}
{isShared && (
<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

@@ -11,6 +11,7 @@ import {
StreamPacket,
UsageLimits,
DirectoryListing,
SharingScope,
} from "@/app/craft/types/streamingTypes";
// =============================================================================
@@ -198,6 +199,23 @@ export async function updateSessionName(
}
}
export async function setSessionSharing(
sessionId: string,
sharingScope: SharingScope
): Promise<{ session_id: string; sharing_scope: SharingScope }> {
const res = await fetch(`${API_BASE}/sessions/${sessionId}/public`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sharing_scope: sharingScope }),
});
if (!res.ok) {
throw new Error(`Failed to update session sharing: ${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

@@ -1,3 +1,9 @@
// =============================================================================
// Sharing Types
// =============================================================================
export type SharingScope = "private" | "public_org" | "public_global";
// =============================================================================
// Session Error Constants
// =============================================================================
@@ -165,6 +171,7 @@ export interface ApiSessionResponse {
last_activity_at: string;
sandbox: ApiSandboxResponse | null;
artifacts: ApiArtifactResponse[];
sharing_scope: SharingScope;
}
export interface ApiDetailedSessionResponse extends ApiSessionResponse {
@@ -196,6 +203,7 @@ export interface ApiWebappInfoResponse {
webapp_url: string | null;
status: string;
ready: boolean;
sharing_scope: SharingScope;
}
export interface FileSystemEntry {