mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): tighten workspace pages to match the new mock
This commit is contained in:
@@ -2,14 +2,21 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Box,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Dithering, MeshGradient } from "@paper-design/shaders-react";
|
||||
import {
|
||||
OPENWORK_APP_CONNECT_BASE_URL,
|
||||
buildOpenworkAppConnectUrl,
|
||||
@@ -18,15 +25,11 @@ import {
|
||||
getWorkerStatusMeta,
|
||||
getWorkerTokens,
|
||||
requestJson,
|
||||
type WorkerStatusBucket,
|
||||
type WorkerListItem,
|
||||
} from "../../../../_lib/den-flow";
|
||||
import { useDenFlow } from "../../../../_providers/den-flow-provider";
|
||||
import { getSharedSetupsRoute } from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
formatTemplateTimestamp,
|
||||
useOrgTemplates,
|
||||
} from "./shared-setup-data";
|
||||
|
||||
type ConnectionDetails = {
|
||||
openworkUrl: string | null;
|
||||
@@ -36,43 +39,267 @@ type ConnectionDetails = {
|
||||
openworkDeepLink: string | null;
|
||||
};
|
||||
|
||||
const statusOptions: Array<{ label: string; value: WorkerStatusBucket | "all" }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Ready", value: "ready" },
|
||||
{ label: "Starting", value: "starting" },
|
||||
{ label: "Attention", value: "attention" },
|
||||
];
|
||||
function getStatusBadgeClass(bucket: ReturnType<typeof getWorkerStatusMeta>["bucket"]) {
|
||||
switch (bucket) {
|
||||
case "ready":
|
||||
return "border-emerald-100 bg-emerald-50 text-emerald-600";
|
||||
case "starting":
|
||||
return "border-amber-100 bg-amber-50 text-amber-600";
|
||||
case "attention":
|
||||
return "border-rose-100 bg-rose-50 text-rose-600";
|
||||
default:
|
||||
return "border-gray-100 bg-gray-50 text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getTemplateAccent(seed: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 33 + seed.charCodeAt(index)) % 360;
|
||||
function CredentialField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onCopy,
|
||||
copied,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onCopy: (field: string, text: string) => void;
|
||||
copied: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] text-gray-500">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={value}
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[12px] font-mono text-gray-600 outline-none shadow-sm transition-colors focus:border-gray-300"
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(id, value)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 shadow-sm transition-colors hover:border-gray-300 hover:text-gray-700"
|
||||
aria-label={`Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxCard({
|
||||
sandbox,
|
||||
expanded,
|
||||
details,
|
||||
connectBusy,
|
||||
renameBusy,
|
||||
onToggle,
|
||||
onRefresh,
|
||||
onRename,
|
||||
}: {
|
||||
sandbox: WorkerListItem;
|
||||
expanded: boolean;
|
||||
details: ConnectionDetails | null;
|
||||
connectBusy: boolean;
|
||||
renameBusy: boolean;
|
||||
onToggle: () => void;
|
||||
onRefresh: () => void;
|
||||
onRename: () => void;
|
||||
}) {
|
||||
const [showTokens, setShowTokens] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const meta = getWorkerStatusMeta(sandbox.status);
|
||||
const canConnect = meta.bucket === "ready";
|
||||
const connectionUrl = details?.openworkUrl ?? sandbox.instanceUrl ?? null;
|
||||
const ownerToken = details?.ownerToken ?? null;
|
||||
const clientToken = details?.clientToken ?? null;
|
||||
const openWebUrl = details?.openworkAppConnectUrl ?? null;
|
||||
const openDesktopUrl = details?.openworkDeepLink ?? null;
|
||||
|
||||
async function handleCopy(field: string, text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
window.setTimeout(() => {
|
||||
setCopiedField((current) => (current === field ? null : current));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return {
|
||||
background: `hsl(${hash} 92% 96%)`,
|
||||
gradient: `radial-gradient(circle at 30% 30%, hsl(${(hash + 60) % 360} 92% 68%), hsl(${hash} 82% 48%), hsl(${(hash + 140) % 360} 88% 28%))`,
|
||||
};
|
||||
const credentialFields = [
|
||||
connectionUrl ? { id: "url", label: "Connection URL", value: connectionUrl } : null,
|
||||
ownerToken ? { id: "owner", label: "Owner token", value: ownerToken } : null,
|
||||
clientToken ? { id: "client", label: "Client token", value: clientToken } : null,
|
||||
].filter((field): field is { id: string; label: string; value: string } => Boolean(field));
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-5 transition-all hover:border-gray-200 hover:shadow-[0_2px_8px_-4px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-gray-100 bg-gray-50">
|
||||
<Box size={18} className="text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-0.5 flex items-center gap-2 text-[14px] font-medium text-gray-900">
|
||||
{sandbox.workerName}
|
||||
<span
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.5px] ${getStatusBadgeClass(meta.bucket)}`}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{meta.label}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-[12px] text-gray-400">
|
||||
Source: {sandbox.provider ? `${sandbox.provider} sandbox` : "cloud sandbox"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (expanded) {
|
||||
setShowTokens(false);
|
||||
}
|
||||
onToggle();
|
||||
}}
|
||||
disabled={!canConnect}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
expanded
|
||||
? "bg-gray-100 text-gray-900 hover:bg-gray-200"
|
||||
: "bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
{expanded ? "Hide details" : "Connect"}
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRename}
|
||||
disabled={renameBusy}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-50 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={`Rename ${sandbox.workerName}`}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="mt-5 border-t border-gray-100 pt-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (openDesktopUrl) {
|
||||
window.location.href = openDesktopUrl;
|
||||
}
|
||||
}}
|
||||
disabled={!openDesktopUrl}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-gray-900 py-2.5 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Monitor size={15} /> Open in desktop
|
||||
</button>
|
||||
|
||||
{openWebUrl ? (
|
||||
<a
|
||||
href={openWebUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white py-2.5 text-[13px] font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<ExternalLink size={15} /> Open in web
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white py-2.5 text-[13px] font-medium text-gray-700 shadow-sm opacity-60"
|
||||
>
|
||||
<ExternalLink size={15} /> Open in web
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canConnect ? (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTokens((current) => !current)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-gray-100 bg-gray-50/50 px-4 py-2.5 text-[12px] font-medium text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-gray-400" />
|
||||
Connection credentials
|
||||
</span>
|
||||
{showTokens ? (
|
||||
<ChevronUp size={14} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showTokens ? (
|
||||
<div className="mt-2 space-y-4 rounded-xl border border-gray-100 bg-gray-50/50 p-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[1px] text-gray-500">
|
||||
Access Tokens
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={connectBusy}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-gray-500 transition-colors hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw size={13} className={connectBusy ? "animate-spin" : ""} />
|
||||
{connectBusy ? "Refreshing..." : "Refresh tokens"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{credentialFields.length > 0 ? (
|
||||
credentialFields.map((field) => (
|
||||
<CredentialField
|
||||
key={field.id}
|
||||
id={field.id}
|
||||
label={field.label}
|
||||
value={field.value}
|
||||
onCopy={handleCopy}
|
||||
copied={copiedField === field.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[12px] text-gray-500">
|
||||
{connectBusy
|
||||
? "Loading connection credentials..."
|
||||
: "Connection credentials will appear here once the workspace is ready."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-[12px] text-gray-500">
|
||||
Connection details will appear once this workspace is ready.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundAgentsScreen() {
|
||||
const router = useRouter();
|
||||
const { orgSlug } = useOrgDashboard();
|
||||
const { templates } = useOrgTemplates(orgSlug);
|
||||
const [expandedWorkerId, setExpandedWorkerId] = useState<string | null>(null);
|
||||
const [connectBusyWorkerId, setConnectBusyWorkerId] = useState<string | null>(null);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [connectionDetailsByWorkerId, setConnectionDetailsByWorkerId] = useState<
|
||||
Record<string, ConnectionDetails>
|
||||
>({});
|
||||
const [showTemplatesBanner, setShowTemplatesBanner] = useState(true);
|
||||
const {
|
||||
filteredWorkers,
|
||||
workerQuery,
|
||||
setWorkerQuery,
|
||||
workerStatusFilter,
|
||||
setWorkerStatusFilter,
|
||||
workersBusy,
|
||||
workersLoadedOnce,
|
||||
workersError,
|
||||
@@ -82,25 +309,13 @@ export function BackgroundAgentsScreen() {
|
||||
renameBusyWorkerId,
|
||||
} = useDenFlow();
|
||||
|
||||
async function handleAddSandbox() {
|
||||
async function handleAddWorkspace() {
|
||||
const result = await launchWorker({ source: "manual" });
|
||||
if (result === "checkout") {
|
||||
router.push("/checkout");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyValue(field: string, value: string | null) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedField(field);
|
||||
window.setTimeout(() => {
|
||||
setCopiedField((current) => (current === field ? null : current));
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function loadConnectionDetails(workerId: string, workerName: string) {
|
||||
setConnectBusyWorkerId(workerId);
|
||||
setConnectError(null);
|
||||
@@ -150,158 +365,90 @@ export function BackgroundAgentsScreen() {
|
||||
...current,
|
||||
[workerId]: nextDetails,
|
||||
}));
|
||||
return nextDetails;
|
||||
} catch (error) {
|
||||
setConnectError(
|
||||
error instanceof Error ? error.message : "Failed to load connection details.",
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectBusyWorkerId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleConnect(workerId: string, workerName: string) {
|
||||
if (expandedWorkerId === workerId) {
|
||||
async function toggleSandbox(worker: WorkerListItem) {
|
||||
const meta = getWorkerStatusMeta(worker.status);
|
||||
if (meta.bucket !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedWorkerId === worker.workerId) {
|
||||
setExpandedWorkerId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedWorkerId(workerId);
|
||||
if (!connectionDetailsByWorkerId[workerId]) {
|
||||
await loadConnectionDetails(workerId, workerName);
|
||||
setExpandedWorkerId(worker.workerId);
|
||||
if (!connectionDetailsByWorkerId[worker.workerId]) {
|
||||
await loadConnectionDetails(worker.workerId, worker.workerName);
|
||||
}
|
||||
}
|
||||
|
||||
const bannerTemplates = useMemo(() => templates.slice(0, 3), [templates]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1200px] px-6 py-8 md:px-8">
|
||||
<div className="mb-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-[-0.5px] text-gray-900">
|
||||
Agents
|
||||
</h1>
|
||||
<p className="mt-2 text-[14px] text-gray-500">
|
||||
Launch cloud sandboxes, connect them to OpenWork, and keep background workflows available for the team.
|
||||
</p>
|
||||
<div className="mx-auto max-w-[860px] p-8">
|
||||
<div className="relative mb-8 flex min-h-[180px] items-center overflow-hidden rounded-3xl border border-gray-100 px-10">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Dithering
|
||||
speed={0}
|
||||
shape="warp"
|
||||
type="4x4"
|
||||
size={2.5}
|
||||
scale={1}
|
||||
frame={5213.4}
|
||||
colorBack="#00000000"
|
||||
colorFront="#FEFEFE"
|
||||
style={{ backgroundColor: "#23301C", width: "100%", height: "100%" }}
|
||||
>
|
||||
<MeshGradient
|
||||
speed={0}
|
||||
distortion={0.8}
|
||||
swirl={0.1}
|
||||
grainMixer={0}
|
||||
grainOverlay={0}
|
||||
frame={176868.9}
|
||||
colors={["#E9FFE0", "#3E9A1D", "#B3F750", "#51F0A3"]}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</Dithering>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={getSharedSetupsRoute(orgSlug)}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Browse templates
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAddSandbox()}
|
||||
disabled={launchBusy}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-gray-900 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{launchBusy ? "Adding..." : "New agent"}
|
||||
</button>
|
||||
<div className="relative z-10 flex flex-col items-start gap-3">
|
||||
<div>
|
||||
<span className="mb-2 inline-block rounded-full border border-white/20 bg-white/20 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[1px] text-white backdrop-blur-md">
|
||||
Alpha
|
||||
</span>
|
||||
<h1 className="mb-1.5 text-[26px] font-medium tracking-[-0.5px] text-white">
|
||||
Shared Workspaces
|
||||
</h1>
|
||||
<p className="max-w-[500px] text-[14px] text-white/80">
|
||||
Keep selected workflows running in the background without asking each teammate to run them locally. Available for selected workflows while the product continues to evolve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTemplatesBanner ? (
|
||||
<div className="relative mb-8 rounded-[20px] border border-gray-100 bg-white p-6 shadow-[0_2px_8px_-4px_rgba(0,0,0,0.02)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTemplatesBanner(false)}
|
||||
className="absolute right-4 top-4 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Dismiss template suggestions"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<h2 className="mb-1 text-[15px] font-semibold text-gray-900">
|
||||
Get started with a template
|
||||
</h2>
|
||||
<p className="mb-5 text-[13px] text-gray-500">
|
||||
Build faster with shared setups your team has already created.
|
||||
</p>
|
||||
|
||||
{bannerTemplates.length > 0 ? (
|
||||
<div className="mb-5 grid gap-4 md:grid-cols-3">
|
||||
{bannerTemplates.map((template) => {
|
||||
const accent = getTemplateAccent(template.name);
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="rounded-xl border border-gray-100 bg-white p-4 transition-all hover:border-gray-200 hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
className="relative flex h-6 w-6 items-center justify-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: accent.background }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundImage: accent.gradient }}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-[13px] font-semibold text-gray-900">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-gray-500">
|
||||
Created by {template.creator.name}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] text-gray-400">
|
||||
Updated {formatTemplateTimestamp(template.createdAt, { includeTime: true })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-5 text-[13px] text-gray-500">
|
||||
No shared templates yet. Create one first, then launch an agent from it.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={getSharedSetupsRoute(orgSlug)}
|
||||
className="inline-flex items-center gap-1 text-[13px] font-semibold text-gray-900 transition-colors hover:text-gray-700"
|
||||
>
|
||||
Browse all templates <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="relative mb-4">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={workerQuery}
|
||||
onChange={(event) => setWorkerQuery(event.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white py-2.5 pl-9 pr-4 text-[14px] text-gray-900 outline-none transition-all placeholder:text-gray-400 focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setWorkerStatusFilter(option.value)}
|
||||
className={`rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
workerStatusFilter === option.value
|
||||
? "bg-gray-900 text-white"
|
||||
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-10 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAddWorkspace()}
|
||||
disabled={launchBusy}
|
||||
className="flex items-center gap-2 rounded-full bg-gray-900 px-5 py-2.5 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Plus size={15} />
|
||||
{launchBusy ? "Adding workspace..." : "Add workspace"}
|
||||
</button>
|
||||
<Link
|
||||
href={getSharedSetupsRoute(orgSlug)}
|
||||
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-5 py-2.5 text-[13px] font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Open shared setups
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{workersError ? (
|
||||
@@ -315,174 +462,76 @@ export function BackgroundAgentsScreen() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 overflow-hidden border-t border-gray-100">
|
||||
<div className="grid grid-cols-[2fr_1.2fr_1.2fr_auto] gap-4 border-b border-gray-100 py-3 text-[12px] font-medium text-gray-500">
|
||||
<div>Name</div>
|
||||
<div>Provider</div>
|
||||
<div>Created at</div>
|
||||
<div className="w-8" />
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-[15px] font-medium tracking-[-0.2px] text-gray-900">
|
||||
Current workspaces
|
||||
</h2>
|
||||
<div className="relative w-full max-w-[240px]">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-gray-400"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={workerQuery}
|
||||
onChange={(event) => setWorkerQuery(event.target.value)}
|
||||
placeholder="Search workspaces..."
|
||||
className="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-4 text-[13px] text-gray-900 outline-none transition-all placeholder:text-gray-400 focus:border-gray-300 focus:ring-2 focus:ring-gray-900/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!workersLoadedOnce ? (
|
||||
<div className="py-8 text-[13px] text-gray-500">Loading agents…</div>
|
||||
) : filteredWorkers.length === 0 ? (
|
||||
<div className="py-10 text-[13px] text-gray-400">
|
||||
{workerQuery.trim()
|
||||
? "No agents match that search yet."
|
||||
: "No agents launched yet. Start a new agent to see it here."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50/50">
|
||||
{filteredWorkers.map((worker) => {
|
||||
const meta = getWorkerStatusMeta(worker.status);
|
||||
const canConnect = meta.bucket === "ready";
|
||||
const isExpanded = expandedWorkerId === worker.workerId;
|
||||
const details = connectionDetailsByWorkerId[worker.workerId] ?? null;
|
||||
return (
|
||||
<div key={worker.workerId} className="group">
|
||||
<div className="grid grid-cols-[2fr_1.2fr_1.2fr_auto] gap-4 items-center rounded-lg px-2 py-4 transition-colors hover:bg-gray-50/50">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-medium text-gray-900">
|
||||
{worker.workerName}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-gray-500">
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-[12px] text-gray-400">
|
||||
{worker.workerId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-[13px] text-gray-500">
|
||||
{worker.provider ? `${worker.provider} sandbox` : "Cloud sandbox"}
|
||||
</div>
|
||||
<div className="text-[13px] text-gray-500">
|
||||
{worker.createdAt
|
||||
? formatTemplateTimestamp(worker.createdAt, { includeTime: true })
|
||||
: "Recently"}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canConnect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleConnect(worker.workerId, worker.workerName)}
|
||||
className="rounded-full border border-gray-200 px-3 py-1.5 text-[12px] font-medium text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
{isExpanded ? "Hide" : "Connect"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextName = window.prompt("Rename sandbox", worker.workerName)?.trim();
|
||||
if (!nextName || nextName === worker.workerName) {
|
||||
return;
|
||||
}
|
||||
void renameWorker(worker.workerId, nextName);
|
||||
}}
|
||||
disabled={renameBusyWorkerId === worker.workerId}
|
||||
className="rounded-full p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={`Rename ${worker.workerName}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && canConnect ? (
|
||||
<div className="mb-4 rounded-[20px] border border-gray-100 bg-white p-4">
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={details?.openworkAppConnectUrl ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`rounded-full bg-gray-900 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-gray-800 ${
|
||||
details?.openworkAppConnectUrl ? "" : "pointer-events-none opacity-60"
|
||||
}`}
|
||||
>
|
||||
Open in web
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (details?.openworkDeepLink) {
|
||||
window.location.href = details.openworkDeepLink;
|
||||
}
|
||||
}}
|
||||
disabled={!details?.openworkDeepLink}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Open in desktop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadConnectionDetails(worker.workerId, worker.workerName)}
|
||||
disabled={connectBusyWorkerId === worker.workerId}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{connectBusyWorkerId === worker.workerId ? "Refreshing..." : "Refresh tokens"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
label: "Connection URL",
|
||||
value: details?.openworkUrl ?? worker.instanceUrl ?? "Preparing...",
|
||||
key: `url-${worker.workerId}`,
|
||||
},
|
||||
{
|
||||
label: "Owner token",
|
||||
value: details?.ownerToken ?? "Preparing...",
|
||||
key: `owner-${worker.workerId}`,
|
||||
},
|
||||
{
|
||||
label: "Client token",
|
||||
value: details?.clientToken ?? "Preparing...",
|
||||
key: `client-${worker.workerId}`,
|
||||
},
|
||||
].map((field) => (
|
||||
<div key={field.key} className="grid gap-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-gray-400">
|
||||
{field.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-gray-200 bg-white px-3 py-2.5">
|
||||
<input
|
||||
readOnly
|
||||
value={field.value}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-mono text-xs text-gray-900 outline-none"
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void copyValue(
|
||||
field.key,
|
||||
field.value === "Preparing..." ? null : field.value,
|
||||
)
|
||||
}
|
||||
disabled={field.value === "Preparing..."}
|
||||
className="rounded-full border border-gray-200 px-3 py-1.5 text-[12px] font-medium text-gray-600 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{copiedField === field.key ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{!workersLoadedOnce ? (
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-5 text-[13px] text-gray-500">
|
||||
Loading workspaces...
|
||||
</div>
|
||||
) : filteredWorkers.length === 0 ? (
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-5 text-[13px] text-gray-500">
|
||||
{workerQuery.trim()
|
||||
? "No workspaces match that search yet."
|
||||
: "No workspaces launched yet. Add one to start connecting cloud workflows."}
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkers.map((sandbox) => (
|
||||
<SandboxCard
|
||||
key={sandbox.workerId}
|
||||
sandbox={sandbox}
|
||||
expanded={expandedWorkerId === sandbox.workerId}
|
||||
details={connectionDetailsByWorkerId[sandbox.workerId] ?? null}
|
||||
connectBusy={connectBusyWorkerId === sandbox.workerId}
|
||||
renameBusy={renameBusyWorkerId === sandbox.workerId}
|
||||
onToggle={() => void toggleSandbox(sandbox)}
|
||||
onRefresh={() => void loadConnectionDetails(sandbox.workerId, sandbox.workerName)}
|
||||
onRename={() => {
|
||||
const nextName = window.prompt("Rename workspace", sandbox.workerName)?.trim();
|
||||
if (!nextName || nextName === sandbox.workerName) {
|
||||
return;
|
||||
}
|
||||
void renameWorker(sandbox.workerId, nextName);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workersLoadedOnce && workersBusy ? (
|
||||
<p className="mt-4 text-[12px] text-gray-400">Refreshing agents…</p>
|
||||
<p className="mt-4 text-[12px] text-gray-400">Refreshing workspaces…</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { getBillingRoute } from "../../../../_lib/den-org";
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import { OPENWORK_DOCS_URL } from "./shared-setup-data";
|
||||
import { Dithering, MeshGradient } from "@paper-design/shaders-react";
|
||||
|
||||
const UPCOMING_BENEFITS = [
|
||||
const comingSoonItems = [
|
||||
"Standardize provider access across your team.",
|
||||
"Keep model choices consistent across shared setups.",
|
||||
"Control rollout without reconfiguring every teammate by hand.",
|
||||
];
|
||||
|
||||
export function CustomLlmProvidersScreen() {
|
||||
const { orgSlug } = useOrgDashboard();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[980px] px-6 py-8 md:px-8">
|
||||
<div className="relative mb-8 flex h-[220px] items-center overflow-hidden rounded-[28px] border border-gray-100 px-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(224,252,255,0.95),transparent_28%),radial-gradient(circle_at_80%_10%,rgba(80,247,212,0.55),transparent_22%),radial-gradient(circle_at_70%_80%,rgba(81,142,240,0.6),transparent_28%),linear-gradient(135deg,#1c2a30_0%,#1d7b9a_45%,#223140_100%)]" />
|
||||
<div className="mx-auto max-w-[860px] p-8">
|
||||
<div className="relative mb-8 flex h-[200px] items-center overflow-hidden rounded-3xl border border-gray-100 px-10">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Dithering
|
||||
speed={0}
|
||||
shape="warp"
|
||||
type="4x4"
|
||||
size={2.5}
|
||||
scale={1}
|
||||
frame={41112.4}
|
||||
colorBack="#00000000"
|
||||
colorFront="#FEFEFE"
|
||||
style={{ backgroundColor: "#1C2A30", width: "100%", height: "100%" }}
|
||||
>
|
||||
<MeshGradient
|
||||
speed={1}
|
||||
distortion={0.8}
|
||||
swirl={0.1}
|
||||
grainMixer={0}
|
||||
grainOverlay={0}
|
||||
frame={176868.9}
|
||||
colors={["#E0FCFF", "#1D7B9A", "#50F7D4", "#518EF0"]}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</Dithering>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-white/30 bg-white/20 backdrop-blur-md">
|
||||
<Cpu className="h-6 w-6 text-white" strokeWidth={1.5} />
|
||||
<Cpu size={24} className="text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-2 inline-block rounded-full border border-white/20 bg-white/20 px-2.5 py-1 text-[10px] uppercase tracking-[1px] text-white backdrop-blur-md">
|
||||
@@ -38,36 +56,19 @@ export function CustomLlmProvidersScreen() {
|
||||
Standardize provider access for your team.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-3">
|
||||
{UPCOMING_BENEFITS.map((benefit) => (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{comingSoonItems.map((text) => (
|
||||
<div
|
||||
key={benefit}
|
||||
key={text}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-6"
|
||||
>
|
||||
<span className="inline-block self-start rounded-full border border-gray-100 bg-gray-50 px-2 py-0.5 text-[10px] uppercase tracking-[1px] text-gray-500">
|
||||
Coming soon
|
||||
</span>
|
||||
<p className="text-[13px] leading-[1.6] text-gray-600">{benefit}</p>
|
||||
<p className="text-[13px] leading-[1.6] text-gray-600">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={OPENWORK_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Link
|
||||
href={getBillingRoute(orgSlug)}
|
||||
className="rounded-full border border-gray-200 bg-white px-4 py-2 text-[13px] font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Review billing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
|
||||
return "Members";
|
||||
}
|
||||
if (pathname.startsWith(getBackgroundAgentsRoute(orgSlug))) {
|
||||
return "Agents";
|
||||
return "Shared Workspaces";
|
||||
}
|
||||
if (pathname.startsWith(getCustomLlmProvidersRoute(orgSlug))) {
|
||||
return "Custom LLM providers";
|
||||
return "Custom LLMs";
|
||||
}
|
||||
if (pathname.startsWith(getBillingRoute(orgSlug)) || pathname === "/checkout") {
|
||||
return "Billing";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paper-design/shaders-react": "0.0.71",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "14.2.5",
|
||||
"react": "18.2.0",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -410,6 +410,9 @@ importers:
|
||||
|
||||
ee/apps/den-web:
|
||||
dependencies:
|
||||
'@paper-design/shaders-react':
|
||||
specifier: 0.0.71
|
||||
version: 0.0.71(@types/react@18.2.79)(react@18.2.0)
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@18.2.0)
|
||||
|
||||
Reference in New Issue
Block a user