This commit is contained in:
ZacharyZhang-NY committed 2026-03-18 01:15:58 -04:00
1 parent 7a3db27ffa
commit 1ba3147ed2
96 files changed
+9400 -6269

No files matched your search

File diff suppressed because it is too large. Load diff
+154 -34
View File
@@ -31,7 +31,8 @@ import type {
} from "@shared/types"
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { Modal, Select, Input, TextArea } from "./ui"
import { unwrap } from "../lib/desktop"
import { InlineNotice, Modal, Select, Input, TextArea } from "./ui"
const TERRACOTTA = "#d97745"
const TERRACOTTA_SOFT = "#d9774518"
@@ -181,18 +182,32 @@ function deriveTaskDiscussionChannels(
tasks: TaskRecord[],
): TaskDiscussionDescriptor[] {
const taskMap = new Map(tasks.map((task) => [task.id, task]))
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
return Array.from(discussedTaskIds)
const activeTaskIds = tasks
.filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status))
.map((task) => task.id)
const discussionTaskIds = new Set([...comments.map((comment) => comment.taskId), ...activeTaskIds])
return Array.from(discussionTaskIds)
.map((taskId) => {
const task = taskMap.get(taskId)
return {
key: `task-${taskId}`,
kind: "task" as const,
taskId,
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
label: task
? `${task.title}${task.status === "blocked" ? " · blocked" : task.status === "in_review" ? " · review" : ""}`
: `Task ${taskId.slice(0, 8)}`,
}
})
.sort((a, b) => a.label.localeCompare(b.label))
.sort((left, right) => {
const leftTask = taskMap.get(left.taskId)
const rightTask = taskMap.get(right.taskId)
const priorityWeight: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 }
const leftWeight = leftTask ? priorityWeight[leftTask.priority] ?? 0 : 0
const rightWeight = rightTask ? priorityWeight[rightTask.priority] ?? 0 : 0
if (leftWeight !== rightWeight) return rightWeight - leftWeight
return left.label.localeCompare(right.label)
})
}
function getChannelMessages(
@@ -509,19 +524,23 @@ function TaskDiscussionComposer({
}) {
const [body, setBody] = useState("")
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const handleSend = useCallback(async () => {
if (!body.trim()) return
setSending(true)
setSendError(null)
try {
await window.agentCompany.addComment({
unwrap(await window.agentCompany.addComment({
companyId,
taskId,
authorName: "Board",
body: body.trim(),
})
}))
setBody("")
onSent()
} catch (error) {
setSendError(error instanceof Error ? error.message : "Failed to send task discussion reply.")
} finally {
setSending(false)
}
@@ -529,6 +548,7 @@ function TaskDiscussionComposer({
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
<div className="flex items-end gap-2">
<textarea
value={body}
@@ -609,6 +629,7 @@ function MessageComposer({
const [body, setBody] = useState("")
const [subject, setSubject] = useState("")
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
@@ -620,8 +641,9 @@ function MessageComposer({
const handleSend = useCallback(async () => {
if (!body.trim() || !fromAgentId) return
setSending(true)
setSendError(null)
try {
await window.agentCompany.sendAgentMessage({
unwrap(await window.agentCompany.sendAgentMessage({
companyId,
fromAgentId,
toAgentId: replyTo ? replyTo.fromAgentId : null,
@@ -632,12 +654,12 @@ function MessageComposer({
priority: "normal",
parentMessageId: replyTo?.id ?? null,
attachmentsJson: "[]",
})
}))
setBody("")
setSubject("")
onSent()
} catch (err) {
void err
} catch (error) {
setSendError(error instanceof Error ? error.message : "Failed to send message.")
} finally {
setSending(false)
}
@@ -657,6 +679,7 @@ function MessageComposer({
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
<AnimatePresence>
{replyTo ? (
<motion.div
@@ -758,6 +781,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
const [body, setBody] = useState("")
const [priority, setPriority] = useState<MessagePriority>("normal")
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const departmentOptions = useMemo(() => {
const depts = new Set<string>()
@@ -768,10 +792,11 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
}, [agents])
const handleSend = useCallback(async () => {
if (!body.trim() || !fromAgentId || !subject.trim()) return
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId)) return
setSending(true)
setSendError(null)
try {
await window.agentCompany.sendAgentMessage({
unwrap(await window.agentCompany.sendAgentMessage({
companyId,
fromAgentId,
toAgentId: channel === "direct" && toAgentId ? toAgentId : null,
@@ -782,14 +807,14 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
priority,
parentMessageId: null,
attachmentsJson: "[]",
})
}))
setSubject("")
setBody("")
setPriority("normal")
onSent()
onClose()
} catch (err) {
void err
} catch (error) {
setSendError(error instanceof Error ? error.message : "Failed to send message.")
} finally {
setSending(false)
}
@@ -798,6 +823,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
return (
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
<div className="space-y-4">
{sendError ? <InlineNotice message={sendError} /> : null}
<Select
label="From Agent"
value={fromAgentId}
@@ -827,7 +853,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
value={toAgentId}
onChange={setToAgentId}
options={[
{ value: "", label: "Broadcast to all" },
{ value: "", label: "Select a recipient..." },
...agents
.filter((a) => a.id !== fromAgentId)
.map((a) => ({
@@ -895,7 +921,7 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
<button
type="button"
onClick={() => void handleSend()}
disabled={!body.trim() || !subject.trim() || !fromAgentId || sending}
disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || sending}
className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
style={{ backgroundColor: TERRACOTTA }}
>
@@ -1060,6 +1086,8 @@ interface CommunicationHubProps {
agents: AgentRecord[]
companyId: string
selectedEntityId?: string | null
onEntityConsumed?: () => void
onNavigateToTask: (taskId: string) => void
onRefresh: () => void
}
@@ -1070,12 +1098,16 @@ export function CommunicationHub({
agents,
companyId,
selectedEntityId,
onEntityConsumed,
onNavigateToTask,
onRefresh,
}: CommunicationHubProps) {
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [showSidebar, setShowSidebar] = useState(true)
const [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
const pendingReadIdsRef = useRef<Set<string>>(new Set())
const [filters, setFilters] = useState<FilterState>({
search: "",
channelType: "all",
@@ -1085,39 +1117,65 @@ export function CommunicationHub({
const messageEndRef = useRef<HTMLDivElement>(null)
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
const effectiveMessages = useMemo(
() =>
messages.map((message) =>
optimisticReadIds.has(message.id)
? { ...message, readAt: message.readAt ?? new Date().toISOString() }
: message,
),
[messages, optimisticReadIds],
)
useEffect(() => {
setOptimisticReadIds((current) => {
if (current.size === 0) return current
const next = new Set(Array.from(current).filter((messageId) => effectiveMessages.some((message) => message.id === messageId)))
return next.size === current.size ? current : next
})
}, [effectiveMessages])
// Derive channels from messages
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
const messageChannels = useMemo(() => deriveChannels(effectiveMessages, agents), [effectiveMessages, agents])
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
// Auto-select first channel
// Auto-select first valid channel
useEffect(() => {
if (!selectedChannelKey && channels.length > 0) {
if (channels.length === 0) {
if (selectedChannelKey) {
setSelectedChannelKey(null)
}
return
}
if (!selectedChannelKey || !channels.some((channel) => channel.key === selectedChannelKey)) {
setSelectedChannelKey(channels[0].key)
}
}, [channels, selectedChannelKey])
useEffect(() => {
if (!selectedEntityId) return
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
const matchingMessage = effectiveMessages.find((message) => message.id === selectedEntityId)
if (matchingMessage) {
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
const matchingChannel = messageChannels.find((channel) => getChannelMessages(effectiveMessages, channel).some((message) => message.id === selectedEntityId))
if (matchingChannel) {
setSelectedChannelKey(matchingChannel.key)
onEntityConsumed?.()
}
return
}
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
if (matchingComment) {
setSelectedChannelKey(`task-${matchingComment.taskId}`)
onEntityConsumed?.()
return
}
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
if (matchingTaskDiscussion) {
setSelectedChannelKey(matchingTaskDiscussion.key)
onEntityConsumed?.()
}
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
}, [selectedEntityId, effectiveMessages, comments, messageChannels, onEntityConsumed, taskDiscussionChannels])
const activeChannel = useMemo(
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
@@ -1127,10 +1185,10 @@ export function CommunicationHub({
// Messages for the selected channel
const channelMessages = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "message") return []
return getChannelMessages(messages, activeChannel).sort(
return getChannelMessages(effectiveMessages, activeChannel).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
}, [messages, activeChannel])
}, [effectiveMessages, activeChannel])
const taskDiscussionComments = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "task") return []
@@ -1173,6 +1231,58 @@ export function CommunicationHub({
return result
}, [channelMessages, filters, agentMap])
useEffect(() => {
if (!activeChannel || activeChannel.kind !== "message") return
const unreadMessageIds = filteredMessages
.filter((message) => !message.readAt && !pendingReadIdsRef.current.has(message.id))
.map((message) => message.id)
if (unreadMessageIds.length === 0) return
let cancelled = false
void (async () => {
for (const messageId of unreadMessageIds) {
pendingReadIdsRef.current.add(messageId)
}
setOptimisticReadIds((current) => {
const next = new Set(current)
for (const messageId of unreadMessageIds) {
next.add(messageId)
}
return next
})
const results = await Promise.all(
unreadMessageIds.map(async (messageId) => {
try {
unwrap(await window.agentCompany.markMessageRead({ messageId, companyId }))
return { messageId, ok: true as const }
} catch {
return { messageId, ok: false as const }
}
}),
)
const failedIds = results.filter((result) => !result.ok).map((result) => result.messageId)
for (const messageId of unreadMessageIds) {
pendingReadIdsRef.current.delete(messageId)
}
if (!cancelled && failedIds.length > 0) {
setOptimisticReadIds((current) => {
const next = new Set(current)
for (const messageId of failedIds) {
next.delete(messageId)
}
return next
})
}
})()
return () => {
cancelled = true
}
}, [activeChannel, companyId, filteredMessages])
// Build thread structure: top-level messages + their replies
const threadedMessages = useMemo(() => {
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
@@ -1195,22 +1305,23 @@ export function CommunicationHub({
counts.set(ch.key, { unread: 0, hasUrgent: false })
continue
}
const msgs = getChannelMessages(messages, ch)
const unread = msgs.filter((m) => !m.readAt).length
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
const visibleMessages = getChannelMessages(effectiveMessages, ch)
const unread = visibleMessages.filter((m) => !m.readAt).length
const hasUrgent = visibleMessages.some((m) => !m.readAt && m.priority === "urgent")
counts.set(ch.key, { unread, hasUrgent })
}
return counts
}, [channels, messages])
}, [channels, effectiveMessages])
const totalUnread = useMemo(() => {
return messages.filter((m) => !m.readAt).length
}, [messages])
return effectiveMessages.filter((m) => !m.readAt).length
}, [effectiveMessages])
// Scroll to bottom when channel changes
// Scroll to bottom when channel changes or new messages arrive
const messageCount = useMemo(() => effectiveMessages.length, [effectiveMessages])
useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
}, [selectedChannelKey])
}, [selectedChannelKey, messageCount])
// Group channels by type for sidebar sections
const channelGroups = useMemo(() => {
@@ -1405,6 +1516,15 @@ export function CommunicationHub({
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
</div>
</div>
{activeChannel.kind === "task" && activeTask ? (
<button
type="button"
onClick={() => onNavigateToTask(activeTask.id)}
className="rounded-[6px] border border-[color:var(--line)] px-2.5 py-1 text-[11px] font-medium text-[color:var(--muted-strong)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
>
Open task
</button>
) : null}
</>
) : (
<span className="text-[13px] text-[color:var(--muted)]">
+215 -17
View File
@@ -1,21 +1,176 @@
import { useEffect, useState } from "react";
import { CheckCircle } from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle, SignIn } from "@phosphor-icons/react";
import type { ConnectorRecord } from "@shared/types";
import { ActionButton, Input, StatusPill, TextArea } from "./ui";
import { unwrap } from "../lib/desktop";
import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui";
function getAuthActionCopy(connector: ConnectorRecord) {
switch (connector.id) {
case "codex_local":
return {
title: "Codex login required",
message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.",
buttonLabel: "Open Terminal To Sign In",
};
case "claude_local":
return {
title: "Claude Code login required",
message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.",
buttonLabel: "Open Terminal To Sign In",
};
case "opencode_local":
return {
title: "OpenCode provider login required",
message: "OpenCode is installed, but the provider login must be completed before runs can start.",
buttonLabel: "Open Terminal To Sign In",
};
case "gemini_local":
return {
title: "Gemini authentication required",
message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.",
buttonLabel: "Open Terminal",
};
case "kimi_local":
return {
title: "Kimi interactive login required",
message: "Kimi Code is available in generic mode. AgentCompany can open the CLI in a terminal so you can complete the interactive login flow there if prompted.",
buttonLabel: "Open Terminal",
};
}
}
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
const [model, setModel] = useState(connector.model ?? "");
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
const [notes, setNotes] = useState(connector.notes);
const [submitError, setSubmitError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false);
const [autoChecking, setAutoChecking] = useState(false);
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
const [loadingModelOptions, setLoadingModelOptions] = useState(false);
const [modelOptionsError, setModelOptionsError] = useState<string | null>(null);
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const authActionCopy = getAuthActionCopy(connector);
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
const requiresExplicitModel = connector.id === "opencode_local";
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery || connector.id === "opencode_local";
useEffect(() => {
setModel(connector.model ?? "");
setEnvBindingText(connector.envBindingText);
setNotes(connector.notes);
setSubmitError(null);
}, [connector]);
useEffect(() => () => {
if (authPollTimeoutRef.current) {
clearTimeout(authPollTimeoutRef.current);
authPollTimeoutRef.current = null;
}
}, []);
useEffect(() => {
let cancelled = false;
if (!shouldLoadModelOptions) {
setModelOptions([]);
setModelOptionsError(null);
setLoadingModelOptions(false);
return;
}
setLoadingModelOptions(true);
setModelOptionsError(null);
void window.agentCompany.listConnectorModels(connector.id).then((result) => {
if (cancelled) return;
if (!result.ok) {
setModelOptions([]);
setModelOptionsError(result.error.message);
setLoadingModelOptions(false);
return;
}
setModelOptions(result.data);
setLoadingModelOptions(false);
});
return () => {
cancelled = true;
};
}, [connector.id, shouldLoadModelOptions]);
const modelSelectOptions = useMemo(() => {
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
const hasCurrentModel = modelOptions.some((option) => option.id === model);
if (model && !hasCurrentModel) {
options.push({ value: model, label: `${model} (saved)` });
}
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
return options;
}, [model, modelOptions, requiresExplicitModel]);
const runHealthCheck = useCallback(async (quiet = false) => {
setAutoChecking(true);
try {
unwrap(await window.agentCompany.testConnector(connector.id));
await onSaved();
if (!quiet) {
setSubmitError(null);
}
return true;
} catch (error) {
if (!quiet) {
setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed.");
}
return false;
} finally {
setAutoChecking(false);
}
}, [connector.id, onSaved]);
const scheduleAuthPolling = useCallback((remainingAttempts: number) => {
if (authPollTimeoutRef.current) {
clearTimeout(authPollTimeoutRef.current);
authPollTimeoutRef.current = null;
}
if (remainingAttempts <= 0) {
return;
}
authPollTimeoutRef.current = setTimeout(() => {
void (async () => {
const ok = await runHealthCheck(true);
if (!ok) {
scheduleAuthPolling(remainingAttempts - 1);
}
})();
}, 5000);
}, [runHealthCheck]);
return (
<div className="grid gap-4">
{submitError ? <InlineNotice message={submitError} /> : null}
{needsLogin ? (
<div className="rounded-[10px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-4 text-[12px] text-[color:var(--warn)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
<div className="mt-3">
<ActionButton
label={authActionCopy.buttonLabel}
icon={SignIn}
onClick={() => void (async () => {
setOpeningAuthTerminal(true);
setSubmitError(null);
try {
unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id));
scheduleAuthPolling(12);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login.");
} finally {
setOpeningAuthTerminal(false);
}
})()}
loading={openingAuthTerminal}
/>
</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1.5 text-[12px] font-medium text-[color:var(--muted-strong)]">Status</div>
@@ -41,29 +196,72 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
</div>
</div>
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
{shouldLoadModelOptions ? (
<div className="grid gap-2">
<Select
label={requiresExplicitModel ? "Model (required)" : "Model"}
value={model}
onChange={setModel}
options={modelSelectOptions}
/>
{loadingModelOptions ? (
<div className="text-[12px] text-[color:var(--muted)]">Loading available models...</div>
) : null}
{modelOptionsError ? (
<InlineNotice message={modelOptionsError} />
) : null}
{requiresExplicitModel && !model.trim() ? (
<div className="rounded-[8px] border border-[color:var(--warn)] bg-[color:var(--warn-soft)] px-4 py-3 text-[12px] text-[color:var(--warn)]">
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
</div>
) : null}
</div>
) : (
<Input label="Model" value={model} onChange={setModel} placeholder="Leave empty to use connector default" />
)}
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
<div className="grid gap-3 md:grid-cols-[auto_1fr]">
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] text-[color:var(--muted-strong)]">
Connector health checks run automatically on app launch, after save, and after terminal login is opened.
{autoChecking ? " Refreshing connector state..." : ""}
</div>
{connector.lastError ? (
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Latest health-check detail</div>
<div>{connector.lastError}</div>
</div>
) : null}
<div className="flex gap-3">
<ActionButton
label="Save connector"
tone="accent"
onClick={() => void (async () => {
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: model || null,
envBindingText,
notes,
});
await onSaved();
setSaving(true);
setSubmitError(null);
try {
unwrap(await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: model || null,
envBindingText,
notes,
}));
await onSaved();
await runHealthCheck(true);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : "Connector could not be saved.");
} finally {
setSaving(false);
}
})()}
icon={CheckCircle}
loading={saving}
disabled={requiresExplicitModel && !model.trim()}
/>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-4 text-[12px] leading-relaxed text-[color:var(--muted)]">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Configuration doc</div>
<pre className="mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
</div>
</div>
);
+3 -3
View File
@@ -819,7 +819,7 @@ export function Dashboard(props: DashboardProps) {
<button
key={connector.id}
type="button"
onClick={() => onNavigate("connectors")}
onClick={() => onNavigate("connectors", connector.id)}
className={`focus-ring flex items-center gap-2.5 rounded-[8px] border px-3 py-2.5 text-left transition duration-150 hover:bg-[color:var(--panel-soft)] ${
isError ? "border-[color:var(--danger)] border-opacity-30" : "border-[color:var(--line)]"
}`}
@@ -870,7 +870,7 @@ export function Dashboard(props: DashboardProps) {
<button
key={run.id}
type="button"
onClick={() => onNavigate("tasks")}
onClick={() => onNavigate("runs", run.id)}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--danger)] border-opacity-20 bg-[color:var(--danger-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<XCircle size={18} className="shrink-0 text-[color:var(--danger)]" />
@@ -890,7 +890,7 @@ export function Dashboard(props: DashboardProps) {
<button
key={message.id}
type="button"
onClick={() => onNavigate("communication")}
onClick={() => onNavigate("communication", message.id)}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
+79 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo } from "react";
import {
ArrowClockwise,
ArrowSquareOut,
@@ -28,6 +28,7 @@ import type {
ActivityRecord,
AgentRecord,
ApprovalRecord,
CommentRecord,
DocumentRecord,
GoalRecord,
ProfileSnapshot,
@@ -46,6 +47,21 @@ function describeActionError(error: unknown) {
return error instanceof Error ? error.message : "The action could not be completed.";
}
function parseApprovalPayloadJson(approval: ApprovalRecord): Record<string, unknown> | null {
if (!approval.payloadJson) {
return null;
}
try {
const parsed = JSON.parse(approval.payloadJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
const ENTITY_ICONS: Record<string, typeof Robot> = {
agent: Robot,
@@ -151,6 +167,9 @@ export function TaskInspector({
onRun,
onCancelRun,
onCreateApproval,
onOpenDiscussion,
comments,
onRefresh,
onDeleted,
}: {
task: TaskRecord;
@@ -164,6 +183,9 @@ export function TaskInspector({
onRun: (taskId: string) => Promise<void>;
onCancelRun: (runId: string) => Promise<void>;
onCreateApproval: () => Promise<void>;
onOpenDiscussion: (taskId: string) => void;
comments: CommentRecord[];
onRefresh: () => Promise<void>;
onDeleted: () => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
@@ -226,6 +248,7 @@ export function TaskInspector({
projectId: editProject || null,
goalId: editGoal || null,
}));
await onRefresh();
setEditing(false);
setActionNotice("Task changes saved.");
} catch (error) {
@@ -252,6 +275,7 @@ export function TaskInspector({
projectId: task.projectId,
goalId: task.goalId,
}));
await onRefresh();
setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`);
} catch (error) {
setActionError(describeActionError(error));
@@ -400,6 +424,7 @@ export function TaskInspector({
)}
</>
)}
<ActionButton label="Discussion" onClick={() => onOpenDiscussion(task.id)} icon={ArrowRight} />
<ActionButton label="Edit" onClick={startEdit} icon={Pencil} />
<ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" />
</div>
@@ -454,7 +479,13 @@ export function TaskInspector({
})()}
{/* Communication */}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} />
<TaskComments
taskId={task.id}
companyId={task.companyId}
agents={agents}
comments={comments}
onRefresh={onRefresh}
/>
{/* Run history — only if there are runs */}
{runs.length > 0 && (
@@ -509,35 +540,32 @@ function TaskComments({
taskId,
companyId,
agents,
comments,
onRefresh,
}: {
taskId: string;
companyId: string;
agents: ProfileSnapshot["agents"];
comments: CommentRecord[];
onRefresh: () => Promise<void>;
}) {
const [comments, setComments] = useState<Array<{ id: string; authorAgentId: string | null; authorName: string; body: string; createdAt: string }>>([]);
const [newComment, setNewComment] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const loadComments = useCallback(async () => {
const result = await window.agentCompany.listComments({ taskId });
if (result.ok) setComments(result.data);
setLoading(false);
}, [taskId]);
useEffect(() => {
void loadComments();
const unsubscribe = window.agentCompany.subscribe((event) => {
if (event.type === "agent-message" && event.taskId === taskId) {
setComments((prev) => [...prev, event.comment]);
void onRefresh();
}
});
return unsubscribe;
}, [taskId, loadComments]);
}, [onRefresh, taskId]);
const handleSubmit = async () => {
if (!newComment.trim()) return;
setSubmitError(null);
setSubmitting(true);
try {
unwrap(await window.agentCompany.addComment({
companyId,
@@ -546,9 +574,11 @@ function TaskComments({
body: newComment.trim(),
}));
setNewComment("");
await loadComments();
await onRefresh();
} catch (error) {
setSubmitError(describeActionError(error));
} finally {
setSubmitting(false);
}
};
@@ -559,9 +589,7 @@ function TaskComments({
<span className="text-[13px] font-semibold text-[color:var(--text)]">Communication</span>
<span className="text-[11px] text-[color:var(--muted)]">({comments.length})</span>
</div>
{loading ? (
<div className="text-[12px] text-[color:var(--muted)]">Loading...</div>
) : comments.length === 0 ? (
{comments.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No messages yet. Agents will post updates here during runs.</div>
) : (
<div className="max-h-[240px] space-y-2 overflow-y-auto rounded-lg border border-[color:var(--line)] p-3">
@@ -592,7 +620,7 @@ function TaskComments({
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(); } }}
/>
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} />
<ActionButton label="Send" tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
</div>
</div>
);
@@ -843,26 +871,36 @@ export function ApprovalInspector({
{/* Hire agent payload details */}
{approval.type === "hire_agent" ? (() => {
const proposedAgent = agents.find(
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
) ?? agents.find(
(a) => a.name.toLowerCase().includes("pending") || a.status === "pending_approval"
);
if (!proposedAgent) return null;
const payload = parseApprovalPayloadJson(approval);
const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null;
const requestedSnapshot =
payload?.requestedConfigurationSnapshot &&
typeof payload.requestedConfigurationSnapshot === "object" &&
!Array.isArray(payload.requestedConfigurationSnapshot)
? payload.requestedConfigurationSnapshot as Record<string, unknown>
: null;
const proposedAgent =
(approval.relatedAgentId ? (agents.find((agent) => agent.id === approval.relatedAgentId) ?? null) : null) ??
(payloadAgentId ? (agents.find((agent) => agent.id === payloadAgentId) ?? null) : null);
const proposedManagerId =
proposedAgent?.reportsTo ??
(typeof requestedSnapshot?.reportsTo === "string" ? requestedSnapshot.reportsTo : null);
const proposedManager = proposedManagerId
? (agents.find((agent) => agent.id === proposedManagerId) ?? null)
: null;
if (!proposedAgent && !requestedSnapshot) return null;
return (
<div className="rounded-[8px] border border-[color:var(--accent)] border-opacity-30 bg-[color:var(--accent-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Proposed Hire</div>
<div className="grid gap-1.5 text-[12px]">
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent.name}</span></div>
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent.role}</span></div>
{proposedAgent.title ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent.title}</span></div> : null}
{proposedAgent.capabilities ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent.capabilities}</span></div> : null}
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent.connectorId}</span></div>
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent.budgetMonthlyUsd)}/mo</span></div>
{proposedAgent.reportsTo ? (() => {
const manager = agents.find((a) => a.id === proposedAgent.reportsTo);
return manager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{manager.name}</span></div> : null;
})() : null}
<div><span className="text-[color:var(--muted)]">Name:</span> <span className="font-medium text-[color:var(--text)]">{proposedAgent?.name ?? approval.payloadSummary.replace(/^Hire\s+/i, "").split(/\s+as\s+/i)[0] ?? "Pending hire"}</span></div>
<div><span className="text-[color:var(--muted)]">Role:</span> <span className="text-[color:var(--text)]">{proposedAgent?.role ?? (typeof requestedSnapshot?.role === "string" ? requestedSnapshot.role : "Unspecified")}</span></div>
{(proposedAgent?.title || typeof requestedSnapshot?.title === "string") ? <div><span className="text-[color:var(--muted)]">Title:</span> <span className="text-[color:var(--text)]">{proposedAgent?.title || requestedSnapshot?.title as string}</span></div> : null}
{(proposedAgent?.capabilities || typeof requestedSnapshot?.capabilities === "string") ? <div><span className="text-[color:var(--muted)]">Capabilities:</span> <span className="text-[color:var(--text)]">{proposedAgent?.capabilities || requestedSnapshot?.capabilities as string}</span></div> : null}
<div><span className="text-[color:var(--muted)]">Connector:</span> <span className="mono text-[color:var(--text)]">{proposedAgent?.connectorId ?? (typeof requestedSnapshot?.connectorId === "string" ? requestedSnapshot.connectorId : "Unknown")}</span></div>
<div><span className="text-[color:var(--muted)]">Budget:</span> <span className="mono text-[color:var(--text)]">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof requestedSnapshot?.budgetMonthlyUsd === "number" ? requestedSnapshot.budgetMonthlyUsd : 0))}/mo</span></div>
{proposedManager ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{proposedManager.name}</span></div> : null}
</div>
</div>
);
@@ -1236,7 +1274,7 @@ export function WorkspaceInspector({
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
</div>
</div>
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label="Open folder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
</div>
@@ -1542,6 +1580,7 @@ export function SettingsPane({
type="checkbox"
checked={company.autoApproveHires}
onChange={async (e) => {
setActionError(null);
try {
unwrap(await window.agentCompany.saveCompany({
id: company.id,
@@ -1551,14 +1590,16 @@ export function SettingsPane({
autoApproveHires: e.target.checked,
}));
await onSaved();
} catch { /* ignore */ }
} catch (error) {
setActionError(describeActionError(error));
}
}}
className="h-4 w-4 rounded accent-[color:var(--accent)]"
/>
<div>
<div className="text-[13px] font-medium text-[color:var(--text)]">Full Autonomy Mode</div>
<div className="text-[13px] font-medium text-[color:var(--text)]">Auto-approve hire and strategy approvals</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
Auto-approve agent hires and CEO strategies. Agents will build the team and start operations without Board approval.
Automatically approve agent hiring requests and CEO strategy approvals so the company can continue operating without waiting on the board for those specific approval types.
</div>
</div>
</label>
+1 -1
View File
@@ -61,7 +61,7 @@ export function Inbox({ items, onNavigate }: InboxProps) {
const handleItemClick = (item: InboxItem) => {
const sectionMap: Record<string, SectionId> = {
approval: "approvals",
run: "tasks",
run: "runs",
agent: "agents",
task: "tasks",
message: "communication",
+504
View File
@@ -0,0 +1,504 @@
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";
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 [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="No runs yet"
detail="Start a task run from the task queue or onboarding handoff. This surface becomes the operator console for queue state, failures, and live log tails."
/>
);
}
return (
<div className="space-y-10">
<div className="grid gap-3 md:grid-cols-3">
<RunSummaryCard
label="Active"
value={groupedCounts.active}
detail="Queued or running right now"
icon={PlayCircle}
/>
<RunSummaryCard
label="Needs attention"
value={groupedCounts.attention}
detail="Failed, timed out, or interrupted"
icon={WarningCircle}
tone="danger"
/>
<RunSummaryCard
label="Completed"
value={groupedCounts.completed}
detail="Finished without current intervention"
icon={ListChecks}
/>
</div>
<div className="grid gap-8 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<div className="space-y-5">
<Panel
title="Run queue"
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="Search by task, agent, connector, workspace, or error"
/>
</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="No runs match this filter"
detail="Widen the status filter or clear the search query to inspect the full run history."
/>
) : null}
</Panel>
</div>
<div className="space-y-6">
{selectedRun ? (
<>
<Panel
title="Run detail"
action={(
<div className="flex items-center gap-2">
<ActionButton
label="Open console"
icon={Lightning}
onClick={() => onOpenConsole(selectedRun.id)}
/>
{isActiveStatus(selectedRun.status) ? (
<ActionButton
label="Cancel run"
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="Open log file"
icon={FolderOpen}
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
/>
</div>
) : null}
</Panel>
<Panel
title="Log tail"
eyebrow="Incremental reader"
action={(
<div className="flex items-center gap-2">
<ActionButton
label="Reload tail"
icon={ArrowClockwise}
onClick={() => setLogReloadKey((current) => current + 1)}
/>
{logState.offset > 0 ? (
<ActionButton
label="Load older"
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>
);
}
+56 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { type ElementType, useCallback, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Browser,
@@ -18,22 +18,30 @@ import {
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";
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
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 }> = {
@@ -58,41 +66,51 @@ function AccountCard({
account: SocialAccountRecord;
onLogin: () => void;
onDelete: () => void;
onClick: () => 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] text-[14px] font-bold"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: platform.bgColor, color: platform.color }}
>
{platform.icon}
<PlatformIcon size={18} weight="fill" />
</div>
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
<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>
</button>
{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 ${
@@ -185,7 +203,9 @@ function AddAccountDialog({
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
}`}
>
<span style={{ color: config.color }}>{config.icon}</span>
<span style={{ color: config.color }}>
<config.icon size={14} />
</span>
{config.label}
</button>
))}
@@ -492,7 +512,6 @@ export function SocialAccountsPanel({
account={account}
onLogin={() => void onLogin(account.id)}
onDelete={() => void onDelete(account.id)}
onClick={() => {}}
/>
))}
</div>
+2 -2
View File
@@ -322,8 +322,8 @@ export function ActionButton({
export function StatusPill({ status }: { status: string }) {
const positive = ["ready", "active", "running", "succeeded", "approved", "achieved", "done"].includes(status);
const negative = ["error", "failed", "cancelled", "blocked", "rejected", "interrupted"].includes(status);
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued"].includes(status);
const checking = status === "detected" || status === "not_installed";
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
const checking = status === "detected";
const toneClass = positive
? "text-[color:var(--success)]"
: negative