feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final

- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
@@ -0,0 +1,417 @@
"use client";
/**
* Platform owner console: the prepaid credit of every organization (D-B).
*
* The owner adds credit by hand. Each addition writes one history row and one audit row on
* the server, and an organization's engine work runs again once its balance is above zero.
* A balance at or below zero pauses that work, so its row says so. The page reloads after
* each action: the wallet event goes to the organization's channel, not to the admin channel.
*
* Nothing here names a model or a provider. The admin layout guards the route; the API is
* the real boundary.
*/
import * as React from "react";
import {
Badge,
Button,
Drawer,
EmptyState,
ErrorState,
Field,
Modal,
Stat,
Table,
type TableColumn,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type CreditTransaction, type OrgBalance, creditApi } from "@/lib/api-credit";
/** Mirrors ``MAX_OWNER_CREDIT`` in ``app/application/services/credit_service.py``. */
const MAX_OWNER_CREDIT = 100_000;
/** Mirrors the service: a reason of 3 to 500 characters once trimmed. */
const REASON_MIN = 3;
const REASON_MAX = 500;
/** The open credit dialog: the organization, what was typed, and the step it is on. */
interface CreditDraft {
org: OrgBalance;
amount: string;
reason: string;
step: "edit" | "confirm";
}
/** The open history drawer for one organization. */
interface HistoryView {
org: OrgBalance;
rows: CreditTransaction[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
/** The typed amount as a number, or null when it is not a positive amount within the ceiling. */
function parseAmount(text: string): number | null {
if (text.trim() === "") return null;
const value = Number(text);
return Number.isFinite(value) && value > 0 && value <= MAX_OWNER_CREDIT ? value : null;
}
function isReasonValid(text: string): boolean {
const length = text.trim().length;
return length >= REASON_MIN && length <= REASON_MAX;
}
function money(value: number, currency: string): string {
const formatted = value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
return currency ? `${formatted} ${currency}` : formatted;
}
function dateTime(iso: string | null): string {
return iso ? new Date(iso).toLocaleString("fa-IR") : "—";
}
function historyColumns(currency: string): Array<TableColumn<CreditTransaction>> {
return [
{
key: "when",
header: "زمان",
render: (row) => <span className="text-xs text-slate-400">{dateTime(row.created_at)}</span>,
},
{
key: "amount",
header: "مبلغ",
render: (row) => (
<span className="ltr-inline text-xs text-slate-300">{money(row.amount, currency)}</span>
),
},
{
key: "reason",
header: "دلیل",
render: (row) => <span className="text-xs text-slate-200">{row.reason}</span>,
},
{
key: "after",
header: "موجودی پس از ثبت",
render: (row) => (
<span className="ltr-inline text-xs text-slate-300">
{money(row.balance_after, currency)}
</span>
),
},
];
}
export default function AdminCreditsPage() {
const [rows, setRows] = React.useState<OrgBalance[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [draft, setDraft] = React.useState<CreditDraft | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const [draftError, setDraftError] = React.useState<string | null>(null);
const [history, setHistory] = React.useState<HistoryView | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
// A reload after an action keeps the rows on screen; only the first load shows the loading state.
const load = React.useCallback(async () => {
setError(null);
try {
setRows(await creditApi.balances());
} catch (err) {
setError(errorMessage(err, "دریافت اعتبار سازمان‌ها ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const openCredit = (org: OrgBalance) => {
setDraft({ org, amount: "", reason: "", step: "edit" });
setDraftError(null);
};
const closeCredit = () => {
if (submitting) return;
setDraft(null);
setDraftError(null);
};
const continueCredit = (event: React.FormEvent) => {
event.preventDefault();
if (!draft || parseAmount(draft.amount) === null || !isReasonValid(draft.reason)) return;
setDraft({ ...draft, step: "confirm" });
};
const submitCredit = async () => {
if (!draft) return;
const amount = parseAmount(draft.amount);
if (amount === null || !isReasonValid(draft.reason)) return;
const { org } = draft;
setSubmitting(true);
setDraftError(null);
try {
const transaction = await creditApi.credit(org.organization_id, {
amount,
reason: draft.reason.trim(),
});
setDraft(null);
toast(
"اعتبار افزوده شد",
"success",
`${money(transaction.amount, org.currency)} به «${org.name}» افزوده شد. موجودی: ${money(transaction.balance_after, org.currency)}`,
);
await load();
} catch (err) {
setDraftError(errorMessage(err, "افزودن اعتبار ناموفق بود."));
} finally {
setSubmitting(false);
}
};
const openHistory = async (org: OrgBalance) => {
setHistory({ org, rows: [], loading: true, error: null });
try {
const next = await creditApi.history(org.organization_id);
setHistory((current) =>
current?.org.organization_id === org.organization_id
? { ...current, rows: next, loading: false, error: null }
: current,
);
} catch (err) {
const message = errorMessage(err, "دریافت تاریخچه ناموفق بود.");
setHistory((current) =>
current?.org.organization_id === org.organization_id
? { ...current, loading: false, error: message }
: current,
);
}
};
const columns: Array<TableColumn<OrgBalance>> = [
{
key: "name",
header: "نام",
render: (row) => <span className="text-slate-100">{row.name}</span>,
},
{
key: "slug",
header: "شناسه",
render: (row) => <span className="ltr-inline text-xs text-slate-500">{row.slug}</span>,
},
{
key: "balance",
header: "اعتبار",
render: (row) => (
<div className="flex flex-wrap items-center gap-2">
<span className="ltr-inline text-xs text-slate-300">
{money(row.balance, row.currency)}
</span>
{row.balance <= 0 && <Badge tone="danger">بدون اعتبار</Badge>}
</div>
),
},
{
key: "paused",
header: "کارهای متوقف",
align: "center",
render: (row) => row.paused_tasks,
},
{
key: "last",
header: "آخرین افزایش",
render: (row) => (
<span className="text-xs text-slate-400">{dateTime(row.last_credit_at)}</span>
),
},
{
key: "actions",
header: "عملیات",
align: "end",
render: (row) => (
<div className="flex justify-end gap-2">
<Button size="sm" onClick={() => openCredit(row)}>
افزودن اعتبار
</Button>
<Button size="sm" variant="secondary" onClick={() => void openHistory(row)}>
تاریخچه
</Button>
</div>
),
},
];
const withoutCredit = rows.filter((row) => row.balance <= 0).length;
const pausedTotal = rows.reduce((sum, row) => sum + row.paused_tasks, 0);
const amountValue = draft ? parseAmount(draft.amount) : null;
const reasonOk = draft ? isReasonValid(draft.reason) : false;
const canContinue = amountValue !== null && reasonOk;
const amountInvalid = draft !== null && draft.amount.trim() !== "" && amountValue === null;
const reasonInvalid = draft !== null && draft.reason.trim() !== "" && !reasonOk;
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">اعتبار سازمان‌ها</h1>
<p className="text-sm text-slate-400">
اعتبار پیش‌پرداخت هر سازمان را این‌جا افزایش می‌دهید. هر افزایش یک ردیف تاریخچه و یک
ردیف ممیزی ثبت می‌کند. سازمانی که موجودی آن به صفر برسد، کار کارمندان هوش مصنوعی‌اش
متوقف می‌شود.
</p>
</header>
{!loading && !error && (
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="تعداد سازمان‌ها" value={rows.length} />
<Stat
label="بدون اعتبار"
value={withoutCredit}
tone={withoutCredit > 0 ? "danger" : "neutral"}
/>
<Stat label="کارهای متوقف" value={pausedTotal} />
</div>
)}
<Table
caption="فهرست سازمان‌ها و اعتبار آن‌ها"
columns={columns}
rows={rows}
rowKey={(row) => row.organization_id}
loading={loading}
error={error}
onRetry={() => void load()}
empty={
<EmptyState
title="سازمانی وجود ندارد"
description="هنوز هیچ سازمانی ساخته نشده است."
/>
}
/>
{draft && (
<Modal
open
title={`افزودن اعتبار به «${draft.org.name}»`}
description={
draft.step === "edit"
? "مبلغ و دلیل را وارد کنید. در مرحلهٔ بعد تأیید می‌خواهیم."
: "مبلغ و دلیل را بررسی کنید."
}
onClose={closeCredit}
footer={
draft.step === "edit" ? (
<>
<Button variant="secondary" onClick={closeCredit}>
انصراف
</Button>
<Button type="submit" form="credit-form" disabled={!canContinue}>
ادامه
</Button>
</>
) : (
<>
<Button
variant="secondary"
disabled={submitting}
onClick={() => setDraft({ ...draft, step: "edit" })}
>
بازگشت
</Button>
<Button loading={submitting} onClick={() => void submitCredit()}>
تأیید و افزودن
</Button>
</>
)
}
>
{draft.step === "edit" ? (
<form id="credit-form" onSubmit={continueCredit} className="flex flex-col gap-4">
<Field
label="مبلغ"
inputMode="decimal"
className="ltr-inline"
placeholder="مثلاً 50"
value={draft.amount}
onChange={(event) => setDraft({ ...draft, amount: event.target.value })}
error={
amountInvalid
? `مبلغ باید عددی بیشتر از صفر و حداکثر ${MAX_OWNER_CREDIT.toLocaleString("en-US")} باشد.`
: undefined
}
/>
<Field
label="دلیل"
maxLength={REASON_MAX}
placeholder="مثلاً واریز بانکی"
value={draft.reason}
onChange={(event) => setDraft({ ...draft, reason: event.target.value })}
hint="این دلیل در تاریخچه و گزارش ممیزی ثبت می‌شود."
error={reasonInvalid ? `دلیل باید حداقل ${REASON_MIN} نویسه باشد.` : undefined}
/>
</form>
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-slate-200">
مبلغ{" "}
<span className="ltr-inline">
{money(amountValue ?? 0, draft.org.currency)}
</span>{" "}
به اعتبار «{draft.org.name}» افزوده شود؟
</p>
<p className="text-xs text-slate-400">دلیل: {draft.reason.trim()}</p>
<p className="text-xs text-slate-500">
این عملیات در تاریخچهٔ اعتبار و گزارش ممیزی ثبت می‌شود.
</p>
{draftError && <ErrorState title="افزودن اعتبار ناموفق" message={draftError} />}
</div>
)}
</Modal>
)}
<Drawer open={history !== null} title="تاریخچهٔ اعتبار" onClose={() => setHistory(null)}>
{history && (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-slate-200">{history.org.name}</p>
<Table
caption={`تاریخچهٔ اعتبار «${history.org.name}»`}
columns={historyColumns(history.org.currency)}
rows={history.rows}
rowKey={(row) => row.id}
loading={history.loading}
error={history.error}
onRetry={() => void openHistory(history.org)}
empty={<EmptyState title="هنوز اعتبار دستی ثبت نشده است." />}
/>
</div>
)}
</Drawer>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))}
/>
</div>
);
}
@@ -67,7 +67,7 @@ export default function AdminModelGrantsPage() {
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [budget, setBudget] = React.useState("");
const [maxEmployees, setMaxEmployees] = React.useState("5");
const [maxEmployees, setMaxEmployees] = React.useState("7");
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
+21 -5
View File
@@ -21,8 +21,8 @@ import {
Stat,
UsageBar,
} from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api";
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
@@ -53,8 +53,14 @@ function money(value: number, currency = ""): string {
return currency ? `${formatted} ${currency}` : formatted;
}
/** The engine's credit notice for the active organization; empty while credit is there. */
function BillingCreditNotice({ orgId }: { orgId: string }) {
const { status } = useEngineStatus(orgId);
return <CreditNotice status={status} />;
}
export default function BillingPage() {
const { activeOrganization } = useSession();
const { activeOrganization, user } = useSession();
const orgId = activeOrganization?.id ?? null;
const [summary, setSummary] = React.useState<BillingSummary | null>(null);
@@ -189,7 +195,6 @@ export default function BillingPage() {
if (error) return <ErrorState message={error} onRetry={load} />;
const currency = summary?.currency ?? "";
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -201,6 +206,8 @@ export default function BillingPage() {
</p>
</header>
<BillingCreditNotice orgId={activeOrganization.id} />
{summary?.low_balance && (
<Card className="border-warning/50">
<div className="flex items-center gap-2">
@@ -223,11 +230,11 @@ export default function BillingPage() {
<Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" />
</div>
{canManage && (
{user?.is_platform_owner && (
<Card>
<CardTitle>افزایش اعتبار</CardTitle>
<CardHint>
افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر
افزایش اعتبار دستی توسط مالک سامانه. اگر درگاه پرداخت متصل باشد، می‌توانید از مسیر
پرداخت آنلاین هم استفاده کنید.
</CardHint>
<form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
@@ -270,6 +277,15 @@ export default function BillingPage() {
</Card>
)}
{!user?.is_platform_owner && (
<Card>
<CardHint>
اعتبار سازمان به صورت پیش‌پرداخت توسط مالک سامانه افزوده می‌شود. هر مصرف از همین اعتبار
کم می‌شود.
</CardHint>
</Card>
)}
<Card>
<CardTitle>تفکیک بر اساس واحد مصرف</CardTitle>
{!summary || summary.by_unit.length === 0 ? (
@@ -0,0 +1,224 @@
"use client";
/**
* The engine's summary on the dashboard: the credit balance (with the notice when work has
* stopped), the active tasks, how many tasks sit in each board column, and the latest completed
* tasks.
*
* The credit and the stage counts come from ``useEngineStatus``. The two task lists come from
* ``engineApi.listTasks``, loaded again on every ``task.step`` of the organization. A legacy task
* (no engine stage) is never listed here: the engine does not own it.
*/
import Link from "next/link";
import * as React from "react";
import { Badge, Card, CardHint, CardTitle, ErrorState, Stat } from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
import { BOARD_COLUMNS, STAGE_LABELS, stageTone } from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** How many tasks each list shows. */
const LIST_LIMIT = 5;
/** A task at one of these stages has finished, so it is not active. */
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
interface EngineTasksState {
tasks: EngineTask[];
loading: boolean;
error: string | null;
}
/** The organization's engine tasks, loaded again on every ``task.step``. */
function useEngineTasks(orgId: string): EngineTasksState {
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setTasks(await engineApi.listTasks(orgId));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "فهرست کارها دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.step") void load();
});
return { tasks, loading, error };
}
/** An engine task (one with a stage) that has not finished yet. */
function isActive(task: EngineTask): boolean {
return task.stage !== null && !FINISHED.has(task.stage);
}
function TaskList({
tasks,
empty,
meta,
}: {
tasks: EngineTask[];
empty: string;
meta: (task: EngineTask) => React.ReactNode;
}) {
if (tasks.length === 0) return <p className="text-xs text-slate-500">{empty}</p>;
return (
<ul className="flex flex-col gap-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<Link
href={`/tasks/${task.id}`}
className="min-w-0 truncate text-xs text-slate-200 transition-colors hover:text-accent"
>
{task.title}
</Link>
{meta(task)}
</li>
))}
</ul>
);
}
export function EngineWidgets({ orgId }: { orgId: string }) {
const engine = useEngineStatus(orgId);
const board = useEngineTasks(orgId);
const { status } = engine;
if (status === null) {
return (
<section aria-label="کارمندان هوش مصنوعی">
{engine.error ? (
<ErrorState
title="وضعیت کارمندان دریافت نشد."
message={engine.error}
onRetry={engine.reload}
/>
) : (
<p className="text-xs text-slate-500">در حال دریافت وضعیت کارمندان…</p>
)}
</section>
);
}
const activeAll = board.tasks.filter(isActive);
const active = activeAll.slice(0, LIST_LIMIT);
const completed = board.tasks
.filter((task) => task.stage === "completed")
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))
.slice(0, LIST_LIMIT);
const columns = BOARD_COLUMNS.map((column) => ({
key: column.key,
label: column.label,
count: column.stages.reduce((sum, stage) => sum + (status.stage_counts[stage] ?? 0), 0),
}));
const largest = Math.max(1, ...columns.map((column) => column.count));
return (
<section aria-label="کارمندان هوش مصنوعی" className="flex flex-col gap-4">
<CreditNotice status={status} />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat
label="موجودی اعتبار"
value={
<>
{status.balance.toLocaleString("fa-IR")}{" "}
<span className="ltr-inline text-sm text-slate-400">{status.currency}</span>
</>
}
tone={status.credit_ok ? "neutral" : "danger"}
/>
</div>
{board.error && (
<p role="alert" className="text-xs text-danger">
{board.error}
</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardTitle>کارهای فعال</CardTitle>
<CardHint>کارهایی که هنوز کامل نشده‌اند. برای جزئیات، عنوان کار را بزنید.</CardHint>
<div className="mt-4">
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
{!board.loading && !board.error && (
<TaskList
tasks={active}
empty="کار فعالی وجود ندارد."
meta={(task) =>
task.stage && (
<Badge tone={stageTone(task.stage)}>{STAGE_LABELS[task.stage]}</Badge>
)
}
/>
)}
{!board.loading && !board.error && activeAll.length > LIST_LIMIT && (
<p className="mt-2 text-xs text-slate-500">
و {(activeAll.length - LIST_LIMIT).toLocaleString("fa-IR")} کار دیگر
</p>
)}
</div>
</Card>
<Card>
<CardTitle>توزیع مراحل</CardTitle>
<CardHint>تعداد کارها در هر ستون تابلوی کارها.</CardHint>
<ul aria-label="توزیع مراحل" className="mt-4 flex flex-col gap-3">
{columns.map((column) => (
<li key={column.key} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{column.label}</span>
<span className="text-slate-200">{column.count.toLocaleString("fa-IR")}</span>
</div>
<div
aria-hidden="true"
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-overlay"
>
<div
className="h-full rounded-full bg-accent transition-[width] duration-500 ease-out"
style={{ width: `${Math.round((column.count / largest) * 100)}%` }}
/>
</div>
</li>
))}
</ul>
</Card>
</div>
<Card>
<CardTitle>آخرین کارهای انجام‌شده</CardTitle>
<CardHint>پنج کار آخری که به مرحلهٔ «انجام شد» رسیده‌اند.</CardHint>
<div className="mt-4">
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
{!board.loading && !board.error && (
<TaskList
tasks={completed}
empty="هنوز کاری انجام نشده است."
meta={(task) => (
<span className="text-xs text-slate-500">
{new Date(task.updated_at).toLocaleString("fa-IR")}
</span>
)}
/>
)}
</div>
</Card>
</section>
);
}
@@ -17,6 +17,7 @@ import {
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { EngineWidgets } from "./_components/engine-widgets";
export default function DashboardPage() {
const { activeOrganization, user } = useSession();
@@ -105,6 +106,8 @@ export default function DashboardPage() {
</p>
</header>
<EngineWidgets orgId={activeOrganization.id} />
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="اعتبار" value="—" hint="پس از فاز billing" />
<Stat label="وظایف فعال" value="0" hint="پس از فاز Task engine" />
@@ -0,0 +1,104 @@
"use client";
/**
* The tasks that wait for a person's answer, shown at the top of the manager page.
*
* A task at ``pending_user_decision`` has asked a question. Each one gets its own answer box and
* «ارسال پاسخ», which calls ``engineApi.answerTask``. A refusal from the server is shown in Persian
* under its box. The banner is hidden while no task waits for an answer.
*/
import Link from "next/link";
import * as React from "react";
import { Button, Card, CardTitle } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
/** The server accepts an answer of 1 to 4,000 characters. */
const MAX_ANSWER_LENGTH = 4000;
export interface DecisionBannerProps {
orgId: string;
/** Any tasks of the organization; only those at ``pending_user_decision`` are shown. */
tasks: EngineTask[];
/** Called once an answer is accepted, so the page reloads its task list. */
onAnswered: () => void;
}
export function DecisionBanner({ orgId, tasks, onAnswered }: DecisionBannerProps) {
const waiting = tasks.filter((task) => task.stage === "pending_user_decision");
if (waiting.length === 0) return null;
return (
<Card className="flex flex-col gap-3 border-warning/50">
<CardTitle>در انتظار تصمیم شما</CardTitle>
<ul className="flex flex-col gap-4">
{waiting.map((task) => (
<li key={task.id}>
<DecisionForm orgId={orgId} task={task} onAnswered={onAnswered} />
</li>
))}
</ul>
</Card>
);
}
interface DecisionFormProps {
orgId: string;
task: EngineTask;
onAnswered: () => void;
}
function DecisionForm({ orgId, task, onAnswered }: DecisionFormProps) {
const [answer, setAnswer] = React.useState("");
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const content = answer.trim();
const fieldId = `decision-${task.id}`;
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!content || sending) return;
setSending(true);
setError(null);
try {
await engineApi.answerTask(orgId, task.id, content);
setAnswer("");
onAnswered();
} catch (err) {
setError(err instanceof ApiError ? err.message : "پاسخ ارسال نشد. دوباره تلاش کنید.");
} finally {
setSending(false);
}
};
return (
<form onSubmit={onSubmit} className="flex flex-col gap-2">
<Link href={`/tasks/${task.id}`} className="text-sm text-slate-100 hover:text-accent">
{task.title}
</Link>
<label htmlFor={fieldId} className="text-xs font-medium text-slate-300">
پاسخ شما
</label>
<textarea
id={fieldId}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
rows={3}
maxLength={MAX_ANSWER_LENGTH}
disabled={sending}
placeholder="پاسخ خود را بنویسید…"
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" loading={sending} disabled={!content}>
ارسال پاسخ
</Button>
</div>
</form>
);
}
@@ -0,0 +1,73 @@
"use client";
/**
* The tasks of the active conversation, shown as compact cards under its messages.
*
* An engine task shows its Persian stage (``STAGE_LABELS``, toned by ``stageTone``). A legacy
* task has no engine stage, so it shows its coarse status instead. Each card links to the task
* page. The manager page patches a card's stage in place on ``task.step``, so the badge moves
* without a reload.
*/
import Link from "next/link";
import { Badge } from "@agy/ui";
import type { EngineTask } from "@/lib/api-engine";
import { STAGE_LABELS, type StageTone, stageTone } from "@/lib/engine-labels";
/** A legacy task has no stage; these are the Persian names of its coarse statuses. */
const LEGACY_LABELS: Record<string, string> = {
new: "جدید",
triaged: "واگذار شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام شد",
failed: "ناموفق",
cancelled: "لغو شد",
};
function legacyTone(status: string): StageTone {
if (status === "done") return "success";
if (status === "failed" || status === "cancelled") return "danger";
if (status === "waiting_approval") return "warning";
return "neutral";
}
export interface ThreadTaskCardsProps {
/** The tasks of the active thread, newest first. Nothing is shown for an empty list. */
tasks: EngineTask[];
}
export function ThreadTaskCards({ tasks }: ThreadTaskCardsProps) {
if (tasks.length === 0) return null;
return (
<section className="flex flex-col gap-2">
<p className="text-xs font-medium text-slate-400">کارهای این گفتگو</p>
<ul className="flex flex-col gap-2">
{tasks.map((task) => (
<li key={task.id}>
<TaskCardLink task={task} />
</li>
))}
</ul>
</section>
);
}
function TaskCardLink({ task }: { task: EngineTask }) {
const label =
task.stage === null
? (LEGACY_LABELS[task.status] ?? task.status)
: (STAGE_LABELS[task.stage] ?? task.stage);
const tone = task.stage === null ? legacyTone(task.status) : stageTone(task.stage);
return (
<Link
href={`/tasks/${task.id}`}
className="flex flex-col gap-1 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs transition-colors hover:border-accent/50"
>
<span className="flex items-start justify-between gap-2">
<span className="min-w-0 break-words text-slate-100">{task.title}</span>
<Badge tone={tone}>{label}</Badge>
</span>
{task.assignee_name && <span className="text-slate-500">{task.assignee_name}</span>}
</Link>
);
}
+58 -49
View File
@@ -1,10 +1,8 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts";
import type { Employee, Message, TaskStepEventData, Thread } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
@@ -15,10 +13,20 @@ import {
LoadingState,
Select,
} from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api";
import { type EngineStage, type EngineTask, engineApi } from "@/lib/api-engine";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { DecisionBanner } from "./_components/decision-banner";
import { ThreadTaskCards } from "./_components/task-cards";
/** The credit notice of one organization; its own component, so no status call runs without one. */
function EngineCreditNotice({ orgId }: { orgId: string }) {
const { status } = useEngineStatus(orgId);
return <CreditNotice status={status} />;
}
/**
* Manager Chat — three columns, matching the plan: conversations, the
@@ -32,7 +40,8 @@ export default function ManagerPage() {
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [tasksError, setTasksError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [loadingThread, setLoadingThread] = React.useState(false);
@@ -49,7 +58,18 @@ export default function ManagerPage() {
const bottomRef = React.useRef<HTMLDivElement | null>(null);
// Live feed: new messages in the active thread, and tasks created from chat.
// The engine's task list: the conversation's cards and the decision banner read it.
const reloadTasks = React.useCallback(async () => {
if (!orgId) return;
try {
setTasks(await engineApi.listTasks(orgId));
setTasksError(null);
} catch (err) {
setTasksError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
}
}, [orgId]);
// Live feed: new messages in the active thread, tasks created, and stage moves.
const liveChannels = React.useMemo(() => {
const base: string[] = [];
if (orgId) base.push(`org:${orgId}`);
@@ -79,26 +99,18 @@ export default function ManagerPage() {
}
break;
}
case "task.created": {
const data = event.data as { task_id: string; title: string; status: string };
setTasks((current) => {
if (current.some((t) => t.id === data.task_id)) return current;
return [
{
id: data.task_id,
organization_id: orgId ?? "",
project_id: null,
thread_id: activeThreadId,
title: data.title,
description: "",
status: data.status as TaskStatus,
assigned_employee_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
...current,
];
});
case "task.created":
void reloadTasks();
break;
case "task.step": {
const data = event.data as unknown as TaskStepEventData;
setTasks((current) =>
current.map((task) =>
task.id === data.task_id
? { ...task, stage: data.to as EngineStage, status: data.status }
: task,
),
);
break;
}
default:
@@ -117,11 +129,12 @@ export default function ManagerPage() {
const [nextThreads, nextEmployees, nextTasks] = await Promise.all([
api.listThreads(orgId),
api.listEmployees(orgId),
api.listTasks(orgId),
engineApi.listTasks(orgId),
]);
setThreads(nextThreads);
setEmployees(nextEmployees);
setTasks(nextTasks);
setTasksError(null);
setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود.");
@@ -196,13 +209,13 @@ export default function ManagerPage() {
setTaskError(null);
setCreatingTask(true);
try {
const task = await api.createTask(orgId, {
await api.createTask(orgId, {
title: taskDraft.trim(),
description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`,
thread_id: activeThreadId,
assigned_employee_id: taskEmployee || null,
});
setTasks((current) => [task, ...current]);
void reloadTasks();
setTaskDraft("");
} catch (err) {
setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
@@ -240,6 +253,13 @@ export default function ManagerPage() {
</Button>
</header>
{orgId && (
<>
<EngineCreditNotice orgId={orgId} />
<DecisionBanner orgId={orgId} tasks={tasks} onAnswered={() => void reloadTasks()} />
</>
)}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[16rem_1fr_20rem]">
{/* Column 1 — conversations */}
<Card className="flex min-h-0 flex-col gap-2 overflow-y-auto">
@@ -293,6 +313,8 @@ export default function ManagerPage() {
</div>
))
)}
<ThreadTaskCards tasks={threadTasks} />
{tasksError && <ErrorState title="دریافت کارها ناموفق بود" message={tasksError} />}
<div ref={bottomRef} />
</div>
@@ -318,29 +340,16 @@ export default function ManagerPage() {
</form>
</Card>
{/* Column 3 — the work this conversation produced */}
{/* Column 3 — a new task. The conversation's tasks are the cards under its messages. */}
<Card className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<CardTitle>کارهای این گفتگو</CardTitle>
<CardHint>از گفتگو یک کار بسازید تا کارمند هوش مصنوعی آن را اجرا کند.</CardHint>
{threadTasks.length === 0 ? (
<p className="text-xs text-slate-500">کاری ساخته نشده است.</p>
<CardTitle>کار جدید</CardTitle>
{canRun ? (
<CardHint>
کار جدید نخست به مدیر هوش مصنوعی می‌رسد. مدیر آن را بررسی می‌کند و در صورت نیاز به
کارمند مسئول می‌سپارد.
</CardHint>
) : (
<ul className="flex flex-col gap-2">
{threadTasks.map((task) => (
<li key={task.id}>
<Link
href={`/tasks/${task.id}`}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs text-slate-300 hover:border-accent/50"
>
<span className="truncate">{task.title}</span>
<Badge tone={task.status === "done" ? "success" : "neutral"}>
{task.status}
</Badge>
</Link>
</li>
))}
</ul>
<CardHint>ساخت کار فقط برای اعضایی ممکن است که اجازهٔ اجرای کار دارند.</CardHint>
)}
{canRun && (
@@ -0,0 +1,60 @@
"use client";
/**
* The last diff of one engine task, read-only, and the sha the change was merged and pushed as.
*
* The diff reads left to right in a monospace block: a line that starts with `+` is green, one that
* starts with `-` is red, and a `@@` hunk header is muted. The text is the engine's and renders as
* text only. Once the task is merged, the short sha shows with a link to the app's pipeline view.
*/
import Link from "next/link";
import { cx } from "@agy/ui";
export interface DiffViewerProps {
/** The task's last diff in unified format; an empty string when none was recorded. */
diff: string;
/** The sha the change was merged and pushed as; an empty string before that. */
mergedSha: string;
}
function lineClass(line: string): string {
if (line.startsWith("+")) return "bg-positive/10 text-positive";
if (line.startsWith("-")) return "bg-danger/10 text-danger";
if (line.startsWith("@@")) return "text-slate-500";
return "text-slate-300";
}
export function DiffViewer({ diff, mergedSha }: DiffViewerProps) {
const text = diff.replace(/\n+$/, "");
const lines = text ? text.split("\n") : [];
return (
<div className="flex flex-col gap-3">
{mergedSha && (
<p className="flex flex-wrap items-center gap-2 text-xs text-slate-300">
<span>ادغام و ارسال شد: </span>
<span dir="ltr" className="font-mono text-slate-100">
{mergedSha.slice(0, 12)}
</span>
<Link href="/app" className="text-accent hover:underline">
مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن
</Link>
</p>
)}
{lines.length === 0 ? (
<p className="text-xs text-slate-500">هنوز تغییری ثبت نشده است.</p>
) : (
<pre
dir="ltr"
className="max-h-[32rem] overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5"
>
{lines.map((line, index) => (
<span key={index} className={cx("block whitespace-pre", lineClass(line))}>
{line || " "}
</span>
))}
</pre>
)}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
/**
* The gate verdicts of one engine task, one row per verdict in the order the engine recorded them.
*
* Each row shows the gate's Persian name, a green or red badge, when the verdict was recorded, and
* a button that opens its detail. The detail is the engine's own text, shown as text in a `pre`.
* When the tests were waived, the reason shows in a warning box.
*/
import * as React from "react";
import { Badge } from "@agy/ui";
import type { EngineGate } from "@/lib/api-engine";
/** The Persian name of each gate the engine runs. An unknown gate shows its own key. */
const GATE_LABELS: Record<string, string> = {
tests: "تست‌ها",
diff_guard: "نگهبان تغییرات",
no_progress: "بدون پیشرفت",
green: "دروازهٔ سبز",
lint: "لینت",
};
export interface GatesPanelProps {
gates: readonly EngineGate[];
/** The reason the tests were waived; an empty string when they were not. */
waiverReason: string;
}
function formatTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
}
function GateRow({ gate }: { gate: EngineGate }) {
const [open, setOpen] = React.useState(false);
const detailId = React.useId();
const label = GATE_LABELS[gate.gate] ?? gate.gate;
return (
<li className="flex flex-col gap-2 py-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-200">{label}</span>
<Badge tone={gate.ok ? "success" : "danger"}>{gate.ok ? "قبول شد" : "رد شد"}</Badge>
<time dateTime={gate.created_at} className="text-xs text-slate-500">
{formatTime(gate.created_at)}
</time>
{gate.detail && (
<button
type="button"
aria-expanded={open}
aria-controls={detailId}
onClick={() => setOpen((value) => !value)}
className="text-xs text-accent hover:underline"
>
{open ? "بستن جزئیات" : "جزئیات"}
</button>
)}
</div>
{open && gate.detail && (
<pre
id={detailId}
dir="ltr"
className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 font-mono text-xs leading-5 text-slate-300"
>
{gate.detail}
</pre>
)}
</li>
);
}
export function GatesPanel({ gates, waiverReason }: GatesPanelProps) {
return (
<div className="flex flex-col gap-3">
{waiverReason && (
<div
role="note"
className="rounded-xl border border-warning/50 bg-warning/10 px-4 py-3 text-xs leading-6 text-warning"
>
صرف‌نظر از تست با دلیل: {waiverReason}
</div>
)}
{gates.length === 0 ? (
<p className="text-xs text-slate-500">هنوز نتیجهٔ دروازه‌ای ثبت نشده است.</p>
) : (
<ul className="divide-y divide-surface-border">
{gates.map((gate) => (
<GateRow key={gate.id} gate={gate} />
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,124 @@
"use client";
/**
* The live log of one engine task: what its runs print, and which tools they called, as the
* server pushes them on the `task:<id>` channel.
*
* A `run.log` event appends all of its lines in one state update; a `run.tool` event appends one
* line in Persian. The panel keeps the newest 2000 lines. It follows the newest line until the
* reader pauses it, with the toggle or by scrolling up by hand. The log body is left-to-right and
* monospace. The lines are server text, and they render as text only.
*/
import * as React from "react";
import type { WsEvent } from "@agy/contracts";
import { Button } from "@agy/ui";
import { useLiveChannel } from "@/lib/realtime";
/** The most lines the panel keeps; the oldest go first. */
const LOG_LIMIT = 2000;
/** How close to the bottom, in pixels, still counts as following the newest line. */
const BOTTOM_SLACK = 8;
interface LogLine {
id: number;
text: string;
}
export interface LiveLogProps {
taskId: string;
}
function keepNewest(lines: LogLine[]): LogLine[] {
return lines.length > LOG_LIMIT ? lines.slice(lines.length - LOG_LIMIT) : lines;
}
export function LiveLog({ taskId }: LiveLogProps) {
const [lines, setLines] = React.useState<LogLine[]>([]);
const [paused, setPaused] = React.useState(false);
const boxRef = React.useRef<HTMLDivElement>(null);
const nextId = React.useRef(0);
// One call per event: every line the event carries is added in a single state update.
const append = React.useCallback((texts: string[]) => {
if (texts.length === 0) return;
const added = texts.map((text) => {
nextId.current += 1;
return { id: nextId.current, text };
});
setLines((previous) => keepNewest([...previous, ...added]));
}, []);
useLiveChannel([`task:${taskId}`], (event: WsEvent) => {
if (event.type === "run.log") {
const data = event.data as { task_id: string; lines: string[] };
if (data.task_id === taskId) append(data.lines);
} else if (event.type === "run.tool") {
const data = event.data as { task_id: string; tool: string; ok: boolean; summary: string };
if (data.task_id !== taskId) return;
const outcome = data.ok ? "موفق" : "ناموفق";
append([
data.summary
? `ابزار ${data.tool}: ${outcome} — ${data.summary}`
: `ابزار ${data.tool}: ${outcome}`,
]);
}
});
// Follow the newest line unless paused. The scroll position is set only here, after the lines
// or the pause state change, so a paused panel never moves.
React.useEffect(() => {
const box = boxRef.current;
if (box && !paused) box.scrollTop = box.scrollHeight;
}, [lines, paused]);
// A scroll up by hand pauses following. Returning to the bottom does not resume it: that is the
// toggle's job, so the reader keeps the position they chose.
const onScroll = () => {
const box = boxRef.current;
if (!box || paused) return;
const distance = box.scrollHeight - box.scrollTop - box.clientHeight;
if (distance > BOTTOM_SLACK) setPaused(true);
};
return (
<section aria-label="گزارش زندهٔ کار" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-slate-400">گزارش زنده · {lines.length.toLocaleString("fa-IR")} سطر</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => setPaused((value) => !value)}>
{paused ? "ادامهٔ پیمایش" : "توقف پیمایش"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={lines.length === 0}
onClick={() => setLines([])}
>
پاک کردن
</Button>
</div>
</div>
<div
ref={boxRef}
role="log"
aria-live="off"
dir="ltr"
onScroll={onScroll}
className="h-72 overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5 text-slate-300"
>
{lines.length === 0 ? (
<p dir="rtl" className="font-sans text-slate-500">
هنوز خروجی‌ای ثبت نشده است.
</p>
) : (
lines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap break-all">
{line.text}
</div>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,91 @@
"use client";
/**
* The stages of one engine task's pipeline, in order, with the task's current stage marked.
*
* The stages before the current one are check-marked, the current one is highlighted, and the
* rest wait. A side state (paused, pending_user_decision, error_review, cancelled, failed) is not
* a stage of the pipeline, so no step is highlighted: the state is shown as a badge beside the
* steps instead. A completed task has every step check-marked. The labels are the engine's
* Persian stage names.
*/
import { Badge, cx } from "@agy/ui";
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
export interface PipelineStepperProps {
/** The stages of the task's pipeline in order (``EngineTaskDetail.stages``). */
stages: readonly string[];
/** The task's current stage (``EngineTask.stage``); null renders nothing. */
current: string | null;
}
type StepState = "done" | "current" | "pending";
const STEP_CLASSES: Record<StepState, string> = {
done: "border-positive/40 text-positive",
current: "border-accent bg-accent-soft font-medium text-accent",
pending: "border-surface-border text-slate-500",
};
function CheckMark() {
return (
<svg
aria-hidden
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.5 8.5l3 3 6-7" />
</svg>
);
}
function stepState(index: number, position: number, finished: boolean): StepState {
if (finished) return "done";
if (position === -1) return "pending";
if (index < position) return "done";
if (index === position) return "current";
return "pending";
}
export function PipelineStepper({ stages, current }: PipelineStepperProps) {
if (current === null) return null;
const position = stages.indexOf(current);
const finished = current === "completed";
const offPath = position === -1 && !finished;
return (
<div dir="rtl" className="flex flex-wrap items-center gap-3">
{stages.length > 0 && (
<ol aria-label="مراحل خط لوله" className="flex flex-wrap items-center gap-2">
{stages.map((stage, index) => {
const state = stepState(index, position, finished);
return (
<li
key={stage}
data-state={state}
aria-current={state === "current" ? "step" : undefined}
className={cx(
"inline-flex items-center gap-2 rounded-lg border px-2.5 py-1 text-xs",
STEP_CLASSES[state],
)}
>
<span aria-hidden className="inline-flex h-4 w-4 items-center justify-center">
{state === "done" ? <CheckMark /> : (index + 1).toLocaleString("fa-IR")}
</span>
<span>{STAGE_LABELS[stage] ?? stage}</span>
{state === "done" && <span className="sr-only">(انجام شد)</span>}
</li>
);
})}
</ol>
)}
{offPath && <Badge tone={stageTone(current)}>{STAGE_LABELS[current] ?? current}</Badge>}
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
/**
* The turns of one engine task as a timeline, in the order the API gives them (oldest first).
*
* Each turn shows who ran (the role in Persian and the employee's name), the stage it ran in, when
* it started, how it ended (موفق, or خطا with the engine's error kind), how many tools it called,
* and the start of its reply in a block the reader opens on demand. Only the fields the API
* defines are read, so a turn never shows a model or provider name, even if one were sent.
*
* ``Timeline`` from ``@agy/ui`` takes plain-text items only, so this list keeps its look but
* can hold the reply block.
*/
import { Badge, EmptyState, cx } from "@agy/ui";
import type { EngineTurn } from "@/lib/api-engine";
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
export interface TurnTimelineProps {
turns: readonly EngineTurn[];
}
function turnTitle(turn: EngineTurn): string {
const role = ROLE_LABELS[turn.role] ?? turn.role;
return turn.employee_name ? `${role} — ${turn.employee_name}` : role;
}
function formatStart(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
}
export function TurnTimeline({ turns }: TurnTimelineProps) {
if (turns.length === 0) {
return <EmptyState title="هنوز نوبتی برای این کار ثبت نشده است." />;
}
return (
<ol aria-label="نوبت‌های کار" className="flex flex-col gap-4">
{turns.map((turn) => (
<li key={turn.id} className="flex gap-3">
<span
aria-hidden
className={cx(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
turn.ok ? "bg-positive" : "bg-danger",
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-xs text-slate-200">{turnTitle(turn)}</p>
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span>{STAGE_LABELS[turn.stage] ?? turn.stage}</span>
<time dateTime={turn.started_at}>{formatStart(turn.started_at)}</time>
<span>{turn.tool_calls.toLocaleString("fa-IR")} فراخوانی ابزار</span>
</p>
<div>
{turn.ok ? (
<Badge tone="success">موفق</Badge>
) : (
<Badge tone="danger">خطا: {turn.error_kind || "نامشخص"}</Badge>
)}
</div>
{turn.reply_excerpt && (
<details className="text-xs">
<summary className="cursor-pointer text-slate-400 hover:text-slate-200">
متن پاسخ
</summary>
<pre
dir="auto"
className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 leading-6 text-slate-200"
>
{turn.reply_excerpt}
</pre>
</details>
)}
</div>
</li>
))}
</ol>
);
}
+257 -17
View File
@@ -15,10 +15,18 @@ import {
Stat,
Timeline,
} from "@agy/ui";
import { TaskActions } from "@/components/engine/task-actions";
import { ApiError, api } from "@/lib/api";
import { type EngineTaskDetail, engineApi } from "@/lib/api-engine";
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { DiffViewer } from "./_components/diff-viewer";
import { GatesPanel } from "./_components/gates-panel";
import { LiveLog } from "./_components/live-log";
import { PipelineStepper } from "./_components/pipeline-stepper";
import { TurnTimeline } from "./_components/turn-timeline";
const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید",
@@ -45,6 +53,177 @@ const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "da
const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]);
/** The answer box of a task that waits for the person's decision. */
function AnswerBox({
orgId,
taskId,
lastReply,
onAnswered,
}: {
orgId: string;
taskId: string;
lastReply: string;
onAnswered: () => void;
}) {
const [content, setContent] = React.useState("");
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const answer = content.trim();
if (!answer) return;
setSending(true);
setError(null);
try {
await engineApi.answerTask(orgId, taskId, answer);
setContent("");
onAnswered();
} catch (err) {
setError(err instanceof ApiError ? err.message : "ارسال پاسخ ناموفق بود.");
} finally {
setSending(false);
}
};
return (
<Card className="border-warning/40">
<CardTitle>این کار منتظر تصمیم شماست</CardTitle>
<CardHint>پاسخ خود را بنویسید تا کار از همان مرحله ادامه یابد.</CardHint>
{lastReply && (
<div className="mt-3">
<p className="mb-1 text-xs text-slate-500">آخرین پاسخ ثبت‌شده در این کار</p>
<pre
dir="auto"
className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 text-xs leading-6 text-slate-300"
>
{lastReply}
</pre>
</div>
)}
<form onSubmit={submit} className="mt-3 flex flex-col gap-2">
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={3}
aria-label="پاسخ شما"
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" loading={sending} disabled={!content.trim()}>
ارسال پاسخ
</Button>
</div>
</form>
</Card>
);
}
/** The engine view of a task with a pipeline stage: stepper, live log, gates, diff and turns. */
function EngineTaskView({
orgId,
detail,
canRun,
actionError,
onChanged,
}: {
orgId: string;
detail: EngineTaskDetail;
canRun: boolean;
actionError: string | null;
onChanged: () => void;
}) {
const lastTurn = detail.turns[detail.turns.length - 1];
const assignee = detail.assignee_name
? `کارمند مسئول: ${detail.assignee_name}`
: "بدون کارمند مسئول — مدیر سازمان اجرا می‌کند.";
return (
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/tasks" className="transition-colors hover:text-slate-300">
کارها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{detail.title}</span>
</nav>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{detail.title}</h1>
{detail.stage !== null && (
<Badge tone={stageTone(detail.stage)}>
{STAGE_LABELS[detail.stage] ?? detail.stage}
</Badge>
)}
</div>
{detail.description && <p className="text-sm text-slate-400">{detail.description}</p>}
<p className="text-xs text-slate-500">{assignee}</p>
</div>
<TaskActions
orgId={orgId}
task={{ id: detail.id, stage: detail.stage }}
canRun={canRun}
onChanged={onChanged}
/>
</header>
{actionError && <ErrorState title="عملیات ناموفق" message={actionError} />}
{canRun && detail.stage === "pending_user_decision" && (
<AnswerBox
orgId={orgId}
taskId={detail.id}
lastReply={lastTurn?.reply_excerpt ?? ""}
onAnswered={onChanged}
/>
)}
<Card>
<CardTitle>خط لوله</CardTitle>
<div className="mt-4">
<PipelineStepper stages={detail.stages} current={detail.stage} />
</div>
</Card>
<Card>
<CardTitle>گزارش زنده</CardTitle>
<div className="mt-4">
<LiveLog taskId={detail.id} />
</div>
</Card>
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardTitle>دروازه‌ها</CardTitle>
<div className="mt-4">
<GatesPanel gates={detail.gates} waiverReason={detail.waiver_reason} />
</div>
</Card>
<Card>
<CardTitle>تغییرات</CardTitle>
<div className="mt-4">
<DiffViewer diff={detail.diff} mergedSha={detail.merged_sha} />
</div>
</Card>
</div>
<Card>
<CardTitle>نوبت‌های کار</CardTitle>
<div className="mt-4">
<TurnTimeline turns={detail.turns} />
</div>
</Card>
</div>
);
}
export default function TaskDetailPage({
params,
}: {
@@ -64,6 +243,8 @@ export default function TaskDetailPage({
const [error, setError] = React.useState<string | null>(null);
const [running, setRunning] = React.useState(false);
const [actionError, setActionError] = React.useState<string | null>(null);
const [engine, setEngine] = React.useState<EngineTaskDetail | null>(null);
const reloadTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Live feed: task status, run steps, approvals, artifacts — all push.
const channels = React.useMemo(() => {
@@ -74,6 +255,35 @@ export default function TaskDetailPage({
return base;
}, [orgId, taskId, selectedRun?.id]);
// The engine view reloads without the loading state, so its live log keeps its lines.
const reloadEngine = React.useCallback(async () => {
if (!orgId) return;
try {
setEngine(await engineApi.getTask(orgId, taskId));
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "به‌روزرسانی کار ناموفق بود.");
}
}, [orgId, taskId]);
// A burst of stage moves and gate verdicts reloads once, 500 ms after the last of them.
const reloadLatest = React.useRef(reloadEngine);
React.useEffect(() => {
reloadLatest.current = reloadEngine;
}, [reloadEngine]);
const scheduleEngineReload = React.useCallback(() => {
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
reloadTimer.current = setTimeout(() => {
reloadTimer.current = null;
void reloadLatest.current();
}, 500);
}, []);
React.useEffect(
() => () => {
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
},
[],
);
useLiveChannel(channels, (event) => {
switch (event.type) {
case "task.status": {
@@ -166,11 +376,36 @@ export default function TaskDetailPage({
});
break;
}
case "task.step":
case "task.gate": {
const data = event.data as { task_id: string };
if (data.task_id === taskId) scheduleEngineReload();
break;
}
default:
break;
}
});
// A legacy task (no engine stage): its runs, approvals and employees, as before.
const loadLegacy = React.useCallback(async () => {
if (!orgId) return;
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
api.getTask(orgId, taskId),
api.listEmployees(orgId),
// Deny-by-default: a dangerous tool stays blocked until a human decides,
// so this list is the only thing standing between a run and the action.
api.listPendingApprovals(orgId),
]);
setTask(nextTask);
setEmployees(nextEmployees);
setApprovals(nextApprovals);
// Hydrate the newest run so the timeline is visible without a click.
const newest = nextTask.runs[0];
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
}, [orgId, taskId]);
// An engine task (it has a stage) shows the engine view; anything else is a legacy task.
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
@@ -179,25 +414,19 @@ export default function TaskDetailPage({
setLoading(true);
setError(null);
try {
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
api.getTask(orgId, taskId),
api.listEmployees(orgId),
// Deny-by-default: a dangerous tool stays blocked until a human decides,
// so this list is the only thing standing between a run and the action.
api.listPendingApprovals(orgId),
]);
setTask(nextTask);
setEmployees(nextEmployees);
setApprovals(nextApprovals);
// Hydrate the newest run so the timeline is visible without a click.
const newest = nextTask.runs[0];
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
const detail = await engineApi.getTask(orgId, taskId);
if (detail.stage !== null) {
setEngine(detail);
return;
}
setEngine(null);
await loadLegacy();
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, taskId]);
}, [orgId, taskId, loadLegacy]);
React.useEffect(() => {
void load();
@@ -217,7 +446,7 @@ export default function TaskDetailPage({
delete next[toolCallId];
return next;
});
await load();
await loadLegacy();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود.");
} finally {
@@ -232,7 +461,7 @@ export default function TaskDetailPage({
try {
const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null);
setSelectedRun(detail);
await load();
await loadLegacy();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود.");
} finally {
@@ -246,7 +475,7 @@ export default function TaskDetailPage({
setRunning(true);
try {
setSelectedRun(await api.cancelRun(orgId, selectedRun.id));
await load();
await loadLegacy();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود.");
} finally {
@@ -274,6 +503,17 @@ export default function TaskDetailPage({
if (loading) return <LoadingState label="در حال دریافت کار…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (engine) {
return (
<EngineTaskView
orgId={activeOrganization.id}
detail={engine}
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
actionError={actionError}
onChanged={reloadEngine}
/>
);
}
if (!task) {
return (
<EmptyState
@@ -0,0 +1,209 @@
"use client";
/**
* The engine's task board: one column per group of stages (``BOARD_COLUMNS``), and a card per
* task with its stage, assignee, cycle and controls. The filters narrow the cards on the client.
* A legacy task (no engine stage) sits in the column of its coarse status.
*
* The board keeps its own copy of the tasks, so a live ``task.step`` moves a card at once. A
* fresh list from the page replaces that copy. A ``task.created`` asks the page to reload.
*/
import Link from "next/link";
import * as React from "react";
import type { Employee, TaskStatus, TaskStepEventData } from "@agy/contracts";
import { Badge, Card, Checkbox, Field, Select } from "@agy/ui";
import { TaskActions } from "@/components/engine/task-actions";
import type { EngineStage, EngineTask } from "@/lib/api-engine";
import {
BOARD_COLUMNS,
columnFor,
groupByColumn,
STAGE_LABELS,
type StageTone,
stageTone,
} from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** The Persian name of a legacy status, for a task the engine does not own. */
const LEGACY_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const LEGACY_TONES: Record<TaskStatus, StageTone> = {
new: "neutral",
triaged: "info",
waiting_approval: "warning",
running: "info",
done: "success",
failed: "danger",
cancelled: "neutral",
};
export interface TaskBoardProps {
orgId: string;
tasks: EngineTask[];
employees: Pick<Employee, "id" | "name">[];
/** False for a viewer: the cards show no controls. */
canRun: boolean;
/** Called after a card's control changed a task, and when a task is created. */
onChanged: () => void;
}
export function TaskBoard({ orgId, tasks, employees, canRun, onChanged }: TaskBoardProps) {
const [items, setItems] = React.useState<EngineTask[]>(tasks);
const [query, setQuery] = React.useState("");
const [assignee, setAssignee] = React.useState("");
const [activeOnly, setActiveOnly] = React.useState(false);
// A fresh list from the page replaces the board's copy.
React.useEffect(() => {
setItems(tasks);
}, [tasks]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.created") {
onChanged();
return;
}
if (event.type !== "task.step") return;
const step = event.data as unknown as TaskStepEventData;
setItems((current) =>
current.map((task) =>
task.id === step.task_id
? { ...task, stage: step.to as EngineStage, status: step.status }
: task,
),
);
});
const needle = query.trim().toLowerCase();
const visible = items.filter((task) => {
if (needle && !task.title.toLowerCase().includes(needle)) return false;
if (assignee === "none" && task.assigned_employee_id !== null) return false;
if (assignee !== "" && assignee !== "none" && task.assigned_employee_id !== assignee) {
return false;
}
if (activeOnly && columnFor(task.stage, task.status) === "done") return false;
return true;
});
const groups = groupByColumn(visible);
const assigneeOptions = [
{ value: "", label: "همه" },
{ value: "none", label: "بدون مسئول" },
...employees.map((employee) => ({ value: employee.id, label: employee.name })),
];
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[12rem] flex-1">
<Field
label="جستجو در عنوان"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="عنوان کار…"
/>
</div>
<div className="w-full sm:w-56">
<Select
label="مسئول"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
options={assigneeOptions}
/>
</div>
<div className="pb-2">
<Checkbox
label="فقط کارهای فعال"
checked={activeOnly}
onChange={(e) => setActiveOnly(e.target.checked)}
/>
</div>
</div>
<div className="flex items-start gap-3 overflow-x-auto pb-3">
{BOARD_COLUMNS.map((column) => {
const cards = groups[column.key];
return (
<section
key={column.key}
aria-label={column.label}
className="flex w-64 shrink-0 flex-col gap-3 rounded-xl border border-surface-border bg-surface-raised/50 p-3"
>
<header className="flex items-center justify-between gap-2">
<h2 className="text-xs font-medium text-slate-300">{column.label}</h2>
<Badge>{cards.length.toLocaleString("fa-IR")}</Badge>
</header>
{cards.length === 0 ? (
<p className="py-6 text-center text-xs text-slate-500">کاری نیست</p>
) : (
cards.map((task) => (
<BoardCard
key={task.id}
orgId={orgId}
task={task}
canRun={canRun}
onChanged={onChanged}
/>
))
)}
</section>
);
})}
</div>
</div>
);
}
function BoardCard({
orgId,
task,
canRun,
onChanged,
}: {
orgId: string;
task: EngineTask;
canRun: boolean;
onChanged: () => void;
}) {
const label =
task.stage === null ? LEGACY_LABELS[task.status] : (STAGE_LABELS[task.stage] ?? task.stage);
const tone = task.stage === null ? LEGACY_TONES[task.status] : stageTone(task.stage);
return (
<Card className="flex flex-col gap-2 p-4">
<div className="flex items-start justify-between gap-2">
<Link
href={`/tasks/${task.id}`}
className="line-clamp-2 min-w-0 text-sm font-medium leading-6 text-slate-100 hover:text-accent"
>
{task.title}
</Link>
<Badge tone={tone} className="shrink-0">
{label}
</Badge>
</div>
<p className="text-xs text-slate-400">{task.assignee_name ?? "بدون مسئول"}</p>
{task.max_cycles > 0 && (
<p className="text-xs text-slate-500">
چرخه {task.cycle.toLocaleString("fa-IR")}/{task.max_cycles.toLocaleString("fa-IR")}
</p>
)}
<TaskActions
orgId={orgId}
task={{ id: task.id, stage: task.stage }}
canRun={canRun}
onChanged={onChanged}
/>
</Card>
);
}
+56 -96
View File
@@ -1,10 +1,8 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Employee, Task, TaskStatus } from "@agy/contracts";
import type { Employee } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
@@ -15,37 +13,39 @@ import {
LoadingState,
Select,
} from "@agy/ui";
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import { ApiError, api } from "@/lib/api";
import { type EngineTask, engineApi } from "@/lib/api-engine";
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const STATUS_LABELS: Record<TaskStatus, string> = {
new: "جدید",
triaged: "دسته‌بندی‌شده",
waiting_approval: "در انتظار تأیید",
running: "در حال اجرا",
done: "انجام‌شده",
failed: "ناموفق",
cancelled: "لغوشده",
};
const STATUS_TONES: Record<TaskStatus, "neutral" | "info" | "success" | "warning" | "danger"> = {
new: "neutral",
triaged: "info",
waiting_approval: "warning",
running: "info",
done: "success",
failed: "danger",
cancelled: "neutral",
};
import { TaskBoard } from "./_components/task-board";
export default function TasksPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tasks, setTasks] = React.useState<Task[]>([]);
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارها ابتدا یک سازمان بسازید."
/>
);
}
return (
<TasksWorkspace
key={activeOrganization.id}
orgId={activeOrganization.id}
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
/>
);
}
function TasksWorkspace({ orgId, canRun }: { orgId: string; canRun: boolean }) {
const engine = useEngineStatus(orgId);
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [filter, setFilter] = React.useState<TaskStatus | "">("");
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
@@ -57,46 +57,45 @@ export default function TasksPage() {
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextTasks, nextEmployees] = await Promise.all([
api.listTasks(orgId, filter || undefined),
engineApi.listTasks(orgId),
api.listEmployees(orgId),
]);
setTasks(nextTasks);
setEmployees(nextEmployees);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, filter]);
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const retry = () => {
setLoading(true);
void load();
};
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setFormError(null);
setCreating(true);
try {
const created = await api.createTask(orgId, {
await api.createTask(orgId, {
title: title.trim(),
description: description.trim(),
assigned_employee_id: assignedTo || null,
});
setTasks((current) => [created, ...current]);
setTitle("");
setDescription("");
setAssignedTo("");
setShowForm(false);
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
} finally {
@@ -104,19 +103,10 @@ export default function TasksPage() {
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن کارها ابتدا یک سازمان بسازید."
/>
);
}
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
return (
<div className="anim-stagger flex flex-col gap-5">
<CreditNotice status={engine.status} />
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارها</h1>
@@ -134,6 +124,7 @@ export default function TasksPage() {
{showForm && (
<Card>
<CardTitle>ساخت کار</CardTitle>
<CardHint>کار جدید ابتدا به مدیر AI سپرده می‌شود.</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field
@@ -174,57 +165,26 @@ export default function TasksPage() {
</Card>
)}
<div className="flex items-center gap-3">
<div className="w-56">
<Select
label="فیلتر وضعیت"
value={filter}
onChange={(e) => setFilter(e.target.value as TaskStatus | "")}
options={[
{ value: "", label: "همه" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
]}
/>
</div>
</div>
{loading ? (
<LoadingState label="در حال دریافت کارها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : tasks.length === 0 ? (
<EmptyState
title="کاری یافت نشد"
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
/>
) : error && tasks.length === 0 ? (
<ErrorState message={error} onRetry={retry} />
) : (
<div className="flex flex-col gap-3">
{tasks.map((task) => {
const employee = employees.find((e) => e.id === task.assigned_employee_id);
return (
<Card key={task.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<p className="truncate text-sm font-medium text-slate-100">{task.title}</p>
{task.description && (
<p className="line-clamp-1 text-xs text-slate-500">{task.description}</p>
)}
<p className="text-xs text-slate-500">
{employee ? `مسئول: ${employee.name}` : "بدون مسئول"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge tone={STATUS_TONES[task.status] ?? "neutral"}>
{STATUS_LABELS[task.status] ?? task.status}
</Badge>
<Link href={`/tasks/${task.id}`}>
<Button variant="secondary" size="sm">
جزئیات
</Button>
</Link>
</div>
</Card>
);
})}
<div className="flex flex-col gap-4">
{error && <ErrorState message={error} onRetry={retry} />}
{tasks.length === 0 && (
<EmptyState
title="کاری یافت نشد"
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
/>
)}
<TaskBoard
orgId={orgId}
tasks={tasks}
employees={employees}
canRun={canRun}
onChanged={load}
/>
</div>
)}
@@ -0,0 +1,136 @@
"use client";
/**
* The organization's newest engine turns, one row per employee turn: the task it belongs to,
* the role and stage it ran at, its result (a failed turn shows its error kind), the tool calls
* it made and how long it took.
*
* The reply text is never shown here: it is the model's own output. The list is loaded again on
* every ``task.step`` of the organization.
*/
import Link from "next/link";
import * as React from "react";
import {
Badge,
Card,
CardHint,
CardTitle,
EmptyState,
Table,
type TableColumn,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineOrgTurn, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
import { useLiveChannel } from "@/lib/realtime";
/** How many of the newest turns the table shows. */
const TURN_LIMIT = 30;
/** The duration of a turn, in the same units as the run list above it. */
function formatDuration(turn: EngineOrgTurn): string {
if (turn.finished_at === null) return "—";
const ms = new Date(turn.finished_at).getTime() - new Date(turn.started_at).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60_000)}m`;
}
const COLUMNS: Array<TableColumn<EngineOrgTurn>> = [
{
key: "task",
header: "کار",
render: (turn) => (
<Link
href={`/tasks/${turn.task_id}`}
className="transition-colors text-xs text-slate-200 hover:text-accent"
>
{turn.task_title || "—"}
</Link>
),
},
{
key: "role",
header: "نقش",
render: (turn) => <span className="text-xs">{ROLE_LABELS[turn.role] ?? turn.role}</span>,
},
{
key: "stage",
header: "مرحله",
render: (turn) => <span className="text-xs">{STAGE_LABELS[turn.stage] ?? turn.stage}</span>,
},
{
key: "result",
header: "نتیجه",
render: (turn) => {
if (turn.finished_at === null) return <Badge tone="info">در جریان</Badge>;
if (turn.ok) return <Badge tone="success">موفق</Badge>;
return (
<div className="flex flex-wrap items-center gap-2">
<Badge tone="danger">ناموفق</Badge>
{turn.error_kind && (
<span className="ltr-inline text-xs text-slate-400">{turn.error_kind}</span>
)}
</div>
);
},
},
{
key: "tools",
header: "ابزارها",
align: "center",
render: (turn) => <span className="text-xs">{turn.tool_calls}</span>,
},
{
key: "duration",
header: "مدت",
render: (turn) => <span className="text-xs text-slate-400">{formatDuration(turn)}</span>,
},
];
export function EngineTurns({ orgId }: { orgId: string }) {
const [turns, setTurns] = React.useState<EngineOrgTurn[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setTurns(await engineApi.listTurns(orgId, TURN_LIMIT));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "نوبت‌های کارمندان دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (event.type === "task.step") void load();
});
return (
<Card>
<CardTitle>نوبت‌های اخیر کارمندان</CardTitle>
<CardHint>
هر نوبت: کارمند با کدام نقش و در کدام مرحله کار کرد و چه نتیجه‌ای داشت. متن پاسخ مدل در این
جدول نمایش داده نمی‌شود.
</CardHint>
<div className="mt-4">
<Table
caption="نوبت‌های اخیر کارمندان"
columns={COLUMNS}
rows={turns}
rowKey={(turn) => turn.id}
loading={loading}
error={error}
onRetry={load}
empty={<EmptyState title="هنوز نوبتی ثبت نشده است." />}
/>
</div>
</Card>
);
}
+3
View File
@@ -34,6 +34,7 @@ import {
import { ApiError, api } from "@/lib/api";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { EngineTurns } from "./_components/engine-turns";
/** How many tasks to fan out into. Bounds the request count. */
const TASK_SCAN_LIMIT = 12;
@@ -427,6 +428,8 @@ export default function TracesPage() {
</Card>
</div>
{orgId && <EngineTurns orgId={orgId} />}
<div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={load}>
بازخوانی
+31 -30
View File
@@ -6,7 +6,6 @@ import type {
EmployeeDetail,
EmployeeStatus,
PromptVersion,
RoleKey,
RoutingChoice,
ToolSpec,
} from "@agy/contracts";
@@ -25,19 +24,10 @@ import {
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { type TeamMember, engineApi } from "@/lib/api-engine";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<RoleKey, string> = {
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
import { roleLabel, tierLabel } from "../_components/team-panel";
const STATUS_LABELS: Record<EmployeeStatus, string> = {
active: "فعال",
@@ -51,10 +41,13 @@ export default function EmployeeDetailPage({
params: Promise<{ id: string }>;
}) {
const { id: employeeId } = React.use(params);
const { activeOrganization } = useSession();
const { activeOrganization, user } = useSession();
const orgId = activeOrganization?.id ?? null;
// Only the platform owner chooses a routing policy. Everyone else sees the employee's tier.
const isOwner = user?.is_platform_owner === true;
const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null);
const [member, setMember] = React.useState<TeamMember | null>(null);
const [choices, setChoices] = React.useState<RoutingChoice[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
@@ -82,14 +75,17 @@ export default function EmployeeDetailPage({
setLoading(true);
setError(null);
try {
const [nextEmployee, nextChoices, nextTools] = await Promise.all([
const [nextEmployee, nextTools, nextChoices, team] = await Promise.all([
api.getEmployee(orgId, employeeId),
api.listRoutingChoices(orgId),
api.listTools(orgId),
// The routing choices belong to the owner's settings, so no one else asks for them.
isOwner ? api.listRoutingChoices(orgId) : Promise.resolve<RoutingChoice[]>([]),
engineApi.getTeam(orgId),
]);
setEmployee(nextEmployee);
setChoices(nextChoices);
setTools(nextTools);
setMember(team.employees.find((entry) => entry.id === employeeId) ?? null);
setName(nextEmployee.name);
setStatus(nextEmployee.status);
setRoutingPolicyId(nextEmployee.routing_policy_id ?? "");
@@ -98,7 +94,7 @@ export default function EmployeeDetailPage({
} finally {
setLoading(false);
}
}, [orgId, employeeId]);
}, [orgId, employeeId, isOwner]);
const onToggleTool = async (toolKey: string, allowed: boolean) => {
if (!orgId) return;
@@ -132,7 +128,8 @@ export default function EmployeeDetailPage({
await api.updateEmployee(orgId, employeeId, {
name: name.trim(),
status,
routing_policy_id: routingPolicyId || null,
// Only the owner sends a routing policy; for anyone else the field is left out.
...(isOwner ? { routing_policy_id: routingPolicyId || null } : {}),
});
setSaved(true);
window.setTimeout(() => setSaved(false), 1800);
@@ -203,7 +200,8 @@ export default function EmployeeDetailPage({
<header className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{employee.name}</h1>
<Badge tone="info">{ROLE_LABELS[employee.role_key]}</Badge>
<Badge tone="info">{roleLabel(employee.role_key)}</Badge>
{member && <Badge tone="neutral">سطح: {tierLabel(member.tier)}</Badge>}
<Badge tone={employee.status === "active" ? "success" : "neutral"}>
{STATUS_LABELS[employee.status]}
</Badge>
@@ -218,8 +216,9 @@ export default function EmployeeDetailPage({
<Card>
<CardTitle>تنظیمات کارمند</CardTitle>
<CardHint>
«سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار
سامانه است و نمایش داده نمی‌شود.
{isOwner
? "«سیاست کاری» تعیین می‌کند کارمند با چه سبکی کار کند. انتخاب مدل و ارائه‌دهنده در اختیار سامانه است و نمایش داده نمی‌شود."
: "سطح کارمند نشان می‌دهد در کدام رده کار می‌کند. انتخاب مدل و ارائه‌دهنده در اختیار سامانه است و نمایش داده نمی‌شود."}
</CardHint>
<form onSubmit={onSave} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
@@ -241,16 +240,18 @@ export default function EmployeeDetailPage({
label,
}))}
/>
<Select
label="سیاست کاری"
value={routingPolicyId}
onChange={(e) => setRoutingPolicyId(e.target.value)}
disabled={!canManage}
options={[
{ value: "", label: "پیش‌فرض سازمان" },
...choices.map((c) => ({ value: c.id, label: c.name })),
]}
/>
{isOwner && (
<Select
label="سیاست کاری"
value={routingPolicyId}
onChange={(e) => setRoutingPolicyId(e.target.value)}
disabled={!canManage}
options={[
{ value: "", label: "پیش‌فرض سازمان" },
...choices.map((c) => ({ value: c.id, label: c.name })),
]}
/>
)}
</div>
{canManage && (
<div className="flex items-center justify-end gap-3">
@@ -0,0 +1,143 @@
"use client";
/**
* Hire one employee: a role, a tier from the tiers the organization has been granted, and an
* optional name. The platform picks the model behind the tier, so the dialog never names one.
* Hiring is disabled when no tier is granted, or when the team is at the owner's cap.
*/
import * as React from "react";
import { Button, Field, Modal, Select } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type TierOption, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS } from "@/lib/engine-labels";
/** The roles a team can be given from this dialog (the engine's hirable roles). */
type HirableRole = "manager" | "planner" | "developer" | "reviewer" | "tester";
const HIRABLE_ROLES: readonly HirableRole[] = [
"manager",
"planner",
"developer",
"reviewer",
"tester",
];
const DEFAULT_ROLE: HirableRole = "developer";
export const NO_TIER_HINT = "مالک سامانه هنوز مدلی برای این سازمان تخصیص نداده است.";
export const AT_CAP_HINT = "سقف تعداد کارمندان سازمان پر است.";
export interface HireDialogProps {
orgId: string;
/** The tiers the organization has been granted, from the team view. */
tiers: TierOption[];
/** True once the team holds the owner's cap: nobody more can be hired. */
atCap: boolean;
/** Called after a hire, so the page reloads the team. */
onHired: () => void;
}
export function HireDialog({ orgId, tiers, atCap, onHired }: HireDialogProps) {
const [open, setOpen] = React.useState(false);
const [role, setRole] = React.useState<HirableRole>(DEFAULT_ROLE);
const [tierKey, setTierKey] = React.useState("");
const [name, setName] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// The chosen tier, or the first granted one while no choice (or a tier no longer granted) is set.
const tier = tiers.find((option) => option.tier === tierKey) ?? tiers[0] ?? null;
const noTier = tiers.length === 0;
const disabled = noTier || atCap;
const hint = noTier ? NO_TIER_HINT : atCap ? AT_CAP_HINT : null;
const openDialog = () => {
setError(null);
setOpen(true);
};
const closeDialog = () => {
if (busy) return;
setOpen(false);
setError(null);
};
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (tier === null || disabled) return;
setBusy(true);
setError(null);
const trimmed = name.trim();
try {
await engineApi.hire(orgId, {
role,
tier: tier.tier,
...(trimmed ? { name: trimmed } : {}),
});
setOpen(false);
setName("");
onHired();
} catch (err) {
setError(err instanceof ApiError ? err.message : "استخدام کارمند انجام نشد.");
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<Button onClick={openDialog} disabled={disabled}>
کارمند جدید
</Button>
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
<Modal
open={open}
title="استخدام کارمند جدید"
description="نقش و سطح را انتخاب کنید. نام اختیاری است."
onClose={closeDialog}
size="sm"
>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Select
label="نقش"
value={role}
disabled={busy}
onChange={(e) => setRole(e.target.value as HirableRole)}
options={HIRABLE_ROLES.map((value) => ({ value, label: ROLE_LABELS[value] }))}
/>
<Select
label="سطح"
value={tier?.tier ?? ""}
disabled={busy || noTier}
onChange={(e) => setTierKey(e.target.value)}
options={tiers.map((option) => ({ value: option.tier, label: option.label }))}
/>
<Field
label="نام (اختیاری)"
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
placeholder="اگر خالی بماند، نام پیش‌فرض نقش داده می‌شود"
/>
{hint && <p className="text-xs text-warning">{hint}</p>}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closeDialog} disabled={busy}>
انصراف
</Button>
<Button type="submit" size="sm" loading={busy} disabled={disabled || tier === null}>
استخدام
</Button>
</div>
</form>
</Modal>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
/**
* The organization's AI team: one row per member, with its role, its tier, its status and the
* task it is busy with. A member is shown by its neutral tier only; no model or provider name is
* ever rendered. Firing a member archives it, and asks first in a confirm dialog.
*/
import Link from "next/link";
import * as React from "react";
import type { EmployeeStatus } from "@agy/contracts";
import { Badge, Button, ConfirmDialog, EmptyState } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type TeamMember, type TeamView, engineApi } from "@/lib/api-engine";
import { ROLE_LABELS, TIER_LABELS } from "@/lib/engine-labels";
/** Roles a team can hold that the hire form does not offer; they keep their own labels. */
const OTHER_ROLE_LABELS: Record<string, string> = {
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_LABELS: Record<EmployeeStatus, string> = {
active: "فعال",
paused: "متوقف",
archived: "بایگانی",
};
const STATUS_TONES: Record<EmployeeStatus, "success" | "warning" | "neutral"> = {
active: "success",
paused: "warning",
archived: "neutral",
};
/** The Persian name of a role. A role without a label shows its key rather than nothing. */
export function roleLabel(roleKey: string): string {
return ROLE_LABELS[roleKey] ?? OTHER_ROLE_LABELS[roleKey] ?? roleKey;
}
/** The neutral name of a tier. ``unknown`` shows while no granted model backs a member. */
export function tierLabel(tier: string): string {
return TIER_LABELS[tier] ?? TIER_LABELS.unknown;
}
export interface TeamPanelProps {
orgId: string;
team: TeamView;
/** False for anyone who may not manage employees: they see the team but cannot fire from it. */
canManage: boolean;
/** Called after a member was fired, so the page reloads the team. */
onChanged: () => void;
}
export function TeamPanel({ orgId, team, canManage, onChanged }: TeamPanelProps) {
const [firing, setFiring] = React.useState<TeamMember | null>(null);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const confirmFire = async () => {
if (firing === null) return;
setBusy(true);
setError(null);
try {
await engineApi.fire(orgId, firing.id);
setFiring(null);
onChanged();
} catch (err) {
setFiring(null);
setError(err instanceof ApiError ? err.message : "حذف کارمند از تیم انجام نشد.");
} finally {
setBusy(false);
}
};
return (
<section aria-label="تیم هوش مصنوعی" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium text-slate-200">اعضای تیم</h2>
<Badge tone="info">
{team.count.toLocaleString("fa-IR")} از {team.max_employees.toLocaleString("fa-IR")} نفر
</Badge>
</div>
{team.employees.length === 0 ? (
<EmptyState
title="هنوز کارمندی در تیم نیست"
description={
canManage
? "با «کارمند جدید» اولین کارمند را استخدام کنید."
: "مدیر سازمان می‌تواند کارمند استخدام کند."
}
/>
) : (
<ul className="flex flex-col gap-2">
{team.employees.map((member) => (
<li
key={member.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-surface-border bg-surface-raised px-4 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<Link
href={`/workforce/${member.id}`}
className="truncate text-sm font-medium text-slate-100 transition-colors hover:text-accent"
>
{member.name}
</Link>
<p className="flex flex-wrap items-center gap-x-2 text-xs text-slate-400">
<span>{roleLabel(member.role_key)}</span>
<span aria-hidden>·</span>
<span>سطح: {tierLabel(member.tier)}</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge tone={STATUS_TONES[member.status]}>{STATUS_LABELS[member.status]}</Badge>
{member.busy_task_id ? (
<Link
href={`/tasks/${member.busy_task_id}`}
className="max-w-xs truncate text-xs text-accent hover:underline"
>
{member.busy_task_title ?? "کار جاری"}
</Link>
) : (
<span className="text-xs text-slate-500">بیکار</span>
)}
{canManage && (
<Button variant="ghost" size="sm" onClick={() => setFiring(member)}>
حذف از تیم
</Button>
)}
</div>
</li>
))}
</ul>
)}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<ConfirmDialog
open={firing !== null}
title={firing === null ? "" : `«${firing.name}» از تیم حذف شود؟`}
description="کارمند بایگانی می‌شود و دیگر کاری به او داده نمی‌شود. آخرین مدیر سازمان را نمی‌توان حذف کرد."
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
onConfirm={() => void confirmFire()}
onCancel={() => setFiring(null)}
/>
</section>
);
}
+38 -214
View File
@@ -1,144 +1,48 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type {
AgentTemplate,
Employee,
EmployeeDetail,
RoleKey,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { EmptyState, ErrorState, LoadingState } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type TeamView, engineApi } from "@/lib/api-engine";
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<RoleKey, string> = {
manager: "مدیر",
planner: "تحلیل‌گر",
developer: "برنامه‌نویس",
reviewer: "بازبین کد",
tester: "تستر",
researcher: "محقق",
content_creator: "تولیدکننده محتوا",
support: "پشتیبان",
};
const STATUS_TONES = {
active: "success",
paused: "warning",
archived: "neutral",
} as const;
/**
* Above this size we stop enriching cards with per-employee detail, because the
* enrichment costs one request per employee. The page stays usable either way.
*/
const ENRICH_LIMIT = 50;
import { HireDialog } from "./_components/hire-dialog";
import { TeamPanel } from "./_components/team-panel";
export default function WorkforcePage() {
const { activeOrganization } = useSession();
const [employees, setEmployees] = React.useState<Employee[]>([]);
const [details, setDetails] = React.useState<Record<string, EmployeeDetail>>({});
const [templates, setTemplates] = React.useState<AgentTemplate[]>([]);
const orgId = activeOrganization?.id ?? null;
const [team, setTeam] = React.useState<TeamView | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showForm, setShowForm] = React.useState(false);
const [name, setName] = React.useState("");
const [templateId, setTemplateId] = React.useState("");
const [roleKey, setRoleKey] = React.useState<RoleKey>("developer");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const orgId = activeOrganization?.id ?? null;
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextEmployees, nextTemplates] = await Promise.all([
api.listEmployees(orgId),
api.listAgentTemplates(orgId),
]);
setEmployees(nextEmployees);
setTemplates(nextTemplates);
if (nextEmployees.length > 0 && nextEmployees.length <= ENRICH_LIMIT) {
const enriched = await Promise.all(
nextEmployees.map(async (employee) => {
try {
return [employee.id, await api.getEmployee(orgId, employee.id)] as const;
} catch {
// A single failed enrichment must not blank the whole page.
return null;
}
}),
);
setDetails(
Object.fromEntries(
enriched.filter((entry): entry is readonly [string, EmployeeDetail] => entry !== null),
),
);
} else {
setDetails({});
}
setTeam(await engineApi.getTeam(orgId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود.");
setError(err instanceof ApiError ? err.message : "دریافت تیم ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
setLoading(true);
setTeam(null);
void load();
}, [load]);
const onTemplateChange = (value: string) => {
setTemplateId(value);
const template = templates.find((t) => t.id === value);
if (template) {
setRoleKey(template.role_key);
if (!name.trim()) setName(template.name);
}
};
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setFormError(null);
setCreating(true);
try {
await api.createEmployee(orgId, {
name: name.trim(),
role_key: roleKey,
template_id: templateId || null,
});
setShowForm(false);
setName("");
setTemplateId("");
await load();
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت کارمند ناموفق بود.");
} finally {
setCreating(false);
}
};
// A hire or a firing by anyone in the organization reloads the team in place.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if (event.type === "team.changed") void load();
});
if (!activeOrganization) {
return (
@@ -149,121 +53,41 @@ export default function WorkforcePage() {
);
}
if (loading) return <LoadingState label="در حال دریافت تیم…" />;
if (team === null) {
return <ErrorState message={error ?? "دریافت تیم ناموفق بود."} onRetry={load} />;
}
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
const atCap = team.count >= team.max_employees;
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex items-start justify-between gap-3">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
<p className="text-sm text-slate-400">
هر کارمند یک نقش، یک پرامپت نسخه‌بندی‌شده و یک سیاست مسیریابی دارد. جزئیات مدل هرگز
نمایش داده نمی‌شود.
هر کارمند یک نقش و یک سطح دارد. جزئیات فنی هرگز نمایش داده نمی‌شود.
</p>
</div>
{canManage && (
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}>
{showForm ? "بستن" : "کارمند جدید"}
</Button>
<HireDialog
orgId={activeOrganization.id}
tiers={team.tiers}
atCap={atCap}
onHired={load}
/>
)}
</header>
{showForm && (
<Card>
<CardTitle>ساخت کارمند</CardTitle>
<CardHint>
با انتخاب یک قالب، پرامپت نسخهٔ ۱ و دسترسی ابزارها به‌صورت خودکار ساخته می‌شود.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Select
label="قالب"
value={templateId}
onChange={(e) => onTemplateChange(e.target.value)}
options={[
{ value: "", label: "بدون قالب (سفارشی)" },
...templates.map((t) => ({ value: t.id, label: t.name })),
]}
/>
<Field
label="نام کارمند"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً برنامه‌نویس ارشد"
/>
<Select
label="نقش"
value={roleKey}
onChange={(e) => setRoleKey(e.target.value as RoleKey)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<div className="flex justify-end">
<Button type="submit" loading={creating}>
ساخت کارمند
</Button>
</div>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت کارمند ناموفق" message={formError} />
</div>
)}
</Card>
)}
{error && <ErrorState title="به‌روزرسانی تیم ناموفق بود" message={error} />}
{loading ? (
<LoadingState label="در حال دریافت کارمندها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : employees.length === 0 ? (
<EmptyState
title="هنوز کارمندی ساخته نشده است"
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{employees.map((employee) => {
const detail = details[employee.id];
return (
<Card key={employee.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{employee.name}</p>
<p className="text-xs text-slate-500">{ROLE_LABELS[employee.role_key]}</p>
</div>
<Badge tone={STATUS_TONES[employee.status] ?? "neutral"}>
{employee.status}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<Badge tone="neutral">
کارهای باز: {detail ? detail.open_task_count : "—"}
</Badge>
<Badge tone="neutral">
نسخهٔ پرامپت: {detail ? detail.prompt_versions.length : "—"}
</Badge>
<Badge tone="neutral">
ابزارها: {detail ? detail.tool_permissions.filter((p) => p.allowed).length : "—"}
</Badge>
</div>
<Link href={`/workforce/${employee.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
تنظیمات و جزئیات
</Button>
</Link>
</Card>
);
})}
</div>
)}
<TeamPanel
orgId={activeOrganization.id}
team={team}
canManage={canManage}
onChanged={load}
/>
</div>
);
}
+34
View File
@@ -21,6 +21,7 @@ import {
Select,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { engineApi } from "@/lib/api-engine";
import { setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session";
@@ -369,6 +370,7 @@ export default function OnboardingPage() {
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
حالا می‌توانید تیم هوش‌مصنوعی خود را راه‌بیندازید.
</CardHint>
<WizardCreditHint orgId={result.organization.id} />
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/manager")}>
برو به مدیر AI
@@ -440,6 +442,38 @@ export default function OnboardingPage() {
);
}
/**
* The credit line on the success screen. Work waits for credit when the engine reports none, and
* also when the status cannot be read, so the owner's action is named either way. Nothing shows
* while the status loads or once credit is there.
*/
function WizardCreditHint({ orgId }: { orgId: string }) {
const [creditOk, setCreditOk] = React.useState<boolean | null>(null);
React.useEffect(() => {
let current = true;
const check = async () => {
try {
const status = await engineApi.status(orgId);
if (current) setCreditOk(status.credit_ok);
} catch {
if (current) setCreditOk(false);
}
};
void check();
return () => {
current = false;
};
}, [orgId]);
if (creditOk !== false) return null;
return (
<p className="mt-3 text-xs leading-6 text-warning">
کار کارمندان پس از افزودن اعتبار توسط مالک سامانه آغاز می‌شود.
</p>
);
}
// ---------------------------------------------------------------------------
// Stepper
// ---------------------------------------------------------------------------
@@ -0,0 +1,69 @@
"use client";
/**
* The engine's credit state for one organization, and the notice that work has stopped.
*
* ``useEngineStatus`` loads ``GET /engine/status`` and loads it again when the balance, a
* task's stage or the team changes, so the notice and the counts follow the live feed.
* ``CreditNotice`` says, in Persian, why work is stopped; it shows nothing while credit is
* available or while the status is still loading.
*/
import * as React from "react";
import { Card, CardTitle } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineStatus, engineApi } from "@/lib/api-engine";
import { useLiveChannel } from "@/lib/realtime";
/** Live events that change the credit state or the team the status describes. */
const RELOAD_EVENTS: ReadonlySet<string> = new Set(["billing.wallet", "task.step", "team.changed"]);
export interface EngineStatusState {
status: EngineStatus | null;
loading: boolean;
error: string | null;
reload: () => void;
}
export function useEngineStatus(orgId: string): EngineStatusState {
const [status, setStatus] = React.useState<EngineStatus | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
try {
setStatus(await engineApi.status(orgId));
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "وضعیت موتور دریافت نشد.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
useLiveChannel([`org:${orgId}`], (event) => {
if (RELOAD_EVENTS.has(event.type)) void load();
});
return { status, loading, error, reload: load };
}
export function CreditNotice({ status }: { status: EngineStatus | null }) {
if (status === null || status.credit_ok) return null;
return (
<Card role="status" className="border-warning/50">
<CardTitle>اعتبار سازمان به پایان رسیده است</CardTitle>
<p className="text-xs leading-6 text-slate-300">
کار کارمندان هوش مصنوعی تا افزودن اعتبار توسط مالک سامانه متوقف است. پس از افزودن اعتبار،
کارهای متوقف‌شده را از صفحهٔ وظایف ادامه دهید.
</p>
<p className="mt-2 text-xs text-slate-400">
موجودی: {status.balance.toLocaleString("fa-IR")}{" "}
<span className="ltr-inline">{status.currency}</span>
</p>
</Card>
);
}
@@ -0,0 +1,126 @@
"use client";
/**
* The pause, resume and cancel controls of one engine task.
*
* Each control asks first, in a confirm dialog, and then calls the engine API. A refusal from
* the server (for example a resume while the organization has no credit) is shown in Persian
* under the buttons, with the server's own message. A legacy task has no engine stage, so it
* shows nothing; neither does anyone without the right to run tasks.
*/
import * as React from "react";
import { Button, ConfirmDialog } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { type EngineStage, engineApi } from "@/lib/api-engine";
type Action = "pause" | "resume" | "cancel";
/** A finished task takes no action at all. */
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
interface ActionCopy {
button: string;
title: string;
description: string;
confirm: string;
}
const COPY: Record<Action, ActionCopy> = {
pause: {
button: "توقف",
title: "کار متوقف شود؟",
description: "کار پیش از نوبت بعدی متوقف می‌شود و با «ادامه» از همان مرحله برمی‌گردد.",
confirm: "توقف کن",
},
resume: {
button: "ادامه",
title: "کار ادامه یابد؟",
description: "کار به بررسی مدیر برمی‌گردد. اگر اعتبار سازمان تمام شده باشد، ادامه ممکن نیست.",
confirm: "ادامه بده",
},
cancel: {
button: "لغو",
title: "کار لغو شود؟",
description:
"کار بسته می‌شود و کارهایی که به آن وابسته‌اند به تصمیم شما منتقل می‌شوند. این کار برگشت‌پذیر نیست.",
confirm: "لغو کن",
},
};
export interface TaskActionsProps {
orgId: string;
task: { id: string; stage: EngineStage | null };
/** False for a viewer, or for anyone who may not run tasks. */
canRun: boolean;
/** Called after a control succeeds, so the page reloads the task. */
onChanged: () => void;
size?: "sm" | "md";
}
export function TaskActions({ orgId, task, canRun, onChanged, size = "sm" }: TaskActionsProps) {
const [pending, setPending] = React.useState<Action | null>(null);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
if (!canRun || task.stage === null) return null;
const stage: string = task.stage;
const finished = FINISHED.has(stage);
const paused = stage === "paused";
const actions: Action[] = [];
if (!finished && !paused) actions.push("pause");
if (paused) actions.push("resume");
if (!finished) actions.push("cancel");
const confirm = async () => {
if (pending === null) return;
setBusy(true);
setError(null);
try {
if (pending === "pause") await engineApi.pauseTask(orgId, task.id);
else if (pending === "resume") await engineApi.resumeTask(orgId, task.id);
else await engineApi.cancelTask(orgId, task.id);
setPending(null);
onChanged();
} catch (err) {
setPending(null);
setError(err instanceof ApiError ? err.message : "این کار انجام نشد.");
} finally {
setBusy(false);
}
};
const current = pending === null ? null : COPY[pending];
return (
<div dir="rtl" className="flex flex-wrap items-center gap-2">
{actions.map((action) => (
<Button
key={action}
size={size}
variant={action === "cancel" ? "danger" : "secondary"}
disabled={busy}
onClick={() => {
setError(null);
setPending(action);
}}
>
{COPY[action].button}
</Button>
))}
{error && (
<p role="alert" className="w-full text-xs text-danger">
{error}
</p>
)}
<ConfirmDialog
open={current !== null}
title={current?.title ?? ""}
description={current?.description}
confirmLabel={busy ? "در حال انجام…" : (current?.confirm ?? "تأیید")}
onConfirm={confirm}
onCancel={() => setPending(null)}
/>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Typed client for the platform owner's credit console (prepaid billing, D-B).
*
* Mirrors the owner routes in ``app/interfaces/http/routers/admin_credits.py`` key for key.
* Owner-only on the server: a member never calls these and reads its own balance through
* the billing and engine status routes. No type here names a model or a provider.
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/
import { apiRequest } from "./api";
/** One organization's wallet as the platform owner sees it. */
export interface OrgBalance {
organization_id: string;
name: string;
slug: string;
balance: number;
currency: string;
/** Tasks the engine has paused for this organization. */
paused_tasks: number;
/** When credit was last added by hand, or null when it never was. */
last_credit_at: string | null;
}
/** One credit history row: a manual credit, the starting credit or an adjustment. */
export interface CreditTransaction {
id: string;
kind: string;
amount: number;
balance_after: number;
reason: string;
created_at: string;
}
/** The body of a credit. The server checks the amount range and the reason length. */
export interface CreditRequest {
amount: number;
reason: string;
}
function orgBase(orgId: string): string {
return `/api/admin/orgs/${encodeURIComponent(orgId)}`;
}
export const creditApi = {
/** Every organization's balance, paused task count and last credit date. */
balances: () => apiRequest<OrgBalance[]>("/api/admin/credits"),
/** Add credit to one organization's wallet; returns the history row it wrote. */
credit: (orgId: string, body: CreditRequest) =>
apiRequest<CreditTransaction>(`${orgBase(orgId)}/credit`, {
method: "POST",
body: { amount: body.amount, reason: body.reason },
}),
/** One organization's manual credit history, newest first; ``limit`` only when given. */
history: (orgId: string, limit?: number) =>
apiRequest<CreditTransaction[]>(
`${orgBase(orgId)}/credit-history${limit === undefined ? "" : `?limit=${limit}`}`,
),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
+195 -2
View File
@@ -1,10 +1,203 @@
/**
* Typed client functions for the AI-workforce workstream (docs/plan/PLAN-v2.md §6).
* Typed client functions for the AI-workforce engine (docs/plan/PLAN-v2.md §6).
*
* Mirrors the engine routes in ``app/interfaces/http/routers/engine.py`` key for key.
* No type here names a model or a provider: a team member shows a neutral tier only.
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/
import type { EmployeeStatus, RoleKey, RunStatus, TaskStatus } from "@agy/contracts";
import { apiRequest } from "./api";
/** The thirteen stages a task can be at, in the engine's own names. */
export type EngineStage =
| "pending_manager_review"
| "pending_analysis"
| "pending_implementation"
| "pending_review"
| "pending_test"
| "pending_final_review"
| "merge_request"
| "completed"
| "pending_user_decision"
| "error_review"
| "paused"
| "cancelled"
| "failed";
/** A tier the organization may be granted, cheapest first. Never a model name. */
export type GrantedTier = "fast" | "balanced" | "strong";
/** A member's tier; ``unknown`` while no granted model backs the member. */
export type EngineTier = GrantedTier | "unknown";
/** ``GET /status``: the credit, the owner's policy, the team size and the stage counts. */
export interface EngineStatus {
balance: number;
currency: string;
credit_ok: boolean;
automation_enabled: boolean;
max_employees: number;
team_size: number;
stage_counts: Record<string, number>;
}
/** One task on the board. ``stage`` is null for a legacy task the engine does not own. */
export interface EngineTask {
id: string;
title: string;
status: TaskStatus;
stage: EngineStage | null;
pipeline: string | null;
kind: string | null;
/** 0 for a legacy task. */
cycle: number;
/** 0 for a legacy task. */
max_cycles: number;
thread_id: string | null;
assigned_employee_id: string | null;
assignee_name: string | null;
created_at: string;
updated_at: string;
}
/** One employee turn: who ran, in which role and stage, and how it ended. */
export interface EngineTurn {
id: string;
run_id: string;
role: RoleKey;
mode: string;
stage: EngineStage;
employee_name: string | null;
ok: boolean;
error_kind: string;
tool_calls: number;
/** At most 1,000 characters of the reply. */
reply_excerpt: string;
started_at: string;
finished_at: string | null;
}
/** A turn in the organization's recent list, with the task it belongs to. */
export interface EngineOrgTurn extends EngineTurn {
task_id: string;
task_title: string;
}
/** One gate verdict on a task. */
export interface EngineGate {
id: string;
gate: string;
ok: boolean;
detail: string;
created_at: string;
}
/** One run of a task. */
export interface EngineRun {
id: string;
status: RunStatus;
started_at: string | null;
finished_at: string | null;
}
/** ``GET /tasks/{id}``: the task with its pipeline, turns, gates, runs and last diff. */
export interface EngineTaskDetail extends EngineTask {
description: string;
/** The stages of the task's pipeline in order; empty for a legacy task. */
stages: EngineStage[];
waiver_reason: string;
branch: string;
depends_on: string[];
merged_sha: string;
diff: string;
turns: EngineTurn[];
gates: EngineGate[];
runs: EngineRun[];
}
/** One team member. */
export interface TeamMember {
id: string;
name: string;
role_key: RoleKey;
status: EmployeeStatus;
tier: EngineTier;
busy_task_id: string | null;
busy_task_title: string | null;
hired_by: string;
}
/** A tier the organization has granted models for, with its Persian label. */
export interface TierOption {
tier: GrantedTier;
label: string;
}
/** ``GET /team``: the members (not archived), the tiers, the owner's cap and the headcount. */
export interface TeamView {
employees: TeamMember[];
tiers: TierOption[];
max_employees: number;
count: number;
}
/** A hire asks for a role and a tier. The platform picks the model. */
export interface HireRequest {
role: RoleKey;
tier: GrantedTier;
name?: string;
}
function base(orgId: string): string {
return `/api/organizations/${orgId}/engine`;
}
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
export const engineApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
/** Credit, policy, team size and stage counts. Every member may read it. */
status: (orgId: string) => apiRequest<EngineStatus>(`${base(orgId)}/status`),
/** The organization's tasks, newest first; ``stage`` narrows the board to one stage. */
listTasks: (orgId: string, stage?: EngineStage) =>
apiRequest<EngineTask[]>(`${base(orgId)}/tasks${query({ stage })}`),
getTask: (orgId: string, taskId: string) =>
apiRequest<EngineTaskDetail>(`${base(orgId)}/tasks/${taskId}`),
/** The organization's newest turns, across its tasks. */
listTurns: (orgId: string, limit?: number) =>
apiRequest<EngineOrgTurn[]>(`${base(orgId)}/turns${query({ limit })}`),
pauseTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/pause`, { method: "POST" }),
resumeTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/resume`, { method: "POST" }),
cancelTask: (orgId: string, taskId: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/cancel`, { method: "POST" }),
/** Answer the question a task is waiting on (only at ``pending_user_decision``). */
answerTask: (orgId: string, taskId: string, content: string) =>
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/answer`, {
method: "POST",
body: { content },
}),
getTeam: (orgId: string) => apiRequest<TeamView>(`${base(orgId)}/team`),
hire: (orgId: string, body: HireRequest) =>
apiRequest<TeamMember>(`${base(orgId)}/team/hire`, { method: "POST", body }),
fire: (orgId: string, employeeId: string) =>
apiRequest<TeamMember>(`${base(orgId)}/team/${employeeId}/fire`, { method: "POST" }),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
+4 -4
View File
@@ -18,6 +18,7 @@ import type {
SetupDraft,
WizardCompleteBody,
WizardCompleteResult,
WsEventType,
} from "@agy/contracts";
import { apiRequest } from "./api";
@@ -303,11 +304,10 @@ export const memoryApi = {
/**
* The event the API publishes on ``org:<id>`` whenever memory changes.
*
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
* frozen, foundation-owned list, so a page compares the raw string instead of
* widening the union for one new type.
* The type itself is part of ``WsEventType`` in ``@agy/contracts``; this
* constant only names it, so a page compares against a literal the union checks.
*/
export const MEMORY_CHANGED_EVENT = "memory.changed";
export const MEMORY_CHANGED_EVENT = "memory.changed" as const satisfies WsEventType;
/* -------------------------------------------------------------------------- */
/* MCP tokens (org admin) + the owner's switches */
+125
View File
@@ -0,0 +1,125 @@
/**
* Persian labels, badge tones and board columns for the AI-workforce engine.
*
* Pure data and pure functions: no React and no transport. The stage names are the
* engine's own (``app/domain/workforce/pipeline.py``). A legacy task has no engine
* stage, so it is placed on the board by its coarse status instead. A tier is always
* neutral: no label here names a model or a provider.
*/
import type { EngineStage } from "./api-engine";
/** The tone of a badge: the ``Badge`` tones of ``@agy/ui``. */
export type StageTone = "neutral" | "info" | "success" | "warning" | "danger";
/** Persian label for each of the thirteen stages. */
export const STAGE_LABELS: Record<string, string> = {
pending_manager_review: "در انتظار بررسی مدیر",
pending_analysis: "در انتظار تحلیل",
pending_implementation: "در انتظار پیاده‌سازی",
pending_review: "در انتظار بازبینی",
pending_test: "در انتظار تست",
pending_final_review: "در انتظار بازبینی نهایی",
merge_request: "در انتظار ادغام",
completed: "انجام شد",
pending_user_decision: "نیازمند تصمیم شما",
error_review: "بررسی خطا",
paused: "متوقف شده",
cancelled: "لغو شد",
failed: "ناموفق",
};
/** Green when the task is done, red when it ended badly, amber when a person must act. */
export function stageTone(stage: string): StageTone {
if (stage === "completed") return "success";
if (stage === "failed" || stage === "cancelled") return "danger";
if (stage === "paused" || stage === "pending_user_decision" || stage === "error_review") {
return "warning";
}
return "info";
}
export type BoardColumnKey =
| "manager_review"
| "analysis"
| "implementation"
| "review"
| "test"
| "merge"
| "done"
| "decision";
export interface BoardColumn {
key: BoardColumnKey;
label: string;
/** The engine stages that land in this column. */
stages: readonly EngineStage[];
}
/** The board's columns, left to right. Every stage belongs to exactly one column. */
export const BOARD_COLUMNS: readonly BoardColumn[] = [
{ key: "manager_review", label: "بررسی مدیر", stages: ["pending_manager_review"] },
{ key: "analysis", label: "تحلیل", stages: ["pending_analysis"] },
{ key: "implementation", label: "پیاده‌سازی", stages: ["pending_implementation"] },
{ key: "review", label: "بازبینی", stages: ["pending_review", "pending_final_review"] },
{ key: "test", label: "تست", stages: ["pending_test"] },
{ key: "merge", label: "ادغام", stages: ["merge_request"] },
{ key: "done", label: "انجام‌شده", stages: ["completed", "cancelled", "failed"] },
{
key: "decision",
label: "نیازمند تصمیم",
stages: ["pending_user_decision", "error_review", "paused"],
},
];
const STAGE_COLUMN: Record<string, BoardColumnKey | undefined> = Object.fromEntries(
BOARD_COLUMNS.flatMap((column) => column.stages.map((stage) => [stage, column.key])),
);
/**
* Where a legacy task (no engine stage) sits, by its coarse status. ``triaged`` is a task
* the manager has already assigned, so it waits for work rather than for a review.
*/
const LEGACY_COLUMN: Record<string, BoardColumnKey | undefined> = {
new: "manager_review",
triaged: "implementation",
running: "implementation",
waiting_approval: "decision",
done: "done",
failed: "done",
cancelled: "done",
};
/**
* The column a task belongs to. An unknown stage or status goes to the decision column,
* so that a task the board does not recognise is still visible to a person.
*/
export function columnFor(stage: string | null, status: string): BoardColumnKey {
if (stage !== null) return STAGE_COLUMN[stage] ?? "decision";
return LEGACY_COLUMN[status] ?? "decision";
}
/** Tasks grouped by column, every column present, each column in the order it was given. */
export function groupByColumn<T extends { stage: string | null; status: string }>(
tasks: readonly T[],
): Record<BoardColumnKey, T[]> {
const groups = {} as Record<BoardColumnKey, T[]>;
for (const column of BOARD_COLUMNS) groups[column.key] = [];
for (const task of tasks) groups[columnFor(task.stage, task.status)].push(task);
return groups;
}
/** Neutral tier labels. ``unknown`` shows when no granted model backs a member. */
export const TIER_LABELS: Record<string, string> = {
fast: "سریع",
balanced: "متعادل",
strong: "قوی",
unknown: "نامشخص",
};
export const ROLE_LABELS: Record<string, string> = {
manager: "مدیر",
planner: "برنامه‌ریز",
developer: "برنامه‌نویس",
reviewer: "بازبین",
tester: "آزمونگر",
};
+1
View File
@@ -39,6 +39,7 @@ export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/rate-cards", label: "تعرفه‌ها", hint: "قیمت واحدها" },
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
{ href: "/admin/organizations", label: "سازمان‌ها", hint: "مستأجرها" },
{ href: "/admin/credits", label: "اعتبار سازمان‌ها", hint: "شارژ پیش‌پرداخت" },
{ href: "/admin/model-grants", label: "مدل‌های سازمان‌ها", hint: "تخصیص مدل به هر سازمان" },
{ href: "/admin/domains", label: "دامنه‌ها", hint: "مسیریابی Caddy" },
{ href: "/admin/sandbox", label: "سندباکس‌ها", hint: "کانتینرها و محدودیت‌ها" },
+35 -10
View File
@@ -67,7 +67,10 @@ export class RealtimeClient {
private status: RealtimeStatus = "idle";
private readonly listeners = new Set<EventListener>();
private readonly statusListeners = new Set<StatusListener>();
private readonly subscriptions = new Set<string>();
/** Channel -> how many callers hold it. A channel stays on the wire while its
* count is above zero, so two live hooks on ``org:<id>`` do not lose their
* feed when one of them unmounts. */
private readonly subscriptions = new Map<string, number>();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
@@ -164,8 +167,9 @@ export class RealtimeClient {
// Re-play subscriptions: a new socket knows nothing about what this
// client cared about, and pages do not (and should not) re-run their
// effects to fix it.
if (this.subscriptions.size > 0) {
this.send({ action: "subscribe", channels: [...this.subscriptions] });
const held = this.activeSubscriptions();
if (held.length > 0) {
this.send({ action: "subscribe", channels: held });
}
resolve();
};
@@ -316,22 +320,43 @@ export class RealtimeClient {
// -- subscriptions ---------------------------------------------------------
/**
* Take one reference on each channel. Only a channel that nobody held before
* is sent to the server; a second holder just adds to the count.
*/
subscribe(channels: string[]): void {
const fresh = channels.filter((channel) => !this.subscriptions.has(channel));
const fresh: string[] = [];
for (const channel of channels) {
const held = this.subscriptions.get(channel) ?? 0;
this.subscriptions.set(channel, held + 1);
if (held === 0) fresh.push(channel);
}
if (fresh.length === 0) return;
fresh.forEach((channel) => this.subscriptions.add(channel));
this.send({ action: "subscribe", channels: fresh });
}
/**
* Release one reference on each channel. The server is told only when the
* last holder lets go. A channel nobody holds is ignored.
*/
unsubscribe(channels: string[]): void {
const active = channels.filter((channel) => this.subscriptions.has(channel));
if (active.length === 0) return;
active.forEach((channel) => this.subscriptions.delete(channel));
this.send({ action: "unsubscribe", channels: active });
const released: string[] = [];
for (const channel of channels) {
const held = this.subscriptions.get(channel);
if (held === undefined) continue;
if (held > 1) {
this.subscriptions.set(channel, held - 1);
continue;
}
this.subscriptions.delete(channel);
released.push(channel);
}
if (released.length === 0) return;
this.send({ action: "unsubscribe", channels: released });
}
activeSubscriptions(): string[] {
return [...this.subscriptions];
return [...this.subscriptions.keys()];
}
// -- listeners --------------------------------------------------------------
@@ -0,0 +1,176 @@
/**
* The platform owner's credit page: every organization's balance, the add-credit dialog (a valid
* amount and reason, then a confirm step) and the history drawer. The API client is mocked, so
* the test sees exactly the calls the page makes and the text the owner reads.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
creditApi: { balances: vi.fn(), credit: vi.fn(), history: vi.fn() },
};
});
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError }));
vi.mock("@/lib/api-credit", () => ({ creditApi: mocks.creditApi }));
import AdminCreditsPage from "@/app/(app)/admin/credits/page";
const ACME = {
organization_id: "org-1",
name: "Acme",
slug: "acme",
balance: 120,
currency: "USD",
paused_tasks: 0,
last_credit_at: "2026-10-01T10:00:00Z",
};
const BETA = {
organization_id: "org-2",
name: "Beta",
slug: "beta",
balance: 0,
currency: "USD",
paused_tasks: 3,
last_credit_at: null,
};
beforeEach(() => {
for (const fn of Object.values(mocks.creditApi)) fn.mockReset();
mocks.creditApi.balances.mockResolvedValue([ACME, BETA]);
mocks.creditApi.credit.mockResolvedValue({
id: "tx-1",
kind: "owner_credit",
amount: 50,
balance_after: 170,
reason: "شارژ دستی",
created_at: "2026-10-14T09:00:00Z",
});
mocks.creditApi.history.mockResolvedValue([]);
});
/** The table row that names the organization, so a button can be pressed inside it. */
async function rowFor(name: string): Promise<HTMLElement> {
const cell = await screen.findByText(name);
return cell.closest("tr") as HTMLElement;
}
/** Opens the add-credit dialog for one organization and returns it. */
async function openCreditDialog(name: string): Promise<HTMLElement> {
fireEvent.click(within(await rowFor(name)).getByRole("button", { name: "افزودن اعتبار" }));
return screen.findByRole("dialog", { name: `افزودن اعتبار به «${name}»` });
}
describe("owner credit page: balances", () => {
it("lists every organization with its balance and marks the one without credit", async () => {
render(<AdminCreditsPage />);
expect(await screen.findByText("Acme")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("120.00 USD")).toBeInTheDocument();
expect(within(await rowFor("Beta")).getByText("بدون اعتبار")).toBeInTheDocument();
expect(within(await rowFor("Acme")).queryByText("بدون اعتبار")).not.toBeInTheDocument();
expect(within(await rowFor("Beta")).getByText("3")).toBeInTheDocument();
});
});
describe("owner credit page: add credit", () => {
it("keeps the continue button disabled until the amount and the reason are valid", async () => {
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
const next = within(dialog).getByRole("button", { name: "ادامه" });
expect(next).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
expect(next).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "ab" } });
expect(next).toBeDisabled();
expect(within(dialog).getByText(/دلیل باید حداقل/)).toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "واریز بانکی" } });
expect(next).toBeEnabled();
for (const amount of ["0", "-5", "100001"]) {
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: amount } });
expect(next).toBeDisabled();
}
});
it("asks for a confirmation, then calls creditApi.credit with the amount and the reason", async () => {
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "شارژ دستی" } });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
expect(mocks.creditApi.credit).not.toHaveBeenCalled();
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
await waitFor(() =>
expect(mocks.creditApi.credit).toHaveBeenCalledWith("org-1", {
amount: 50,
reason: "شارژ دستی",
}),
);
expect(await screen.findByText("اعتبار افزوده شد")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).not.toBeInTheDocument(),
);
// The balances are read again after the credit, so the row shows the new state.
expect(mocks.creditApi.balances).toHaveBeenCalledTimes(2);
});
it("shows the server's Persian message when the credit is refused", async () => {
mocks.creditApi.credit.mockRejectedValueOnce(
new mocks.FakeApiError(422, "validation_error", "دلیل افزودن اعتبار لازم است."),
);
render(<AdminCreditsPage />);
const dialog = await openCreditDialog("Acme");
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "10" } });
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "تست خطا" } });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"دلیل افزودن اعتبار لازم است.",
);
expect(screen.getByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).toBeInTheDocument();
});
});
describe("owner credit page: history", () => {
it("opens a drawer that lists one organization's credit history", async () => {
mocks.creditApi.history.mockResolvedValueOnce([
{
id: "tx-9",
kind: "owner_credit",
amount: 20,
balance_after: 140,
reason: "واریز مرداد",
created_at: "2026-10-01T10:00:00Z",
},
]);
render(<AdminCreditsPage />);
fireEvent.click(within(await rowFor("Acme")).getByRole("button", { name: "تاریخچه" }));
expect(await screen.findByText("واریز مرداد")).toBeInTheDocument();
expect(mocks.creditApi.history).toHaveBeenCalledWith("org-1");
expect(screen.getByText("140.00 USD")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /تاریخچه/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
/**
* The owner credit client calls the owner routes it names, with the right method and body.
* ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
import { creditApi } from "@/lib/api-credit";
const ORG = "org-1";
beforeEach(() => {
mocks.apiRequest.mockReset();
mocks.apiRequest.mockResolvedValue({});
});
describe("creditApi reads", () => {
it("reads every organization's balance with a plain GET", async () => {
await creditApi.balances();
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/credits");
});
it("reads one organization's history, with the limit only when one is given", async () => {
await creditApi.history(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit-history");
await creditApi.history(ORG, 20);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(
"/api/admin/orgs/org-1/credit-history?limit=20",
);
});
it("encodes the organization id in the path", async () => {
await creditApi.history("a/b");
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/a%2Fb/credit-history");
});
});
describe("creditApi actions", () => {
it("posts the amount and the reason as the body, and nothing else", async () => {
await creditApi.credit(ORG, { amount: 50, reason: "شارژ پس از واریز" });
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit", {
method: "POST",
body: { amount: 50, reason: "شارژ پس از واریز" },
});
});
});
+249
View File
@@ -0,0 +1,249 @@
/**
* Prepaid billing (D-B) for the members: the billing page shows the add-credit form only to the
* platform owner, tells every other member where credit comes from, and shows the engine's credit
* notice while the balance is zero. The wizard's success screen also says that work waits for
* credit. The API and the engine status are mocked, so the test reads what each page renders.
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
replace: vi.fn(),
session: {
ready: true,
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "member" },
user: null as null | {
id: string;
email: string;
is_platform_owner: boolean;
platform_role?: string;
},
organizations: [] as Array<Record<string, unknown>>,
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
api: {
billingSummary: vi.fn(),
billingUsageEvents: vi.fn(),
billingInvoices: vi.fn(),
billingPayments: vi.fn(),
billingAddCredit: vi.fn(),
billingCheckout: vi.fn(),
listEmployees: vi.fn(),
},
setupApi: {
getCatalog: vi.fn(),
getDraft: vi.fn(),
saveDraft: vi.fn(async () => {}),
complete: vi.fn(),
},
status: vi.fn(),
};
});
vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
vi.mock("@/lib/realtime", () => ({ useLiveChannel: () => undefined }));
import OnboardingPage from "@/app/onboarding/page";
import BillingPage from "@/app/(app)/billing/page";
const SUMMARY = {
balance: 0,
currency: "USD",
low_balance: true,
low_balance_threshold: 5,
total_spend: 12.5,
by_unit: [],
by_employee: {},
};
const CATALOG = {
app_types: ["web_app"],
app_type_labels: { web_app: "وب‌اپ" },
git_sources: ["platform", "external"],
languages: [
{
key: "node",
display: "Node.js",
frameworks: [
{
key: "next",
display: "Next.js",
app_type: "web_app",
defaults: {
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: 3000,
healthcheck_path: "/",
docker_image: "node:20",
},
},
],
},
],
};
function status(overrides: Partial<{ balance: number; credit_ok: boolean }> = {}) {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
/** Lets the pending status call finish and its state update land before the assertion. */
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
beforeEach(() => {
for (const fn of [...Object.values(mocks.api), ...Object.values(mocks.setupApi), mocks.status]) {
fn.mockReset();
}
mocks.api.billingSummary.mockResolvedValue(SUMMARY);
mocks.api.billingUsageEvents.mockResolvedValue([]);
mocks.api.billingInvoices.mockResolvedValue([]);
mocks.api.billingPayments.mockResolvedValue([]);
mocks.api.listEmployees.mockResolvedValue([]);
mocks.status.mockResolvedValue(status());
mocks.setupApi.saveDraft.mockResolvedValue(undefined);
});
describe("billing page for a member", () => {
beforeEach(() => {
mocks.session.user = { id: "u2", email: "member@example.com", is_platform_owner: false };
});
it("offers no add-credit form and says the platform owner adds the credit", async () => {
render(<BillingPage />);
expect(await screen.findByText(/توسط مالک سامانه افزوده/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "افزودن اعتبار" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "پرداخت آنلاین" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("مبلغ")).not.toBeInTheDocument();
});
it("shows the credit notice at zero balance", async () => {
render(<BillingPage />);
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith("org-1");
});
});
describe("billing page for the platform owner", () => {
beforeEach(() => {
mocks.session.user = { id: "u1", email: "owner@example.com", is_platform_owner: true };
});
it("shows the add-credit form and not the member hint", async () => {
render(<BillingPage />);
expect(await screen.findByRole("button", { name: "افزودن اعتبار" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "پرداخت آنلاین" })).toBeInTheDocument();
expect(screen.queryByText(/توسط مالک سامانه افزوده/)).not.toBeInTheDocument();
});
it("hides the credit notice once the organization has credit", async () => {
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 40 }));
render(<BillingPage />);
await screen.findByRole("button", { name: "افزودن اعتبار" });
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
await settle();
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).not.toBeInTheDocument();
});
});
describe("wizard success screen", () => {
beforeEach(() => {
mocks.session.user = {
id: "u1",
email: "owner@example.com",
is_platform_owner: false,
platform_role: "manager",
};
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
// Resume the wizard at its last step with every field filled in, so one click completes it.
mocks.setupApi.getDraft.mockResolvedValue({
step: 5,
data: {
org_name: "Acme",
slug: "acme",
app_name: "app",
app_type: "web_app",
language: "node",
framework: "next",
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: "3000",
healthcheck_path: "/",
git_source: "platform",
},
});
mocks.setupApi.complete.mockResolvedValue({
organization: { id: "org-1", name: "Acme", slug: "acme" },
project: { id: "p-1", name: "app", slug: "app" },
deploy_target_id: null,
});
});
async function completeWizard(): Promise<void> {
render(<OnboardingPage />);
fireEvent.click(await screen.findByRole("button", { name: "ساخت سازمان" }));
expect(await screen.findByText("سازمان ساخته شد")).toBeInTheDocument();
}
it("says that work starts only after credit is added when the engine reports none", async () => {
await completeWizard();
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith("org-1");
});
it("says nothing about credit when the organization already has credit", async () => {
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
await completeWizard();
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
await settle();
expect(screen.queryByText(/پس از افزودن اعتبار/)).not.toBeInTheDocument();
});
it("still says it when the engine status cannot be read", async () => {
mocks.status.mockRejectedValue(new mocks.FakeApiError(503, "unavailable", "موتور در دسترس نیست."));
await completeWizard();
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* The engine client sends each call to the engine route it names, with the right method and
* body. ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
import { engineApi } from "@/lib/api-engine";
const ORG = "org-1";
const BASE = "/api/organizations/org-1/engine";
beforeEach(() => {
mocks.apiRequest.mockReset();
mocks.apiRequest.mockResolvedValue({});
});
describe("engineApi reads", () => {
it("reads the status with a plain GET", async () => {
await engineApi.status(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/status`);
});
it("lists the board, with the stage only when one is given", async () => {
await engineApi.listTasks(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks`);
await engineApi.listTasks(ORG, "pending_test");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks?stage=pending_test`);
});
it("reads one task by id", async () => {
await engineApi.getTask(ORG, "task-9");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-9`);
});
it("lists the recent turns, with the limit only when one is given", async () => {
await engineApi.listTurns(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns`);
await engineApi.listTurns(ORG, 20);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns?limit=20`);
});
it("reads the team with a plain GET", async () => {
await engineApi.getTeam(ORG);
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team`);
});
});
describe("engineApi actions", () => {
it("posts pause, resume and cancel with no body", async () => {
await engineApi.pauseTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/pause`, {
method: "POST",
});
await engineApi.resumeTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/resume`, {
method: "POST",
});
await engineApi.cancelTask(ORG, "task-1");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/cancel`, {
method: "POST",
});
});
it("posts the answer as the content field", async () => {
await engineApi.answerTask(ORG, "task-2", "نسخهٔ دوم را انجام بده");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-2/answer`, {
method: "POST",
body: { content: "نسخهٔ دوم را انجام بده" },
});
});
it("hires with the role, the tier and the name, and fires by employee id", async () => {
await engineApi.hire(ORG, { role: "developer", tier: "strong", name: "علی" });
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/hire`, {
method: "POST",
body: { role: "developer", tier: "strong", name: "علی" },
});
await engineApi.fire(ORG, "emp-3");
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/emp-3/fire`, {
method: "POST",
});
});
});
+298
View File
@@ -0,0 +1,298 @@
/**
* The task board: one column per group of stages, every stage and a legacy task in the right
* column, a live task.step moving its card, a task.created asking the page to reload, the filters
* narrowing the cards, and the controls shown only to someone who may run tasks.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
pauseTask: vi.fn(),
resumeTask: vi.fn(),
cancelTask: vi.fn(),
channels: [] as string[][],
listener: null as null | ((event: { type: string; data: Record<string, unknown> }) => void),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({
engineApi: { pauseTask: mocks.pauseTask, resumeTask: mocks.resumeTask, cancelTask: mocks.cancelTask },
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (
channels: string[],
onEvent: (event: { type: string; data: Record<string, unknown> }) => void,
) => {
mocks.channels.push(channels);
mocks.listener = onEvent;
},
}));
import { type TaskBoardProps, TaskBoard } from "@/app/(app)/tasks/_components/task-board";
import type { EngineStage, EngineTask } from "@/lib/api-engine";
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
const ORG = "org-1";
const EMPLOYEES = [
{ id: "emp-1", name: "علی" },
{ id: "emp-2", name: "سارا" },
];
/** The coarse status a stage implies (a finished stage is a finished task). */
const COARSE: Partial<Record<EngineStage, EngineTask["status"]>> = {
completed: "done",
failed: "failed",
cancelled: "cancelled",
};
function engineTask(stage: EngineStage, overrides: Partial<EngineTask> = {}): EngineTask {
return {
id: `task-${stage}`,
title: `کار ${stage}`,
status: COARSE[stage] ?? "running",
stage,
pipeline: "feature",
kind: "feature",
cycle: 1,
max_cycles: 3,
thread_id: null,
assigned_employee_id: "emp-1",
assignee_name: "علی",
created_at: "2026-10-01T08:00:00Z",
updated_at: "2026-10-01T08:00:00Z",
...overrides,
};
}
const LEGACY: EngineTask = {
id: "legacy-1",
title: "کار قدیمی",
status: "running",
stage: null,
pipeline: null,
kind: null,
cycle: 0,
max_cycles: 0,
thread_id: null,
assigned_employee_id: null,
assignee_name: null,
created_at: "2025-01-01T08:00:00Z",
updated_at: "2025-01-01T08:00:00Z",
};
/** Every one of the thirteen stages once, and one legacy task after the implementation card. */
const FIXTURE: EngineTask[] = [
engineTask("pending_manager_review"),
engineTask("pending_analysis"),
engineTask("pending_implementation"),
LEGACY,
engineTask("pending_review", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
engineTask("pending_final_review"),
engineTask("pending_test", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
engineTask("merge_request"),
engineTask("completed"),
engineTask("cancelled"),
engineTask("failed"),
engineTask("pending_user_decision"),
engineTask("error_review"),
engineTask("paused"),
];
/** The card titles each column shows, in the order of the fixture. */
const EXPECTED: Record<BoardColumnKey, string[]> = {
manager_review: ["کار pending_manager_review"],
analysis: ["کار pending_analysis"],
implementation: ["کار pending_implementation", "کار قدیمی"],
review: ["کار pending_review", "کار pending_final_review"],
test: ["کار pending_test"],
merge: ["کار merge_request"],
done: ["کار completed", "کار cancelled", "کار failed"],
decision: ["کار pending_user_decision", "کار error_review", "کار paused"],
};
function columnLabel(key: BoardColumnKey): string {
const column = BOARD_COLUMNS.find((item) => item.key === key);
if (!column) throw new Error(`no board column ${key}`);
return column.label;
}
function cardTitles(): string[] {
return screen.getAllByRole("link").map((link) => link.textContent ?? "");
}
function renderBoard(overrides: Partial<TaskBoardProps> = {}) {
const props: TaskBoardProps = {
orgId: ORG,
tasks: FIXTURE,
employees: EMPLOYEES,
canRun: true,
onChanged: vi.fn(),
...overrides,
};
return { ...render(<TaskBoard {...props} />), props };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [];
mocks.listener = null;
});
describe("TaskBoard columns", () => {
it("shows the eight columns in board order", () => {
renderBoard();
const regions = screen.getAllByRole("region");
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual(
BOARD_COLUMNS.map((column) => column.label),
);
});
it("puts every stage and a legacy task in exactly one column", () => {
renderBoard();
for (const column of BOARD_COLUMNS) {
const region = screen.getByRole("region", { name: column.label });
const titles = within(region)
.getAllByRole("link")
.map((link) => link.textContent);
expect(titles).toEqual(EXPECTED[column.key]);
}
expect(cardTitles()).toHaveLength(FIXTURE.length);
});
it("shows the Persian stage on each card", () => {
renderBoard();
const link = within(screen.getByRole("region", { name: columnLabel("test") })).getByRole(
"link",
{ name: "کار pending_test" },
);
expect(link.parentElement).toHaveTextContent(STAGE_LABELS.pending_test);
});
});
describe("TaskBoard live updates", () => {
it("subscribes to the organization channel", () => {
renderBoard();
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
});
it("moves a card to the column of its new stage on task.step", () => {
const { props } = renderBoard();
act(() => {
mocks.listener?.({
type: "task.step",
data: {
task_id: "task-pending_test",
from: "pending_test",
to: "merge_request",
pipeline: "feature",
status: "running",
},
});
});
const test = screen.getByRole("region", { name: columnLabel("test") });
expect(within(test).queryByRole("link", { name: "کار pending_test" })).toBeNull();
const merge = screen.getByRole("region", { name: columnLabel("merge") });
const moved = within(merge).getByRole("link", { name: "کار pending_test" });
expect(moved.parentElement).toHaveTextContent(STAGE_LABELS.merge_request);
expect(props.onChanged).not.toHaveBeenCalled();
});
it("asks the page to reload on task.created", () => {
const { props } = renderBoard();
act(() => {
mocks.listener?.({ type: "task.created", data: {} });
});
expect(props.onChanged).toHaveBeenCalledTimes(1);
});
it("takes a fresh list from the page in place of its moved copy", () => {
const { rerender, props } = renderBoard();
act(() => {
mocks.listener?.({
type: "task.step",
data: {
task_id: "task-pending_test",
from: "pending_test",
to: "merge_request",
pipeline: "feature",
status: "running",
},
});
});
rerender(<TaskBoard {...props} tasks={[...FIXTURE]} />);
const test = screen.getByRole("region", { name: columnLabel("test") });
expect(within(test).getByRole("link", { name: "کار pending_test" })).toBeInTheDocument();
});
});
describe("TaskBoard filters", () => {
it("hides the cards whose title does not match the search", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("جستجو در عنوان"), { target: { value: "merge" } });
expect(cardTitles()).toEqual(["کار merge_request"]);
});
it("shows only the tasks of the chosen assignee", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "emp-2" } });
expect(cardTitles()).toEqual(["کار pending_review", "کار pending_test"]);
});
it("shows only the unassigned tasks for the no-assignee choice", () => {
renderBoard();
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "none" } });
expect(cardTitles()).toEqual(["کار قدیمی"]);
});
it("empties the finished column when only active tasks are asked for", () => {
renderBoard();
fireEvent.click(screen.getByLabelText("فقط کارهای فعال"));
const done = screen.getByRole("region", { name: columnLabel("done") });
expect(within(done).queryAllByRole("link")).toHaveLength(0);
expect(cardTitles()).toContain("کار pending_test");
expect(cardTitles()).not.toContain("کار completed");
});
});
describe("TaskBoard controls", () => {
it("shows the task controls only to someone who may run tasks", () => {
const { unmount } = renderBoard({ canRun: false });
expect(screen.queryByRole("button", { name: "توقف" })).toBeNull();
unmount();
renderBoard({ canRun: true });
expect(screen.getAllByRole("button", { name: "توقف" }).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,117 @@
/**
* The engine's credit notice shows at zero credit and not otherwise. The status hook loads the
* status for the organization's channel, and loads it again on a balance, stage or team change,
* but not on any other live event.
*/
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
status: vi.fn(),
channels: [] as string[][],
listener: null as null | ((event: { type: string }) => void),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: { type: string }) => void) => {
mocks.channels.push(channels);
mocks.listener = onEvent;
},
}));
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
import type { EngineStatus } from "@/lib/api-engine";
const ORG = "org-1";
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
function Probe({ orgId }: { orgId: string }) {
const { status: current, loading } = useEngineStatus(orgId);
if (loading) return <p>در حال بارگذاری</p>;
return <p>{current?.credit_ok ? "اعتبار موجود" : "بدون اعتبار"}</p>;
}
beforeEach(() => {
mocks.status.mockReset();
mocks.channels.length = 0;
mocks.listener = null;
});
describe("CreditNotice", () => {
it("explains in Persian why work stopped, at zero credit", () => {
render(<CreditNotice status={status()} />);
expect(screen.getByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
);
});
it("shows nothing while credit is available", () => {
const { container } = render(
<CreditNotice status={status({ credit_ok: true, balance: 12 })} />,
);
expect(container).toBeEmptyDOMElement();
});
it("shows nothing before the status has loaded", () => {
const { container } = render(<CreditNotice status={null} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("useEngineStatus", () => {
it("subscribes to the organization and reloads on a balance, stage or team change only", async () => {
mocks.status
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
render(<Probe orgId={ORG} />);
expect(await screen.findByText("بدون اعتبار")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledWith(ORG);
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
act(() => mocks.listener?.({ type: "run.log" }));
expect(mocks.status).toHaveBeenCalledTimes(1);
act(() => mocks.listener?.({ type: "billing.wallet" }));
expect(await screen.findByText("اعتبار موجود")).toBeInTheDocument();
expect(mocks.status).toHaveBeenCalledTimes(2);
act(() => mocks.listener?.({ type: "task.step" }));
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(3));
act(() => mocks.listener?.({ type: "team.changed" }));
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(4));
});
});
+306
View File
@@ -0,0 +1,306 @@
/**
* The dashboard's engine summary: the balance and the credit notice, at most five active engine
* tasks (never a finished or a legacy task), the count in each board column, the five newest
* completed tasks, a Persian message when the task list fails, and a reload on a task step only.
*/
import * as React from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
status: vi.fn(),
listTasks: vi.fn(),
listeners: new Set<(event: { type: string }) => void>(),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({
engineApi: { status: mocks.status, listTasks: mocks.listTasks },
}));
// The realtime client keeps one subscription per channel, so every hook registers its own listener
// and the test delivers an event to all of them.
vi.mock("@/lib/realtime", async () => {
const { useEffect, useRef } = await import("react");
return {
useLiveChannel: (_channels: string[], onEvent: (event: { type: string }) => void) => {
const latest = useRef(onEvent);
latest.current = onEvent;
useEffect(() => {
const listener = (event: { type: string }) => latest.current(event);
mocks.listeners.add(listener);
return () => {
mocks.listeners.delete(listener);
};
}, []);
},
};
});
import { ApiError } from "@/lib/api";
import type { EngineStage, EngineStatus, EngineTask } from "@/lib/api-engine";
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
import { EngineWidgets } from "@/app/(app)/dashboard/_components/engine-widgets";
const ORG = "org-1";
/** The Persian digits the widget prints for a number. */
function fa(value: number): string {
return value.toLocaleString("fa-IR");
}
/** A board column's Persian label, read from the module so the test never retypes it. */
function label(key: BoardColumnKey): string {
const column = BOARD_COLUMNS.find((item) => item.key === key);
if (!column) throw new Error(`no board column ${key}`);
return column.label;
}
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
return {
balance: 0,
currency: "USD",
credit_ok: false,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
function taskStatus(stage: EngineStage | null): EngineTask["status"] {
if (stage === "completed") return "done";
if (stage === "failed" || stage === "cancelled") return stage;
return "running";
}
function task(
id: string,
stage: EngineStage | null,
updatedAt = "2026-10-14T10:00:00Z",
): EngineTask {
return {
id,
title: `کار ${id}`,
status: taskStatus(stage),
stage,
pipeline: stage === null ? null : "default",
kind: null,
cycle: 0,
max_cycles: 0,
thread_id: null,
assigned_employee_id: null,
assignee_name: null,
created_at: "2026-10-14T09:00:00Z",
updated_at: updatedAt,
};
}
/** Delivers one live event to every subscribed hook. */
function emit(type: string) {
act(() => {
for (const listener of [...mocks.listeners]) listener({ type });
});
}
/** The row of the distribution list for one board column. */
function columnRow(key: BoardColumnKey): HTMLElement {
const list = screen.getByRole("list", { name: "توزیع مراحل" });
const row = within(list).getByText(label(key)).closest("li");
if (!row) throw new Error(`no row for ${key}`);
return row;
}
/** The card under a section title (the title sits directly inside its card), once it has rendered. */
async function cardOf(title: RegExp): Promise<HTMLElement> {
const heading = await screen.findByRole("heading", { name: title });
const card = heading.parentElement;
if (!card) throw new Error(`no card under ${String(title)}`);
return card;
}
beforeEach(() => {
mocks.status.mockReset();
mocks.listTasks.mockReset();
mocks.listeners.clear();
});
describe("EngineWidgets credit", () => {
it("shows the balance and the credit notice at zero credit", async () => {
mocks.status.mockResolvedValue(status({ balance: 0, credit_ok: false }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
);
});
it("shows the balance and no notice while credit is available", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByText("موجودی اعتبار")).toBeInTheDocument();
expect(screen.getByText("موجودی اعتبار").parentElement).toHaveTextContent(fa(50));
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).toBeNull();
});
});
describe("EngineWidgets board", () => {
it("counts the tasks in each board column from the stage counts", async () => {
mocks.status.mockResolvedValue(
status({
balance: 50,
credit_ok: true,
stage_counts: {
pending_analysis: 2,
pending_review: 1,
pending_final_review: 3,
completed: 4,
paused: 1,
},
}),
);
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
const list = await screen.findByRole("list", { name: "توزیع مراحل" });
expect(within(list).getAllByRole("listitem")).toHaveLength(BOARD_COLUMNS.length);
const expected: Array<[BoardColumnKey, number]> = [
["manager_review", 0],
["analysis", 2],
["implementation", 0],
["review", 4],
["test", 0],
["merge", 0],
["done", 4],
["decision", 1],
];
for (const [key, count] of expected) {
expect(columnRow(key).textContent).toBe(`${label(key)}${fa(count)}`);
}
// The bars are relative to the largest column.
const bar = (key: BoardColumnKey) =>
columnRow(key).querySelector<HTMLElement>('[aria-hidden="true"] > div');
expect(bar("review")).toHaveStyle({ width: "100%" });
expect(bar("analysis")).toHaveStyle({ width: "50%" });
expect(bar("test")).toHaveStyle({ width: "0%" });
});
it("lists at most five active engine tasks, each with its stage and a link", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([
task("a1", "pending_analysis"),
task("a2", "pending_test"),
task("a3", "paused"),
task("done1", "completed"),
task("a4", "pending_manager_review"),
task("legacy1", null),
task("a5", "pending_implementation"),
task("a6", "pending_review"),
task("failed1", "failed"),
]);
render(<EngineWidgets orgId={ORG} />);
const card = await cardOf(/^کارهای فعال$/);
expect(await within(card).findByRole("link", { name: "کار a1" })).toHaveAttribute(
"href",
"/tasks/a1",
);
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
"کار a1",
"کار a2",
"کار a3",
"کار a4",
"کار a5",
]);
expect(within(card).getByText(STAGE_LABELS.pending_analysis)).toBeInTheDocument();
expect(within(card).getByText(STAGE_LABELS.paused)).toBeInTheDocument();
expect(within(card).getByText(`و ${fa(1)} کار دیگر`)).toBeInTheDocument();
});
it("lists the five newest completed tasks, newest first", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([
task("c1", "completed", "2026-10-10T10:00:00Z"),
task("c2", "completed", "2026-10-11T10:00:00Z"),
task("c3", "completed", "2026-10-12T10:00:00Z"),
task("c4", "completed", "2026-10-13T10:00:00Z"),
task("c5", "completed", "2026-10-14T08:00:00Z"),
task("c6", "completed", "2026-10-14T09:00:00Z"),
task("a1", "pending_analysis"),
]);
render(<EngineWidgets orgId={ORG} />);
const card = await cardOf(/^آخرین کارهای انجام/);
await waitFor(() => expect(within(card).getAllByRole("link")).toHaveLength(5));
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
"کار c6",
"کار c5",
"کار c4",
"کار c3",
"کار c2",
]);
expect(within(card).queryByRole("link", { name: "کار c1" })).toBeNull();
expect(within(card).queryByRole("link", { name: "کار a1" })).toBeNull();
});
it("shows the task list's failure in Persian and keeps the balance", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockRejectedValue(
new ApiError(500, "server_error", "فهرست کارها در دسترس نیست."),
);
render(<EngineWidgets orgId={ORG} />);
expect(await screen.findByRole("alert")).toHaveTextContent("فهرست کارها در دسترس نیست.");
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
});
it("reloads the status and the task list on a task step, and on no other event", async () => {
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
mocks.listTasks.mockResolvedValue([]);
render(<EngineWidgets orgId={ORG} />);
await screen.findByText("موجودی اعتبار");
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(1));
expect(mocks.listTasks).toHaveBeenCalledWith(ORG);
expect(mocks.status).toHaveBeenCalledTimes(1);
emit("run.log");
expect(mocks.status).toHaveBeenCalledTimes(1);
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
emit("task.step");
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(2));
expect(mocks.status).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,116 @@
/**
* The gates panel and the diff viewer: each gate is named in Persian with its verdict, a red gate
* opens to its detail, a waiver shows its reason, diff lines carry their colour, and a merged task
* shows its short sha with a link to the pipeline view.
*/
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DiffViewer } from "@/app/(app)/tasks/[id]/_components/diff-viewer";
import { GatesPanel } from "@/app/(app)/tasks/[id]/_components/gates-panel";
import type { EngineGate } from "@/lib/api-engine";
const GATES: EngineGate[] = [
{
id: "gate-1",
gate: "tests",
ok: true,
detail: "3 passed",
created_at: "2026-10-13T08:00:00Z",
},
{
id: "gate-2",
gate: "diff_guard",
ok: false,
detail: "diff touches protected/settings.py",
created_at: "2026-10-13T08:05:00Z",
},
];
const DIFF = ["@@ -1,2 +1,2 @@", " keep", "-old line", "+new line"].join("\n");
function rowOf(label: string): HTMLElement {
const row = screen.getByText(label).closest("li");
if (!row) throw new Error(`no row for ${label}`);
return row;
}
describe("GatesPanel", () => {
it("names each gate in Persian with a green or red verdict", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
expect(screen.getByText("تست‌ها")).toBeInTheDocument();
expect(screen.getByText("نگهبان تغییرات")).toBeInTheDocument();
expect(screen.getByText("قبول شد")).toBeInTheDocument();
expect(screen.getByText("رد شد")).toBeInTheDocument();
});
it("opens a red gate to its detail", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
const row = rowOf("نگهبان تغییرات");
expect(within(row).queryByText("diff touches protected/settings.py")).toBeNull();
fireEvent.click(within(row).getByRole("button", { name: "جزئیات" }));
expect(within(row).getByText("diff touches protected/settings.py").tagName).toBe("PRE");
expect(within(row).getByRole("button", { name: "بستن جزئیات" })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("shows the waiver reason in a warning box", () => {
render(<GatesPanel gates={[]} waiverReason="اجرای تست روی محیط قبلی" />);
expect(screen.getByRole("note")).toHaveTextContent(
"صرف‌نظر از تست با دلیل: اجرای تست روی محیط قبلی",
);
});
it("shows no warning box without a waiver reason", () => {
render(<GatesPanel gates={GATES} waiverReason="" />);
expect(screen.queryByRole("note")).toBeNull();
});
it("says so when no gate has run yet", () => {
render(<GatesPanel gates={[]} waiverReason="" />);
expect(screen.getByText("هنوز نتیجهٔ دروازه‌ای ثبت نشده است.")).toBeInTheDocument();
});
});
describe("DiffViewer", () => {
it("colours added, removed and hunk lines", () => {
render(<DiffViewer diff={DIFF} mergedSha="" />);
expect(screen.getByText("+new line")).toHaveClass("text-positive");
expect(screen.getByText("-old line")).toHaveClass("text-danger");
expect(screen.getByText("@@ -1,2 +1,2 @@")).toHaveClass("text-slate-500");
});
it("says so when no change has been recorded", () => {
render(<DiffViewer diff="" mergedSha="" />);
expect(screen.getByText("هنوز تغییری ثبت نشده است.")).toBeInTheDocument();
});
it("shows the short merged sha with a link to the pipeline view", () => {
render(<DiffViewer diff={DIFF} mergedSha="0123456789abcdef0123" />);
const line = screen.getByText(/ادغام و ارسال شد/).closest("p");
expect(line).toHaveTextContent("ادغام و ارسال شد: 0123456789ab");
expect(screen.queryByText("0123456789abcdef0123")).toBeNull();
expect(screen.getByRole("link", { name: "مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن" })).toHaveAttribute(
"href",
"/app",
);
});
it("shows no sha and no link before the change is merged", () => {
render(<DiffViewer diff={DIFF} mergedSha="" />);
expect(screen.queryByRole("link")).toBeNull();
expect(screen.queryByText(/ادغام و ارسال شد/)).toBeNull();
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* The engine's labels and board columns: every stage lands in exactly one column, a legacy
* task lands by its status, and grouping keeps the order the tasks came in.
*/
import { describe, expect, it } from "vitest";
import {
BOARD_COLUMNS,
columnFor,
groupByColumn,
ROLE_LABELS,
STAGE_LABELS,
stageTone,
TIER_LABELS,
} from "@/lib/engine-labels";
const STAGES = [
"pending_manager_review",
"pending_analysis",
"pending_implementation",
"pending_review",
"pending_test",
"pending_final_review",
"merge_request",
"completed",
"pending_user_decision",
"error_review",
"paused",
"cancelled",
"failed",
];
describe("the board", () => {
it("places every one of the thirteen stages in exactly one column", () => {
for (const stage of STAGES) {
const homes = BOARD_COLUMNS.filter((column) => column.stages.includes(stage as never));
expect(homes, stage).toHaveLength(1);
expect(STAGE_LABELS[stage], stage).toBeTruthy();
}
const placed = BOARD_COLUMNS.flatMap((column) => column.stages as readonly string[]);
expect([...placed].sort()).toEqual([...STAGES].sort());
});
it("uses the stage column whenever the engine owns the task", () => {
expect(columnFor("pending_test", "running")).toBe("test");
expect(columnFor("pending_final_review", "running")).toBe("review");
expect(columnFor("paused", "waiting_approval")).toBe("decision");
expect(columnFor("completed", "done")).toBe("done");
});
it("places a legacy task by its coarse status", () => {
expect(columnFor(null, "new")).toBe("manager_review");
expect(columnFor(null, "triaged")).toBe("implementation");
expect(columnFor(null, "running")).toBe("implementation");
expect(columnFor(null, "waiting_approval")).toBe("decision");
for (const status of ["done", "failed", "cancelled"]) {
expect(columnFor(null, status)).toBe("done");
}
});
it("sends an unknown stage or status to the decision column", () => {
expect(columnFor("some_new_stage", "running")).toBe("decision");
expect(columnFor(null, "some_new_status")).toBe("decision");
});
it("groups tasks by column, keeps every column, and keeps the order inside each", () => {
const tasks = [
{ id: "a", stage: "pending_test", status: "running" },
{ id: "b", stage: null, status: "new" },
{ id: "c", stage: "pending_test", status: "running" },
{ id: "d", stage: "pending_final_review", status: "running" },
];
const groups = groupByColumn(tasks);
expect(groups.test.map((task) => task.id)).toEqual(["a", "c"]);
expect(groups.review.map((task) => task.id)).toEqual(["d"]);
expect(groups.manager_review.map((task) => task.id)).toEqual(["b"]);
expect(groups.merge).toEqual([]);
expect(Object.keys(groups)).toHaveLength(BOARD_COLUMNS.length);
});
});
describe("labels and tones", () => {
it("tones a stage by how it ended or whether a person must act", () => {
expect(stageTone("completed")).toBe("success");
expect(stageTone("failed")).toBe("danger");
expect(stageTone("cancelled")).toBe("danger");
expect(stageTone("paused")).toBe("warning");
expect(stageTone("pending_user_decision")).toBe("warning");
expect(stageTone("error_review")).toBe("warning");
expect(stageTone("pending_test")).toBe("info");
});
it("labels the four tiers in Persian", () => {
expect(TIER_LABELS).toEqual({
fast: "سریع",
balanced: "متعادل",
strong: "قوی",
unknown: "نامشخص",
});
});
it("labels the five hirable roles in Persian", () => {
expect(Object.keys(ROLE_LABELS).sort()).toEqual(
["developer", "manager", "planner", "reviewer", "tester"].sort(),
);
expect(ROLE_LABELS.manager).toBe("مدیر");
expect(ROLE_LABELS.reviewer).toBe("بازبین");
expect(ROLE_LABELS.tester).toBe("آزمونگر");
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* The live log: one `run.log` event appends its lines in one render, a `run.tool` event appends one
* Persian line, events for another task are ignored, the newest 2000 lines are kept, the panel
* follows the newest line until it is paused (by the toggle or by a scroll up by hand), and a paused
* panel leaves the scroll position alone.
*/
import { Profiler } from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WsEvent } from "@agy/contracts";
const mocks = vi.hoisted(() => ({
channels: [] as string[][],
listener: null as null | ((event: WsEvent) => void),
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: WsEvent) => void) => {
mocks.channels.push(channels);
mocks.listener = onEvent;
},
}));
import { LiveLog } from "@/app/(app)/tasks/[id]/_components/live-log";
const TASK = "task-1";
function emit(type: WsEvent["type"], data: Record<string, unknown>) {
act(() => {
mocks.listener?.({
id: "evt-1",
type,
data,
organization_id: "org-1",
at: "2026-10-14T08:00:00Z",
});
});
}
function rowsOf(box: HTMLElement): Array<string | null> {
return Array.from(box.children, (row) => row.textContent);
}
/** jsdom has no layout: give the log box a height and a scroll position of its own. */
function measure(box: HTMLElement, scrollTop: number) {
Object.defineProperty(box, "scrollHeight", { configurable: true, value: 500 });
Object.defineProperty(box, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(box, "scrollTop", { configurable: true, writable: true, value: scrollTop });
}
beforeEach(() => {
mocks.channels.length = 0;
mocks.listener = null;
});
describe("LiveLog", () => {
it("subscribes to the task's channel", () => {
render(<LiveLog taskId={TASK} />);
expect(mocks.channels).toContainEqual([`task:${TASK}`]);
});
it("appends all three lines of one run.log event in one render", () => {
const onRender = vi.fn();
render(
<Profiler id="live-log" onRender={onRender}>
<LiveLog taskId={TASK} />
</Profiler>,
);
const box = screen.getByRole("log");
const before = onRender.mock.calls.length;
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["npm test", "3 passed", "done"] });
expect(rowsOf(box)).toEqual(["npm test", "3 passed", "done"]);
expect(onRender.mock.calls.length - before).toBe(1);
});
it("appends one Persian line for each tool call, with its outcome", () => {
render(<LiveLog taskId={TASK} />);
emit("run.tool", { run_id: "run-1", task_id: TASK, tool: "read_file", ok: true, summary: "README.md" });
emit("run.tool", { run_id: "run-1", task_id: TASK, tool: "run_tests", ok: false, summary: "exit 1" });
expect(screen.getByText("ابزار read_file: موفق — README.md")).toBeInTheDocument();
expect(screen.getByText("ابزار run_tests: ناموفق — exit 1")).toBeInTheDocument();
});
it("ignores events that belong to another task", () => {
render(<LiveLog taskId={TASK} />);
emit("run.log", { run_id: "run-9", task_id: "task-2", lines: ["foreign line"] });
expect(screen.queryByText("foreign line")).toBeNull();
expect(screen.getByText("هنوز خروجی‌ای ثبت نشده است.")).toBeInTheDocument();
});
it("keeps only the newest 2000 lines", () => {
render(<LiveLog taskId={TASK} />);
const first = Array.from({ length: 1500 }, (_, index) => `line ${index}`);
const second = Array.from({ length: 1500 }, (_, index) => `line ${index + 1500}`);
emit("run.log", { run_id: "run-1", task_id: TASK, lines: first });
emit("run.log", { run_id: "run-1", task_id: TASK, lines: second });
const rows = rowsOf(screen.getByRole("log"));
expect(rows).toHaveLength(2000);
expect(rows[0]).toBe("line 1000");
expect(rows[1999]).toBe("line 2999");
expect(screen.queryByText("line 999")).toBeNull();
});
it("follows the newest line while it is not paused", () => {
render(<LiveLog taskId={TASK} />);
const box = screen.getByRole("log");
measure(box, 0);
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["a fresh line"] });
expect(box.scrollTop).toBe(500);
});
it("leaves scrollTop alone once paused with the toggle", () => {
render(<LiveLog taskId={TASK} />);
const box = screen.getByRole("log");
measure(box, 0);
fireEvent.click(screen.getByRole("button", { name: "توقف پیمایش" }));
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["while paused"] });
expect(box.scrollTop).toBe(0);
expect(screen.getByText("while paused")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ادامهٔ پیمایش" })).toBeInTheDocument();
});
it("pauses when the reader scrolls up by hand, and not when they stay at the bottom", () => {
render(<LiveLog taskId={TASK} />);
const box = screen.getByRole("log");
measure(box, 400);
fireEvent.scroll(box);
expect(screen.getByRole("button", { name: "توقف پیمایش" })).toBeInTheDocument();
measure(box, 0);
fireEvent.scroll(box);
expect(screen.getByRole("button", { name: "ادامهٔ پیمایش" })).toBeInTheDocument();
});
it("clears the panel on request", () => {
render(<LiveLog taskId={TASK} />);
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["one", "two"] });
fireEvent.click(screen.getByRole("button", { name: "پاک کردن" }));
expect(screen.queryByText("one")).toBeNull();
expect(screen.getByText("هنوز خروجی‌ای ثبت نشده است.")).toBeInTheDocument();
});
});
@@ -0,0 +1,232 @@
/**
* The manager page wired to the engine. The conversation's task cards show the live stage, a
* ``task.step`` event moves a card's badge in place, a pending decision shows in the banner and
* its answer reloads the list, the credit notice shows at zero credit, and a new task still goes
* through the same create call with a hint that the AI manager takes it first.
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {
listThreads: vi.fn(),
listEmployees: vi.fn(),
getThread: vi.fn(),
createThread: vi.fn(),
postMessage: vi.fn(),
createTask: vi.fn(),
},
engineApi: {
status: vi.fn(),
listTasks: vi.fn(),
answerTask: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
},
/** The newest handler per channel list, as the page and the status hook registered them. */
handlers: new Map<string, (event: unknown) => void>(),
};
});
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engineApi }));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.handlers.set(channels.join("|"), onEvent);
},
}));
import ManagerPage from "@/app/(app)/manager/page";
import type { EngineStatus, EngineTask } from "@/lib/api-engine";
const ORG = "org-1";
const THREAD = {
id: "thread-1",
organization_id: ORG,
project_id: null,
title: "گفتگوی اول",
created_at: "2026-10-01T00:00:00Z",
};
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
return {
balance: 50,
currency: "USD",
credit_ok: true,
automation_enabled: true,
max_employees: 7,
team_size: 4,
stage_counts: {},
...overrides,
};
}
function engineTask(overrides: Partial<EngineTask> = {}): EngineTask {
return {
id: "task-1",
title: "صفحهٔ گزارش فروش",
status: "running",
stage: "pending_test",
pipeline: "default",
kind: "feature",
cycle: 1,
max_cycles: 3,
thread_id: "thread-1",
assigned_employee_id: null,
assignee_name: null,
created_at: "2026-10-01T00:00:00Z",
updated_at: "2026-10-01T00:00:00Z",
...overrides,
};
}
/** Deliver one live event to every handler the page and its hooks registered. */
function emit(type: string, data: Record<string, unknown>) {
const event = { id: `evt-${type}`, type, data, organization_id: ORG, at: "2026-10-14T10:00:00Z" };
act(() => {
for (const handler of mocks.handlers.values()) handler(event);
});
}
beforeEach(() => {
for (const fn of [...Object.values(mocks.api), ...Object.values(mocks.engineApi)]) fn.mockReset();
mocks.handlers.clear();
// jsdom has no scrollIntoView; the page scrolls its message list to the bottom.
Element.prototype.scrollIntoView = vi.fn();
mocks.api.listThreads.mockResolvedValue([THREAD]);
mocks.api.listEmployees.mockResolvedValue([]);
mocks.api.getThread.mockResolvedValue({ ...THREAD, messages: [] });
mocks.engineApi.status.mockResolvedValue(status());
mocks.engineApi.listTasks.mockResolvedValue([]);
});
describe("manager page: task cards and live stages", () => {
it("shows the conversation's tasks with their Persian stage", async () => {
mocks.engineApi.listTasks.mockResolvedValue([engineTask({ stage: "pending_test" })]);
render(<ManagerPage />);
expect(await screen.findByText("در انتظار تست")).toBeInTheDocument();
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute("href", "/tasks/task-1");
});
it("moves a card's badge on a task.step event", async () => {
mocks.engineApi.listTasks.mockResolvedValue([engineTask({ stage: "pending_test" })]);
render(<ManagerPage />);
expect(await screen.findByText("در انتظار تست")).toBeInTheDocument();
emit("task.step", {
task_id: "task-1",
from: "pending_test",
to: "pending_review",
pipeline: "default",
status: "running",
});
expect(await screen.findByText("در انتظار بازبینی")).toBeInTheDocument();
expect(screen.queryByText("در انتظار تست")).toBeNull();
});
it("reloads the engine task list on a task.created event", async () => {
mocks.engineApi.listTasks
.mockResolvedValueOnce([])
.mockResolvedValue([engineTask({ id: "task-9", title: "کار تازه", stage: "pending_manager_review" })]);
render(<ManagerPage />);
await screen.findByRole("button", { name: "ساخت کار" });
emit("task.created", { task_id: "task-9", title: "کار تازه", status: "new" });
expect(await screen.findByText("کار تازه")).toBeInTheDocument();
expect(screen.getByText("در انتظار بررسی مدیر")).toBeInTheDocument();
expect(mocks.engineApi.listTasks).toHaveBeenCalledTimes(2);
});
});
describe("manager page: pending decisions and credit", () => {
it("shows the banner for a task at a pending decision", async () => {
mocks.engineApi.listTasks.mockResolvedValue([
engineTask({ id: "task-2", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
]);
render(<ManagerPage />);
expect(await screen.findByText("در انتظار تصمیم شما")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ارسال پاسخ" })).toBeInTheDocument();
});
it("posts the answer, then reloads so the banner clears", async () => {
mocks.engineApi.listTasks
.mockResolvedValueOnce([
engineTask({ id: "task-2", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
])
.mockResolvedValue([]);
mocks.engineApi.answerTask.mockResolvedValue({});
render(<ManagerPage />);
fireEvent.change(await screen.findByLabelText("پاسخ شما"), {
target: { value: "PostgreSQL" },
});
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
await waitFor(() =>
expect(mocks.engineApi.answerTask).toHaveBeenCalledWith(ORG, "task-2", "PostgreSQL"),
);
await waitFor(() => expect(screen.queryByText("در انتظار تصمیم شما")).toBeNull());
expect(mocks.engineApi.listTasks).toHaveBeenCalledTimes(2);
});
it("shows the credit notice at zero credit", async () => {
mocks.engineApi.status.mockResolvedValue(status({ credit_ok: false, balance: 0 }));
render(<ManagerPage />);
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
});
it("shows no credit notice while credit is available", async () => {
render(<ManagerPage />);
await screen.findByRole("button", { name: "ساخت کار" });
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).toBeNull();
});
});
describe("manager page: new task form", () => {
it("keeps the create call and says the AI manager takes the task first", async () => {
mocks.api.createTask.mockResolvedValue({ id: "task-7" });
mocks.engineApi.listTasks
.mockResolvedValueOnce([])
.mockResolvedValue([engineTask({ id: "task-7", title: "تحلیل بازار", stage: "pending_manager_review" })]);
render(<ManagerPage />);
expect(await screen.findByText(/نخست به مدیر هوش مصنوعی/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("عنوان کار جدید"), { target: { value: "تحلیل بازار" } });
fireEvent.click(screen.getByRole("button", { name: "ساخت کار" }));
await waitFor(() =>
expect(mocks.api.createTask).toHaveBeenCalledWith(
ORG,
expect.objectContaining({ title: "تحلیل بازار", thread_id: "thread-1" }),
),
);
expect(await screen.findByText("در انتظار بررسی مدیر")).toBeInTheDocument();
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* The manager page's engine parts. The task cards under a conversation show each engine task's
* Persian stage (and a legacy task's status), and link to the task page. The decision banner lists
* only the tasks that wait for an answer, is hidden when there are none, posts the answer, and
* shows a refusal from the server in Persian without losing what was typed.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
answerTask: vi.fn(),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: { answerTask: mocks.answerTask } }));
import { ApiError } from "@/lib/api";
import type { EngineTask } from "@/lib/api-engine";
import { DecisionBanner } from "@/app/(app)/manager/_components/decision-banner";
import { ThreadTaskCards } from "@/app/(app)/manager/_components/task-cards";
const ORG = "org-1";
function task(overrides: Partial<EngineTask> = {}): EngineTask {
return {
id: "task-1",
title: "صفحهٔ گزارش فروش",
status: "running",
stage: "pending_test",
pipeline: "default",
kind: "feature",
cycle: 1,
max_cycles: 3,
thread_id: "thread-1",
assigned_employee_id: null,
assignee_name: null,
created_at: "2026-10-01T00:00:00Z",
updated_at: "2026-10-01T00:00:00Z",
...overrides,
};
}
beforeEach(() => {
mocks.answerTask.mockReset();
});
describe("ThreadTaskCards", () => {
it("shows each engine task's Persian stage and links the card to the task page", () => {
render(<ThreadTaskCards tasks={[task({ stage: "pending_test" })]} />);
expect(screen.getByText("در انتظار تست")).toBeInTheDocument();
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute(
"href",
"/tasks/task-1",
);
});
it("shows a legacy task by its status, since the engine has no stage for it", () => {
const legacy = task({ id: "task-2", title: "کار قدیمی", stage: null, status: "done" });
render(<ThreadTaskCards tasks={[legacy]} />);
expect(screen.getByText("کار قدیمی")).toBeInTheDocument();
expect(screen.getByText("انجام شد")).toBeInTheDocument();
});
it("renders nothing for a conversation without tasks", () => {
const { container } = render(<ThreadTaskCards tasks={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("DecisionBanner", () => {
it("is hidden when no task waits for an answer", () => {
const { container } = render(
<DecisionBanner orgId={ORG} tasks={[task({ stage: "pending_test" })]} onAnswered={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("lists only the tasks at a pending decision", () => {
render(
<DecisionBanner
orgId={ORG}
tasks={[
task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
task({ id: "task-2", title: "تست واحد", stage: "pending_test" }),
]}
onAnswered={vi.fn()}
/>,
);
expect(screen.getByText("در انتظار تصمیم شما")).toBeInTheDocument();
expect(screen.getByText("کدام پایگاه داده؟")).toBeInTheDocument();
expect(screen.queryByText("تست واحد")).toBeNull();
});
it("posts the trimmed answer to the task and reports it", async () => {
mocks.answerTask.mockResolvedValue({});
const onAnswered = vi.fn();
render(
<DecisionBanner
orgId={ORG}
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
onAnswered={onAnswered}
/>,
);
const send = screen.getByRole("button", { name: "ارسال پاسخ" });
expect(send).toBeDisabled();
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: " PostgreSQL " } });
expect(send).toBeEnabled();
fireEvent.click(send);
await waitFor(() => expect(onAnswered).toHaveBeenCalledTimes(1));
expect(mocks.answerTask).toHaveBeenCalledWith(ORG, "task-1", "PostgreSQL");
});
it("shows the server's refusal in Persian and keeps the typed answer", async () => {
mocks.answerTask.mockRejectedValue(
new ApiError(409, "conflict", "این کار دیگر منتظر پاسخ نیست."),
);
const onAnswered = vi.fn();
render(
<DecisionBanner
orgId={ORG}
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
onAnswered={onAnswered}
/>,
);
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: "PostgreSQL" } });
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
expect(await screen.findByRole("alert")).toHaveTextContent("این کار دیگر منتظر پاسخ نیست.");
expect(onAnswered).not.toHaveBeenCalled();
expect(screen.getByLabelText("پاسخ شما")).toHaveValue("PostgreSQL");
});
});
@@ -0,0 +1,147 @@
/**
* The task controls: the buttons each stage shows, the confirm step, the API call and what
* happens after it, the server's refusal shown in Persian, and nothing for a legacy task or a
* viewer.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
pauseTask: vi.fn(),
resumeTask: vi.fn(),
cancelTask: vi.fn(),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks }));
import { ApiError } from "@/lib/api";
import { TaskActions, type TaskActionsProps } from "@/components/engine/task-actions";
const ORG = "org-1";
function renderActions(props: Partial<TaskActionsProps> = {}) {
const onChanged = vi.fn();
const view = render(
<TaskActions
orgId={ORG}
task={{ id: "task-1", stage: "pending_test" }}
canRun
onChanged={onChanged}
{...props}
/>,
);
return { ...view, onChanged };
}
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
});
describe("TaskActions buttons", () => {
it("offers pause and cancel while a task runs", () => {
renderActions({ task: { id: "task-1", stage: "pending_test" } });
expect(screen.getByRole("button", { name: "توقف" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "لغو" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "ادامه" })).toBeNull();
});
it("offers resume and cancel once a task is paused", () => {
renderActions({ task: { id: "task-1", stage: "paused" } });
expect(screen.getByRole("button", { name: "ادامه" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "لغو" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "توقف" })).toBeNull();
});
it("offers nothing for a finished task", () => {
renderActions({ task: { id: "task-1", stage: "completed" } });
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
});
describe("TaskActions confirm and call", () => {
it("asks first, then pauses through the API and reports the change", async () => {
mocks.pauseTask.mockResolvedValue({});
const { onChanged } = renderActions();
fireEvent.click(screen.getByRole("button", { name: "توقف" }));
const dialog = await screen.findByRole("dialog", { name: "کار متوقف شود؟" });
expect(mocks.pauseTask).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole("button", { name: "توقف کن" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
expect(mocks.pauseTask).toHaveBeenCalledWith(ORG, "task-1");
});
it("cancels through the API once confirmed", async () => {
mocks.cancelTask.mockResolvedValue({});
const { onChanged } = renderActions();
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
const dialog = await screen.findByRole("dialog", { name: "کار لغو شود؟" });
fireEvent.click(within(dialog).getByRole("button", { name: "لغو کن" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
expect(mocks.cancelTask).toHaveBeenCalledWith(ORG, "task-1");
});
it("resumes a paused task through the API once confirmed", async () => {
mocks.resumeTask.mockResolvedValue({});
const { onChanged } = renderActions({ task: { id: "task-1", stage: "paused" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه" }));
const dialog = await screen.findByRole("dialog", { name: "کار ادامه یابد؟" });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه بده" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
expect(mocks.resumeTask).toHaveBeenCalledWith(ORG, "task-1");
});
it("shows the server's refusal in Persian and does not report a change", async () => {
mocks.resumeTask.mockRejectedValue(
new ApiError(
409,
"conflict",
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد.",
),
);
const { onChanged } = renderActions({ task: { id: "task-1", stage: "paused" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه" }));
const dialog = await screen.findByRole("dialog", { name: "کار ادامه یابد؟" });
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه بده" }));
expect(await screen.findByRole("alert")).toHaveTextContent("اعتبار سازمان تمام شده است");
expect(onChanged).not.toHaveBeenCalled();
});
});
describe("TaskActions visibility", () => {
it("renders nothing for a legacy task", () => {
const { container } = renderActions({ task: { id: "task-1", stage: null } });
expect(container).toBeEmptyDOMElement();
});
it("renders nothing for someone who may not run tasks", () => {
const { container } = renderActions({ canRun: false });
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,129 @@
/**
* The task detail's pipeline and turn timeline. The current stage is highlighted and the stages
* before it check-marked; a side state is a badge and highlights no step; a completed task has
* every step check-marked; the roles read in Persian; and a field the API never defines (a model
* name) never reaches the page.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PipelineStepper } from "@/app/(app)/tasks/[id]/_components/pipeline-stepper";
import { TurnTimeline } from "@/app/(app)/tasks/[id]/_components/turn-timeline";
import type { EngineTurn } from "@/lib/api-engine";
const STAGES = [
"pending_manager_review",
"pending_analysis",
"pending_implementation",
"pending_test",
"pending_final_review",
"merge_request",
"completed",
];
const TURNS: EngineTurn[] = [
{
id: "turn-1",
run_id: "run-1",
role: "developer",
mode: "implement",
stage: "pending_implementation",
employee_name: "علی",
ok: true,
error_kind: "",
tool_calls: 3,
reply_excerpt: "کد نوشته شد",
started_at: "2026-10-13T08:00:00Z",
finished_at: "2026-10-13T08:05:00Z",
},
{
id: "turn-2",
run_id: "run-2",
role: "tester",
mode: "test",
stage: "pending_test",
employee_name: null,
ok: false,
error_kind: "tool_error",
tool_calls: 1,
reply_excerpt: "",
started_at: "2026-10-13T08:10:00Z",
finished_at: null,
},
];
function steps(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>("ol > li"));
}
describe("PipelineStepper", () => {
it("highlights the current stage and check-marks the stages before it", () => {
const { container } = render(<PipelineStepper stages={STAGES} current="pending_test" />);
const items = steps(container);
expect(items.map((item) => item.dataset.state)).toEqual([
"done",
"done",
"done",
"current",
"pending",
"pending",
"pending",
]);
expect(items[3]).toHaveAttribute("aria-current", "step");
expect(items[3]).toHaveTextContent("در انتظار تست");
expect(items[0]).toHaveTextContent("در انتظار بررسی مدیر");
});
it("shows a side state as a badge and highlights no step", () => {
const { container } = render(<PipelineStepper stages={STAGES} current="paused" />);
expect(screen.getByText("متوقف شده")).toBeInTheDocument();
expect(container.querySelector('[aria-current="step"]')).toBeNull();
expect(steps(container).every((item) => item.dataset.state === "pending")).toBe(true);
});
it("check-marks every stage once the task is completed", () => {
const { container } = render(<PipelineStepper stages={STAGES} current="completed" />);
expect(steps(container).every((item) => item.dataset.state === "done")).toBe(true);
expect(container.querySelector('[aria-current="step"]')).toBeNull();
});
it("renders nothing for a legacy task", () => {
const { container } = render(<PipelineStepper stages={[]} current={null} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("TurnTimeline", () => {
it("renders the roles in Persian with the employee, the outcome and the tool count", () => {
render(<TurnTimeline turns={TURNS} />);
expect(screen.getByText("برنامه‌نویس — علی")).toBeInTheDocument();
expect(screen.getByText("آزمونگر")).toBeInTheDocument();
expect(screen.getByText("موفق")).toBeInTheDocument();
expect(screen.getByText("خطا: tool_error")).toBeInTheDocument();
expect(screen.getByText("۳ فراخوانی ابزار")).toBeInTheDocument();
});
it("keeps the reply excerpt in a collapsible block", () => {
render(<TurnTimeline turns={TURNS} />);
expect(screen.getByText("کد نوشته شد").closest("details")).not.toBeNull();
});
it("never renders a model name, even when a turn carries one", () => {
const leaked = { ...TURNS[0], model: "gpt-secret-x" };
const { container } = render(<TurnTimeline turns={[leaked]} />);
expect(container.textContent).not.toContain("gpt-secret-x");
expect(screen.getByText("برنامه‌نویس — علی")).toBeInTheDocument();
});
it("says so when no turn has run yet", () => {
render(<TurnTimeline turns={[]} />);
expect(screen.getByText("هنوز نوبتی برای این کار ثبت نشده است.")).toBeInTheDocument();
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* The task detail page. An engine task (it has a pipeline stage) shows the engine view: the
* stepper, and no legacy run button. A legacy task keeps its run button. A `task.gate` or
* `task.step` event reloads an engine task once, after a 500 ms pause, and an answer to a pending
* decision goes to the engine API and reloads the task.
*/
import { Suspense } from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WsEvent } from "@agy/contracts";
import type { EngineTaskDetail, EngineTurn } from "@/lib/api-engine";
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: WsEvent) => void>(),
engine: {
getTask: vi.fn(),
answerTask: vi.fn(),
pauseTask: vi.fn(),
resumeTask: vi.fn(),
cancelTask: vi.fn(),
},
api: {
getTask: vi.fn(),
listEmployees: vi.fn(),
listPendingApprovals: vi.fn(),
getRun: vi.fn(),
decideApproval: vi.fn(),
startRun: vi.fn(),
cancelRun: vi.fn(),
},
session: { activeOrganization: { id: "org-1", name: "سازمان", role: "member" } },
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError, api: mocks.api };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engine }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
// Each subscriber keeps only its latest handler, keyed by its channels; an event reaches them all.
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: WsEvent) => void) => {
mocks.handlers.set(channels.join("|"), onEvent);
},
}));
import TaskDetailPage from "@/app/(app)/tasks/[id]/page";
const ORG = "org-1";
const TASK = "task-1";
function engineTask(overrides: Partial<EngineTaskDetail> = {}): EngineTaskDetail {
return {
id: TASK,
title: "افزودن صفحهٔ ورود",
status: "running",
stage: "pending_test",
pipeline: "plan_code_test",
kind: "code",
cycle: 1,
max_cycles: 3,
thread_id: null,
assigned_employee_id: null,
assignee_name: "علی",
created_at: "2026-10-13T08:00:00Z",
updated_at: "2026-10-13T08:05:00Z",
description: "یک صفحهٔ ورود بسازید",
stages: [
"pending_manager_review",
"pending_implementation",
"pending_test",
"completed",
],
waiver_reason: "",
branch: "task/1",
depends_on: [],
merged_sha: "",
diff: "",
turns: [],
gates: [],
runs: [],
...overrides,
};
}
function turn(reply: string): EngineTurn {
return {
id: "turn-1",
run_id: "run-1",
role: "developer",
mode: "implement",
stage: "pending_implementation",
employee_name: "علی",
ok: true,
error_kind: "",
tool_calls: 2,
reply_excerpt: reply,
started_at: "2026-10-13T08:00:00Z",
finished_at: "2026-10-13T08:04:00Z",
};
}
function legacyTask() {
return {
id: TASK,
organization_id: ORG,
project_id: null,
title: "کار قدیمی",
description: "",
status: "new",
assigned_employee_id: null,
created_at: "2026-10-01T08:00:00Z",
updated_at: "2026-10-01T08:00:00Z",
runs: [],
};
}
function emit(type: WsEvent["type"], data: Record<string, unknown>) {
act(() => {
for (const handler of mocks.handlers.values()) {
handler({ id: "evt-1", type, data, organization_id: ORG, at: "2026-10-14T08:00:00Z" });
}
});
}
/** A promise React reads at once (status "fulfilled"), so the first render does not suspend. */
function settledParams(id: string): Promise<{ id: string }> {
return Object.assign(Promise.resolve({ id }), { status: "fulfilled", value: { id } });
}
function renderPage() {
return render(
<Suspense fallback={null}>
<TaskDetailPage params={settledParams(TASK)} />
</Suspense>,
);
}
beforeEach(() => {
mocks.handlers.clear();
for (const fn of [...Object.values(mocks.engine), ...Object.values(mocks.api)]) fn.mockReset();
mocks.api.listEmployees.mockResolvedValue([]);
mocks.api.listPendingApprovals.mockResolvedValue([]);
});
describe("the task page for an engine task", () => {
it("shows the pipeline stepper and no legacy run button", async () => {
mocks.engine.getTask.mockResolvedValue(engineTask());
renderPage();
expect(await screen.findByRole("list", { name: "مراحل خط لوله" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "اجرای کار" })).toBeNull();
expect(mocks.api.getTask).not.toHaveBeenCalled();
});
it("reloads once, after the debounce, for a burst of task.gate and task.step events", async () => {
mocks.engine.getTask.mockResolvedValue(engineTask());
renderPage();
await screen.findByRole("list", { name: "مراحل خط لوله" });
expect(mocks.engine.getTask).toHaveBeenCalledTimes(1);
emit("task.gate", { task_id: TASK, gate: "tests", ok: false, detail: "3 failed" });
emit("task.step", {
task_id: TASK,
from: "pending_test",
to: "pending_final_review",
pipeline: "plan_code_test",
status: "running",
});
expect(mocks.engine.getTask).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mocks.engine.getTask).toHaveBeenCalledTimes(2), { timeout: 2000 });
expect(mocks.engine.getTask).toHaveBeenLastCalledWith(ORG, TASK);
});
it("sends a pending decision's answer through the engine API and reloads", async () => {
mocks.engine.getTask.mockResolvedValue(
engineTask({
stage: "pending_user_decision",
turns: [turn("کدام نسخه از کتابخانه را بسازم؟")],
}),
);
mocks.engine.answerTask.mockResolvedValue({});
renderPage();
expect((await screen.findAllByText("کدام نسخه از کتابخانه را بسازم؟")).length).toBeGreaterThan(0);
fireEvent.change(screen.getByRole("textbox", { name: "پاسخ شما" }), {
target: { value: " نسخهٔ دو " },
});
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
await waitFor(() => expect(mocks.engine.answerTask).toHaveBeenCalledWith(ORG, TASK, "نسخهٔ دو"));
await waitFor(() => expect(mocks.engine.getTask).toHaveBeenCalledTimes(2));
});
});
describe("the task page for a legacy task", () => {
it("keeps the legacy run button and shows no pipeline", async () => {
mocks.engine.getTask.mockResolvedValue(
engineTask({ stage: null, stages: [], turns: [], gates: [] }),
);
mocks.api.getTask.mockResolvedValue(legacyTask());
renderPage();
expect(await screen.findByRole("button", { name: "اجرای کار" })).toBeInTheDocument();
expect(screen.queryByRole("list", { name: "مراحل خط لوله" })).toBeNull();
expect(mocks.api.getTask).toHaveBeenCalledWith(ORG, TASK);
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* The traces page's engine turns: one row per turn with its Persian role, stage and result, a
* failed turn that shows its error kind, a turn still running that is not shown as failed, no
* reply text, and a reload on a task step only.
*/
import * as React from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTurns: vi.fn(),
listeners: new Set<(event: { type: string }) => void>(),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: { listTurns: mocks.listTurns } }));
// The realtime client keeps one subscription per channel, so every hook registers its own listener
// and the test delivers an event to all of them.
vi.mock("@/lib/realtime", async () => {
const { useEffect, useRef } = await import("react");
return {
useLiveChannel: (_channels: string[], onEvent: (event: { type: string }) => void) => {
const latest = useRef(onEvent);
latest.current = onEvent;
useEffect(() => {
const listener = (event: { type: string }) => latest.current(event);
mocks.listeners.add(listener);
return () => {
mocks.listeners.delete(listener);
};
}, []);
},
};
});
import { ApiError } from "@/lib/api";
import type { EngineOrgTurn } from "@/lib/api-engine";
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
import { EngineTurns } from "@/app/(app)/traces/_components/engine-turns";
const ORG = "org-1";
const REPLY = "reply text written by the model";
const EMPTY = "هنوز نوبتی ثبت نشده است.";
function turn(overrides: Partial<EngineOrgTurn> & { id: string }): EngineOrgTurn {
return {
run_id: "run-1",
role: "developer",
mode: "implement",
stage: "pending_implementation",
employee_name: "employee",
ok: true,
error_kind: "",
tool_calls: 3,
reply_excerpt: REPLY,
started_at: "2026-10-14T10:00:00Z",
finished_at: "2026-10-14T10:00:02Z",
task_id: "task-1",
task_title: "افزودن ورود",
...overrides,
};
}
/** The table row of one task, found through its link. */
function rowOf(table: HTMLElement, taskTitle: string): HTMLElement {
const row = within(table).getByRole("link", { name: taskTitle }).closest("tr");
if (!row) throw new Error(`no row for ${taskTitle}`);
return row;
}
/** Delivers one live event to every subscribed hook. */
function emit(type: string) {
act(() => {
for (const listener of [...mocks.listeners]) listener({ type });
});
}
beforeEach(() => {
mocks.listTurns.mockReset();
mocks.listeners.clear();
});
describe("EngineTurns table", () => {
it("renders one row per turn with its Persian role, stage, result and tool calls", async () => {
mocks.listTurns.mockResolvedValue([
turn({ id: "t1" }),
turn({
id: "t2",
task_id: "task-2",
task_title: "ثبت سفارش",
role: "reviewer",
stage: "pending_review",
ok: false,
error_kind: "timeout",
tool_calls: 0,
finished_at: "2026-10-14T10:00:05Z",
}),
]);
render(<EngineTurns orgId={ORG} />);
const table = await screen.findByRole("table");
const okRow = rowOf(table, "افزودن ورود");
expect(okRow).toHaveTextContent(ROLE_LABELS.developer);
expect(okRow).toHaveTextContent(STAGE_LABELS.pending_implementation);
expect(within(okRow).getByText("موفق")).toBeInTheDocument();
expect(within(okRow).getByText("3")).toBeInTheDocument();
expect(within(okRow).getByText("2.0s")).toBeInTheDocument();
expect(within(okRow).getByRole("link", { name: "افزودن ورود" })).toHaveAttribute(
"href",
"/tasks/task-1",
);
const failedRow = rowOf(table, "ثبت سفارش");
expect(failedRow).toHaveTextContent(ROLE_LABELS.reviewer);
expect(failedRow).toHaveTextContent(STAGE_LABELS.pending_review);
expect(within(failedRow).getByText("ناموفق")).toBeInTheDocument();
expect(within(failedRow).getByText("0")).toBeInTheDocument();
expect(within(failedRow).getByText("5.0s")).toBeInTheDocument();
expect(within(failedRow).getByRole("link", { name: "ثبت سفارش" })).toHaveAttribute(
"href",
"/tasks/task-2",
);
});
it("shows the error kind of a failed turn, and a turn still running is not shown as failed", async () => {
mocks.listTurns.mockResolvedValue([
turn({
id: "t1",
task_id: "task-1",
task_title: "افزودن ورود",
ok: false,
error_kind: "rate_limit",
finished_at: "2026-10-14T10:00:03Z",
}),
turn({
id: "t2",
task_id: "task-2",
task_title: "ثبت سفارش",
ok: false,
error_kind: "",
finished_at: null,
}),
]);
render(<EngineTurns orgId={ORG} />);
const table = await screen.findByRole("table");
const failedRow = rowOf(table, "افزودن ورود");
expect(within(failedRow).getByText("ناموفق")).toBeInTheDocument();
expect(within(failedRow).getByText("rate_limit")).toBeInTheDocument();
const runningRow = rowOf(table, "ثبت سفارش");
expect(within(runningRow).getByText("در جریان")).toBeInTheDocument();
expect(within(runningRow).queryByText("ناموفق")).toBeNull();
expect(within(runningRow).getByText("—")).toBeInTheDocument();
});
it("never shows the model's reply text", async () => {
mocks.listTurns.mockResolvedValue([turn({ id: "t1" })]);
render(<EngineTurns orgId={ORG} />);
await screen.findByRole("table");
expect(document.body).not.toHaveTextContent(REPLY);
});
it("shows a Persian message when there are no turns yet", async () => {
mocks.listTurns.mockResolvedValue([]);
render(<EngineTurns orgId={ORG} />);
expect(await screen.findByText(EMPTY)).toBeInTheDocument();
});
it("shows the failure in Persian when the turns cannot be loaded", async () => {
mocks.listTurns.mockRejectedValue(new ApiError(500, "server_error", "خطای موقت سرور."));
render(<EngineTurns orgId={ORG} />);
expect(await screen.findByRole("alert")).toHaveTextContent("خطای موقت سرور.");
});
});
describe("EngineTurns reload", () => {
it("loads the newest 30 turns and loads them again on a task step, and on no other event", async () => {
mocks.listTurns.mockResolvedValue([]);
render(<EngineTurns orgId={ORG} />);
expect(await screen.findByText(EMPTY)).toBeInTheDocument();
expect(mocks.listTurns).toHaveBeenCalledTimes(1);
expect(mocks.listTurns).toHaveBeenCalledWith(ORG, 30);
emit("run.log");
expect(mocks.listTurns).toHaveBeenCalledTimes(1);
emit("task.step");
await waitFor(() => expect(mocks.listTurns).toHaveBeenCalledTimes(2));
expect(mocks.listTurns).toHaveBeenLastCalledWith(ORG, 30);
});
});
@@ -0,0 +1,180 @@
/**
* The employee page for a member and for the platform owner. Both see the employee's role and
* tier. Only the owner sees the routing-policy select and the routing choices, and only the owner's
* save sends a routing policy; for a member the update leaves the field out.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import * as React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {
getEmployee: vi.fn(),
listTools: vi.fn(),
listRoutingChoices: vi.fn(),
updateEmployee: vi.fn(),
setToolPermission: vi.fn(),
addPromptVersion: vi.fn(),
},
engineApi: { getTeam: vi.fn() },
session: {
activeOrganization: { id: "org-1", name: "Acme", role: "manager" },
user: { is_platform_owner: false },
},
};
});
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engineApi }));
import type { EmployeeDetail, RoutingChoice } from "@agy/contracts";
import type { TeamView } from "@/lib/api-engine";
import { TIER_LABELS } from "@/lib/engine-labels";
import EmployeeDetailPage from "@/app/(app)/workforce/[id]/page";
import { roleLabel, tierLabel } from "@/app/(app)/workforce/_components/team-panel";
const EMPLOYEE: EmployeeDetail = {
id: "emp-1",
organization_id: "org-1",
name: "علی",
role_key: "developer",
status: "active",
routing_policy_id: "policy-1",
created_at: "2026-10-01T00:00:00Z",
prompt_versions: [],
tool_permissions: [],
open_task_count: 0,
};
const CHOICES: RoutingChoice[] = [
{ id: "policy-1", name: "مسیر تهاجمی", description: "" },
{ id: "policy-2", name: "مسیر محتاطانه", description: "" },
];
const TEAM: TeamView = {
employees: [
{
id: "emp-1",
name: "علی",
role_key: "developer",
status: "active",
tier: "strong",
busy_task_id: null,
busy_task_title: null,
hired_by: "manager",
},
],
tiers: [
{ tier: "fast", label: TIER_LABELS.fast },
{ tier: "strong", label: TIER_LABELS.strong },
],
max_employees: 7,
count: 1,
};
// A promise that is already settled: React.use reads its value at once, without suspending.
const PARAMS = Object.assign(Promise.resolve({ id: "emp-1" }), {
status: "fulfilled" as const,
value: { id: "emp-1" },
});
function setOwner(owner: boolean) {
mocks.session.user = { is_platform_owner: owner };
}
function renderPage() {
return render(
<React.Suspense fallback={<p>در حال بارگذاری</p>}>
<EmployeeDetailPage params={PARAMS} />
</React.Suspense>,
);
}
function stubLoad() {
mocks.api.getEmployee.mockResolvedValue(EMPLOYEE);
mocks.api.listTools.mockResolvedValue([]);
mocks.api.updateEmployee.mockResolvedValue(EMPLOYEE);
mocks.engineApi.getTeam.mockResolvedValue(TEAM);
}
beforeEach(() => {
for (const fn of [...Object.values(mocks.api), mocks.engineApi.getTeam]) fn.mockReset();
setOwner(false);
});
describe("employee page for a member", () => {
it("shows the role and the tier, with no routing select and no routing choice", async () => {
stubLoad();
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
renderPage();
expect(await screen.findByRole("heading", { name: "علی" })).toBeInTheDocument();
expect(screen.getByText(roleLabel("developer"))).toBeInTheDocument();
expect(screen.getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
expect(screen.queryByLabelText("سیاست کاری")).toBeNull();
expect(mocks.api.listRoutingChoices).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("مسیر تهاجمی");
expect(document.body.textContent).not.toContain("مسیر محتاطانه");
});
it("leaves the routing policy out of its save", async () => {
stubLoad();
renderPage();
fireEvent.click(await screen.findByRole("button", { name: /ذخیره/ }));
await waitFor(() => expect(mocks.api.updateEmployee).toHaveBeenCalledTimes(1));
const [orgId, employeeId, body] = mocks.api.updateEmployee.mock.calls[0];
expect(orgId).toBe("org-1");
expect(employeeId).toBe("emp-1");
expect(body).toEqual({ name: "علی", status: "active" });
expect(body).not.toHaveProperty("routing_policy_id");
});
});
describe("employee page for the platform owner", () => {
it("shows the routing select with its choices, and the tier", async () => {
setOwner(true);
stubLoad();
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
renderPage();
const select = await screen.findByLabelText("سیاست کاری");
expect(select).toHaveValue("policy-1");
expect(within(select).getByRole("option", { name: "مسیر تهاجمی" })).toBeInTheDocument();
expect(within(select).getByRole("option", { name: "مسیر محتاطانه" })).toBeInTheDocument();
expect(screen.getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
expect(mocks.api.listRoutingChoices).toHaveBeenCalledWith("org-1");
});
it("sends the routing policy with its save", async () => {
setOwner(true);
stubLoad();
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
renderPage();
fireEvent.change(await screen.findByLabelText("سیاست کاری"), {
target: { value: "policy-2" },
});
fireEvent.click(screen.getByRole("button", { name: /ذخیره/ }));
await waitFor(() => expect(mocks.api.updateEmployee).toHaveBeenCalledTimes(1));
const [, , body] = mocks.api.updateEmployee.mock.calls[0];
expect(body).toEqual({ name: "علی", status: "active", routing_policy_id: "policy-2" });
});
});
+211
View File
@@ -0,0 +1,211 @@
/**
* The team panel and the hire dialog. The panel shows the headcount and each member's role, tier
* and busy task, and never a model name, even when a record carries one. Firing asks first. The
* dialog hires with a role and a tier (a name only when one is typed), and it is disabled, with
* the reason shown, when no tier is granted or the team is at the owner's cap.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fire: vi.fn(),
hire: vi.fn(),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks }));
import { ApiError } from "@/lib/api";
import type { TeamMember, TeamView, TierOption } from "@/lib/api-engine";
import { TIER_LABELS } from "@/lib/engine-labels";
import {
AT_CAP_HINT,
HireDialog,
NO_TIER_HINT,
} from "@/app/(app)/workforce/_components/hire-dialog";
import { TeamPanel, roleLabel, tierLabel } from "@/app/(app)/workforce/_components/team-panel";
const ORG = "org-1";
const TIERS: TierOption[] = [
{ tier: "fast", label: TIER_LABELS.fast },
{ tier: "strong", label: TIER_LABELS.strong },
];
function member(overrides: Partial<TeamMember> = {}): TeamMember {
return {
id: "emp-1",
name: "علی",
role_key: "developer",
status: "active",
tier: "strong",
busy_task_id: null,
busy_task_title: null,
hired_by: "manager",
...overrides,
};
}
function teamOf(employees: TeamMember[], overrides: Partial<TeamView> = {}): TeamView {
return { employees, tiers: TIERS, max_employees: 7, count: employees.length, ...overrides };
}
beforeEach(() => {
mocks.fire.mockReset();
mocks.hire.mockReset();
});
describe("TeamPanel rows", () => {
it("shows the headcount and each member's role, tier and busy task", () => {
const team = teamOf([
member({
id: "emp-1",
name: "علی",
role_key: "developer",
tier: "strong",
busy_task_id: "task-9",
busy_task_title: "صفحه ورود",
}),
member({ id: "emp-2", name: "مریم", role_key: "tester", tier: "fast" }),
]);
render(<TeamPanel orgId={ORG} team={team} canManage onChanged={vi.fn()} />);
expect(screen.getByText("۲ از ۷ نفر")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "علی" })).toHaveAttribute("href", "/workforce/emp-1");
const rows = screen.getAllByRole("listitem");
expect(rows).toHaveLength(2);
expect(within(rows[0]).getByText(roleLabel("developer"))).toBeInTheDocument();
expect(within(rows[0]).getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
expect(within(rows[0]).getByRole("link", { name: "صفحه ورود" })).toHaveAttribute(
"href",
"/tasks/task-9",
);
expect(within(rows[1]).getByText(roleLabel("tester"))).toBeInTheDocument();
expect(within(rows[1]).getByText(`سطح: ${tierLabel("fast")}`)).toBeInTheDocument();
expect(within(rows[1]).getByText("بیکار")).toBeInTheDocument();
});
it("never shows a model name, even when the record carries one", () => {
const leaked = { ai_model_id: "m-123", model_name: "gpt-secret-x" };
const team = teamOf([{ ...member(), ...leaked }]);
render(<TeamPanel orgId={ORG} team={team} canManage onChanged={vi.fn()} />);
expect(screen.getByRole("link", { name: "علی" })).toBeInTheDocument();
expect(document.body.textContent).not.toContain("m-123");
expect(document.body.textContent).not.toContain("gpt-secret-x");
});
it("offers no fire control to someone who may not manage employees", () => {
render(
<TeamPanel orgId={ORG} team={teamOf([member()])} canManage={false} onChanged={vi.fn()} />,
);
expect(screen.queryByRole("button", { name: "حذف از تیم" })).toBeNull();
expect(screen.getByRole("link", { name: "علی" })).toBeInTheDocument();
});
});
describe("TeamPanel fire", () => {
it("asks first, then fires through the API and reports the change", async () => {
mocks.fire.mockResolvedValue({});
const onChanged = vi.fn();
render(<TeamPanel orgId={ORG} team={teamOf([member()])} canManage onChanged={onChanged} />);
fireEvent.click(screen.getByRole("button", { name: "حذف از تیم" }));
const dialog = await screen.findByRole("dialog");
expect(mocks.fire).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole("button", { name: "حذف کن" }));
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
expect(mocks.fire).toHaveBeenCalledWith(ORG, "emp-1");
});
it("shows the server's refusal in Persian and does not report a change", async () => {
mocks.fire.mockRejectedValue(new ApiError(409, "conflict", "آخرین مدیر سازمان حذف نشد."));
const onChanged = vi.fn();
render(<TeamPanel orgId={ORG} team={teamOf([member()])} canManage onChanged={onChanged} />);
fireEvent.click(screen.getByRole("button", { name: "حذف از تیم" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "حذف کن" }));
expect(await screen.findByRole("alert")).toHaveTextContent("آخرین مدیر سازمان حذف نشد.");
expect(onChanged).not.toHaveBeenCalled();
});
});
describe("HireDialog", () => {
it("hires with the chosen role and tier", async () => {
mocks.hire.mockResolvedValue({});
const onHired = vi.fn();
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={onHired} />);
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
fireEvent.change(within(dialog).getByLabelText("نقش"), { target: { value: "tester" } });
fireEvent.change(within(dialog).getByLabelText("سطح"), { target: { value: "fast" } });
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
await waitFor(() => expect(onHired).toHaveBeenCalledTimes(1));
expect(mocks.hire).toHaveBeenCalledWith(ORG, { role: "tester", tier: "fast" });
});
it("sends the name only when one is typed", async () => {
mocks.hire.mockResolvedValue({});
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
fireEvent.change(within(dialog).getByLabelText("نام (اختیاری)"), {
target: { value: " رضا " },
});
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
await waitFor(() => expect(mocks.hire).toHaveBeenCalledTimes(1));
expect(mocks.hire).toHaveBeenCalledWith(ORG, { role: "developer", tier: "fast", name: "رضا" });
});
it("shows the server's refusal and keeps the dialog open", async () => {
mocks.hire.mockRejectedValue(new ApiError(409, "conflict", "سقف تعداد کارمندان پر شده است."));
const onHired = vi.fn();
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={onHired} />);
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"سقف تعداد کارمندان پر شده است.",
);
expect(onHired).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "استخدام کارمند جدید" })).toBeInTheDocument();
});
it("is disabled, with the reason, when no tier is granted", () => {
render(<HireDialog orgId={ORG} tiers={[]} atCap={false} onHired={vi.fn()} />);
expect(screen.getByRole("button", { name: "کارمند جدید" })).toBeDisabled();
expect(screen.getByText(NO_TIER_HINT)).toBeInTheDocument();
});
it("is disabled, with the reason, at the owner's cap", () => {
render(<HireDialog orgId={ORG} tiers={TIERS} atCap onHired={vi.fn()} />);
expect(screen.getByRole("button", { name: "کارمند جدید" })).toBeDisabled();
expect(screen.getByText(AT_CAP_HINT)).toBeInTheDocument();
});
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Channel reference counts in the realtime client.
*
* Two live hooks on the same channel (``org:<id>``) must not lose their feed
* when one of them unmounts: the server hears about a channel only when its
* last holder leaves, and a reconnect replays only what is still held.
*
* The browser WebSocket is replaced by a small fake that records every frame
* the client sends and lets the test open or drop the link by hand.
*/
import { createElement } from "react";
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useLiveChannel } from "@/lib/realtime";
import { RealtimeClient, getRealtimeClient } from "@/lib/ws";
interface Frame {
action: string;
channels?: string[];
}
class FakeSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
/** Every socket the client has dialled, oldest first. */
static instances: FakeSocket[] = [];
readyState = FakeSocket.CONNECTING;
readonly sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((message: { data: unknown }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(readonly url: string) {
FakeSocket.instances.push(this);
}
send(data: string): void {
this.sent.push(JSON.parse(data) as Frame);
}
close(): void {
this.readyState = FakeSocket.CLOSED;
}
/** The handshake finished: the client's onopen runs (the replay happens there). */
open(): void {
this.readyState = FakeSocket.OPEN;
this.onopen?.();
}
/** The link died: the client's onclose runs and schedules a reconnect. */
drop(): void {
this.readyState = FakeSocket.CLOSED;
this.onclose?.();
}
}
function latestSocket(): FakeSocket {
const socket = FakeSocket.instances.at(-1);
if (!socket) throw new Error("the client has not dialled a socket");
return socket;
}
function framesOf(socket: FakeSocket, action: string): Frame[] {
return socket.sent.filter((frame) => frame.action === action);
}
describe("RealtimeClient channel reference counts", () => {
let client: RealtimeClient;
beforeEach(() => {
vi.useFakeTimers();
FakeSocket.instances = [];
vi.stubGlobal("WebSocket", FakeSocket);
client = new RealtimeClient(() => "token-1");
client.connect();
latestSocket().open();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("keeps a channel on the wire while another holder still has it", () => {
client.subscribe(["org:1"]);
client.subscribe(["org:1"]);
client.unsubscribe(["org:1"]);
expect(client.activeSubscriptions()).toEqual(["org:1"]);
expect(framesOf(latestSocket(), "subscribe")).toEqual([
{ action: "subscribe", channels: ["org:1"] },
]);
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
});
it("tells the server only when the last holder leaves", () => {
client.subscribe(["org:1"]);
client.subscribe(["org:1"]);
client.unsubscribe(["org:1"]);
client.unsubscribe(["org:1"]);
expect(client.activeSubscriptions()).toEqual([]);
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([
{ action: "unsubscribe", channels: ["org:1"] },
]);
});
it("ignores an unsubscribe for a channel nobody holds", () => {
client.unsubscribe(["org:9"]);
expect(client.activeSubscriptions()).toEqual([]);
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
});
it("counts a channel listed twice in one call as two holders", () => {
client.subscribe(["org:1", "org:1"]);
expect(framesOf(latestSocket(), "subscribe")).toEqual([
{ action: "subscribe", channels: ["org:1"] },
]);
client.unsubscribe(["org:1"]);
expect(client.activeSubscriptions()).toEqual(["org:1"]);
client.unsubscribe(["org:1"]);
expect(client.activeSubscriptions()).toEqual([]);
});
it("replays only the channels still held after a reconnect", () => {
client.subscribe(["org:1", "task:7"]);
client.subscribe(["org:1"]);
client.unsubscribe(["org:1"]); // the other hook on org:1 is still mounted
client.unsubscribe(["task:7"]); // this one is gone for good
const first = latestSocket();
first.drop(); // the client schedules a reconnect after its backoff
vi.advanceTimersByTime(1_000);
const next = latestSocket();
expect(next).not.toBe(first);
next.open();
expect(framesOf(next, "subscribe")).toEqual([
{ action: "subscribe", channels: ["org:1"] },
]);
});
});
function OrgFeed(): null {
useLiveChannel(["org:1"], () => undefined);
return null;
}
describe("useLiveChannel on a channel two components share", () => {
beforeEach(() => {
FakeSocket.instances = [];
vi.stubGlobal("WebSocket", FakeSocket);
getRealtimeClient().connect();
latestSocket().open();
});
afterEach(() => {
cleanup();
getRealtimeClient().disconnect();
vi.unstubAllGlobals();
});
it("keeps the org feed when one of the two components unmounts", () => {
const first = render(createElement(OrgFeed));
render(createElement(OrgFeed));
first.unmount();
expect(getRealtimeClient().activeSubscriptions()).toEqual(["org:1"]);
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
});
});