refactor(main): extract getOrCreateStore in RuntimeBridge

P3-56: three call sites repeated the same 'lookup → fall back to
new → set if missing' dance for session stores. The duplication hid the
fact that the fallback allocation in the second and third sites could
be wasted when the entry already existed. Extract a single
getOrCreateStore helper that does one map lookup, one conditional
construction, and always returns the cached instance.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 23:27:57 -04:00
1 parent 326bfb3e38
commit 3edb2fc6c1
3 files changed
+440 -34

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AppDatabase } from "@main/database";
import type { DesktopEvent } from "@shared/types";
import { ensureTaskThread } from "@main/thread-integration/thread-helpers";
import { SqliteSessionStore } from "./sqlite-session-store";
import { HumanApprovalHook } from "./approval-hook";
import { CostWriter } from "./cost-writer";
export interface RuntimeBridgeDeps {
db: AppDatabase;
emitEvent: (event: DesktopEvent) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
interface ActiveRunContext {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
connectorId: string;
modelId: string;
taskSessionId: string;
}
/**
* Single owner of pi runtime side-effects in main.
*
* - Forwards `agent-event` from the worker to the renderer (push channel).
* - Persists each `message_end` message via `SqliteSessionStore`.
* - Calls `CostWriter` for assistant messages with usage.
* - Translates `beforeToolCall` RPC into an approval row + waits.
* - Translates `afterToolCall` RPC into best-effort audit hooks.
*/
export class RuntimeBridge {
readonly approvalHook: HumanApprovalHook;
private readonly costWriter: CostWriter;
private readonly activeRuns = new Map<string, ActiveRunContext>();
private readonly sessionStores = new Map<string, SqliteSessionStore>();
constructor(private readonly deps: RuntimeBridgeDeps) {
this.approvalHook = new HumanApprovalHook({
db: deps.db,
emitEvent: deps.emitEvent,
logger: deps.logger,
});
this.costWriter = new CostWriter({ db: deps.db, logger: deps.logger });
}
registerActiveRun(context: ActiveRunContext): void {
this.activeRuns.set(context.runId, context);
this.getOrCreateStore(context.taskSessionId);
}
releaseActiveRun(runId: string): void {
this.activeRuns.delete(runId);
}
loadPriorMessages(taskSessionId: string): AgentMessage[] {
return this.getOrCreateStore(taskSessionId).loadMessages();
}
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
const existing = this.sessionStores.get(taskSessionId);
if (existing) return existing;
const store = new SqliteSessionStore(this.deps.db, taskSessionId);
this.sessionStores.set(taskSessionId, store);
return store;
}
/** Receives an `agent-event` envelope from the worker; emits to renderer. */
handleAgentEvent(envelope: {
runId: string;
agentId: string;
companyId: string;
taskId: string | null;
emittedAt: string;
event: { type: string } & Record<string, unknown>;
}): void {
this.deps.emitEvent({
type: "agent-event",
runId: envelope.runId,
agentId: envelope.agentId,
companyId: envelope.companyId,
taskId: envelope.taskId,
emittedAt: envelope.emittedAt,
event: envelope.event,
});
if (envelope.event.type === "message_end") {
const ctx = this.activeRuns.get(envelope.runId);
const messageRaw = envelope.event.message as
| { role?: string; usage?: unknown; content?: unknown; toolCallId?: string }
| undefined;
if (ctx && messageRaw?.role === "assistant" && messageRaw.usage) {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
}
this.recordThreadEvent(envelope, messageRaw);
}
}
/**
* Project the pi `message_end` event into the task thread as a
* `conversation_events` row so renderer thread views stay live.
* Best-effort: thread persistence failures must not interrupt the run.
*/
private recordThreadEvent(
envelope: { runId: string; agentId: string; companyId: string; taskId: string | null },
message: { role?: string; content?: unknown; toolCallId?: string } | undefined,
): void {
if (!envelope.taskId || !message?.role) return;
let task: { title: string } | null = null;
try {
task = this.deps.db.getTask(envelope.taskId);
} catch {
return;
}
if (!task) return;
try {
const thread = ensureTaskThread(this.deps.db, envelope.taskId, envelope.companyId, task.title);
const body = extractTextBody(message.content);
const actorType: "agent" | "user" | "system" = message.role === "user"
? "user"
: message.role === "assistant"
? "agent"
: "system";
this.deps.db.addThreadEvent({
threadId: thread.id,
actorId: envelope.agentId,
actorType,
eventType: message.role === "toolResult" ? "system_note" : "message",
body: body.length > 4000 ? `${body.slice(0, 3997)}...` : body,
metadataJson: JSON.stringify({ runId: envelope.runId, role: message.role }),
});
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] failed to project agent-event into thread for task ${envelope.taskId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/** Handles RPC requests issued by the worker (`rpc-request` envelope). */
async handleRpc(request: {
rpcId: string;
method: "beforeToolCall" | "afterToolCall" | "persistMessage";
runId: string;
payload: unknown;
}): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
try {
if (request.method === "beforeToolCall") {
const ctx = this.activeRuns.get(request.runId);
if (!ctx) return { ok: true, result: {} };
const payload = request.payload as {
agentId: string;
companyId: string;
taskId: string | null;
toolCallId: string;
toolName: string;
args: unknown;
};
const verdict = await this.approvalHook.before(request.runId, payload);
return { ok: true, result: verdict };
}
if (request.method === "afterToolCall") {
const payload = request.payload as { toolCallId: string; toolName: string; isError: boolean };
await this.approvalHook.after(request.runId, payload);
return { ok: true, result: undefined };
}
if (request.method === "persistMessage") {
const payload = request.payload as { taskSessionId: string; message: AgentMessage };
this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message);
return { ok: true, result: undefined };
}
return { ok: false, error: `Unknown RPC method: ${(request as { method: string }).method}` };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
/** Best-effort textual rendering of a pi message's content for thread rows. */
function extractTextBody(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const obj = block as { type?: string; text?: string; thinking?: string; name?: string };
if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`);
else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`);
}
return parts.join("\n").trim();
}
@@ -61,18 +61,121 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
return (
<div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><Lightning size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}</p></div></div><ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{rules.length > 0 ? <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">{[{ l: "Total Rules", v: counts.total, accent: false }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true }, { l: "Recent Activity", v: actLog.length, accent: true }].map((s) => <div key={s.l} className="rounded-lg border border-border bg-card px-4 py-3.5"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div><div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div></div>)}</div> : null}
<div className="flex gap-1 rounded-lg bg-muted p-1"><button type="button" onClick={() => setActiveTab("rules")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Rules ({rules.length})</button><button type="button" onClick={() => setActiveTab("activity")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Activity Log ({actLog.length})</button></div>
{activeTab === "rules" ? <>
{rules.length > 0 ? <div className="flex flex-wrap items-center gap-1.5">{(["all","active","paused","error"] as const).map((s) => <button key={s} type="button" onClick={() => setFilterStatus(s)} className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}>{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}</button>)}</div> : null}
{filtered.length === 0 && rules.length === 0 ? <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"><Lightning size={36} className="mb-3 text-muted-foreground" /><div className="text-[14px] font-medium text-muted-foreground">No automation rules</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">Create your first rule to make the company autonomous.</div><div className="mt-4"><ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" /></div></div>
: filtered.length === 0 ? <div className="py-8 text-center text-[13px] text-muted-foreground">No rules match the current filter.</div>
: <div className="space-y-2"><AnimatePresence>{filtered.map((r) => <RuleCard key={r.id} rule={r} onToggle={() => void handleToggle(r)} onDelete={() => setDelTarget(r)} onEdit={() => setEditRule(r)} />)}</AnimatePresence></div>}
</> : <AutomationActivityLog entries={actLog} />}
<AnimatePresence>{showCreate ? <RuleFormModal open={showCreate} editingRule={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} /> : null}{editRule ? <RuleFormModal open={!!editRule} editingRule={editRule} companyId={companyId} onClose={() => setEditRule(null)} onSaved={onRefresh} /> : null}</AnimatePresence>
<ConfirmDialog open={!!delTarget} title="Delete rule" message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""} onConfirm={() => void handleDelete()} onCancel={() => setDelTarget(null)} />
{actionError ? (
<div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">
<span>{actionError}</span>
<button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground">
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Lightning size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2>
<p className="text-[12px] text-muted-foreground">
{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}
</p>
</div>
</div>
<ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{rules.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ l: "Total Rules", v: counts.total, accent: false },
{ l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
{ l: "Recent Activity", v: actLog.length, accent: true },
].map((s) => (
<div key={s.l} className="rounded-lg border border-border bg-card px-4 py-3.5">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div>
<div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div>
</div>
))}
</div>
) : null}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button
type="button"
onClick={() => setActiveTab("rules")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
Rules ({rules.length})
</button>
<button
type="button"
onClick={() => setActiveTab("activity")}
className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
Activity Log ({actLog.length})
</button>
</div>
{activeTab === "rules" ? (
<>
{rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setFilterStatus(s)}
className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`}
>
{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
) : null}
{filtered.length === 0 && rules.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Lightning size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No automation rules</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
Create your first rule to make the company autonomous.
</div>
<div className="mt-4">
<ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
</div>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-[13px] text-muted-foreground">No rules match the current filter.</div>
) : (
<div className="space-y-2">
<AnimatePresence>
{filtered.map((r) => (
<RuleCard
key={r.id}
rule={r}
onToggle={() => void handleToggle(r)}
onDelete={() => setDelTarget(r)}
onEdit={() => setEditRule(r)}
/>
))}
</AnimatePresence>
</div>
)}
</>
) : (
<AutomationActivityLog entries={actLog} />
)}
<AnimatePresence>
{showCreate ? (
<RuleFormModal open={showCreate} editingRule={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} />
) : null}
{editRule ? (
<RuleFormModal open={!!editRule} editingRule={editRule} companyId={companyId} onClose={() => setEditRule(null)} onSaved={onRefresh} />
) : null}
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete rule"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
</div>
)
}
@@ -85,12 +188,71 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
return (
<div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><ArrowsClockwise size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.draft} draft{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}</p></div></div><ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{workflows.length === 0 ? <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center"><ArrowsClockwise size={36} className="mb-3 text-muted-foreground" /><div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">Workflows chain actions into multi-step pipelines.</div><div className="mt-4"><ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /></div></div>
: <div className="space-y-2"><AnimatePresence>{workflows.map((wf) => <WorkflowCard key={wf.id} workflow={wf} isRunning={runningId === wf.id} onDelete={() => setDelTarget(wf)} onEdit={() => setEditWf(wf)} onRun={() => void handleRun(wf)} />)}</AnimatePresence></div>}
<AnimatePresence>{showCreate ? <WorkflowFormModal open={showCreate} editingWorkflow={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} /> : null}{editWf ? <WorkflowFormModal open={!!editWf} editingWorkflow={editWf} companyId={companyId} onClose={() => setEditWf(null)} onSaved={onRefresh} /> : null}</AnimatePresence>
<ConfirmDialog open={!!delTarget} title="Delete workflow" message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""} onConfirm={() => void handleDelete()} onCancel={() => setDelTarget(null)} />
{actionError ? (
<div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">
<span>{actionError}</span>
<button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground">
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ArrowsClockwise size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2>
<p className="text-[12px] text-muted-foreground">
{counts.active} active, {counts.draft} draft
{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}
</p>
</div>
</div>
<ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
</div>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<ArrowsClockwise size={36} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
Workflows chain actions into multi-step pipelines.
</div>
<div className="mt-4">
<ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
</div>
</div>
) : (
<div className="space-y-2">
<AnimatePresence>
{workflows.map((wf) => (
<WorkflowCard
key={wf.id}
workflow={wf}
isRunning={runningId === wf.id}
onDelete={() => setDelTarget(wf)}
onEdit={() => setEditWf(wf)}
onRun={() => void handleRun(wf)}
/>
))}
</AnimatePresence>
</div>
)}
<AnimatePresence>
{showCreate ? (
<WorkflowFormModal open={showCreate} editingWorkflow={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} />
) : null}
{editWf ? (
<WorkflowFormModal open={!!editWf} editingWorkflow={editWf} companyId={companyId} onClose={() => setEditWf(null)} onSaved={onRefresh} />
) : null}
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete workflow"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
</div>
)
}
@@ -99,12 +261,52 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
const t = useT(); const [activeSection, setActiveSection] = useState<"rules"|"workflows">("rules")
return (
<div className="space-y-6">
<div className="rounded-lg border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/[0.02] px-5 py-4"><div className="flex items-start gap-3"><div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Robot size={20} weight="fill" /></div><div><div className="text-[13px] font-semibold text-foreground">{t("auto.automationEngine")}</div><div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously.</div></div></div></div>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button type="button" onClick={() => setActiveSection("rules")} className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}><Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} className={activeSection === "rules" ? "text-primary" : ""} />Automation Rules ({rules.length})</button>
<button type="button" onClick={() => setActiveSection("workflows")} className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}><ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} className={activeSection === "workflows" ? "text-primary" : ""} />Workflows ({workflows.length})</button>
<div className="rounded-lg border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/[0.02] px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Robot size={20} weight="fill" />
</div>
<div>
<div className="text-[13px] font-semibold text-foreground">{t("auto.automationEngine")}</div>
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously.
</div>
</div>
</div>
</div>
<AnimatePresence mode="wait"><motion.div key={activeSection} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}>{activeSection === "rules" ? <RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} /> : <WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />}</motion.div></AnimatePresence>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<button
type="button"
onClick={() => setActiveSection("rules")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} className={activeSection === "rules" ? "text-primary" : ""} />
Automation Rules ({rules.length})
</button>
<button
type="button"
onClick={() => setActiveSection("workflows")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
<ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} className={activeSection === "workflows" ? "text-primary" : ""} />
Workflows ({workflows.length})
</button>
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{activeSection === "rules" ? (
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} />
) : (
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />
)}
</motion.div>
</AnimatePresence>
</div>
)
}
@@ -6,12 +6,12 @@ import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import { useT } from "../../i18n"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
direct: { label: "Direct Messages", icon: EnvelopeSimple, color: "var(--primary)" },
department: { label: "Department", icon: Buildings, color: "var(--muted-foreground)" },
company: { label: "Company-wide", icon: Megaphone, color: "var(--primary)" },
project: { label: "Project", icon: Hash, color: "var(--primary)" },
incident: { label: "Incident", icon: Fire, color: "var(--destructive)" },
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType }> = {
direct: { label: "Direct Messages", icon: EnvelopeSimple },
department: { label: "Department", icon: Buildings },
company: { label: "Company-wide", icon: Megaphone },
project: { label: "Project", icon: Hash },
incident: { label: "Incident", icon: Fire },
}
function ChannelItem({
@@ -45,11 +45,8 @@ function ChannelItem({
{descriptor.channel === "direct" && agent ? (
<AgentAvatar agent={agent} size="sm" />
) : (
<div
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${config.color}18` }}
>
<Icon size={14} weight={active ? "fill" : "regular"} style={{ color: config.color }} />
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon size={14} weight={active ? "fill" : "regular"} className="text-muted-foreground" />
</div>
)}