From 93e599befd4c90661069b926a53e74056d6efbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 02:54:02 -0400 Subject: [PATCH] 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 --- src/renderer/components/runs.tsx | 508 +--------------- src/renderer/components/runs/RunCenter.tsx | 288 ++++++++++ src/renderer/components/runs/RunDetail.tsx | 132 +++++ src/renderer/components/runs/RunQueue.tsx | 104 ++++ src/renderer/components/runs/run-helpers.tsx | 72 +++ src/renderer/components/social.tsx | 543 +----------------- .../components/social/AccountCard.tsx | 98 ++++ .../components/social/ActionHistory.tsx | 122 ++++ .../components/social/AddAccountDialog.tsx | 120 ++++ .../components/social/SocialPanel.tsx | 161 ++++++ .../components/social/social-config.ts | 46 ++ 11 files changed, 1147 insertions(+), 1047 deletions(-) create mode 100644 src/renderer/components/runs/RunCenter.tsx create mode 100644 src/renderer/components/runs/RunDetail.tsx create mode 100644 src/renderer/components/runs/RunQueue.tsx create mode 100644 src/renderer/components/runs/run-helpers.tsx create mode 100644 src/renderer/components/social/AccountCard.tsx create mode 100644 src/renderer/components/social/ActionHistory.tsx create mode 100644 src/renderer/components/social/AddAccountDialog.tsx create mode 100644 src/renderer/components/social/SocialPanel.tsx create mode 100644 src/renderer/components/social/social-config.ts diff --git a/src/renderer/components/runs.tsx b/src/renderer/components/runs.tsx index d126146f..dc1ce833 100644 --- a/src/renderer/components/runs.tsx +++ b/src/renderer/components/runs.tsx @@ -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; - onOpenLogPath: (path: string) => Promise | 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(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 ( - - ); - } - - return ( -
-
- - - -
- -
-
- 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" }, - ]} - /> - )} - > -
- -
- - {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 ( - onSelectRun(run.id)} - className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"} - > -
-
-
-
{title}
-
- {agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `· ${workspace.name}` : ""} -
-
- -
-
- {supportingText} - {timeAgo(run.updatedAt)} -
-
-
- ); - })} -
- {filteredRuns.length === 0 ? ( - - ) : null} -
-
- -
- {selectedRun ? ( - <> - - onOpenConsole(selectedRun.id)} - /> - {isActiveStatus(selectedRun.status) ? ( - void onCancelRun(selectedRun.id)} - /> - ) : null} -
- )} - > -
- } /> - - - - - - - - - -
- {selectedRun.errorMessage ? ( -
- {selectedRun.errorMessage} -
- ) : null} - {selectedRun.logPath ? ( -
- void onOpenLogPath(selectedRun.logPath!)} - /> -
- ) : null} - - - - setLogReloadKey((current) => current + 1)} - /> - {logState.offset > 0 ? ( - void loadOlderLogs()} - disabled={logState.loading} - /> - ) : null} -
- )} - > - {logState.error ? ( -
- {logState.error} -
- ) : null} -
- - Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()} - - {logState.loading ? "Refreshing…" : logState.eof ? "At latest tail" : "Tail window loaded"} -
-
-
-                    {logState.content || "No log output captured yet."}
-                  
-
- - - ) : null} -
- - - ); -} - -function RunSummaryCard({ - label, - value, - detail, - icon: Icon, - tone = "default", -}: { - label: string; - value: number; - detail: string; - icon: React.ElementType; - tone?: "default" | "danger"; -}) { - return ( -
-
-
-
{label}
-
- {value} -
-
{detail}
-
-
- -
-
-
- ); -} - -function RunMeta({ label, value }: { label: string; value: ReactNode }) { - return ( -
-
{label}
-
{value}
-
- ); -} +// Backward-compatible re-exports from decomposed run components +export { RunCenter } from "./runs/RunCenter"; diff --git a/src/renderer/components/runs/RunCenter.tsx b/src/renderer/components/runs/RunCenter.tsx new file mode 100644 index 00000000..963235c8 --- /dev/null +++ b/src/renderer/components/runs/RunCenter.tsx @@ -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; + onOpenLogPath: (path: string) => Promise | 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(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 ( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + {selectedRun ? ( + setLogReloadKey((current) => current + 1)} + onLoadOlderLogs={() => void loadOlderLogs()} + t={t} + /> + ) : null} +
+
+ ); +} diff --git a/src/renderer/components/runs/RunDetail.tsx b/src/renderer/components/runs/RunDetail.tsx new file mode 100644 index 00000000..d46e0d6b --- /dev/null +++ b/src/renderer/components/runs/RunDetail.tsx @@ -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; + agentMap: Map; + workspaceMap: Map; + logState: RunLogState; + onOpenConsole: (runId: string) => void; + onCancelRun: (runId: string) => Promise | void; + onOpenLogPath: (path: string) => Promise | void; + onReloadLog: () => void; + onLoadOlderLogs: () => void; + t: TranslationFn; +} + +export function RunDetail({ + selectedRun, + taskMap, + agentMap, + workspaceMap, + logState, + onOpenConsole, + onCancelRun, + onOpenLogPath, + onReloadLog, + onLoadOlderLogs, + t, +}: RunDetailProps) { + return ( +
+ + onOpenConsole(selectedRun.id)} + /> + {isActiveStatus(selectedRun.status) ? ( + void onCancelRun(selectedRun.id)} + /> + ) : null} +
+ )} + > +
+ } /> + + + + + + + + + +
+ {selectedRun.errorMessage ? ( +
+ {selectedRun.errorMessage} +
+ ) : null} + {selectedRun.logPath ? ( +
+ void onOpenLogPath(selectedRun.logPath!)} + /> +
+ ) : null} + + + + + {logState.offset > 0 ? ( + + ) : null} + + )} + > + {logState.error ? ( +
+ {logState.error} +
+ ) : null} +
+ + Showing bytes {logState.offset.toLocaleString()} to {logState.nextOffset.toLocaleString()} of {logState.totalBytes.toLocaleString()} + + {logState.loading ? "Refreshing\u2026" : logState.eof ? "At latest tail" : "Tail window loaded"} +
+
+
+            {logState.content || "No log output captured yet."}
+          
+
+
+ + ); +} diff --git a/src/renderer/components/runs/RunQueue.tsx b/src/renderer/components/runs/RunQueue.tsx new file mode 100644 index 00000000..65c4febd --- /dev/null +++ b/src/renderer/components/runs/RunQueue.tsx @@ -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; + agentMap: Map; + workspaceMap: Map; + 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 ( +
+ 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" }, + ]} + /> + )} + > +
+ +
+ + {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 ( + onSelectRun(run.id)} + className={selectedRun?.id === run.id ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"} + > +
+
+
+
{title}
+
+ {agent?.name ?? "Unknown agent"} via {run.connectorId} {workspace ? `\u00B7 ${workspace.name}` : ""} +
+
+ +
+
+ {supportingText} + {timeAgo(run.updatedAt)} +
+
+
+ ); + })} +
+ {filteredRuns.length === 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/src/renderer/components/runs/run-helpers.tsx b/src/renderer/components/runs/run-helpers.tsx new file mode 100644 index 00000000..cffc4e69 --- /dev/null +++ b/src/renderer/components/runs/run-helpers.tsx @@ -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 ( +
+
+
+
{label}
+
+ {value} +
+
{detail}
+
+
+ +
+
+
+ ); +} + +export function RunMeta({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/renderer/components/social.tsx b/src/renderer/components/social.tsx index 105a8440..76e2e25a 100644 --- a/src/renderer/components/social.tsx +++ b/src/renderer/components/social.tsx @@ -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 = { - 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 = { - 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 = ( - <> -
- {account.displayName || account.accountName} - - {platform.label} - -
-
- @{account.accountName} - {account.requireApproval ? ( - - - Approval required - - ) : null} - {account.lastUsedAt ? Last used {timeAgo(account.lastUsedAt)} : null} -
- - ); - - return ( -
-
- -
- - {onClick ? ( - - ) : ( -
{cardContent}
- )} - -
- - - {account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")} - - - {account.status === "login_required" ? ( - - ) : null} - - -
-
- ); -} - -function AddAccountDialog({ - onAdd, - onClose, -}: { - onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void; - onClose: () => void; -}) { - const [platform, setPlatform] = useState("twitter"); - const [accountName, setAccountName] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [requireApproval, setRequireApproval] = useState(true); - - return ( - - e.stopPropagation()} - > -
-

Add Social Account

- -
- -
-
- -
- {(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => ( - - ))} -
-
- - - - - -
- -
-
- -
- - { - if (accountName.trim()) { - onAdd(platform, accountName.trim(), displayName.trim(), requireApproval); - } - }} - icon={Plus} - tone="accent" - disabled={!accountName.trim()} - /> -
-
-
- ); -} - -function ActionHistory({ - actions, - agents, - onOpenPath, - onCancelAction, - onNavigate, -}: { - actions: BrowserActionRecord[]; - agents: Array<{ id: string; name: string }>; - onOpenPath: (path: string) => Promise; - onCancelAction: (actionId: string) => Promise; - onNavigate: (section: "tasks" | "approvals", entityId: string) => void; -}) { - if (actions.length === 0) { - return ( -
- No browser actions yet. Agents will create actions when working on social media tasks. -
- ); - } - - return ( -
- {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 ( -
- -
-
- {config.label} - {agent ? ( - - - {agent.name} - - ) : null} -
- {action.resultSummary ? ( -
{action.resultSummary}
- ) : null} - {action.errorMessage ? ( -
{action.errorMessage}
- ) : null} -
- {action.taskId ? ( - - ) : null} - {action.approvalId ? ( - - ) : null} - {action.screenshotPath ? ( - - ) : null} - {["queued", "running", "approval_required"].includes(action.status) ? ( - - ) : null} -
-
-
- {timeAgo(action.createdAt)} -
- - {action.status.replace("_", " ")} - -
- ); - })} -
- ); -} - -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; - onDelete: (id: string) => Promise; - onLogin: (accountId: string) => Promise; - onOpenPath: (path: string) => Promise; - onCancelAction: (actionId: string) => Promise; - 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 ( -
-
-
-
- -
-
-

{t("social.socialMedia")}

-

- {accounts.length} account{accounts.length !== 1 ? "s" : ""} connected -

-
-
- setShowAddDialog(true)} - icon={Plus} - tone="accent" - /> -
- -
-
- -
-
How it works
-
- 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. -
-
-
-
- -
- - -
- - {activeTab === "accounts" ? ( - accounts.length === 0 ? ( -
- -
{t("social.noAccounts")}
-
- {t("social.noAccountsDesc")} -
-
- setShowAddDialog(true)} - icon={Plus} - tone="accent" - /> -
-
- ) : ( -
- {accounts.map((account) => ( - void onLogin(account.id)} - onDelete={() => void onDelete(account.id)} - /> - ))} -
- ) - ) : ( - - )} - - - {showAddDialog ? ( - void handleAdd(platform, accountName, displayName, requireApproval)} - onClose={() => setShowAddDialog(false)} - /> - ) : null} - -
- ); -} +// Backward-compatible re-exports from decomposed social components +export { SocialAccountsPanel } from "./social/SocialPanel"; diff --git a/src/renderer/components/social/AccountCard.tsx b/src/renderer/components/social/AccountCard.tsx new file mode 100644 index 00000000..e9b28313 --- /dev/null +++ b/src/renderer/components/social/AccountCard.tsx @@ -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 = ( + <> +
+ {account.displayName || account.accountName} + + {platform.label} + +
+
+ @{account.accountName} + {account.requireApproval ? ( + + + Approval required + + ) : null} + {account.lastUsedAt ? Last used {timeAgo(account.lastUsedAt)} : null} +
+ + ); + + return ( +
+
+ +
+ + {onClick ? ( + + ) : ( +
{cardContent}
+ )} + +
+ + + {account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")} + + + {account.status === "login_required" ? ( + + ) : null} + + +
+
+ ); +} diff --git a/src/renderer/components/social/ActionHistory.tsx b/src/renderer/components/social/ActionHistory.tsx new file mode 100644 index 00000000..9bf98eab --- /dev/null +++ b/src/renderer/components/social/ActionHistory.tsx @@ -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; + onCancelAction: (actionId: string) => Promise; + onNavigate: (section: "tasks" | "approvals", entityId: string) => void; +} + +export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onNavigate }: ActionHistoryProps) { + if (actions.length === 0) { + return ( +
+ No browser actions yet. Agents will create actions when working on social media tasks. +
+ ); + } + + return ( +
+ {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 ( +
+ +
+
+ {config.label} + {agent ? ( + + + {agent.name} + + ) : null} +
+ {action.resultSummary ? ( +
{action.resultSummary}
+ ) : null} + {action.errorMessage ? ( +
{action.errorMessage}
+ ) : null} +
+ {action.taskId ? ( + + ) : null} + {action.approvalId ? ( + + ) : null} + {action.screenshotPath ? ( + + ) : null} + {["queued", "running", "approval_required"].includes(action.status) ? ( + + ) : null} +
+
+
+ {timeAgo(action.createdAt)} +
+ + {action.status.replace("_", " ")} + +
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/social/AddAccountDialog.tsx b/src/renderer/components/social/AddAccountDialog.tsx new file mode 100644 index 00000000..7c49ec6d --- /dev/null +++ b/src/renderer/components/social/AddAccountDialog.tsx @@ -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("twitter"); + const [accountName, setAccountName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [requireApproval, setRequireApproval] = useState(true); + + return ( + + e.stopPropagation()} + > +
+

Add Social Account

+ +
+ +
+
+ +
+ {(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => ( + + ))} +
+
+ + + + + +
+ +
+
+ +
+ + { + if (accountName.trim()) { + onAdd(platform, accountName.trim(), displayName.trim(), requireApproval); + } + }} + icon={Plus} + tone="accent" + disabled={!accountName.trim()} + /> +
+
+
+ ); +} diff --git a/src/renderer/components/social/SocialPanel.tsx b/src/renderer/components/social/SocialPanel.tsx new file mode 100644 index 00000000..8af3f376 --- /dev/null +++ b/src/renderer/components/social/SocialPanel.tsx @@ -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; + onDelete: (id: string) => Promise; + onLogin: (accountId: string) => Promise; + onOpenPath: (path: string) => Promise; + onCancelAction: (actionId: string) => Promise; + 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 ( +
+
+
+
+ +
+
+

{t("social.socialMedia")}

+

+ {accounts.length} account{accounts.length !== 1 ? "s" : ""} connected +

+
+
+ setShowAddDialog(true)} + icon={Plus} + tone="accent" + /> +
+ +
+
+ +
+
How it works
+
+ 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. +
+
+
+
+ +
+ + +
+ + {activeTab === "accounts" ? ( + accounts.length === 0 ? ( +
+ +
{t("social.noAccounts")}
+
+ {t("social.noAccountsDesc")} +
+
+ setShowAddDialog(true)} + icon={Plus} + tone="accent" + /> +
+
+ ) : ( +
+ {accounts.map((account) => ( + void onLogin(account.id)} + onDelete={() => void onDelete(account.id)} + /> + ))} +
+ ) + ) : ( + + )} + + + {showAddDialog ? ( + void handleAdd(platform, accountName, displayName, requireApproval)} + onClose={() => setShowAddDialog(false)} + /> + ) : null} + +
+ ); +} diff --git a/src/renderer/components/social/social-config.ts b/src/renderer/components/social/social-config.ts new file mode 100644 index 00000000..3649e814 --- /dev/null +++ b/src/renderer/components/social/social-config.ts @@ -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 = { + 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 = { + 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 }, +};