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:
ZacharyZhang-NY committed 2026-03-22 02:54:02 -04:00
1 parent d77486504d
commit 93e599befd
11 files changed
+1147 -1047

No files matched your search

+2 -506
View File
@@ -1,506 +1,2 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
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>
);
}
// Backward-compatible re-exports from decomposed run components
export { RunCenter } from "./runs/RunCenter";
+288
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+2 -541
View File
@@ -1,541 +1,2 @@
import { type ElementType, useCallback, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
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>
);
}
// Backward-compatible re-exports from decomposed social components
export { SocialAccountsPanel } from "./social/SocialPanel";
@@ -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 },
};