refactor(01-06): decompose social.tsx and runs.tsx into focused sub-components
- social.tsx (541 lines) -> 5 files: SocialPanel, AccountCard, AddAccountDialog, ActionHistory, social-config - runs.tsx (506 lines) -> 4 files: RunCenter, RunQueue, RunDetail, run-helpers - All sub-component files under 300 lines - Backward-compatible re-export barrels preserve existing App.tsx imports
This commit is contained in:
1 parent
d77486504d
commit
93e599befd
11 files changed
+1147
-1047
No files matched your search
@@ -1,506 +1,2 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
// Backward-compatible re-exports from decomposed run components
|
||||||
import {
|
export { RunCenter } from "./runs/RunCenter";
|
||||||
ArrowClockwise,
|
|
||||||
FolderOpen,
|
|
||||||
Lightning,
|
|
||||||
ListChecks,
|
|
||||||
PlayCircle,
|
|
||||||
WarningCircle,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
|
||||||
import { formatMoney, formatTime, timeAgo } from "../lib/formatters";
|
|
||||||
import { ActionButton, EmptyState, Input, ListFrame, ListRow, Panel, Select, StatusPill } from "./ui";
|
|
||||||
import { useT } from "../i18n";
|
|
||||||
|
|
||||||
const LOG_CHUNK_BYTES = 65_536;
|
|
||||||
|
|
||||||
interface RunCenterProps {
|
|
||||||
runs: RunRecord[];
|
|
||||||
tasks: TaskRecord[];
|
|
||||||
agents: AgentRecord[];
|
|
||||||
workspaces: WorkspaceRecord[];
|
|
||||||
selectedRunId: string | null;
|
|
||||||
onSelectRun: (runId: string) => void;
|
|
||||||
onCancelRun: (runId: string) => Promise<void> | void;
|
|
||||||
onOpenLogPath: (path: string) => Promise<void> | void;
|
|
||||||
onOpenConsole: (runId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunLogState {
|
|
||||||
content: string;
|
|
||||||
offset: number;
|
|
||||||
nextOffset: number;
|
|
||||||
totalBytes: number;
|
|
||||||
eof: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialLogState: RunLogState = {
|
|
||||||
content: "",
|
|
||||||
offset: 0,
|
|
||||||
nextOffset: 0,
|
|
||||||
totalBytes: 0,
|
|
||||||
eof: true,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isActiveStatus(status: RunRecord["status"]) {
|
|
||||||
return status === "queued" || status === "running";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAttentionStatus(status: RunRecord["status"]) {
|
|
||||||
return status === "failed" || status === "timed_out" || status === "interrupted";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RunCenter({
|
|
||||||
runs,
|
|
||||||
tasks,
|
|
||||||
agents,
|
|
||||||
workspaces,
|
|
||||||
selectedRunId,
|
|
||||||
onSelectRun,
|
|
||||||
onCancelRun,
|
|
||||||
onOpenLogPath,
|
|
||||||
onOpenConsole,
|
|
||||||
}: RunCenterProps) {
|
|
||||||
const t = useT();
|
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [logState, setLogState] = useState<RunLogState>(initialLogState);
|
|
||||||
const [logReloadKey, setLogReloadKey] = useState(0);
|
|
||||||
|
|
||||||
const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]);
|
|
||||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
|
||||||
const workspaceMap = useMemo(() => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces]);
|
|
||||||
|
|
||||||
const selectedRun = useMemo(
|
|
||||||
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
|
||||||
[runs, selectedRunId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRuns = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase();
|
|
||||||
return runs.filter((run) => {
|
|
||||||
if (statusFilter !== "all" && run.status !== statusFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const task = taskMap.get(run.taskId);
|
|
||||||
const agent = agentMap.get(run.agentId);
|
|
||||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
|
||||||
return [
|
|
||||||
task?.title ?? "",
|
|
||||||
agent?.name ?? "",
|
|
||||||
workspace?.name ?? "",
|
|
||||||
run.summary ?? "",
|
|
||||||
run.errorMessage ?? "",
|
|
||||||
run.connectorId,
|
|
||||||
run.id,
|
|
||||||
].some((value) => value.toLowerCase().includes(query));
|
|
||||||
});
|
|
||||||
}, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]);
|
|
||||||
|
|
||||||
const groupedCounts = useMemo(() => {
|
|
||||||
let active = 0;
|
|
||||||
let attention = 0;
|
|
||||||
let completed = 0;
|
|
||||||
|
|
||||||
for (const run of runs) {
|
|
||||||
if (isActiveStatus(run.status)) {
|
|
||||||
active += 1;
|
|
||||||
} else if (isAttentionStatus(run.status)) {
|
|
||||||
attention += 1;
|
|
||||||
} else {
|
|
||||||
completed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { active, attention, completed };
|
|
||||||
}, [runs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
|
||||||
const fallbackRun = runs[0];
|
|
||||||
if (fallbackRun) {
|
|
||||||
onSelectRun(fallbackRun.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [onSelectRun, runs, selectedRunId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function loadInitialChunk() {
|
|
||||||
if (!selectedRun) {
|
|
||||||
setLogState(initialLogState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
|
||||||
|
|
||||||
const metaResult = await window.agentCompany.getRunLogChunk({
|
|
||||||
companyId: selectedRun.companyId,
|
|
||||||
runId: selectedRun.id,
|
|
||||||
offset: 0,
|
|
||||||
limit: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!metaResult.ok) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLogState((current) => ({
|
|
||||||
...current,
|
|
||||||
loading: false,
|
|
||||||
error: metaResult.error.message,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalBytes = metaResult.data.totalBytes;
|
|
||||||
const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES);
|
|
||||||
const chunkResult =
|
|
||||||
startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES
|
|
||||||
? metaResult
|
|
||||||
: await window.agentCompany.getRunLogChunk({
|
|
||||||
companyId: selectedRun.companyId,
|
|
||||||
runId: selectedRun.id,
|
|
||||||
offset: startOffset,
|
|
||||||
limit: LOG_CHUNK_BYTES,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!chunkResult.ok) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLogState((current) => ({
|
|
||||||
...current,
|
|
||||||
loading: false,
|
|
||||||
error: chunkResult.error.message,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setLogState({
|
|
||||||
...chunkResult.data,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadInitialChunk();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [logReloadKey, selectedRun]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedRun || !isActiveStatus(selectedRun.status)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const intervalId = window.setInterval(async () => {
|
|
||||||
const latest = await window.agentCompany.getRunLogChunk({
|
|
||||||
companyId: selectedRun.companyId,
|
|
||||||
runId: selectedRun.id,
|
|
||||||
offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES),
|
|
||||||
limit: LOG_CHUNK_BYTES,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latest.ok || cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLogState({
|
|
||||||
...latest.data,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}, 2500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [logState.totalBytes, selectedRun]);
|
|
||||||
|
|
||||||
async function loadOlderLogs() {
|
|
||||||
if (!selectedRun || logState.offset <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES);
|
|
||||||
setLogState((current) => ({ ...current, loading: true, error: null }));
|
|
||||||
|
|
||||||
const result = await window.agentCompany.getRunLogChunk({
|
|
||||||
companyId: selectedRun.companyId,
|
|
||||||
runId: selectedRun.id,
|
|
||||||
offset: nextOffset,
|
|
||||||
limit: logState.offset - nextOffset,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
setLogState((current) => ({
|
|
||||||
...current,
|
|
||||||
loading: false,
|
|
||||||
error: result.error.message,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLogState({
|
|
||||||
...result.data,
|
|
||||||
content: `${result.data.content}${logState.content}`,
|
|
||||||
nextOffset: logState.nextOffset,
|
|
||||||
eof: logState.eof,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runs.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title={t("runs.noRunsYet")}
|
|
||||||
detail={t("runs.noRunsDetail")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10">
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
<RunSummaryCard
|
|
||||||
label={t("runs.activeLabel")}
|
|
||||||
value={groupedCounts.active}
|
|
||||||
detail={t("runs.queuedOrRunning")}
|
|
||||||
icon={PlayCircle}
|
|
||||||
/>
|
|
||||||
<RunSummaryCard
|
|
||||||
label={t("runs.needsAttention")}
|
|
||||||
value={groupedCounts.attention}
|
|
||||||
detail={t("runs.failedTimedInterrupted")}
|
|
||||||
icon={WarningCircle}
|
|
||||||
tone="danger"
|
|
||||||
/>
|
|
||||||
<RunSummaryCard
|
|
||||||
label={t("runs.completedLabel")}
|
|
||||||
value={groupedCounts.completed}
|
|
||||||
detail={t("runs.finishedWithout")}
|
|
||||||
icon={ListChecks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<Panel
|
|
||||||
title={t("runs.runQueue")}
|
|
||||||
action={(
|
|
||||||
<Select
|
|
||||||
compact
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(value) => setStatusFilter(value as "all" | RunRecord["status"])}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "All" },
|
|
||||||
{ value: "queued", label: "Queued" },
|
|
||||||
{ value: "running", label: "Running" },
|
|
||||||
{ value: "succeeded", label: "Succeeded" },
|
|
||||||
{ value: "failed", label: "Failed" },
|
|
||||||
{ value: "timed_out", label: "Timed out" },
|
|
||||||
{ value: "cancelled", label: "Cancelled" },
|
|
||||||
{ value: "interrupted", label: "Interrupted" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
|
||||||
<Input
|
|
||||||
ariaLabel="Search runs"
|
|
||||||
value={search}
|
|
||||||
onChange={setSearch}
|
|
||||||
placeholder={t("runs.searchRuns")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ListFrame>
|
|
||||||
{filteredRuns.map((run) => {
|
|
||||||
const task = taskMap.get(run.taskId);
|
|
||||||
const agent = agentMap.get(run.agentId);
|
|
||||||
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
|
||||||
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
|
|
||||||
const supportingText = run.errorMessage || run.summary || "No summary yet";
|
|
||||||
return (
|
|
||||||
<ListRow
|
|
||||||
key={run.id}
|
|
||||||
onClick={() => onSelectRun(run.id)}
|
|
||||||
className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}
|
|
||||||
>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{title}</div>
|
|
||||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">
|
|
||||||
{agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `· ${workspace.name}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={run.status} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span className="truncate">{supportingText}</span>
|
|
||||||
<span className="shrink-0">{timeAgo(run.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ListFrame>
|
|
||||||
{filteredRuns.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t("runs.noRunsMatch")}
|
|
||||||
detail={t("runs.noRunsMatchDetail")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{selectedRun ? (
|
|
||||||
<>
|
|
||||||
<Panel
|
|
||||||
title={t("runs.runDetail")}
|
|
||||||
action={(
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ActionButton
|
|
||||||
label={t("runs.openConsole")}
|
|
||||||
icon={Lightning}
|
|
||||||
onClick={() => onOpenConsole(selectedRun.id)}
|
|
||||||
/>
|
|
||||||
{isActiveStatus(selectedRun.status) ? (
|
|
||||||
<ActionButton
|
|
||||||
label={t("runs.cancelRun")}
|
|
||||||
tone="danger"
|
|
||||||
icon={WarningCircle}
|
|
||||||
onClick={() => void onCancelRun(selectedRun.id)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<RunMeta label="Status" value={<StatusPill status={selectedRun.status} />} />
|
|
||||||
<RunMeta label="Connector" value={selectedRun.connectorId} />
|
|
||||||
<RunMeta label="Task" value={taskMap.get(selectedRun.taskId)?.title ?? selectedRun.taskId} />
|
|
||||||
<RunMeta label="Agent" value={agentMap.get(selectedRun.agentId)?.name ?? selectedRun.agentId} />
|
|
||||||
<RunMeta label="Workspace" value={selectedRun.workspaceId ? (workspaceMap.get(selectedRun.workspaceId)?.name ?? selectedRun.workspaceId) : "No exclusive workspace lock"} />
|
|
||||||
<RunMeta label="Model" value={selectedRun.model ?? "Connector default"} />
|
|
||||||
<RunMeta label="Started" value={formatTime(selectedRun.startedAt)} />
|
|
||||||
<RunMeta label="Finished" value={formatTime(selectedRun.finishedAt)} />
|
|
||||||
<RunMeta label="Cost" value={formatMoney(selectedRun.costUsd)} />
|
|
||||||
<RunMeta label="Log bytes" value={logState.totalBytes.toLocaleString()} />
|
|
||||||
</div>
|
|
||||||
{selectedRun.errorMessage ? (
|
|
||||||
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
|
||||||
{selectedRun.errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{selectedRun.logPath ? (
|
|
||||||
<div className="mt-5">
|
|
||||||
<ActionButton
|
|
||||||
label={t("runs.openLogFile")}
|
|
||||||
icon={FolderOpen}
|
|
||||||
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel
|
|
||||||
title={t("runs.logTail")}
|
|
||||||
eyebrow={t("runs.incrementalReader")}
|
|
||||||
action={(
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ActionButton
|
|
||||||
label={t("runs.reloadTail")}
|
|
||||||
icon={ArrowClockwise}
|
|
||||||
onClick={() => setLogReloadKey((current) => current + 1)}
|
|
||||||
/>
|
|
||||||
{logState.offset > 0 ? (
|
|
||||||
<ActionButton
|
|
||||||
label={t("runs.loadOlder")}
|
|
||||||
icon={ListChecks}
|
|
||||||
onClick={() => void loadOlderLogs()}
|
|
||||||
disabled={logState.loading}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{logState.error ? (
|
|
||||||
<div className="rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
|
||||||
{logState.error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mb-3 flex items-center justify-between text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span>
|
|
||||||
Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span>{logState.loading ? "Refreshing…" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[420px] overflow-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
|
||||||
<pre className="mono whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
|
||||||
{logState.content || "No log output captured yet."}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunSummaryCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
detail,
|
|
||||||
icon: Icon,
|
|
||||||
tone = "default",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
detail: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
tone?: "default" | "danger";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
|
||||||
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{detail}</div>
|
|
||||||
</div>
|
|
||||||
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] ${tone === "danger" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>
|
|
||||||
<Icon size={18} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3.5 py-3">
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
|
||||||
<div className="mt-1 text-[13px] text-[color:var(--text)]">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ListChecks, PlayCircle, WarningCircle } from "@phosphor-icons/react";
|
||||||
|
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||||
|
import { EmptyState } from "../ui";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { LOG_CHUNK_BYTES, initialLogState, isActiveStatus, isAttentionStatus, RunSummaryCard } from "./run-helpers";
|
||||||
|
import type { RunLogState } from "./run-helpers";
|
||||||
|
import { RunQueue } from "./RunQueue";
|
||||||
|
import { RunDetail } from "./RunDetail";
|
||||||
|
|
||||||
|
interface RunCenterProps {
|
||||||
|
runs: RunRecord[];
|
||||||
|
tasks: TaskRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
workspaces: WorkspaceRecord[];
|
||||||
|
selectedRunId: string | null;
|
||||||
|
onSelectRun: (runId: string) => void;
|
||||||
|
onCancelRun: (runId: string) => Promise<void> | void;
|
||||||
|
onOpenLogPath: (path: string) => Promise<void> | void;
|
||||||
|
onOpenConsole: (runId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunCenter({
|
||||||
|
runs,
|
||||||
|
tasks,
|
||||||
|
agents,
|
||||||
|
workspaces,
|
||||||
|
selectedRunId,
|
||||||
|
onSelectRun,
|
||||||
|
onCancelRun,
|
||||||
|
onOpenLogPath,
|
||||||
|
onOpenConsole,
|
||||||
|
}: RunCenterProps) {
|
||||||
|
const t = useT();
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | RunRecord["status"]>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [logState, setLogState] = useState<RunLogState>(initialLogState);
|
||||||
|
const [logReloadKey, setLogReloadKey] = useState(0);
|
||||||
|
|
||||||
|
const agentMap = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]);
|
||||||
|
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||||
|
const workspaceMap = useMemo(() => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces]);
|
||||||
|
|
||||||
|
const selectedRun = useMemo(
|
||||||
|
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||||
|
[runs, selectedRunId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRuns = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return runs.filter((run) => {
|
||||||
|
if (statusFilter !== "all" && run.status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const task = taskMap.get(run.taskId);
|
||||||
|
const agent = agentMap.get(run.agentId);
|
||||||
|
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||||
|
return [
|
||||||
|
task?.title ?? "",
|
||||||
|
agent?.name ?? "",
|
||||||
|
workspace?.name ?? "",
|
||||||
|
run.summary ?? "",
|
||||||
|
run.errorMessage ?? "",
|
||||||
|
run.connectorId,
|
||||||
|
run.id,
|
||||||
|
].some((value) => value.toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
}, [agentMap, runs, search, statusFilter, taskMap, workspaceMap]);
|
||||||
|
|
||||||
|
const groupedCounts = useMemo(() => {
|
||||||
|
let active = 0, attention = 0, completed = 0;
|
||||||
|
for (const run of runs) {
|
||||||
|
if (isActiveStatus(run.status)) active += 1;
|
||||||
|
else if (isAttentionStatus(run.status)) attention += 1;
|
||||||
|
else completed += 1;
|
||||||
|
}
|
||||||
|
return { active, attention, completed };
|
||||||
|
}, [runs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
|
||||||
|
const fallbackRun = runs[0];
|
||||||
|
if (fallbackRun) {
|
||||||
|
onSelectRun(fallbackRun.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onSelectRun, runs, selectedRunId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadInitialChunk() {
|
||||||
|
if (!selectedRun) {
|
||||||
|
setLogState(initialLogState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||||
|
|
||||||
|
const metaResult = await window.agentCompany.getRunLogChunk({
|
||||||
|
companyId: selectedRun.companyId,
|
||||||
|
runId: selectedRun.id,
|
||||||
|
offset: 0,
|
||||||
|
limit: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!metaResult.ok) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLogState((current) => ({
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error: metaResult.error.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = metaResult.data.totalBytes;
|
||||||
|
const startOffset = Math.max(0, totalBytes - LOG_CHUNK_BYTES);
|
||||||
|
const chunkResult =
|
||||||
|
startOffset === 0 && totalBytes <= LOG_CHUNK_BYTES
|
||||||
|
? metaResult
|
||||||
|
: await window.agentCompany.getRunLogChunk({
|
||||||
|
companyId: selectedRun.companyId,
|
||||||
|
runId: selectedRun.id,
|
||||||
|
offset: startOffset,
|
||||||
|
limit: LOG_CHUNK_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chunkResult.ok) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLogState((current) => ({
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error: chunkResult.error.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setLogState({
|
||||||
|
...chunkResult.data,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadInitialChunk();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [logReloadKey, selectedRun]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRun || !isActiveStatus(selectedRun.status)) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const intervalId = window.setInterval(async () => {
|
||||||
|
const latest = await window.agentCompany.getRunLogChunk({
|
||||||
|
companyId: selectedRun.companyId,
|
||||||
|
runId: selectedRun.id,
|
||||||
|
offset: Math.max(0, logState.totalBytes - LOG_CHUNK_BYTES),
|
||||||
|
limit: LOG_CHUNK_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest.ok || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogState({
|
||||||
|
...latest.data,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [logState.totalBytes, selectedRun]);
|
||||||
|
|
||||||
|
async function loadOlderLogs() {
|
||||||
|
if (!selectedRun || logState.offset <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOffset = Math.max(0, logState.offset - LOG_CHUNK_BYTES);
|
||||||
|
setLogState((current) => ({ ...current, loading: true, error: null }));
|
||||||
|
|
||||||
|
const result = await window.agentCompany.getRunLogChunk({
|
||||||
|
companyId: selectedRun.companyId,
|
||||||
|
runId: selectedRun.id,
|
||||||
|
offset: nextOffset,
|
||||||
|
limit: logState.offset - nextOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
setLogState((current) => ({
|
||||||
|
...current,
|
||||||
|
loading: false,
|
||||||
|
error: result.error.message,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogState({
|
||||||
|
...result.data,
|
||||||
|
content: `${result.data.content}${logState.content}`,
|
||||||
|
nextOffset: logState.nextOffset,
|
||||||
|
eof: logState.eof,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={t("runs.noRunsYet")}
|
||||||
|
detail={t("runs.noRunsDetail")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<RunSummaryCard
|
||||||
|
label={t("runs.activeLabel")}
|
||||||
|
value={groupedCounts.active}
|
||||||
|
detail={t("runs.queuedOrRunning")}
|
||||||
|
icon={PlayCircle}
|
||||||
|
/>
|
||||||
|
<RunSummaryCard
|
||||||
|
label={t("runs.needsAttention")}
|
||||||
|
value={groupedCounts.attention}
|
||||||
|
detail={t("runs.failedTimedInterrupted")}
|
||||||
|
icon={WarningCircle}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<RunSummaryCard
|
||||||
|
label={t("runs.completedLabel")}
|
||||||
|
value={groupedCounts.completed}
|
||||||
|
detail={t("runs.finishedWithout")}
|
||||||
|
icon={ListChecks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||||
|
<RunQueue
|
||||||
|
filteredRuns={filteredRuns}
|
||||||
|
selectedRun={selectedRun}
|
||||||
|
taskMap={taskMap}
|
||||||
|
agentMap={agentMap}
|
||||||
|
workspaceMap={workspaceMap}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedRun ? (
|
||||||
|
<RunDetail
|
||||||
|
selectedRun={selectedRun}
|
||||||
|
taskMap={taskMap}
|
||||||
|
agentMap={agentMap}
|
||||||
|
workspaceMap={workspaceMap}
|
||||||
|
logState={logState}
|
||||||
|
onOpenConsole={onOpenConsole}
|
||||||
|
onCancelRun={onCancelRun}
|
||||||
|
onOpenLogPath={onOpenLogPath}
|
||||||
|
onReloadLog={() => setLogReloadKey((current) => current + 1)}
|
||||||
|
onLoadOlderLogs={() => void loadOlderLogs()}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
ArrowClockwise,
|
||||||
|
FolderOpen,
|
||||||
|
Lightning,
|
||||||
|
ListChecks,
|
||||||
|
WarningCircle,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||||
|
import { formatMoney, formatTime } from "../../lib/formatters";
|
||||||
|
import { ActionButton, Panel, StatusPill } from "../ui";
|
||||||
|
import type { TranslationFn } from "../../i18n";
|
||||||
|
import { RunMeta, isActiveStatus } from "./run-helpers";
|
||||||
|
import type { RunLogState } from "./run-helpers";
|
||||||
|
|
||||||
|
interface RunDetailProps {
|
||||||
|
selectedRun: RunRecord;
|
||||||
|
taskMap: Map<string, TaskRecord>;
|
||||||
|
agentMap: Map<string, AgentRecord>;
|
||||||
|
workspaceMap: Map<string, WorkspaceRecord>;
|
||||||
|
logState: RunLogState;
|
||||||
|
onOpenConsole: (runId: string) => void;
|
||||||
|
onCancelRun: (runId: string) => Promise<void> | void;
|
||||||
|
onOpenLogPath: (path: string) => Promise<void> | void;
|
||||||
|
onReloadLog: () => void;
|
||||||
|
onLoadOlderLogs: () => void;
|
||||||
|
t: TranslationFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunDetail({
|
||||||
|
selectedRun,
|
||||||
|
taskMap,
|
||||||
|
agentMap,
|
||||||
|
workspaceMap,
|
||||||
|
logState,
|
||||||
|
onOpenConsole,
|
||||||
|
onCancelRun,
|
||||||
|
onOpenLogPath,
|
||||||
|
onReloadLog,
|
||||||
|
onLoadOlderLogs,
|
||||||
|
t,
|
||||||
|
}: RunDetailProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Panel
|
||||||
|
title={t("runs.runDetail")}
|
||||||
|
action={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ActionButton
|
||||||
|
label={t("runs.openConsole")}
|
||||||
|
icon={Lightning}
|
||||||
|
onClick={() => onOpenConsole(selectedRun.id)}
|
||||||
|
/>
|
||||||
|
{isActiveStatus(selectedRun.status) ? (
|
||||||
|
<ActionButton
|
||||||
|
label={t("runs.cancelRun")}
|
||||||
|
tone="danger"
|
||||||
|
icon={WarningCircle}
|
||||||
|
onClick={() => void onCancelRun(selectedRun.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<RunMeta label="Status" value={<StatusPill status={selectedRun.status} />} />
|
||||||
|
<RunMeta label="Connector" value={selectedRun.connectorId} />
|
||||||
|
<RunMeta label="Task" value={taskMap.get(selectedRun.taskId)?.title ?? selectedRun.taskId} />
|
||||||
|
<RunMeta label="Agent" value={agentMap.get(selectedRun.agentId)?.name ?? selectedRun.agentId} />
|
||||||
|
<RunMeta label="Workspace" value={selectedRun.workspaceId ? (workspaceMap.get(selectedRun.workspaceId)?.name ?? selectedRun.workspaceId) : "No exclusive workspace lock"} />
|
||||||
|
<RunMeta label="Model" value={selectedRun.model ?? "Connector default"} />
|
||||||
|
<RunMeta label="Started" value={formatTime(selectedRun.startedAt)} />
|
||||||
|
<RunMeta label="Finished" value={formatTime(selectedRun.finishedAt)} />
|
||||||
|
<RunMeta label="Cost" value={formatMoney(selectedRun.costUsd)} />
|
||||||
|
<RunMeta label="Log bytes" value={logState.totalBytes.toLocaleString()} />
|
||||||
|
</div>
|
||||||
|
{selectedRun.errorMessage ? (
|
||||||
|
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||||
|
{selectedRun.errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedRun.logPath ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
<ActionButton
|
||||||
|
label={t("runs.openLogFile")}
|
||||||
|
icon={FolderOpen}
|
||||||
|
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title={t("runs.logTail")}
|
||||||
|
eyebrow={t("runs.incrementalReader")}
|
||||||
|
action={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ActionButton
|
||||||
|
label={t("runs.reloadTail")}
|
||||||
|
icon={ArrowClockwise}
|
||||||
|
onClick={onReloadLog}
|
||||||
|
/>
|
||||||
|
{logState.offset > 0 ? (
|
||||||
|
<ActionButton
|
||||||
|
label={t("runs.loadOlder")}
|
||||||
|
icon={ListChecks}
|
||||||
|
onClick={onLoadOlderLogs}
|
||||||
|
disabled={logState.loading}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{logState.error ? (
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]">
|
||||||
|
{logState.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mb-3 flex items-center justify-between text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span>
|
||||||
|
Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span>{logState.loading ? "Refreshing\u2026" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[420px] overflow-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||||
|
<pre className="mono whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||||
|
{logState.content || "No log output captured yet."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
|
||||||
|
import { timeAgo } from "../../lib/formatters";
|
||||||
|
import { EmptyState, Input, ListFrame, ListRow, Panel, Select, StatusPill } from "../ui";
|
||||||
|
import type { TranslationFn } from "../../i18n";
|
||||||
|
|
||||||
|
interface RunQueueProps {
|
||||||
|
filteredRuns: RunRecord[];
|
||||||
|
selectedRun: RunRecord | null;
|
||||||
|
taskMap: Map<string, TaskRecord>;
|
||||||
|
agentMap: Map<string, AgentRecord>;
|
||||||
|
workspaceMap: Map<string, WorkspaceRecord>;
|
||||||
|
statusFilter: "all" | RunRecord["status"];
|
||||||
|
setStatusFilter: (value: "all" | RunRecord["status"]) => void;
|
||||||
|
search: string;
|
||||||
|
setSearch: (value: string) => void;
|
||||||
|
onSelectRun: (runId: string) => void;
|
||||||
|
t: TranslationFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunQueue({
|
||||||
|
filteredRuns,
|
||||||
|
selectedRun,
|
||||||
|
taskMap,
|
||||||
|
agentMap,
|
||||||
|
workspaceMap,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
onSelectRun,
|
||||||
|
t,
|
||||||
|
}: RunQueueProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Panel
|
||||||
|
title={t("runs.runQueue")}
|
||||||
|
action={(
|
||||||
|
<Select
|
||||||
|
compact
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(value) => setStatusFilter(value as "all" | RunRecord["status"])}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "queued", label: "Queued" },
|
||||||
|
{ value: "running", label: "Running" },
|
||||||
|
{ value: "succeeded", label: "Succeeded" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "timed_out", label: "Timed out" },
|
||||||
|
{ value: "cancelled", label: "Cancelled" },
|
||||||
|
{ value: "interrupted", label: "Interrupted" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
ariaLabel="Search runs"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder={t("runs.searchRuns")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ListFrame>
|
||||||
|
{filteredRuns.map((run) => {
|
||||||
|
const task = taskMap.get(run.taskId);
|
||||||
|
const agent = agentMap.get(run.agentId);
|
||||||
|
const workspace = run.workspaceId ? workspaceMap.get(run.workspaceId) : null;
|
||||||
|
const title = task?.title ?? (run.summary || `Run ${run.id.slice(0, 8)}`);
|
||||||
|
const supportingText = run.errorMessage || run.summary || "No summary yet";
|
||||||
|
return (
|
||||||
|
<ListRow
|
||||||
|
key={run.id}
|
||||||
|
onClick={() => onSelectRun(run.id)}
|
||||||
|
className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{title}</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">
|
||||||
|
{agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `\u00B7 ${workspace.name}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={run.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span className="truncate">{supportingText}</span>
|
||||||
|
<span className="shrink-0">{timeAgo(run.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ListFrame>
|
||||||
|
{filteredRuns.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("runs.noRunsMatch")}
|
||||||
|
detail={t("runs.noRunsMatchDetail")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { RunRecord } from "@shared/types";
|
||||||
|
|
||||||
|
export const LOG_CHUNK_BYTES = 65_536;
|
||||||
|
|
||||||
|
export interface RunLogState {
|
||||||
|
content: string;
|
||||||
|
offset: number;
|
||||||
|
nextOffset: number;
|
||||||
|
totalBytes: number;
|
||||||
|
eof: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialLogState: RunLogState = {
|
||||||
|
content: "",
|
||||||
|
offset: 0,
|
||||||
|
nextOffset: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eof: true,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isActiveStatus(status: RunRecord["status"]) {
|
||||||
|
return status === "queued" || status === "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttentionStatus(status: RunRecord["status"]) {
|
||||||
|
return status === "failed" || status === "timed_out" || status === "interrupted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunSummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
icon: Icon,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
||||||
|
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{detail}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] ${tone === "danger" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunMeta({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3.5 py-3">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
|
||||||
|
<div className="mt-1 text-[13px] text-[color:var(--text)]">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,541 +1,2 @@
|
|||||||
import { type ElementType, useCallback, useState } from "react";
|
// Backward-compatible re-exports from decomposed social components
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
export { SocialAccountsPanel } from "./social/SocialPanel";
|
||||||
import {
|
|
||||||
Browser,
|
|
||||||
ArrowSquareOut,
|
|
||||||
Camera,
|
|
||||||
ClockCounterClockwise,
|
|
||||||
ChatCircle,
|
|
||||||
Eye,
|
|
||||||
Globe,
|
|
||||||
Heart,
|
|
||||||
MagnifyingGlass,
|
|
||||||
Megaphone,
|
|
||||||
PaperPlaneTilt,
|
|
||||||
Plus,
|
|
||||||
Robot,
|
|
||||||
ShieldCheck,
|
|
||||||
SignIn,
|
|
||||||
Trash,
|
|
||||||
UserPlus,
|
|
||||||
FacebookLogo,
|
|
||||||
GlobeHemisphereWest,
|
|
||||||
InstagramLogo,
|
|
||||||
LinkedinLogo,
|
|
||||||
RedditLogo,
|
|
||||||
TiktokLogo,
|
|
||||||
XLogo,
|
|
||||||
X,
|
|
||||||
YoutubeLogo,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
|
||||||
import { timeAgo } from "../lib/formatters";
|
|
||||||
import { ActionButton, Input } from "./ui";
|
|
||||||
import { useT } from "../i18n";
|
|
||||||
|
|
||||||
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: ElementType }> = {
|
|
||||||
twitter: { label: "Twitter / X", color: "#1D9BF0", bgColor: "#1D9BF014", icon: XLogo },
|
|
||||||
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C214", icon: LinkedinLogo },
|
|
||||||
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F14", icon: InstagramLogo },
|
|
||||||
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F214", icon: FacebookLogo },
|
|
||||||
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450014", icon: RedditLogo },
|
|
||||||
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
|
||||||
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
|
||||||
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
|
||||||
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
|
||||||
post: { label: "Post", icon: PaperPlaneTilt },
|
|
||||||
reply: { label: "Reply", icon: ChatCircle },
|
|
||||||
like: { label: "Like", icon: Heart },
|
|
||||||
follow: { label: "Follow", icon: UserPlus },
|
|
||||||
browse_feed: { label: "Browse Feed", icon: Eye },
|
|
||||||
screenshot: { label: "Screenshot", icon: Camera },
|
|
||||||
navigate: { label: "Navigate", icon: Globe },
|
|
||||||
search: { label: "Search", icon: MagnifyingGlass },
|
|
||||||
dm: { label: "Direct Message", icon: ChatCircle },
|
|
||||||
custom_script: { label: "Legacy Action", icon: Browser },
|
|
||||||
};
|
|
||||||
|
|
||||||
function AccountCard({
|
|
||||||
account,
|
|
||||||
onLogin,
|
|
||||||
onDelete,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
account: SocialAccountRecord;
|
|
||||||
onLogin: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
|
||||||
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
|
||||||
const PlatformIcon = platform.icon;
|
|
||||||
const cardContent = (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
|
||||||
<span
|
|
||||||
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
|
||||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
|
||||||
>
|
|
||||||
{platform.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
|
||||||
<span>@{account.accountName}</span>
|
|
||||||
{account.requireApproval ? (
|
|
||||||
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
|
||||||
<ShieldCheck size={10} />
|
|
||||||
Approval required
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
|
||||||
<div
|
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
|
|
||||||
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
|
||||||
>
|
|
||||||
<PlatformIcon size={18} weight="fill" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{onClick ? (
|
|
||||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
|
||||||
{cardContent}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="min-w-0 flex-1 text-left">{cardContent}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
|
||||||
isLoggedIn
|
|
||||||
? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
|
||||||
: account.status === "login_required"
|
|
||||||
? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
|
||||||
: account.status === "suspended"
|
|
||||||
? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
|
||||||
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
|
||||||
}`}>
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
|
||||||
isLoggedIn ? "bg-[color:var(--success)]"
|
|
||||||
: account.status === "login_required" ? "bg-[color:var(--warn)]"
|
|
||||||
: account.status === "suspended" ? "bg-[color:var(--danger)]"
|
|
||||||
: "bg-[color:var(--muted)]"
|
|
||||||
}`} />
|
|
||||||
{account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{account.status === "login_required" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onLogin}
|
|
||||||
className="flex items-center gap-1 rounded-[6px] bg-[color:var(--accent)] px-2.5 py-1.5 text-[10px] font-semibold text-white transition hover:opacity-90"
|
|
||||||
>
|
|
||||||
<SignIn size={12} />
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDelete}
|
|
||||||
className="rounded-[6px] p-1.5 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Trash size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddAccountDialog({
|
|
||||||
onAdd,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
|
|
||||||
const [accountName, setAccountName] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [requireApproval, setRequireApproval] = useState(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
|
||||||
className="w-full max-w-[420px] rounded-[14px] border border-[color:var(--line)] bg-[color:var(--panel)] p-6 shadow-xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="mb-5 flex items-center justify-between">
|
|
||||||
<h3 className="text-[16px] font-bold text-[color:var(--text)]">Add Social Account</h3>
|
|
||||||
<button type="button" onClick={onClose} className="rounded-[6px] p-1 text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)]">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
|
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
|
||||||
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPlatform(key)}
|
|
||||||
className={`flex items-center gap-2 rounded-[8px] border px-3 py-2 text-[11px] font-medium transition ${
|
|
||||||
platform === key
|
|
||||||
? "border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
|
||||||
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span style={{ color: config.color }}>
|
|
||||||
<config.icon size={14} />
|
|
||||||
</span>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Username / Handle"
|
|
||||||
value={accountName}
|
|
||||||
onChange={setAccountName}
|
|
||||||
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Display Name (optional)"
|
|
||||||
value={displayName}
|
|
||||||
onChange={setDisplayName}
|
|
||||||
placeholder="Your display name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
|
||||||
<label className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={requireApproval}
|
|
||||||
onChange={(e) => setRequireApproval(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="text-[12px] font-medium text-[color:var(--text)]">Require Board Approval for Posts</div>
|
|
||||||
<div className="mt-0.5 text-[10px] text-[color:var(--muted)]">
|
|
||||||
Agents must get approval before publishing content to this account
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<ActionButton
|
|
||||||
label="Add Account"
|
|
||||||
onClick={() => {
|
|
||||||
if (accountName.trim()) {
|
|
||||||
onAdd(platform, accountName.trim(), displayName.trim(), requireApproval);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={Plus}
|
|
||||||
tone="accent"
|
|
||||||
disabled={!accountName.trim()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionHistory({
|
|
||||||
actions,
|
|
||||||
agents,
|
|
||||||
onOpenPath,
|
|
||||||
onCancelAction,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
actions: BrowserActionRecord[];
|
|
||||||
agents: Array<{ id: string; name: string }>;
|
|
||||||
onOpenPath: (path: string) => Promise<void>;
|
|
||||||
onCancelAction: (actionId: string) => Promise<void>;
|
|
||||||
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
|
||||||
}) {
|
|
||||||
if (actions.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
|
|
||||||
No browser actions yet. Agents will create actions when working on social media tasks.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{actions.slice(0, 30).map((action) => {
|
|
||||||
const config = actionTypeLabels[action.actionType] ?? actionTypeLabels.custom_script;
|
|
||||||
const Icon = config.icon;
|
|
||||||
const agent = agents.find((a) => a.id === action.agentId);
|
|
||||||
const isSuccess = action.status === "succeeded";
|
|
||||||
const isFailed = action.status === "failed";
|
|
||||||
const isRunning = action.status === "running";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={action.id}
|
|
||||||
className="flex items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5 text-[12px]"
|
|
||||||
>
|
|
||||||
<Icon size={16} className={
|
|
||||||
isSuccess ? "text-[color:var(--success)]"
|
|
||||||
: isFailed ? "text-[color:var(--danger)]"
|
|
||||||
: isRunning ? "text-[color:var(--accent)]"
|
|
||||||
: "text-[color:var(--muted)]"
|
|
||||||
} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-[color:var(--text)]">{config.label}</span>
|
|
||||||
{agent ? (
|
|
||||||
<span className="flex items-center gap-1 text-[color:var(--muted)]">
|
|
||||||
<Robot size={10} />
|
|
||||||
{agent.name}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{action.resultSummary ? (
|
|
||||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">{action.resultSummary}</div>
|
|
||||||
) : null}
|
|
||||||
{action.errorMessage ? (
|
|
||||||
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
||||||
{action.taskId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onNavigate("tasks", action.taskId!)}
|
|
||||||
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
|
||||||
>
|
|
||||||
Open task
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{action.approvalId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onNavigate("approvals", action.approvalId!)}
|
|
||||||
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
|
||||||
>
|
|
||||||
Open approval
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{action.screenshotPath ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onOpenPath(action.screenshotPath!)}
|
|
||||||
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
|
||||||
>
|
|
||||||
<ArrowSquareOut size={10} />
|
|
||||||
Screenshot
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{["queued", "running", "approval_required"].includes(action.status) ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onCancelAction(action.id)}
|
|
||||||
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--danger-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--danger)] transition hover:brightness-95"
|
|
||||||
>
|
|
||||||
<ClockCounterClockwise size={10} />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
|
|
||||||
{timeAgo(action.createdAt)}
|
|
||||||
</div>
|
|
||||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
|
|
||||||
isSuccess ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
|
||||||
: isFailed ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
|
||||||
: isRunning ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
|
||||||
: action.status === "approval_required" ? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
|
||||||
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
|
||||||
}`}>
|
|
||||||
{action.status.replace("_", " ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SocialPanelProps {
|
|
||||||
accounts: SocialAccountRecord[];
|
|
||||||
actions: BrowserActionRecord[];
|
|
||||||
agents: Array<{ id: string; name: string }>;
|
|
||||||
companyId: string;
|
|
||||||
onSave: (input: {
|
|
||||||
companyId: string;
|
|
||||||
platform: SocialPlatform;
|
|
||||||
accountName: string;
|
|
||||||
displayName: string;
|
|
||||||
requireApproval: boolean;
|
|
||||||
}) => Promise<void>;
|
|
||||||
onDelete: (id: string) => Promise<void>;
|
|
||||||
onLogin: (accountId: string) => Promise<void>;
|
|
||||||
onOpenPath: (path: string) => Promise<void>;
|
|
||||||
onCancelAction: (actionId: string) => Promise<void>;
|
|
||||||
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SocialAccountsPanel({
|
|
||||||
accounts,
|
|
||||||
actions,
|
|
||||||
agents,
|
|
||||||
companyId,
|
|
||||||
onSave,
|
|
||||||
onDelete,
|
|
||||||
onLogin,
|
|
||||||
onOpenPath,
|
|
||||||
onCancelAction,
|
|
||||||
onNavigate,
|
|
||||||
}: SocialPanelProps) {
|
|
||||||
const t = useT();
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts");
|
|
||||||
|
|
||||||
const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => {
|
|
||||||
await onSave({ companyId, platform, accountName, displayName, requireApproval });
|
|
||||||
setShowAddDialog(false);
|
|
||||||
}, [companyId, onSave]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
|
||||||
<Megaphone size={20} weight="fill" className="text-[color:var(--accent)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-[16px] font-bold text-[color:var(--text)]">{t("social.socialMedia")}</h2>
|
|
||||||
<p className="text-[12px] text-[color:var(--muted)]">
|
|
||||||
{accounts.length} account{accounts.length !== 1 ? "s" : ""} connected
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ActionButton
|
|
||||||
label="Add Account"
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
icon={Plus}
|
|
||||||
tone="accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
|
|
||||||
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
|
|
||||||
<Browser size={16} className="mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">How it works</div>
|
|
||||||
<div className="mt-1 text-[11px] opacity-80">
|
|
||||||
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
|
|
||||||
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
|
|
||||||
using browser automation. All actions are logged and can require Board approval.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("accounts")}
|
|
||||||
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
|
||||||
activeTab === "accounts"
|
|
||||||
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
|
||||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Accounts ({accounts.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("history")}
|
|
||||||
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
|
||||||
activeTab === "history"
|
|
||||||
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
|
||||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Action History ({actions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "accounts" ? (
|
|
||||||
accounts.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
|
||||||
<Megaphone size={32} className="mb-3 text-[color:var(--muted)]" />
|
|
||||||
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">{t("social.noAccounts")}</div>
|
|
||||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
|
||||||
{t("social.noAccountsDesc")}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<ActionButton
|
|
||||||
label="Add Account"
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
icon={Plus}
|
|
||||||
tone="accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<AccountCard
|
|
||||||
key={account.id}
|
|
||||||
account={account}
|
|
||||||
onLogin={() => void onLogin(account.id)}
|
|
||||||
onDelete={() => void onDelete(account.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<ActionHistory
|
|
||||||
actions={actions}
|
|
||||||
agents={agents}
|
|
||||||
onOpenPath={onOpenPath}
|
|
||||||
onCancelAction={onCancelAction}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showAddDialog ? (
|
|
||||||
<AddAccountDialog
|
|
||||||
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
|
|
||||||
onClose={() => setShowAddDialog(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ShieldCheck, SignIn, Trash } from "@phosphor-icons/react";
|
||||||
|
import type { SocialAccountRecord } from "@shared/types";
|
||||||
|
import { timeAgo } from "../../lib/formatters";
|
||||||
|
import { platformConfig } from "./social-config";
|
||||||
|
|
||||||
|
interface AccountCardProps {
|
||||||
|
account: SocialAccountRecord;
|
||||||
|
onLogin: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCardProps) {
|
||||||
|
const platform = platformConfig[account.platform] ?? platformConfig.other;
|
||||||
|
const isLoggedIn = account.status === "logged_in" || account.status === "active";
|
||||||
|
const PlatformIcon = platform.icon;
|
||||||
|
const cardContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
|
||||||
|
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||||
|
>
|
||||||
|
{platform.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||||
|
<span>@{account.accountName}</span>
|
||||||
|
{account.requireApproval ? (
|
||||||
|
<span className="flex items-center gap-1 text-[color:var(--warn)]">
|
||||||
|
<ShieldCheck size={10} />
|
||||||
|
Approval required
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
|
||||||
|
<div
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
|
||||||
|
style={{ backgroundColor: platform.bgColor, color: platform.color }}
|
||||||
|
>
|
||||||
|
<PlatformIcon size={18} weight="fill" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onClick ? (
|
||||||
|
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||||
|
{cardContent}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex-1 text-left">{cardContent}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
|
||||||
|
isLoggedIn
|
||||||
|
? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: account.status === "login_required"
|
||||||
|
? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||||
|
: account.status === "suspended"
|
||||||
|
? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
||||||
|
}`}>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
isLoggedIn ? "bg-[color:var(--success)]"
|
||||||
|
: account.status === "login_required" ? "bg-[color:var(--warn)]"
|
||||||
|
: account.status === "suspended" ? "bg-[color:var(--danger)]"
|
||||||
|
: "bg-[color:var(--muted)]"
|
||||||
|
}`} />
|
||||||
|
{account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{account.status === "login_required" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogin}
|
||||||
|
className="flex items-center gap-1 rounded-[6px] bg-[color:var(--accent)] px-2.5 py-1.5 text-[10px] font-semibold text-white transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<SignIn size={12} />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded-[6px] p-1.5 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
ArrowSquareOut,
|
||||||
|
ClockCounterClockwise,
|
||||||
|
Robot,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { BrowserActionRecord } from "@shared/types";
|
||||||
|
import { timeAgo } from "../../lib/formatters";
|
||||||
|
import { actionTypeLabels } from "./social-config";
|
||||||
|
|
||||||
|
interface ActionHistoryProps {
|
||||||
|
actions: BrowserActionRecord[];
|
||||||
|
agents: Array<{ id: string; name: string }>;
|
||||||
|
onOpenPath: (path: string) => Promise<void>;
|
||||||
|
onCancelAction: (actionId: string) => Promise<void>;
|
||||||
|
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onNavigate }: ActionHistoryProps) {
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
No browser actions yet. Agents will create actions when working on social media tasks.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{actions.slice(0, 30).map((action) => {
|
||||||
|
const config = actionTypeLabels[action.actionType] ?? actionTypeLabels.custom_script;
|
||||||
|
const Icon = config.icon;
|
||||||
|
const agent = agents.find((a) => a.id === action.agentId);
|
||||||
|
const isSuccess = action.status === "succeeded";
|
||||||
|
const isFailed = action.status === "failed";
|
||||||
|
const isRunning = action.status === "running";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action.id}
|
||||||
|
className="flex items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5 text-[12px]"
|
||||||
|
>
|
||||||
|
<Icon size={16} className={
|
||||||
|
isSuccess ? "text-[color:var(--success)]"
|
||||||
|
: isFailed ? "text-[color:var(--danger)]"
|
||||||
|
: isRunning ? "text-[color:var(--accent)]"
|
||||||
|
: "text-[color:var(--muted)]"
|
||||||
|
} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[color:var(--text)]">{config.label}</span>
|
||||||
|
{agent ? (
|
||||||
|
<span className="flex items-center gap-1 text-[color:var(--muted)]">
|
||||||
|
<Robot size={10} />
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{action.resultSummary ? (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">{action.resultSummary}</div>
|
||||||
|
) : null}
|
||||||
|
{action.errorMessage ? (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{action.taskId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("tasks", action.taskId!)}
|
||||||
|
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Open task
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{action.approvalId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate("approvals", action.approvalId!)}
|
||||||
|
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
Open approval
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{action.screenshotPath ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onOpenPath(action.screenshotPath!)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
|
||||||
|
>
|
||||||
|
<ArrowSquareOut size={10} />
|
||||||
|
Screenshot
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{["queued", "running", "approval_required"].includes(action.status) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onCancelAction(action.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--danger-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--danger)] transition hover:brightness-95"
|
||||||
|
>
|
||||||
|
<ClockCounterClockwise size={10} />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
|
||||||
|
{timeAgo(action.createdAt)}
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
|
||||||
|
isSuccess ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
|
||||||
|
: isFailed ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||||
|
: isRunning ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||||
|
: action.status === "approval_required" ? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||||
|
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
||||||
|
}`}>
|
||||||
|
{action.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Plus, X } from "@phosphor-icons/react";
|
||||||
|
import type { SocialPlatform } from "@shared/types";
|
||||||
|
import { ActionButton, Input } from "../ui";
|
||||||
|
import { platformConfig } from "./social-config";
|
||||||
|
|
||||||
|
interface AddAccountDialogProps {
|
||||||
|
onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
|
||||||
|
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
|
||||||
|
const [accountName, setAccountName] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [requireApproval, setRequireApproval] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="w-full max-w-[420px] rounded-[14px] border border-[color:var(--line)] bg-[color:var(--panel)] p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h3 className="text-[16px] font-bold text-[color:var(--text)]">Add Social Account</h3>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-[6px] p-1 text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)]">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform(key)}
|
||||||
|
className={`flex items-center gap-2 rounded-[8px] border px-3 py-2 text-[11px] font-medium transition ${
|
||||||
|
platform === key
|
||||||
|
? "border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||||
|
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span style={{ color: config.color }}>
|
||||||
|
<config.icon size={14} />
|
||||||
|
</span>
|
||||||
|
{config.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Username / Handle"
|
||||||
|
value={accountName}
|
||||||
|
onChange={setAccountName}
|
||||||
|
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Display Name (optional)"
|
||||||
|
value={displayName}
|
||||||
|
onChange={setDisplayName}
|
||||||
|
placeholder="Your display name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requireApproval}
|
||||||
|
onChange={(e) => setRequireApproval(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-medium text-[color:var(--text)]">Require Board Approval for Posts</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-[color:var(--muted)]">
|
||||||
|
Agents must get approval before publishing content to this account
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => {
|
||||||
|
if (accountName.trim()) {
|
||||||
|
onAdd(platform, accountName.trim(), displayName.trim(), requireApproval);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
disabled={!accountName.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import { Browser, Megaphone, Plus } from "@phosphor-icons/react";
|
||||||
|
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
|
||||||
|
import { ActionButton } from "../ui";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { AccountCard } from "./AccountCard";
|
||||||
|
import { AddAccountDialog } from "./AddAccountDialog";
|
||||||
|
import { ActionHistory } from "./ActionHistory";
|
||||||
|
|
||||||
|
interface SocialPanelProps {
|
||||||
|
accounts: SocialAccountRecord[];
|
||||||
|
actions: BrowserActionRecord[];
|
||||||
|
agents: Array<{ id: string; name: string }>;
|
||||||
|
companyId: string;
|
||||||
|
onSave: (input: {
|
||||||
|
companyId: string;
|
||||||
|
platform: SocialPlatform;
|
||||||
|
accountName: string;
|
||||||
|
displayName: string;
|
||||||
|
requireApproval: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onLogin: (accountId: string) => Promise<void>;
|
||||||
|
onOpenPath: (path: string) => Promise<void>;
|
||||||
|
onCancelAction: (actionId: string) => Promise<void>;
|
||||||
|
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialAccountsPanel({
|
||||||
|
accounts,
|
||||||
|
actions,
|
||||||
|
agents,
|
||||||
|
companyId,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onLogin,
|
||||||
|
onOpenPath,
|
||||||
|
onCancelAction,
|
||||||
|
onNavigate,
|
||||||
|
}: SocialPanelProps) {
|
||||||
|
const t = useT();
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts");
|
||||||
|
|
||||||
|
const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => {
|
||||||
|
await onSave({ companyId, platform, accountName, displayName, requireApproval });
|
||||||
|
setShowAddDialog(false);
|
||||||
|
}, [companyId, onSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
|
||||||
|
<Megaphone size={20} weight="fill" className="text-[color:var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[16px] font-bold text-[color:var(--text)]">{t("social.socialMedia")}</h2>
|
||||||
|
<p className="text-[12px] text-[color:var(--muted)]">
|
||||||
|
{accounts.length} account{accounts.length !== 1 ? "s" : ""} connected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => setShowAddDialog(true)}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
|
||||||
|
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
|
||||||
|
<Browser size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">How it works</div>
|
||||||
|
<div className="mt-1 text-[11px] opacity-80">
|
||||||
|
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
|
||||||
|
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
|
||||||
|
using browser automation. All actions are logged and can require Board approval.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("accounts")}
|
||||||
|
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
||||||
|
activeTab === "accounts"
|
||||||
|
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
||||||
|
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Accounts ({accounts.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("history")}
|
||||||
|
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
|
||||||
|
activeTab === "history"
|
||||||
|
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
||||||
|
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Action History ({actions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "accounts" ? (
|
||||||
|
accounts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||||
|
<Megaphone size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||||
|
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">{t("social.noAccounts")}</div>
|
||||||
|
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||||
|
{t("social.noAccountsDesc")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ActionButton
|
||||||
|
label="Add Account"
|
||||||
|
onClick={() => setShowAddDialog(true)}
|
||||||
|
icon={Plus}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<AccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onLogin={() => void onLogin(account.id)}
|
||||||
|
onDelete={() => void onDelete(account.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ActionHistory
|
||||||
|
actions={actions}
|
||||||
|
agents={agents}
|
||||||
|
onOpenPath={onOpenPath}
|
||||||
|
onCancelAction={onCancelAction}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showAddDialog ? (
|
||||||
|
<AddAccountDialog
|
||||||
|
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
|
||||||
|
onClose={() => setShowAddDialog(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ElementType } from "react";
|
||||||
|
import {
|
||||||
|
Browser,
|
||||||
|
Camera,
|
||||||
|
ChatCircle,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Heart,
|
||||||
|
MagnifyingGlass,
|
||||||
|
PaperPlaneTilt,
|
||||||
|
UserPlus,
|
||||||
|
FacebookLogo,
|
||||||
|
GlobeHemisphereWest,
|
||||||
|
InstagramLogo,
|
||||||
|
LinkedinLogo,
|
||||||
|
RedditLogo,
|
||||||
|
TiktokLogo,
|
||||||
|
XLogo,
|
||||||
|
YoutubeLogo,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { SocialPlatform } from "@shared/types";
|
||||||
|
|
||||||
|
export const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: ElementType }> = {
|
||||||
|
twitter: { label: "Twitter / X", color: "#1D9BF0", bgColor: "#1D9BF014", icon: XLogo },
|
||||||
|
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C214", icon: LinkedinLogo },
|
||||||
|
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F14", icon: InstagramLogo },
|
||||||
|
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F214", icon: FacebookLogo },
|
||||||
|
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450014", icon: RedditLogo },
|
||||||
|
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
|
||||||
|
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
|
||||||
|
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
|
||||||
|
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
|
||||||
|
post: { label: "Post", icon: PaperPlaneTilt },
|
||||||
|
reply: { label: "Reply", icon: ChatCircle },
|
||||||
|
like: { label: "Like", icon: Heart },
|
||||||
|
follow: { label: "Follow", icon: UserPlus },
|
||||||
|
browse_feed: { label: "Browse Feed", icon: Eye },
|
||||||
|
screenshot: { label: "Screenshot", icon: Camera },
|
||||||
|
navigate: { label: "Navigate", icon: Globe },
|
||||||
|
search: { label: "Search", icon: MagnifyingGlass },
|
||||||
|
dm: { label: "Direct Message", icon: ChatCircle },
|
||||||
|
custom_script: { label: "Legacy Action", icon: Browser },
|
||||||
|
};
|
||||||
Reference in new issue
Block a user