نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import * as React from "react";
import { Badge, Button, cx } from "@agy/ui";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
const NAV_ITEMS: { href: string; label: string; hint: string }[] = [
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
{ href: "/projects", label: "پروژه‌ها", hint: "Git و محیط اجرا" },
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
];
/**
* Command/search over the navigation.
*
* Deliberately client-side: the app has a fixed set of destinations, so
* filtering them needs no round trip and works while offline.
*/
function CommandSearch() {
const router = useRouter();
const [query, setQuery] = React.useState("");
const [open, setOpen] = React.useState(false);
const matches = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return [];
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(needle) ||
item.hint.toLowerCase().includes(needle) ||
item.href.includes(needle),
);
}, [query]);
const go = (href: string) => {
setQuery("");
setOpen(false);
router.push(href);
};
return (
<div className="relative hidden md:block">
<input
type="search"
value={query}
placeholder="جست‌وجو…"
aria-label="جست‌وجو در بخش‌ها"
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(event) => {
if (event.key === "Enter" && matches.length > 0) go(matches[0].href);
if (event.key === "Escape") setQuery("");
}}
className="h-9 w-56 rounded-lg border border-surface-border bg-surface-overlay px-3 text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{open && query.trim() !== "" && (
<div className="absolute start-0 top-11 z-40 w-72 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
{matches.length === 0 ? (
<p className="px-2 py-2 text-xs text-slate-400">نتیجه‌ای پیدا نشد.</p>
) : (
matches.map((item) => (
<button
key={item.href}
type="button"
onMouseDown={() => go(item.href)}
className="flex w-full flex-col rounded-md px-2 py-1.5 text-start hover:bg-surface-raised"
>
<span className="text-xs text-slate-200">{item.label}</span>
<span className="text-xs text-slate-500">{item.hint}</span>
</button>
))
)}
</div>
)}
</div>
);
}
/** Credit badge — reads the organization's real balance, never a placeholder. */
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
const [balance, setBalance] = React.useState<number | null>(null);
const [low, setLow] = React.useState(false);
React.useEffect(() => {
if (!organizationId) {
setBalance(null);
return;
}
let cancelled = false;
api
.billingSummary(organizationId)
.then((summary) => {
if (cancelled) return;
setBalance(summary.balance);
setLow(summary.low_balance);
})
.catch(() => {
// A failed balance must not break the shell; show it as unknown.
if (!cancelled) setBalance(null);
});
return () => {
cancelled = true;
};
}, [organizationId]);
if (balance === null) return <Badge tone="neutral">اعتبار: —</Badge>;
return (
<Badge tone={low ? "warning" : "success"}>
اعتبار: {balance.toFixed(2)} {low ? "(کم)" : ""}
</Badge>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { user, organizations, activeOrganization, setActiveOrganization, logout } = useSession();
const [menuOpen, setMenuOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const handleLogout = async () => {
await logout();
router.replace("/login");
};
return (
<div className="flex min-h-screen bg-surface">
<aside className="hidden w-64 shrink-0 flex-col border-l border-surface-border bg-surface-raised lg:flex">
<div className="flex h-16 items-center gap-2 border-b border-surface-border px-5">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent text-xs font-medium text-white">
AG
</span>
<span className="text-sm font-medium text-slate-100">AGY Web</span>
</div>
<nav className="flex flex-1 flex-col gap-1 p-3">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={cx(
"flex flex-col rounded-lg px-3 py-2 transition-colors",
active
? "bg-accent-soft text-slate-100"
: "text-slate-400 hover:bg-surface-overlay hover:text-slate-200",
)}
>
<span className="text-sm">{item.label}</span>
<span className="text-xs text-slate-500">{item.hint}</span>
</Link>
);
})}
{user?.is_platform_owner && (
<>
<div className="my-2 border-t border-surface-border" />
<Link
href="/admin"
className={cx(
"flex flex-col rounded-lg px-3 py-2 transition-colors",
pathname.startsWith("/admin")
? "bg-accent-soft text-slate-100"
: "text-slate-400 hover:bg-surface-overlay hover:text-slate-200",
)}
>
<span className="text-sm">کنسول مالک سامانه</span>
<span className="text-xs text-slate-500">Provider، مدل، تعرفه</span>
</Link>
</>
)}
</nav>
<div className="border-t border-surface-border p-3">
<p className="truncate text-xs text-slate-400">{user?.email}</p>
<Button variant="ghost" size="sm" className="mt-2 w-full" onClick={handleLogout}>
خروج
</Button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-16 items-center justify-between gap-3 border-b border-surface-border bg-surface-raised px-5">
<div className="flex min-w-0 items-center gap-3">
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
>
{activeOrganization?.name ?? "بدون سازمان"}
</Button>
{menuOpen && (
<div className="absolute right-0 top-10 z-40 w-64 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
{organizations.length === 0 && (
<p className="px-2 py-2 text-xs text-slate-400">هنوز سازمانی نساخته‌اید.</p>
)}
{organizations.map((org) => (
<button
key={org.id}
type="button"
onClick={() => {
setActiveOrganization(org.id);
setMenuOpen(false);
}}
className={cx(
"flex w-full items-center justify-between rounded-md px-2 py-2 text-right text-xs",
org.id === activeOrganization?.id
? "bg-accent-soft text-slate-100"
: "text-slate-300 hover:bg-surface-raised",
)}
>
<span>{org.name}</span>
<span className="text-slate-500">{org.role}</span>
</button>
))}
<div className="my-1 border-t border-surface-border" />
<Link
href="/onboarding"
className="block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised"
onClick={() => setMenuOpen(false)}
>
ساخت سازمان جدید
</Link>
</div>
)}
</div>
<span className="hidden sm:inline-flex">
<CreditBadge organizationId={activeOrganization?.id} />
</span>
<CommandSearch />
</div>
<div className="relative flex items-center gap-2">
<button
type="button"
onClick={() => setUserMenuOpen((open) => !open)}
aria-expanded={userMenuOpen}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-overlay"
>
<span className="hidden text-xs text-slate-300 sm:block">
{user?.name || user?.email}
</span>
{user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>}
</button>
{userMenuOpen && (
<div className="absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
<div className="px-2 py-1.5">
<p className="truncate text-xs text-slate-200">
{user?.name || "کاربر"}
</p>
<p className="truncate text-xs text-slate-500">{user?.email}</p>
</div>
<div className="my-1 border-t border-surface-border" />
<Link
href="/settings"
onClick={() => setUserMenuOpen(false)}
className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
>
تنظیمات سازمان
</Link>
{user?.is_platform_owner && (
<Link
href="/admin"
onClick={() => setUserMenuOpen(false)}
className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
>
کنسول مالک سامانه
</Link>
)}
<button
type="button"
onClick={handleLogout}
className="mt-1 block w-full rounded-md px-2 py-1.5 text-start text-xs text-danger hover:bg-surface-raised"
>
خروج
</button>
</div>
)}
</div>
</header>
<main className="min-w-0 flex-1 p-5">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Card, CardHint, CardTitle } from "@agy/ui";
/**
* Placeholder for a page whose backend phase has not landed yet.
*
* It is deliberately explicit about *what* will live here rather than showing a
* vague "coming soon" — a reader can tell whether the page is missing or the
* feature is.
*/
export function PlaceholderPage({
title,
description,
planned,
}: {
title: string;
description: string;
planned: string[];
}) {
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">{title}</h1>
<p className="text-sm text-slate-400">{description}</p>
</header>
<Card>
<CardTitle>در این صفحه چه چیزی ساخته می‌شود</CardTitle>
<CardHint>
این بخش در فاز بعدی پیاده‌سازی می‌شود. فهرست زیر قرارداد نهایی این صفحه است.
</CardHint>
<ul className="mt-3 flex flex-col gap-2">
{planned.map((item) => (
<li key={item} className="flex items-start gap-2 text-xs text-slate-300">
<span aria-hidden className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<span>{item}</span>
</li>
))}
</ul>
</Card>
</div>
);
}
+594
View File
@@ -0,0 +1,594 @@
"use client";
/**
* Sandbox panel for a project.
*
* Two things this UI deliberately does NOT do:
*
* 1. It never sends a timeout or an output cap. Those are resolved server-side
* from policy (project -> organization -> platform) and clamped, so a
* tampered client cannot widen them.
* 2. It shows no shell/exec affordance for the platform host. The only execution
* surface is a workspace session, which is isolated and metered.
*/
import * as React from "react";
import type {
SandboxCommandRun,
SandboxResourceLimit,
SandboxSession,
SandboxTemplate,
SandboxWorkspace,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const STATUS_TONES = {
ready: "success",
running: "success",
pending: "warning",
starting: "warning",
paused: "neutral",
stopped: "neutral",
destroyed: "danger",
failed: "danger",
} as const;
function toneFor(status: string) {
return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral";
}
function humanBytes(bytes: number): string {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} مگابایت`;
if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} کیلوبایت`;
return `${bytes} بایت`;
}
export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [templates, setTemplates] = React.useState<SandboxTemplate[]>([]);
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [templateId, setTemplateId] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [pendingDestroy, setPendingDestroy] = React.useState<SandboxWorkspace | null>(null);
const [destroying, setDestroying] = React.useState(false);
const [activeWorkspace, setActiveWorkspace] = React.useState<SandboxWorkspace | null>(null);
const [session, setSession] = React.useState<SandboxSession | null>(null);
const [starting, setStarting] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextTemplates, nextWorkspaces, nextLimits] = await Promise.all([
api.sandboxTemplates(orgId),
api.sandboxWorkspaces(orgId, projectId),
api.sandboxLimits(orgId, projectId),
]);
setTemplates(nextTemplates);
setWorkspaces(nextWorkspaces);
setLimits(nextLimits);
setTemplateId((current) => current || nextTemplates[0]?.id || "");
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات sandbox ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onCreateWorkspace = async (event: React.FormEvent) => {
event.preventDefault();
if (!templateId) return;
setCreating(true);
setError(null);
try {
const created = await api.sandboxCreateWorkspace(orgId, {
project_id: projectId,
template_id: templateId,
});
setWorkspaces((current) => [created, ...current]);
setActiveWorkspace(created);
setSession(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت محیط ایزوله ناموفق بود.");
} finally {
setCreating(false);
}
};
const onConfirmDestroy = async () => {
if (!pendingDestroy) return;
setDestroying(true);
try {
await api.sandboxDestroyWorkspace(orgId, pendingDestroy.id);
setWorkspaces((current) => current.filter((w) => w.id !== pendingDestroy.id));
if (activeWorkspace?.id === pendingDestroy.id) {
setActiveWorkspace(null);
setSession(null);
}
setPendingDestroy(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف محیط ناموفق بود.");
setPendingDestroy(null);
} finally {
setDestroying(false);
}
};
const onStartSession = async (workspace: SandboxWorkspace) => {
setStarting(true);
setError(null);
try {
setSession(await api.sandboxStartSession(orgId, workspace.id));
setActiveWorkspace(workspace);
} catch (err) {
setError(err instanceof ApiError ? err.message : "شروع نشست ناموفق بود.");
} finally {
setStarting(false);
}
};
if (loading) return <LoadingState label="در حال دریافت اطلاعات sandbox…" />;
return (
<div className="flex flex-col gap-4">
{error && <ErrorState message={error} onRetry={load} />}
{limits && (
<Card>
<CardTitle>محدودیت‌های مؤثر</CardTitle>
<CardHint>
این مقادیر از سیاست سازمان و سکو محاسبه و در سرور محدود می‌شوند. کلاینت نمی‌تواند
آن‌ها را تغییر دهد.
</CardHint>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Stat label="CPU" value={`${limits.cpu} هسته`} />
<Stat label="حافظه" value={`${limits.memory_mb} مگابایت`} />
<Stat label="دیسک" value={`${limits.disk_mb} مگابایت`} />
<Stat label="مهلت اجرا" value={`${limits.timeout_seconds} ثانیه`} />
<Stat label="سقف خروجی" value={humanBytes(limits.max_output_bytes)} />
</div>
<p className="mt-3 text-xs text-slate-500">
سیاست شبکه: <span className="ltr-inline">{limits.network_policy}</span>
{limits.network_policy === "deny_all" && " — دسترسی خروجی بسته است."}
</p>
</Card>
)}
<Card>
<CardTitle>ساخت محیط ایزوله</CardTitle>
<CardHint>
کد در یک محیط جداگانه اجرا می‌شود؛ هیچ دستوری روی سرور میزبان اجرا نمی‌گردد. هر
دستور در دفتر مصرف ثبت می‌شود.
</CardHint>
{templates.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
) : (
<form
onSubmit={onCreateWorkspace}
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<Select
label="قالب محیط"
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
options={templates.map((template) => ({
value: template.id,
label: `${template.name} — ${template.image}`,
}))}
/>
</div>
<Button type="submit" loading={creating}>
ساخت محیط
</Button>
</form>
)}
{templates.length > 0 && (
<p className="mt-2 text-xs text-slate-500">
{templates.find((t) => t.id === templateId)?.description ?? ""}
</p>
)}
</Card>
<Card>
<CardTitle>محیط‌های این پروژه</CardTitle>
{workspaces.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">هنوز محیطی ساخته نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{workspaces.map((workspace) => {
const isActive = activeWorkspace?.id === workspace.id;
return (
<li
key={workspace.id}
className={
"flex flex-col gap-3 rounded-lg border px-3 py-3 sm:flex-row sm:items-center sm:justify-between " +
(isActive
? "border-accent/60 bg-surface-overlay"
: "border-surface-border")
}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge tone={toneFor(workspace.status)}>{workspace.status}</Badge>
<span className="ltr-inline truncate text-xs text-slate-400">
{workspace.id}
</span>
</div>
<p className="text-xs text-slate-500">
ارائه‌دهنده: <span className="ltr-inline">{workspace.provider}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant={isActive ? "ghost" : "secondary"}
onClick={() => onStartSession(workspace)}
loading={starting && !isActive}
>
{isActive ? "انتخاب‌شده" : "انتخاب و شروع نشست"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPendingDestroy(workspace)}
>
حذف
</Button>
</div>
</li>
);
})}
</ul>
)}
</Card>
{activeWorkspace && session && (
<SessionPanel
orgId={orgId}
session={session}
onSessionChange={setSession}
/>
)}
<ConfirmDialog
open={pendingDestroy !== null}
title="حذف محیط ایزوله"
description={
pendingDestroy
? "این محیط و همهٔ فایل‌های داخل آن از بین می‌رود و نشست‌های فعال بسته می‌شوند."
: undefined
}
confirmLabel={destroying ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmDestroy}
onCancel={() => setPendingDestroy(null)}
/>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Session: commands, files, snapshot, preview */
/* -------------------------------------------------------------------------- */
function SessionPanel({
orgId,
session,
onSessionChange,
}: {
orgId: string;
session: SandboxSession;
onSessionChange: (session: SandboxSession) => void;
}) {
const [command, setCommand] = React.useState("");
const [runs, setRuns] = React.useState<SandboxCommandRun[]>([]);
const [running, setRunning] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [filePath, setFilePath] = React.useState("");
const [fileContent, setFileContent] = React.useState("");
const [fileBusy, setFileBusy] = React.useState(false);
const [fileNotice, setFileNotice] = React.useState<string | null>(null);
const [snapshotName, setSnapshotName] = React.useState("");
const [snapshotBusy, setSnapshotBusy] = React.useState(false);
const [previewPort, setPreviewPort] = React.useState("8080");
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const loadRuns = React.useCallback(async () => {
try {
setRuns(await api.sandboxCommands(orgId, session.id));
} catch {
/* history is a convenience; a failure here must not break the panel */
}
}, [orgId, session.id]);
React.useEffect(() => {
void loadRuns();
}, [loadRuns]);
const onRun = async (event: React.FormEvent) => {
event.preventDefault();
const trimmed = command.trim();
if (!trimmed) return;
setRunning(true);
setError(null);
try {
const result = await api.sandboxRunCommand(orgId, session.id, trimmed);
setRuns((current) => [...current, result]);
setCommand("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "اجرای دستور ناموفق بود.");
} finally {
setRunning(false);
}
};
const onStop = async () => {
setStopping(true);
setError(null);
try {
onSessionChange(await api.sandboxStopSession(orgId, session.id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "پایان نشست ناموفق بود.");
} finally {
setStopping(false);
}
};
const onReadFile = async () => {
if (!filePath.trim()) return;
setFileBusy(true);
setFileNotice(null);
try {
const result = await api.sandboxReadFile(orgId, session.id, filePath.trim());
setFileContent(result.content);
setFileNotice(result.content === "" ? "فایل خالی است یا وجود ندارد." : null);
} catch (err) {
setFileNotice(err instanceof ApiError ? err.message : "خواندن فایل ناموفق بود.");
} finally {
setFileBusy(false);
}
};
const onWriteFile = async () => {
if (!filePath.trim()) return;
setFileBusy(true);
setFileNotice(null);
try {
await api.sandboxWriteFile(orgId, session.id, filePath.trim(), fileContent);
setFileNotice("فایل نوشته شد.");
} catch (err) {
setFileNotice(err instanceof ApiError ? err.message : "نوشتن فایل ناموفق بود.");
} finally {
setFileBusy(false);
}
};
const onSnapshot = async (event: React.FormEvent) => {
event.preventDefault();
if (!snapshotName.trim()) return;
setSnapshotBusy(true);
setFileNotice(null);
try {
const snapshot = await api.sandboxSnapshot(orgId, session.id, snapshotName.trim());
setFileNotice(`نقطهٔ بازیابی «${snapshot.name}» ساخته شد.`);
setSnapshotName("");
} catch (err) {
setFileNotice(err instanceof ApiError ? err.message : "ساخت نقطهٔ بازیابی ناموفق بود.");
} finally {
setSnapshotBusy(false);
}
};
const onPreview = async (event: React.FormEvent) => {
event.preventDefault();
const port = Number(previewPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setFileNotice("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
return;
}
setFileBusy(true);
setFileNotice(null);
try {
const result = await api.sandboxPreviewUrl(orgId, session.id, port);
setPreviewUrl(result.url);
} catch (err) {
setFileNotice(err instanceof ApiError ? err.message : "ساخت آدرس پیش‌نمایش ناموفق بود.");
} finally {
setFileBusy(false);
}
};
const isStopped = session.status === "stopped" || session.status === "failed";
return (
<Card>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>نشست فعال</CardTitle>
<CardHint>
<span className="ltr-inline">{session.id}</span>
</CardHint>
</div>
<div className="flex items-center gap-2">
<Badge tone={toneFor(session.status)}>{session.status}</Badge>
<Button
size="sm"
variant="secondary"
onClick={onStop}
loading={stopping}
disabled={isStopped}
>
پایان نشست
</Button>
</div>
</div>
{error && (
<div className="mt-3">
<ErrorState title="خطا" message={error} />
</div>
)}
<form onSubmit={onRun} className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="دستور"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="مثلاً: python -m pytest -q"
className="ltr-inline"
disabled={isStopped}
/>
</div>
<Button type="submit" loading={running} disabled={isStopped || !command.trim()}>
اجرا
</Button>
</form>
{runs.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{runs.map((run) => (
<div
key={run.id}
className="rounded-lg border border-surface-border bg-surface-overlay p-3"
>
<div className="flex items-center justify-between gap-2">
<code className="ltr-inline truncate text-xs text-slate-300">{run.command}</code>
<div className="flex shrink-0 items-center gap-2">
{run.truncated && <Badge tone="warning">بریده‌شده</Badge>}
<Badge tone={run.exit_code === 0 ? "success" : "danger"}>
<span className="ltr-inline">exit {run.exit_code ?? "—"}</span>
</Badge>
<span className="text-xs text-slate-500">{run.duration_ms}ms</span>
</div>
</div>
{run.stdout && (
<pre className="ltr-inline mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-300">
{run.stdout}
</pre>
)}
{run.stderr && (
<pre className="ltr-inline mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-rose-300">
{run.stderr}
</pre>
)}
</div>
))}
</div>
)}
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-3 rounded-lg border border-surface-border p-3">
<p className="text-sm font-medium text-slate-200">فایل‌ها</p>
<Field
label="مسیر"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/workspace/main.py"
className="ltr-inline"
/>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onReadFile} loading={fileBusy}>
خواندن
</Button>
<Button size="sm" onClick={onWriteFile} loading={fileBusy}>
نوشتن
</Button>
</div>
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
rows={6}
spellCheck={false}
className="ltr-inline w-full rounded-lg border border-surface-border bg-surface-base px-3 py-2 font-mono text-xs text-slate-200 outline-none focus:border-accent"
placeholder="محتوای فایل"
/>
</div>
<div className="flex flex-col gap-4">
<form
onSubmit={onSnapshot}
className="flex flex-col gap-3 rounded-lg border border-surface-border p-3"
>
<p className="text-sm font-medium text-slate-200">نقطهٔ بازیابی</p>
<Field
label="نام"
value={snapshotName}
onChange={(e) => setSnapshotName(e.target.value)}
placeholder="before-refactor"
className="ltr-inline"
/>
<div>
<Button
type="submit"
size="sm"
variant="secondary"
loading={snapshotBusy}
disabled={!snapshotName.trim()}
>
ساخت نقطهٔ بازیابی
</Button>
</div>
</form>
<form
onSubmit={onPreview}
className="flex flex-col gap-3 rounded-lg border border-surface-border p-3"
>
<p className="text-sm font-medium text-slate-200">پیش‌نمایش</p>
<Field
label="پورت"
inputMode="numeric"
value={previewPort}
onChange={(e) => setPreviewPort(e.target.value)}
className="ltr-inline"
/>
<div>
<Button type="submit" size="sm" variant="secondary" loading={fileBusy}>
ساخت آدرس
</Button>
</div>
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline truncate text-xs text-accent hover:underline"
>
{previewUrl}
</a>
)}
</form>
</div>
</div>
{fileNotice && <p className="mt-3 text-xs text-slate-400">{fileNotice}</p>}
</Card>
);
}