This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:03:56 -04:00
1 parent 806200b3d1
commit 5547c29491
216 files changed
+5043 -3352

No files matched your search

@@ -1,2 +0,0 @@
// Backward-compatible re-exports from decomposed onboarding components
export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard";
+6 -4
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from "react";
import { Copy, Minus, Square, X } from "./ui/icons";
import { useT } from "../i18n";
export default function TitleBar() {
const t = useT();
const [maximized, setMaximized] = useState(false);
const refreshMaximized = useCallback(() => {
@@ -26,7 +28,7 @@ export default function TitleBar() {
<div className="flex h-full">
<button
type="button"
aria-label="Minimize"
aria-label={t("titlebar.minimize")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => void window.agentCompany.windowMinimize()}
>
@@ -34,7 +36,7 @@ export default function TitleBar() {
</button>
<button
type="button"
aria-label={maximized ? "Restore" : "Maximize"}
aria-label={maximized ? t("titlebar.restore") : t("titlebar.maximize")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => {
void window.agentCompany.windowMaximize().then(refreshMaximized);
@@ -48,8 +50,8 @@ export default function TitleBar() {
</button>
<button
type="button"
aria-label="Close"
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-red-500 hover:text-white"
aria-label={t("titlebar.close")}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-destructive hover:text-primary-foreground"
onClick={() => void window.agentCompany.windowClose()}
>
<X size={14} weight="bold" />
-1
View File
@@ -1 +0,0 @@
export { AutomationPanel } from "./automation/AutomationPanel"
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, X } from "../ui/icons"
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
import { ActionButton, ConfirmDialog } from "../ui"
import { useT } from "../../i18n"
import { ActionButton, ConfirmDialog, Card, Eyebrow } from "../ui"
import { useT, type TranslationFn } from "../../i18n"
import { triggerConfig, actionConfig, type AutomationLogEntry } from "./automation-config"
import { timeAgo } from "../../lib/formatters"
import { RuleCard } from "./RuleCard"
@@ -11,9 +11,9 @@ import { RuleFormModal } from "./RuleEditor"
import { WorkflowCard } from "./WorkflowList"
import { WorkflowFormModal } from "./WorkflowEditor"
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
function AutomationActivityLog({ entries, t }: { entries: AutomationLogEntry[]; t: TranslationFn }) {
if (entries.length === 0) {
return <div className="py-6 text-center text-[13px] text-muted-foreground">No activity yet</div>
return <div className="py-6 text-center text-[13px] text-muted-foreground">{t("auto.activity.noActivity")}</div>
}
return (
<div className="space-y-1">
@@ -48,7 +48,7 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
)
}
function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void }) {
function RulesPanel({ rules, companyId, onRefresh, t }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editRule, setEditRule] = useState<AutomationRuleRecord | null>(null); const [delTarget, setDelTarget] = useState<AutomationRuleRecord | null>(null)
const [actLog, setActLog] = useState<AutomationLogEntry[]>([]); const [activeTab, setActiveTab] = useState<"rules"|"activity">("rules"); const [filterStatus, setFilterStatus] = useState<AutomationStatus|"all">("all")
const [actionError, setActionError] = useState<string | null>(null)
@@ -56,8 +56,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } }, [companyId, onRefresh, t])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
return (
<div className="space-y-5">
@@ -75,25 +75,25 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
<Lightning size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.rules.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}
{t("auto.rules.summaryActive").replace("{n}", String(counts.active))},{t("auto.rules.summaryPaused").replace("{n}", String(counts.paused))}{counts.error > 0 ? `,${t("auto.rules.summaryErrored").replace("{n}", String(counts.error))}` : ""}
</p>
</div>
</div>
<ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
<ActionButton label={t("auto.rules.newRule")} 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 },
{ l: t("auto.rules.totalRules"), v: counts.total, accent: false },
{ l: t("auto.rules.totalExecutions"), v: rules.reduce((s, r) => s + r.executionCount, 0), accent: true },
{ l: t("auto.rules.recentActivity"), 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>
<Card key={s.l} padding="sm" className="!py-3.5">
<Eyebrow>{s.l}</Eyebrow>
<div className={`text-[26px] font-bold tabular-nums ${s.accent ? "text-primary" : ""}`}>{s.v}</div>
</div>
</Card>
))}
</div>
) : null}
@@ -103,45 +103,51 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
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})
{t("auto.rules.tabRules").replace("{n}", String(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})
{t("auto.rules.tabActivity").replace("{n}", String(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>
))}
{(["all", "active", "paused", "error"] as const).map((s) => {
const filterLabel = s === "all" ? t("auto.rules.filterAll")
: s === "active" ? t("auto.rules.filterActive")
: s === "paused" ? t("auto.rules.filterPaused")
: t("auto.rules.filterError");
return (
<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)]"}`}
>
{filterLabel}
</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="text-[14px] font-medium text-muted-foreground">{t("auto.rules.noRules")}</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.
{t("auto.rules.noRulesDetail")}
</div>
<div className="mt-4">
<ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" />
<ActionButton label={t("auto.rules.createFirstRule")} 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="py-8 text-center text-[13px] text-muted-foreground">{t("auto.rules.noRulesFilter")}</div>
) : (
<div className="space-y-2">
<AnimatePresence>
@@ -159,7 +165,7 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
)}
</>
) : (
<AutomationActivityLog entries={actLog} />
<AutomationActivityLog entries={actLog} t={t} />
)}
<AnimatePresence>
{showCreate ? (
@@ -171,8 +177,8 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete rule"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
title={t("auto.rules.deleteRule")}
message={delTarget ? t("auto.rules.deleteRuleConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
@@ -180,11 +186,11 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
)
}
function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
function WorkflowsPanel({ workflows, companyId, onRefresh, t }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void; t: TranslationFn }) {
const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState<WorkflowPipelineRecord | null>(null); const [delTarget, setDelTarget] = useState<WorkflowPipelineRecord | null>(null)
const [actionError, setActionError] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null)
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } finally { setRunningId(null) } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")); setDelTarget(null) } }, [delTarget, companyId, onRefresh, t])
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : t("auto.failedAction")) } finally { setRunningId(null) } }, [companyId, onRefresh, t])
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">
@@ -202,24 +208,24 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
<ArrowsClockwise size={20} weight="fill" />
</div>
<div>
<h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2>
<h2 className="text-[16px] font-bold text-foreground">{t("auto.workflows.title")}</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` : ""}
{t("auto.workflows.summaryActive").replace("{n}", String(counts.active))},{t("auto.workflows.summaryDraft").replace("{n}", String(counts.draft))}
{counts.total > counts.active + counts.draft ? `,${t("auto.workflows.summaryOther").replace("{n}", String(counts.total - counts.active - counts.draft))}` : ""}
</p>
</div>
</div>
<ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" />
<ActionButton label={t("auto.workflows.newWorkflow")} 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="text-[14px] font-medium text-muted-foreground">{t("auto.workflows.noWorkflows")}</div>
<div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">
Workflows chain actions into multi-step pipelines.
{t("auto.workflows.noWorkflowsDetail")}
</div>
<div className="mt-4">
<ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
<ActionButton label={t("auto.workflows.createFirstWorkflow")} onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" />
</div>
</div>
) : (
@@ -248,8 +254,8 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
</AnimatePresence>
<ConfirmDialog
open={!!delTarget}
title="Delete workflow"
message={delTarget ? `Are you sure you want to delete ${delTarget.name}? This action cannot be undone.` : ""}
title={t("auto.workflows.deleteWorkflow")}
message={delTarget ? t("auto.workflows.deleteWorkflowConfirm").replace("{name}", delTarget.name) : ""}
onConfirm={() => void handleDelete()}
onCancel={() => setDelTarget(null)}
/>
@@ -269,7 +275,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
<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.
{t("auto.engineDescription")}
</div>
</div>
</div>
@@ -281,7 +287,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
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})
{t("auto.tabRules").replace("{n}", String(rules.length))}
</button>
<button
type="button"
@@ -289,7 +295,7 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
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})
{t("auto.tabWorkflows").replace("{n}", String(workflows.length))}
</button>
</div>
<AnimatePresence mode="wait">
@@ -301,9 +307,9 @@ export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { ru
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{activeSection === "rules" ? (
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} />
<RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} t={t} />
) : (
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />
<WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} t={t} />
)}
</motion.div>
</AnimatePresence>
@@ -1 +0,0 @@
export { CommunicationHub } from "./communication/CommunicationHub"
@@ -5,13 +5,16 @@ import type { AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import { useT } from "../../i18n"
import { Eyebrow } from "../ui"
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 },
type ChannelConfig = { labelKey: string; icon: React.ElementType }
const channelConfig: Record<MessageChannel, ChannelConfig> = {
direct: { labelKey: "comm.channelLabel.direct", icon: EnvelopeSimple },
department: { labelKey: "comm.channelLabel.department", icon: Buildings },
company: { labelKey: "comm.channelLabel.company", icon: Megaphone },
project: { labelKey: "comm.channelLabel.project", icon: Hash },
incident: { labelKey: "comm.channelLabel.incident", icon: Fire },
}
function ChannelItem({
@@ -111,9 +114,7 @@ export function ChannelSidebar({
<div className="flex-1 overflow-y-auto px-2 py-3">
{channelGroups.map((group) => (
<div key={group.type} className="mb-3">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</div>
<Eyebrow className="mb-1 px-2">{group.label}</Eyebrow>
{group.items.map((ch) => {
const stats = unreadByChannel.get(ch.key)
return (
@@ -133,9 +134,7 @@ export function ChannelSidebar({
{taskDiscussionChannels.length > 0 ? (
<div className="mb-3">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("comm.taskDiscussions")}
</div>
<Eyebrow className="mb-1 px-2">{t("comm.taskDiscussions")}</Eyebrow>
{taskDiscussionChannels.map((discussion) => (
<button
key={discussion.key}
@@ -165,8 +164,8 @@ export function ChannelSidebar({
<div className="border-t border-border px-4 py-2">
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{totalChannels} channels</span>
<span>{agents.length} agents</span>
<span>{t("comm.channelsTotal").replace("{count}", String(totalChannels))}</span>
<span>{t("comm.agentsTotal").replace("{count}", String(agents.length))}</span>
</div>
</div>
</motion.div>
@@ -11,6 +11,7 @@ import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./Me
import { SearchResults } from "./SearchResults"
import ThreadPanel from "./ThreadPanel"
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
import { useT, type TranslationFn } from "../../i18n"
interface CommunicationHubProps {
@@ -19,12 +20,12 @@ interface CommunicationHubProps {
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
}
function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void }) {
function SearchScopeToggle({ scope, onChangeScope, t }: { scope: "global" | "channel"; onChangeScope: (scope: "global" | "channel") => void; t: TranslationFn }) {
return (
<div
className="inline-flex items-center gap-0.5 rounded-full bg-card p-0.5"
role="radiogroup"
aria-label="Search scope"
aria-label={t("comm.searchScope")}
>
<button
type="button"
@@ -38,7 +39,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
: "bg-muted text-muted-foreground"
}`}
>
All Channels
{t("comm.allChannels")}
</button>
<button
type="button"
@@ -52,13 +53,14 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
: "bg-muted text-muted-foreground"
}`}
>
This Channel
{t("comm.thisChannel")}
</button>
</div>
)
}
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
const t = useT()
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [showSidebar, setShowSidebar] = useState(true)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
@@ -156,7 +158,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
} else {
setSearchResults([], currentRequestId)
}
} catch {
} catch (error) {
console.warn(
`[communication] searchMessages failed: ${error instanceof Error ? error.message : String(error)}`,
)
setSearchResults([], currentRequestId)
}
}, 300)
@@ -203,7 +208,7 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
void (async () => {
for (const id of ids) pendingReadIdsRef.current.add(id)
setOptimisticReadIds((c) => { const n = new Set(c); for (const id of ids) n.add(id); return n })
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch { return { id, ok: false as const } } }))
const results = await Promise.all(ids.map(async (id) => { try { unwrap(await window.agentCompany.markMessageRead({ messageId: id, companyId })); return { id, ok: true as const } } catch (error) { console.warn(`[communication] markMessageRead(${id}) failed: ${error instanceof Error ? error.message : String(error)}`); return { id, ok: false as const } } }))
const failed = results.filter((r) => !r.ok).map((r) => r.id)
for (const id of ids) pendingReadIdsRef.current.delete(id)
if (!cancelled && failed.length > 0) setOptimisticReadIds((c) => { const n = new Set(c); for (const id of failed) n.delete(id); return n })
@@ -220,16 +225,22 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
const channelGroups = useMemo(() => {
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const cfgs: Record<MessageChannel, string> = { company: "Company-wide", department: "Department", project: "Project", incident: "Incident", direct: "Direct Messages" }
const cfgs: Record<MessageChannel, string> = {
company: t("comm.channelGroup.company"),
department: t("comm.channelGroup.department"),
project: t("comm.channelGroup.project"),
incident: t("comm.channelGroup.incident"),
direct: t("comm.channelGroup.direct"),
}
for (const type of (["company", "department", "project", "incident", "direct"] as MessageChannel[])) {
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
if (items.length > 0) groups.push({ type, label: cfgs[type], items })
}
return groups
}, [channels])
}, [channels, t])
const scopeToggleElement = isSearchActive ? (
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} />
<SearchScopeToggle scope={searchScope} onChangeScope={setSearchScope} t={t} />
) : null
return (
@@ -1,377 +1,155 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowBendUpLeft, X, ChatCircle, UserCircle, Paperclip, FileText } from "../ui/icons"
import type { AgentMessageRecord, AgentRecord, DocumentRecord, MessageChannel } from "@shared/types"
import { unwrap } from "../../lib/desktop"
import { cmdOrCtrlLabel } from "../../lib/platform"
import { getAgentInitials } from "../../lib/formatters"
import { useAppStore } from "../../state/store"
import { InlineNotice } from "../ui"
import { useT } from "../../i18n"
import { ComposerShell } from "./composer-shell/ComposerShell"
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AgentMessageRecord,
AgentRecord,
DocumentRecord,
MessageChannel,
} from "@shared/types";
import { cmdOrCtrlLabel } from "../../lib/platform";
import { InlineNotice } from "../ui";
import { useT } from "../../i18n";
import { ComposerShell } from "./composer-shell/ComposerShell";
import {
AttachmentChips,
ComposerAttachments,
type AttachmentItem,
} from "./composer/ComposerAttachments";
import { ComposerMentionDropdown } from "./composer/ComposerMentionDropdown";
import { ComposerReplyBanner } from "./composer/ComposerReplyBanner";
import { ComposerSubjectRow } from "./composer/ComposerSubjectRow";
import { useMentionState } from "./composer/useMentionState";
import { useMessageSend } from "./composer/useMessageSend";
export { TaskDiscussionComposer } from "./composer/TaskDiscussionComposer";
export { NoChannelComposer } from "./composer/NoChannelComposer";
const HUMAN_SENDER_ID = "__board__";
// ─── Attachment Picker ──────────────────────────────────────────────────────
interface AttachmentItem { type: string; id: string; title: string }
function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
documents: DocumentRecord[];
attachments: AttachmentItem[];
onAdd: (doc: DocumentRecord) => void;
onRemove: (id: string) => void;
}) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState("")
const containerRef = useRef<HTMLDivElement>(null)
const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments])
const filtered = useMemo(() => {
const q = filter.toLowerCase()
return documents.filter((d) => !attachedIds.has(d.id) && (d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)))
}, [documents, attachedIds, filter])
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("mousedown", handler)
return () => document.removeEventListener("mousedown", handler)
}, [open])
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="Attach document"
>
<Paperclip size={16} />
</button>
{open && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-72 rounded-xl border border-border bg-card shadow-xl">
<div className="border-b border-border px-3 py-2">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search documents..."
className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">
{documents.length === 0 ? "No documents in this company" : "No matching documents"}
</div>
) : (
filtered.slice(0, 20).map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => { onAdd(doc); setFilter(""); setOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted"
>
<FileText size={14} className="shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-foreground">{doc.title}</div>
<div className="truncate text-[10px] text-muted-foreground">{doc.type.replaceAll("_", " ")} · {doc.status}</div>
</div>
</button>
))
)}
</div>
</div>
)}
{attachments.length > 0 && (
<div className="absolute bottom-full left-10 mb-1 flex flex-wrap gap-1">
{attachments.map((att) => (
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
<FileText size={10} />
<span className="max-w-[120px] truncate">{att.title}</span>
<button type="button" onClick={() => onRemove(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
</span>
))}
</div>
)}
</div>
)
}
// ─── @Mention Autocomplete ──────────────────────────────────────────────────
/**
* Compute pixel coordinates of the caret inside a textarea relative to the
* textarea's own top-left corner. Uses a hidden mirror div that replicates
* the textarea's type/font metrics so the span we drop at the caret offset
* lays out in the same position the real caret does.
*/
function getTextareaCaretCoords(textarea: HTMLTextAreaElement, caretIndex: number): { top: number; left: number } {
const style = window.getComputedStyle(textarea)
const mirror = document.createElement("div")
const mirrorStyle = mirror.style
const copyProps = [
"boxSizing", "width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
"fontFamily", "fontSize", "fontWeight", "fontStyle", "fontVariant", "lineHeight",
"letterSpacing", "wordSpacing", "textTransform", "textIndent", "whiteSpace", "wordWrap",
] as const
for (const prop of copyProps) {
mirrorStyle[prop as never] = style[prop as never]
}
mirrorStyle.position = "absolute"
mirrorStyle.visibility = "hidden"
mirrorStyle.overflow = "hidden"
mirrorStyle.whiteSpace = "pre-wrap"
mirrorStyle.wordWrap = "break-word"
mirrorStyle.top = "0"
mirrorStyle.left = "-9999px"
mirror.textContent = textarea.value.slice(0, caretIndex)
const marker = document.createElement("span")
marker.textContent = "\u200b"
mirror.appendChild(marker)
document.body.appendChild(mirror)
const coords = { top: marker.offsetTop - textarea.scrollTop, left: marker.offsetLeft - textarea.scrollLeft }
document.body.removeChild(mirror)
return coords
}
function MentionDropdown({ agents, query, onSelect, position }: {
agents: AgentRecord[];
query: string;
onSelect: (agent: AgentRecord | null) => void;
position: { top: number; left: number };
}) {
const [selectedIndex, setSelectedIndex] = useState(0)
const filtered = useMemo(() => {
const q = query.toLowerCase()
return agents.filter((a) => a.status !== "terminated" && (
a.name.toLowerCase().includes(q) ||
(a.title?.toLowerCase().includes(q)) ||
(a.department?.toLowerCase().includes(q))
)).slice(0, 8)
}, [agents, query])
useEffect(() => { setSelectedIndex(0) }, [query])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) }
else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)) }
else if (e.key === "Enter" || e.key === "Tab") {
if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) }
}
else if (e.key === "Escape") { onSelect(null) }
}
document.addEventListener("keydown", handler, true)
return () => document.removeEventListener("keydown", handler, true)
}, [filtered, selectedIndex, onSelect])
if (filtered.length === 0) return null
return (
<div
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
style={{ top: position.top, left: position.left }}
>
{filtered.map((agent, i) => (
<button
key={agent.id}
type="button"
onClick={() => onSelect(agent)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition ${
i === selectedIndex ? "bg-primary/10" : "hover:bg-muted"
} ${i === 0 ? "rounded-t-xl" : ""} ${i === filtered.length - 1 ? "rounded-b-xl" : ""}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
{getAgentInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-foreground">{agent.name}</div>
<div className="truncate text-[10px] text-muted-foreground">{agent.title}{agent.department ? ` · ${agent.department}` : ""}</div>
</div>
</button>
))}
</div>
)
}
// ─── Main Composer ──────────────────────────────────────────────────────────
interface MessageComposerProps {
agents: AgentRecord[]; companyId: string; channel: MessageChannel; channelTargetId: string | null
directParticipantIds?: string[]; replyTo: AgentMessageRecord | null; onSent: () => void; onCancelReply: () => void
documents?: DocumentRecord[]
agents: AgentRecord[];
companyId: string;
channel: MessageChannel;
channelTargetId: string | null;
directParticipantIds?: string[];
replyTo: AgentMessageRecord | null;
onSent: () => void;
onCancelReply: () => void;
documents?: DocumentRecord[];
/** Thread ID for precise checkpoint resolution. */
threadId?: string | null
threadId?: string | null;
/** Wait state ID for precise checkpoint resolution. */
waitStateId?: string | null
waitStateId?: string | null;
}
export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply, documents = [], threadId = null, waitStateId = null }: MessageComposerProps) {
const [body, setBody] = useState(""); const [subject, setSubject] = useState("")
const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
const [attachments, setAttachments] = useState<AttachmentItem[]>([])
const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number; caretTop: number; caretLeft: number }>({ active: false, query: "", startIndex: -1, caretTop: 0, caretLeft: 0 })
const textareaRef = useRef<HTMLTextAreaElement>(null)
const addToast = useAppStore((s) => s.addToast)
export function MessageComposer({
agents,
companyId,
channel,
channelTargetId,
directParticipantIds = [],
replyTo,
onSent,
onCancelReply,
documents = [],
threadId = null,
waitStateId = null,
}: MessageComposerProps) {
const t = useT();
const [body, setBody] = useState("");
const [subject, setSubject] = useState("");
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { if (replyTo) textareaRef.current?.focus() }, [replyTo])
useEffect(() => {
if (replyTo) textareaRef.current?.focus();
}, [replyTo]);
const directRecipientId = useMemo(() => {
if (channel !== "direct") return null
if (replyTo?.fromAgentId) return replyTo.fromAgentId
if (directParticipantIds.length === 0) return null
const nonBoardParticipant = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID)
return nonBoardParticipant ?? directParticipantIds[0] ?? null
}, [channel, replyTo, directParticipantIds])
if (channel !== "direct") return null;
if (replyTo?.fromAgentId) return replyTo.fromAgentId;
if (directParticipantIds.length === 0) return null;
const nonBoard = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID);
return nonBoard ?? directParticipantIds[0] ?? null;
}, [channel, replyTo, directParticipantIds]);
const handleAddAttachment = useCallback((doc: DocumentRecord) => {
setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }])
}, [])
const onCleared = useCallback(() => {
setBody("");
setSubject("");
setAttachments([]);
onSent();
}, [onSent]);
const handleRemoveAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id))
}, [])
const { send, sending, sendError } = useMessageSend({
body, subject, attachments, channel, channelTargetId, companyId,
directRecipientId, replyTo, threadId, waitStateId, onCleared,
});
const handleMentionSelect = useCallback((agent: AgentRecord | null) => {
if (!agent) { setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })); return }
setBody((prev) => {
const before = prev.slice(0, mentionState.startIndex)
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1)
return `${before}@${agent.name} ${after}`
})
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }))
textareaRef.current?.focus()
}, [mentionState])
const { mentionState, detect, selectAgent } = useMentionState({
setBody,
focusTextarea: () => textareaRef.current?.focus(),
});
const handleBodyChange = useCallback((value: string, e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBody(value)
const handleBodyChange = useCallback(
(value: string, e: React.ChangeEvent<HTMLTextAreaElement>) => {
setBody(value);
detect(value, e.target);
},
[detect],
);
const cursorPos = e.target.selectionStart
const windowStart = Math.max(0, cursorPos - 50)
const windowText = value.slice(windowStart, cursorPos)
const match = windowText.match(/(?:^|\s)@(\w*)$/)
if (match) {
const query = match[1]
const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1)
const coords = getTextareaCaretCoords(e.target, atIndex)
const style = window.getComputedStyle(e.target)
const lineHeightPx = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3
setMentionState({
active: true,
query,
startIndex: atIndex,
caretTop: coords.top + lineHeightPx,
caretLeft: coords.left,
})
return
}
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }))
}, [])
const handleSend = useCallback(async () => {
if (!body.trim()) return; if (channel === "direct" && !directRecipientId) return
setSending(true); setSendError(null)
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]"
try {
const result = unwrap(await window.agentCompany.sendAgentMessage({
companyId, fromAgentId: HUMAN_SENDER_ID,
toAgentId: channel === "direct" ? directRecipientId : null,
channel, channelTargetId,
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
body: body.trim(), priority: "normal",
parentMessageId: replyTo?.id ?? null, attachmentsJson,
threadId: threadId ?? undefined,
waitStateId: waitStateId ?? undefined,
}))
if (result.mentionResults && result.mentionResults.length > 0) {
const woken = result.mentionResults.filter((m) => m.outcome === "woken")
const queued = result.mentionResults.filter((m) => m.outcome === "queued")
const busy = result.mentionResults.filter((m) => m.outcome === "busy")
if (woken.length > 0) addToast(`Notified: ${woken.map((m) => m.agentName).join(", ")}`, "success")
if (queued.length > 0) addToast(`Queued: ${queued.map((m) => m.agentName).join(", ")}`, "warn")
if (busy.length > 0) addToast(`Busy: ${busy.map((m) => m.agentName).join(", ")}`, "warn")
}
setBody(""); setSubject(""); setAttachments([]); onSent()
} catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") }
finally { setSending(false) }
}, [body, channel, channelTargetId, companyId, directRecipientId, onSent, replyTo, subject, attachments, threadId, waitStateId, addToast])
const handleKeyDownExtra = useCallback(() => {
return mentionState.active // when mention active, ComposerShell should skip its own Enter handler
}, [mentionState.active])
const directMissingNotice = channel === "direct" && !directRecipientId
? <div className="mb-2"><InlineNotice message="Select a direct conversation partner before sending a DM." /></div>
: null
const replyBanner = (
<AnimatePresence>
{replyTo ? (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="mb-2 flex items-center gap-2 overflow-hidden rounded-lg bg-muted px-3 py-2">
<ArrowBendUpLeft size={12} className="text-primary" />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">Replying to <span className="font-semibold">{agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? "agent"}</span>{replyTo.subject ? `: ${replyTo.subject}` : ""}</span>
<button type="button" onClick={onCancelReply} className="shrink-0 rounded-[4px] p-0.5 text-muted-foreground transition hover:text-foreground"><X size={12} /></button>
</motion.div>
) : null}
</AnimatePresence>
)
const subjectRow = (
<div className="mb-2 flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={16} weight="fill" /></div>
<span className="text-[11px] font-medium text-foreground">You (Board)</span>
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
</div>
)
const attachmentsRow = attachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => (
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
<FileText size={10} />
<span className="max-w-[160px] truncate">{att.title}</span>
<button type="button" onClick={() => handleRemoveAttachment(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
</span>
))}
</div>
) : null
const addAttachment = useCallback((doc: DocumentRecord) => {
setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }]);
}, []);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
return (
<div className="relative border-t border-border bg-card px-4 py-3">
{directMissingNotice}
{channel === "direct" && !directRecipientId ? (
<div className="mb-2">
<InlineNotice message={t("composer.directMissingNotice")} />
</div>
) : null}
<ComposerShell
ref={textareaRef}
value={body}
onChange={handleBodyChange}
onSend={() => void handleSend()}
placeholder="Type a message... (use @ to mention agents)"
onSend={() => void send()}
placeholder={t("composer.typeMessage")}
sending={sending}
error={sendError}
shortcutHint={`Press ${cmdOrCtrlLabel()}+Enter to send`}
sendLabel="Send message"
sendLabel={t("composer.sendMessage")}
sendDisabled={!body.trim() || (channel === "direct" && !directRecipientId)}
leading={<AttachmentPicker documents={documents} attachments={attachments} onAdd={handleAddAttachment} onRemove={handleRemoveAttachment} />}
belowHeader={<>{replyBanner}{subjectRow}{attachmentsRow}</>}
onKeyDownExtra={handleKeyDownExtra}
textareaAriaLabel="Message body"
leading={
<ComposerAttachments
documents={documents}
attachments={attachments}
onAdd={addAttachment}
onRemove={removeAttachment}
/>
}
belowHeader={
<>
<ComposerReplyBanner replyTo={replyTo} agents={agents} onCancel={onCancelReply} />
<ComposerSubjectRow
subject={subject}
onSubjectChange={setSubject}
showSubjectInput={!replyTo}
/>
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
</>
}
onKeyDownExtra={() => mentionState.active}
textareaAriaLabel={t("composer.messageBody")}
/>
{mentionState.active && textareaRef.current ? (
<MentionDropdown
<ComposerMentionDropdown
agents={agents}
query={mentionState.query}
onSelect={handleMentionSelect}
onSelect={selectAgent}
position={{
top: textareaRef.current.offsetTop + mentionState.caretTop,
left: textareaRef.current.offsetLeft + mentionState.caretLeft,
@@ -379,32 +157,5 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
/>
) : null}
</div>
)
}
export function TaskDiscussionComposer({ companyId, taskId, onSent }: { companyId: string; taskId: string; onSent: () => void }) {
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 { unwrap(await window.agentCompany.addComment({ companyId, taskId, authorName: "Board", body: body.trim() })); setBody(""); onSent() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send.") } finally { setSending(false) }
}, [body, companyId, taskId, onSent])
return (
<div className="border-t border-border bg-card px-4 py-3">
<ComposerShell
value={body}
onChange={(v) => setBody(v)}
onSend={() => void handleSend()}
placeholder="Reply in task discussion..."
sending={sending}
error={sendError}
sendLabel="Send task comment"
textareaAriaLabel="Task discussion reply"
/>
</div>
)
}
export function NoChannelComposer() {
const t = useT()
return <div className="border-t border-border bg-card px-4 py-2.5"><div className="flex items-center gap-2 text-[11px] text-muted-foreground"><ChatCircle size={14} /><span>{t("comm.selectChannelSend")}</span></div></div>
);
}
@@ -13,14 +13,17 @@ import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } fro
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
import { safeJson } from "../../lib/safe-json"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { useT, type TranslationFn } from "../../i18n"
const HUMAN_SENDER_ID = "__board__";
const priorityConfig: Record<string, { label: string; className: string }> = {
urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" },
normal: { label: "Normal", className: "bg-muted text-muted-foreground" },
low: { label: "Low", className: "bg-muted text-muted-foreground" },
function priorityConfig(t: TranslationFn): Record<string, { label: string; className: string }> {
return {
urgent: { label: t("msg.priority.urgent"), className: "bg-destructive-muted text-destructive" },
normal: { label: t("msg.priority.normal"), className: "bg-muted text-muted-foreground" },
low: { label: t("msg.priority.low"), className: "bg-muted text-muted-foreground" },
}
}
function getDepartmentColor(department: DepartmentId | null | undefined): string {
@@ -62,11 +65,13 @@ export const MessageBubble = memo(function MessageBubble({
replyCount: number
onReply: () => void
}) {
const t = useT()
const priorityMap = priorityConfig(t)
const isUnread = !message.readAt
const isHuman = message.fromAgentId === HUMAN_SENDER_ID
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
const senderColor = isHuman ? "var(--primary)" : deptColor
const senderName = isHuman ? "You" : (agent?.name ?? "Unknown Agent")
const senderName = isHuman ? t("msg.you") : (agent?.name ?? t("msg.unknownAgent"))
return (
<motion.div
@@ -105,14 +110,14 @@ export const MessageBubble = memo(function MessageBubble({
</span>
{message.priority !== "normal" ? (
<span
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.06em] ${priorityConfig[message.priority]?.className ?? ""}`}
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.06em] ${priorityMap[message.priority]?.className ?? ""}`}
>
{message.priority === "urgent" ? <WarningCircle size={10} weight="fill" /> : null}
{priorityConfig[message.priority]?.label}
{priorityMap[message.priority]?.label}
</span>
) : null}
{isUnread ? (
<Circle size={8} weight="fill" className="shrink-0" style={{ color: "var(--primary)" }} aria-label="Unread" />
<Circle size={8} weight="fill" className="shrink-0" style={{ color: "var(--primary)" }} aria-label={t("msg.unread")} />
) : (
<CheckCircle
size={10}
@@ -145,7 +150,7 @@ export const MessageBubble = memo(function MessageBubble({
style={{ color: "var(--primary)" }}
>
<ChatCircle size={12} />
{replyCount} {replyCount === 1 ? "reply" : "replies"}
{replyCount} {t("msg.replies")}
</button>
) : null}
<button
@@ -154,7 +159,7 @@ export const MessageBubble = memo(function MessageBubble({
className="flex items-center gap-1 rounded-lg px-2 py-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition hover:bg-muted hover:text-foreground group-hover:opacity-100"
>
<ArrowBendUpLeft size={10} />
Reply
{t("msg.reply")}
</button>
</div>
</div>
@@ -132,7 +132,7 @@ export function MessageList({
ref={parentRef}
className="h-full overflow-auto"
role="listbox"
aria-label="Messages"
aria-label={t("comm.messagesAria")}
onKeyDown={handleKeyDown}
>
<div
@@ -7,13 +7,16 @@ import { AgentAvatar } from "./MessageItem"
import { formatMessageTime } from "../../lib/formatters"
import { EmptyState, useReducedMotion } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { useT } from "../../i18n"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
direct: { label: "Direct", icon: EnvelopeSimple, color: "var(--primary)" },
department: { label: "Department", icon: Buildings, color: "var(--muted-foreground)" },
company: { label: "Company", icon: Megaphone, color: "var(--primary)" },
project: { label: "Project", icon: Hash, color: "var(--primary)" },
incident: { label: "Incident", icon: Fire, color: "var(--destructive)" },
type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string }
const channelConfig: Record<MessageChannel, ChannelConfig> = {
direct: { labelKey: "comm.channelLabel.directShort", icon: EnvelopeSimple, color: "var(--primary)" },
department: { labelKey: "comm.channelLabel.department", icon: Buildings, color: "var(--muted-foreground)" },
company: { labelKey: "comm.channelLabel.companyShort", icon: Megaphone, color: "var(--primary)" },
project: { labelKey: "comm.channelLabel.project", icon: Hash, color: "var(--primary)" },
incident: { labelKey: "comm.channelLabel.incident", icon: Fire, color: "var(--destructive)" },
}
function highlightMatch(text: string, query: string): React.ReactNode {
@@ -50,6 +53,7 @@ interface SearchResultsProps {
}
export function SearchResults({ results, query, loading, agents, onNavigateToResult }: SearchResultsProps) {
const t = useT()
const reducedMotion = useReducedMotion()
const parentRef = useRef<HTMLDivElement>(null)
const agentMap = new Map(agents.map((a) => [a.id, a]))
@@ -81,8 +85,8 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title="No messages matched your search"
detail="Try different keywords or search across all channels."
title={t("comm.searchEmptyTitle")}
detail={t("comm.searchEmptyDetail")}
/>
</div>
)
@@ -98,7 +102,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
<motion.div className="flex h-full flex-col" {...animationProps}>
<div className="shrink-0 px-3 py-2">
<span className="text-[11px] font-semibold text-muted-foreground">
{results.length} result{results.length !== 1 ? "s" : ""}
{(results.length === 1 ? t("comm.resultCount") : t("comm.resultCountPlural")).replace("{count}", String(results.length))}
</span>
</div>
@@ -106,7 +110,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
ref={parentRef}
className="flex-1 overflow-auto"
role="listbox"
aria-label="Search results"
aria-label={t("comm.searchResultsAria")}
onKeyDown={handleKeyDown}
>
<div
@@ -153,14 +157,14 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
{/* Row 1: Agent name, channel badge, timestamp */}
<div className="flex items-center gap-2">
<span className="truncate text-[11px] font-semibold text-foreground">
{agent?.name ?? "Unknown Agent"}
{agent?.name ?? t("comm.unknownAgent")}
</span>
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `${config.color}18`, color: config.color }}
>
<ChannelIcon size={10} weight="bold" />
{config.label}
{t(config.labelKey)}
</span>
<span className="ml-auto whitespace-nowrap shrink-0 font-mono text-[11px] text-muted-foreground">
{formatMessageTime(message.createdAt)}
@@ -12,6 +12,7 @@ import { motion } from "framer-motion"
import type { AgentRecord, ConversationEventRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types"
import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
import { MessageComposer } from "./MessageComposer"
import { useT, type TranslationFn } from "../../i18n"
interface ThreadPanelProps {
threadId: string
@@ -22,19 +23,19 @@ interface ThreadPanelProps {
onNavigateApproval?: (approvalId: string) => void
}
function EventItem({ event, agents }: { event: ConversationEventRecord; agents: AgentRecord[] }) {
function EventItem({ event, agents, t }: { event: ConversationEventRecord; agents: AgentRecord[]; t: TranslationFn }) {
const actorName = event.actorType === "user"
? "You"
? t("thread.actor.you")
: agents.find((a) => a.id === event.actorId)?.name ?? event.actorId
const time = new Date(event.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
const iconClass = event.eventType === "status_change"
? "text-blue-500"
? "text-primary"
: event.eventType === "approval_request"
? "text-amber-500"
? "text-warning"
: event.eventType === "approval_decision"
? "text-emerald-500"
? "text-success"
: "text-muted-foreground"
return (
@@ -56,6 +57,7 @@ function EventItem({ event, agents }: { event: ConversationEventRecord; agents:
}
export default function ThreadPanel({ threadId, companyId, agents, documents, onClose, onNavigateApproval }: ThreadPanelProps) {
const t = useT()
const [thread, setThread] = useState<ConversationThreadRecord | null>(null)
const [events, setEvents] = useState<ConversationEventRecord[]>([])
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
@@ -106,12 +108,12 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<ChatCircleDots size={18} className="text-muted-foreground" />
<h3 className="flex-1 truncate text-sm font-medium text-foreground">
{thread?.title ?? "Thread"}
{thread?.title ?? t("thread.title")}
</h3>
<button
onClick={onClose}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close thread"
aria-label={t("thread.close")}
>
<X size={16} />
</button>
@@ -136,12 +138,12 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{events.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
No events yet
{t("thread.noEvents")}
</div>
) : (
<div className="divide-y divide-border/50 py-1">
{events.map((event) => (
<EventItem key={event.id} event={event} agents={agents} />
<EventItem key={event.id} event={event} agents={agents} t={t} />
))}
</div>
)}
@@ -8,6 +8,8 @@
import { Hourglass, ChatCircle, CheckCircle } from "../ui/icons"
import type { AgentRecord } from "@shared/types"
import { useT } from "../../i18n"
import { timeAgo } from "../../lib/formatters"
export interface WaitStateInfo {
id: string
@@ -26,44 +28,34 @@ interface WaitStateIndicatorProps {
onNavigateApproval?: (approvalId: string) => void
}
function formatWaitDuration(createdAt: string): string {
const diff = Date.now() - new Date(createdAt).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return "just now"
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export default function WaitStateIndicator({ waitState, onReply, onNavigateApproval }: WaitStateIndicatorProps) {
const t = useT()
const isApproval = waitState.waitingFor === "user_approval" && waitState.relatedApprovalId
const isReview = waitState.waitingFor === "user_review"
const duration = formatWaitDuration(waitState.createdAt)
const duration = timeAgo(waitState.createdAt)
return (
<div className="flex items-center gap-3 rounded-md border-l-2 border-amber-500 bg-amber-50 px-3 py-2 dark:border-amber-400 dark:bg-amber-950/30">
<Hourglass size={18} weight="fill" className="shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex items-center gap-3 rounded-md border-l-2 border-warning bg-warning-muted px-3 py-2">
<Hourglass size={18} weight="fill" className="shrink-0 text-warning" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{waitState.reason}</p>
<p className="text-xs text-muted-foreground">Waiting since {duration}</p>
<p className="text-xs text-muted-foreground">{t("wait.waitingSince")} {duration}</p>
</div>
{isApproval && onNavigateApproval ? (
<button
onClick={() => { const id = waitState.relatedApprovalId; if (id) onNavigateApproval(id); }}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
className="flex shrink-0 items-center gap-1.5 rounded-md bg-warning px-3 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-95"
>
<CheckCircle size={14} />
Review
{t("wait.review")}
</button>
) : (
<button
onClick={() => onReply(waitState.id, waitState.threadId)}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
className="flex shrink-0 items-center gap-1.5 rounded-md bg-warning px-3 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-95"
>
<ChatCircle size={14} />
{isReview ? "Review" : "Reply"}
{isReview ? t("wait.review") : t("wait.reply")}
</button>
)}
</div>
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { FileText, Paperclip, X } from "../../ui/icons";
import type { DocumentRecord } from "@shared/types";
import { useT } from "../../../i18n";
export interface AttachmentItem {
type: string;
id: string;
title: string;
}
interface ComposerAttachmentsProps {
documents: DocumentRecord[];
attachments: AttachmentItem[];
onAdd: (doc: DocumentRecord) => void;
onRemove: (id: string) => void;
}
export function ComposerAttachments({
documents,
attachments,
onAdd,
onRemove,
}: ComposerAttachmentsProps) {
const t = useT();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
return documents.filter(
(d) =>
!attachedIds.has(d.id) &&
(d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)),
);
}, [documents, attachedIds, filter]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label={t("composer.attachDocument")}
>
<Paperclip size={16} />
</button>
{open && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-72 rounded-xl border border-border bg-card shadow-xl">
<div className="border-b border-border px-3 py-2">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("composer.searchDocuments")}
className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">
{documents.length === 0
? t("composer.noDocuments")
: t("composer.noMatchingDocuments")}
</div>
) : (
filtered.slice(0, 20).map((doc) => (
<button
key={doc.id}
type="button"
onClick={() => {
onAdd(doc);
setFilter("");
setOpen(false);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted"
>
<FileText size={14} className="shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-foreground">
{doc.title}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{doc.type.replaceAll("_", " ")} · {doc.status}
</div>
</div>
</button>
))
)}
</div>
</div>
)}
{attachments.length > 0 && (
<div className="absolute bottom-full left-10 mb-1 flex flex-wrap gap-1">
{attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
<FileText size={10} />
<span className="max-w-[120px] truncate">{att.title}</span>
<button
type="button"
onClick={() => onRemove(att.id)}
className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"
>
<X size={8} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export function AttachmentChips({
attachments,
onRemove,
}: {
attachments: AttachmentItem[];
onRemove: (id: string) => void;
}) {
if (attachments.length === 0) return null;
return (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
<FileText size={10} />
<span className="max-w-[160px] truncate">{att.title}</span>
<button
type="button"
onClick={() => onRemove(att.id)}
className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"
>
<X size={8} />
</button>
</span>
))}
</div>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useState } from "react";
import type { AgentRecord } from "@shared/types";
import { getAgentInitials } from "../../../lib/formatters";
interface ComposerMentionDropdownProps {
agents: AgentRecord[];
query: string;
onSelect: (agent: AgentRecord | null) => void;
position: { top: number; left: number };
}
export function ComposerMentionDropdown({
agents,
query,
onSelect,
position,
}: ComposerMentionDropdownProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return agents
.filter(
(a) =>
a.status !== "terminated" &&
(a.name.toLowerCase().includes(q) ||
a.title?.toLowerCase().includes(q) ||
a.department?.toLowerCase().includes(q)),
)
.slice(0, 8);
}, [agents, query]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" || e.key === "Tab") {
if (filtered[selectedIndex]) {
e.preventDefault();
onSelect(filtered[selectedIndex]);
}
} else if (e.key === "Escape") {
onSelect(null);
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [filtered, selectedIndex, onSelect]);
if (filtered.length === 0) return null;
return (
<div
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
style={{ top: position.top, left: position.left }}
>
{filtered.map((agent, i) => (
<button
key={agent.id}
type="button"
onClick={() => onSelect(agent)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition ${
i === selectedIndex ? "bg-primary/10" : "hover:bg-muted"
} ${i === 0 ? "rounded-t-xl" : ""} ${i === filtered.length - 1 ? "rounded-b-xl" : ""}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
{getAgentInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-semibold text-foreground">
{agent.name}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{agent.title}
{agent.department ? ` · ${agent.department}` : ""}
</div>
</div>
</button>
))}
</div>
);
}
@@ -0,0 +1,42 @@
import { AnimatePresence, motion } from "framer-motion";
import { ArrowBendUpLeft, X } from "../../ui/icons";
import type { AgentMessageRecord, AgentRecord } from "@shared/types";
import { useT } from "../../../i18n";
interface ComposerReplyBannerProps {
replyTo: AgentMessageRecord | null;
agents: AgentRecord[];
onCancel: () => void;
}
export function ComposerReplyBanner({ replyTo, agents, onCancel }: ComposerReplyBannerProps) {
const t = useT();
return (
<AnimatePresence>
{replyTo ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mb-2 flex items-center gap-2 overflow-hidden rounded-lg bg-muted px-3 py-2"
>
<ArrowBendUpLeft size={12} className="text-primary" />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{t("composer.replyingTo")}{" "}
<span className="font-semibold">
{agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? t("composer.replyingToAgent")}
</span>
{replyTo.subject ? `: ${replyTo.subject}` : ""}
</span>
<button
type="button"
onClick={onCancel}
className="shrink-0 rounded-[4px] p-0.5 text-muted-foreground transition hover:text-foreground"
>
<X size={12} />
</button>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -0,0 +1,34 @@
import { UserCircle } from "../../ui/icons";
import { useT } from "../../../i18n";
interface ComposerSubjectRowProps {
subject: string;
onSubjectChange: (value: string) => void;
showSubjectInput: boolean;
}
export function ComposerSubjectRow({
subject,
onSubjectChange,
showSubjectInput,
}: ComposerSubjectRowProps) {
const t = useT();
return (
<div className="mb-2 flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
<UserCircle size={16} weight="fill" />
</div>
<span className="text-[11px] font-medium text-foreground">{t("composer.youBoard")}</span>
{showSubjectInput ? (
<input
type="text"
value={subject}
onChange={(e) => onSubjectChange(e.target.value)}
placeholder={t("composer.subjectOptional")}
aria-label={t("composer.messageSubject")}
className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground"
/>
) : null}
</div>
);
}
@@ -0,0 +1,14 @@
import { ChatCircle } from "../../ui/icons";
import { useT } from "../../../i18n";
export function NoChannelComposer() {
const t = useT();
return (
<div className="border-t border-border bg-card px-4 py-2.5">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<ChatCircle size={14} />
<span>{t("comm.selectChannelSend")}</span>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { useCallback, useState } from "react";
import { unwrap } from "../../../lib/desktop";
import { ComposerShell } from "../composer-shell/ComposerShell";
import { useT } from "../../../i18n";
export function TaskDiscussionComposer({
companyId,
taskId,
onSent,
}: {
companyId: string;
taskId: string;
onSent: () => void;
}) {
const t = useT();
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 {
unwrap(
await window.agentCompany.addComment({
companyId,
taskId,
authorName: "Board",
body: body.trim(),
}),
);
setBody("");
onSent();
} catch (e) {
setSendError(e instanceof Error ? e.message : t("composer.sendFailedShort"));
} finally {
setSending(false);
}
}, [body, companyId, taskId, onSent, t]);
return (
<div className="border-t border-border bg-card px-4 py-3">
<ComposerShell
value={body}
onChange={(v) => setBody(v)}
onSend={() => void handleSend()}
placeholder={t("composer.taskDiscussionPlaceholder")}
sending={sending}
error={sendError}
sendLabel={t("composer.sendTaskComment")}
textareaAriaLabel={t("composer.taskDiscussionReply")}
/>
</div>
);
}
@@ -0,0 +1,79 @@
/**
* Compute pixel coordinates of the caret inside a textarea relative to the
* textarea's own top-left corner. Uses a hidden mirror div that replicates
* the textarea's type/font metrics so the span we drop at the caret offset
* lays out in the same position the real caret does.
*/
export function getTextareaCaretCoords(
textarea: HTMLTextAreaElement,
caretIndex: number,
): { top: number; left: number } {
const style = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const mirrorStyle = mirror.style;
const copyProps = [
"boxSizing",
"width",
"height",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textTransform",
"textIndent",
"whiteSpace",
"wordWrap",
] as const;
for (const prop of copyProps) {
mirrorStyle[prop as never] = style[prop as never];
}
mirrorStyle.position = "absolute";
mirrorStyle.visibility = "hidden";
mirrorStyle.overflow = "hidden";
mirrorStyle.whiteSpace = "pre-wrap";
mirrorStyle.wordWrap = "break-word";
mirrorStyle.top = "0";
mirrorStyle.left = "-9999px";
mirror.textContent = textarea.value.slice(0, caretIndex);
const marker = document.createElement("span");
marker.textContent = "\u200b";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const coords = {
top: marker.offsetTop - textarea.scrollTop,
left: marker.offsetLeft - textarea.scrollLeft,
};
document.body.removeChild(mirror);
return coords;
}
export interface CaretMeasurement {
top: number;
left: number;
lineHeight: number;
}
export function measureCaret(
textarea: HTMLTextAreaElement,
caretIndex: number,
): CaretMeasurement {
const coords = getTextareaCaretCoords(textarea, caretIndex);
const style = window.getComputedStyle(textarea);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3;
return { top: coords.top, left: coords.left, lineHeight };
}
@@ -0,0 +1,76 @@
import { useCallback, useState } from "react";
import type { AgentRecord } from "@shared/types";
import { measureCaret } from "./useCaretMeasurement";
export interface MentionState {
active: boolean;
query: string;
startIndex: number;
caretTop: number;
caretLeft: number;
}
const INITIAL_MENTION: MentionState = {
active: false,
query: "",
startIndex: -1,
caretTop: 0,
caretLeft: 0,
};
export function useMentionState({
setBody,
focusTextarea,
}: {
setBody: (update: (prev: string) => string) => void;
focusTextarea: () => void;
}) {
const [mentionState, setMentionState] = useState<MentionState>(INITIAL_MENTION);
const dismiss = useCallback(() => {
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }));
}, []);
const detect = useCallback(
(value: string, textarea: HTMLTextAreaElement) => {
const cursorPos = textarea.selectionStart;
const windowStart = Math.max(0, cursorPos - 50);
const windowText = value.slice(windowStart, cursorPos);
const match = windowText.match(/(?:^|\s)@(\w*)$/);
if (match) {
const query = match[1];
const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1);
const { top, left, lineHeight } = measureCaret(textarea, atIndex);
setMentionState({
active: true,
query,
startIndex: atIndex,
caretTop: top + lineHeight,
caretLeft: left,
});
return;
}
dismiss();
},
[dismiss],
);
const selectAgent = useCallback(
(agent: AgentRecord | null) => {
if (!agent) {
dismiss();
return;
}
setBody((prev) => {
const before = prev.slice(0, mentionState.startIndex);
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1);
return `${before}@${agent.name} ${after}`;
});
dismiss();
focusTextarea();
},
[mentionState, setBody, focusTextarea, dismiss],
);
return { mentionState, detect, selectAgent };
}
@@ -0,0 +1,99 @@
import { useCallback, useState } from "react";
import type { AgentMessageRecord, MessageChannel } from "@shared/types";
import { unwrap } from "../../../lib/desktop";
import { useAppStore } from "../../../state/store";
import { useT } from "../../../i18n";
import type { AttachmentItem } from "./ComposerAttachments";
const HUMAN_SENDER_ID = "__board__";
interface UseMessageSendArgs {
body: string;
subject: string;
attachments: AttachmentItem[];
channel: MessageChannel;
channelTargetId: string | null;
companyId: string;
directRecipientId: string | null;
replyTo: AgentMessageRecord | null;
threadId?: string | null;
waitStateId?: string | null;
onCleared: () => void;
}
export function useMessageSend({
body,
subject,
attachments,
channel,
channelTargetId,
companyId,
directRecipientId,
replyTo,
threadId = null,
waitStateId = null,
onCleared,
}: UseMessageSendArgs) {
const t = useT();
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const addToast = useAppStore((s) => s.addToast);
const send = useCallback(async () => {
if (!body.trim()) return;
if (channel === "direct" && !directRecipientId) return;
setSending(true);
setSendError(null);
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]";
try {
const result = unwrap(
await window.agentCompany.sendAgentMessage({
companyId,
fromAgentId: HUMAN_SENDER_ID,
toAgentId: channel === "direct" ? directRecipientId : null,
channel,
channelTargetId,
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
body: body.trim(),
priority: "normal",
parentMessageId: replyTo?.id ?? null,
attachmentsJson,
threadId: threadId ?? undefined,
waitStateId: waitStateId ?? undefined,
}),
);
if (result.mentionResults && result.mentionResults.length > 0) {
const woken = result.mentionResults.filter((m) => m.outcome === "woken");
const queued = result.mentionResults.filter((m) => m.outcome === "queued");
const busy = result.mentionResults.filter((m) => m.outcome === "busy");
if (woken.length > 0)
addToast(`${t("composer.notified")}: ${woken.map((m) => m.agentName).join(", ")}`, "success");
if (queued.length > 0)
addToast(`${t("composer.queued")}: ${queued.map((m) => m.agentName).join(", ")}`, "warn");
if (busy.length > 0)
addToast(`${t("composer.busy")}: ${busy.map((m) => m.agentName).join(", ")}`, "warn");
}
onCleared();
} catch (e) {
setSendError(e instanceof Error ? e.message : t("composer.sendFailed"));
} finally {
setSending(false);
}
}, [
body,
channel,
channelTargetId,
companyId,
directRecipientId,
onCleared,
replyTo,
subject,
attachments,
threadId,
waitStateId,
addToast,
t,
]);
return { send, sending, sendError };
}
@@ -1,20 +1,34 @@
import { useCallback, useState } from "react";
import { Buildings, FolderOpen } from "../ui/icons";
import type { ConnectorId } from "@shared/types";
import { ActionButton, Input, TextArea, Select } from "../ui";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
export interface CompanyStepValues {
companyName: string;
companyDescription: string;
workspacePath: string;
}
interface CompanyStepProps {
onContinue: () => void;
initialValues: CompanyStepValues;
onPickDirectory: () => Promise<string | null>;
onContinue: (values: CompanyStepValues) => void;
onBack: () => void;
}
export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
export function CompanyStep({
initialValues,
onPickDirectory,
onContinue,
onBack,
}: CompanyStepProps) {
const { currentLocale, t, error } = useOnboardingWizard();
const {
companyName,
setCompanyName,
companyDescription,
setCompanyDescription,
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
@@ -22,13 +36,20 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
executionReadyConnectors,
modelOptions,
loadingModels,
workspacePath,
setWorkspacePath,
onPickDirectory,
error,
currentLocale,
t,
} = useOnboarding();
} = useOnboardingConnectors();
const [companyName, setCompanyName] = useState(initialValues.companyName);
const [companyDescription, setCompanyDescription] = useState(initialValues.companyDescription);
const [workspacePath, setWorkspacePath] = useState(initialValues.workspacePath);
const handlePick = useCallback(async () => {
const picked = await onPickDirectory();
if (picked) setWorkspacePath(picked);
}, [onPickDirectory]);
const handleContinue = useCallback(() => {
onContinue({ companyName, companyDescription, workspacePath });
}, [companyName, companyDescription, workspacePath, onContinue]);
return (
<OnboardingStep
@@ -88,7 +109,10 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
htmlFor="onboarding-workspace-path"
className="mb-1.5 block text-[12px] font-medium text-muted-foreground"
>
{t("onboarding.workspaceFolder")} <span className="font-normal text-muted-foreground">{t("onboarding.workspaceFolderOptional")}</span>
{t("onboarding.workspaceFolder")}{" "}
<span className="font-normal text-muted-foreground">
{t("onboarding.workspaceFolderOptional")}
</span>
</label>
<div className="flex gap-3">
<Input
@@ -98,7 +122,11 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
onChange={setWorkspacePath}
placeholder={t("onboarding.workspaceFolderPlaceholder")}
/>
<ActionButton label={currentLocale === "zh" ? "选择" : "Choose"} onClick={onPickDirectory} icon={FolderOpen} />
<ActionButton
label={currentLocale === "zh" ? "选择" : "Choose"}
onClick={() => void handlePick()}
icon={FolderOpen}
/>
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
{t("onboarding.workspaceHelp")}
@@ -122,7 +150,7 @@ export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
</button>
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
onClick={handleContinue}
icon={Buildings}
tone="accent"
disabled={!companyName.trim() || !selectedConnectorId}
@@ -1,14 +1,18 @@
import { useState } from "react";
import { ArrowRight, Brain, UserCircle, type Icon } from "../ui/icons";
import type { ExecutionMode } from "@shared/types";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
import { useOnboardingWizard } from "./OnboardingContext";
interface ExecutionModeStepProps {
onContinue: () => void;
initialValue: ExecutionMode;
onContinue: (mode: ExecutionMode) => void;
onBack: () => void;
}
export function ExecutionModeStep({ onContinue, onBack }: ExecutionModeStepProps) {
const { executionMode, setExecutionMode, t } = useOnboarding();
export function ExecutionModeStep({ initialValue, onContinue, onBack }: ExecutionModeStepProps) {
const { t } = useOnboardingWizard();
const [executionMode, setExecutionMode] = useState<ExecutionMode>(initialValue);
return (
<OnboardingStep
@@ -49,7 +53,7 @@ export function ExecutionModeStep({ onContinue, onBack }: ExecutionModeStepProps
</button>
<button
type="button"
onClick={onContinue}
onClick={() => onContinue(executionMode)}
className="flex items-center gap-2 rounded-md bg-[color:var(--primary)] px-4 py-2 text-[13px] font-medium text-white transition hover:opacity-90"
>
{t("onboarding.continue")}
@@ -1,14 +1,18 @@
import { motion } from "framer-motion";
import { CheckCircle, Rocket } from "../ui/icons";
import { ActionButton } from "../ui";
import { useOnboarding } from "./OnboardingContext";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
interface LaunchStepProps {
onFinish: () => void;
}
export function LaunchStep({ onFinish }: LaunchStepProps) {
const { launchSummary, selectedConnector, loading, error, t } = useOnboarding();
const { launchSummary, loading, error, t } = useOnboardingWizard();
const { selectedConnector } = useOnboardingConnectors();
if (!launchSummary) return null;
@@ -1,24 +1,29 @@
import { useCallback, useState } from "react";
import { motion } from "framer-motion";
import { Cpu, Lightning } from "../ui/icons";
import { ActionButton, Input, TextArea } from "../ui";
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
import { useOnboarding } from "./OnboardingContext";
import { useOnboardingWizard } from "./OnboardingContext";
export interface MissionStepValues {
goalTitle: string;
goalDescription: string;
}
interface MissionStepProps {
onLaunch: () => void;
initialValues: MissionStepValues;
onLaunch: (values: MissionStepValues) => void;
onBack: () => void;
}
export function MissionStep({ onLaunch, onBack }: MissionStepProps) {
const {
goalTitle,
setGoalTitle,
goalDescription,
setGoalDescription,
loading,
error,
t,
} = useOnboarding();
export function MissionStep({ initialValues, onLaunch, onBack }: MissionStepProps) {
const { loading, error, t } = useOnboardingWizard();
const [goalTitle, setGoalTitle] = useState(initialValues.goalTitle);
const [goalDescription, setGoalDescription] = useState(initialValues.goalDescription);
const handleLaunch = useCallback(() => {
onLaunch({ goalTitle, goalDescription });
}, [goalTitle, goalDescription, onLaunch]);
return (
<motion.div
@@ -80,7 +85,7 @@ export function MissionStep({ onLaunch, onBack }: MissionStepProps) {
</button>
<ActionButton
label={loading ? t("onboarding.launching") : t("onboarding.launchCompany")}
onClick={onLaunch}
onClick={handleLaunch}
icon={Lightning}
tone="accent"
loading={loading}
@@ -1,7 +1,10 @@
import { ActionButton } from "../ui";
import { ModelToggleList, StatusBadge } from "../execution/ModelManagementShared";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
import {
useOnboardingConnectors,
useOnboardingWizard,
} from "./OnboardingContext";
interface ModelPoolStepProps {
onBack: () => void;
@@ -9,16 +12,14 @@ interface ModelPoolStepProps {
}
export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) {
const { error, t } = useOnboardingWizard();
const {
currentLocale,
connectorModelPools,
error,
onToggleConnectorModel,
selectedConnectorId,
selectedConnectorEnabledModels,
totalEnabledModels,
t,
} = useOnboarding();
} = useOnboardingConnectors();
const selectedConnectorIsRunnable = selectedConnectorEnabledModels > 0;
const hasAnyEnabledModels = totalEnabledModels > 0;
@@ -53,23 +54,20 @@ export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) {
</span>
{connector.id === selectedConnectorId ? (
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-primary">
{currentLocale === "zh" ? "CEO 当前运行时" : "CEO runtime"}
{t("models.ceoRuntime")}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{currentLocale === "zh"
? `已启用 ${enabledCount} / ${models.length || 0} 个模型`
: `${enabledCount} / ${models.length || 0} models enabled`}
{t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))}
</div>
</div>
<StatusBadge status={connector.status} zh={currentLocale === "zh"} />
<StatusBadge status={connector.status} />
</div>
<ModelToggleList
models={models}
onToggle={(modelId) => void onToggleConnectorModel(connector.id, modelId)}
canRemove={false}
zh={currentLocale === "zh"}
/>
</div>
);
@@ -8,7 +8,7 @@ import {
import { ActionButton } from "../ui";
import type { TerminalCommandKey } from "@shared/contracts";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
import { useOnboardingWizard } from "./OnboardingContext";
interface NodeCheckStepProps {
onContinue: () => void;
@@ -44,7 +44,7 @@ node -v`,
};
export function NodeCheckStep({ onContinue, onBack }: NodeCheckStepProps) {
const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboarding();
const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboardingWizard();
const platform = window.agentCompany.platform;
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
@@ -2,7 +2,6 @@ import { createContext, useContext, type ReactNode } from "react";
import type { AppLocale } from "@shared/locale";
import type {
ConnectorId,
ExecutionMode,
OnboardingBootstrapResult,
ProfileSnapshot,
} from "@shared/types";
@@ -10,18 +9,47 @@ import type { ConnectorModelEntry } from "@shared/connector-models";
import type { TerminalCommandKey } from "@shared/contracts";
import type { TranslationFn } from "../../i18n";
export interface OnboardingContextValue {
/**
* Wizard-level shell state: locale, translation, load/error, finalized
* launch snapshot. Narrow on purpose so that typing in a step does not
* invalidate every consumer.
*/
export interface OnboardingWizardContextValue {
currentLocale: AppLocale;
t: TranslationFn;
error: string | null;
loading: boolean;
launchSummary: OnboardingBootstrapResult | null;
nodeStatus: { installed: boolean; version: string | null } | null;
nodeChecking: boolean;
onCheckNode: () => void;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
}
const OnboardingWizardContext = createContext<OnboardingWizardContextValue | null>(null);
export function OnboardingWizardProvider({
value,
children,
}: {
value: OnboardingWizardContextValue;
children: ReactNode;
}) {
return <OnboardingWizardContext.Provider value={value}>{children}</OnboardingWizardContext.Provider>;
}
export function useOnboardingWizard(): OnboardingWizardContextValue {
const ctx = useContext(OnboardingWizardContext);
if (!ctx) {
throw new Error("useOnboardingWizard must be used within an OnboardingWizardProvider");
}
return ctx;
}
/**
* Connector/model slice. Shared by CompanyStep, ModelPoolStep, LaunchStep.
*/
export interface OnboardingConnectorsContextValue {
connectors: ProfileSnapshot["connectors"];
hasAtLeastOneReady: boolean;
onTestConnector: (connectorId: string) => void;
@@ -33,11 +61,6 @@ export interface OnboardingContextValue {
) => void;
onLoginConnector: (connectorId: string) => void;
companyName: string;
setCompanyName: (value: string) => void;
companyDescription: string;
setCompanyDescription: (value: string) => void;
selectedConnectorId: ConnectorId | "";
setSelectedConnectorId: (value: ConnectorId | "") => void;
selectedModel: string;
@@ -54,38 +77,30 @@ export interface OnboardingContextValue {
totalEnabledModels: number;
selectedConnectorEnabledModels: number;
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
workspacePath: string;
setWorkspacePath: (value: string) => void;
onPickDirectory: () => void;
executionMode: ExecutionMode;
setExecutionMode: (value: ExecutionMode) => void;
goalTitle: string;
setGoalTitle: (value: string) => void;
goalDescription: string;
setGoalDescription: (value: string) => void;
launchSummary: OnboardingBootstrapResult | null;
}
const OnboardingContext = createContext<OnboardingContextValue | null>(null);
const OnboardingConnectorsContext = createContext<OnboardingConnectorsContextValue | null>(null);
export function OnboardingProvider({
export function OnboardingConnectorsProvider({
value,
children,
}: {
value: OnboardingContextValue;
value: OnboardingConnectorsContextValue;
children: ReactNode;
}) {
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
return (
<OnboardingConnectorsContext.Provider value={value}>
{children}
</OnboardingConnectorsContext.Provider>
);
}
export function useOnboarding(): OnboardingContextValue {
const ctx = useContext(OnboardingContext);
export function useOnboardingConnectors(): OnboardingConnectorsContextValue {
const ctx = useContext(OnboardingConnectorsContext);
if (!ctx) {
throw new Error("useOnboarding must be used within an OnboardingProvider");
throw new Error(
"useOnboardingConnectors must be used within an OnboardingConnectorsProvider",
);
}
return ctx;
}
@@ -1,13 +1,14 @@
import { AnimatePresence } from "framer-motion";
import type { ExecutionMode } from "@shared/types";
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
import { NodeCheckStep } from "./NodeCheckStep";
import { ProvidersStep } from "./ProvidersStep";
import { CompanyStep } from "./CompanyStep";
import { CompanyStep, type CompanyStepValues } from "./CompanyStep";
import { ModelPoolStep } from "./ModelPoolStep";
import { ExecutionModeStep } from "./ExecutionModeStep";
import { MissionStep } from "./MissionStep";
import { MissionStep, type MissionStepValues } from "./MissionStep";
import { LaunchStep } from "./LaunchStep";
import { useOnboarding } from "./OnboardingContext";
import { useOnboardingWizard } from "./OnboardingContext";
interface OnboardingStepPanelProps {
step: OnboardingStep;
@@ -17,11 +18,15 @@ interface OnboardingStepPanelProps {
onBack: (step: OnboardingStep) => void;
onContinueFromNodeCheck: () => void;
onContinueFromProviders: () => void;
onContinueFromCompany: () => void;
onContinueFromCompany: (values: CompanyStepValues) => void;
onContinueFromModelPool: () => void;
onContinueFromExecutionMode: () => void;
onLaunchCompany: () => void;
onContinueFromExecutionMode: (mode: ExecutionMode) => void;
onLaunchCompany: (values: MissionStepValues) => void;
onFinishLaunch: () => void;
onPickDirectory: () => Promise<string | null>;
companyValues: CompanyStepValues;
missionValues: MissionStepValues;
executionMode: ExecutionMode;
}
export function OnboardingStepPanel({
@@ -37,16 +42,16 @@ export function OnboardingStepPanel({
onContinueFromExecutionMode,
onLaunchCompany,
onFinishLaunch,
onPickDirectory,
companyValues,
missionValues,
executionMode,
}: OnboardingStepPanelProps) {
const { currentLocale, t, launchSummary } = useOnboarding();
const { currentLocale, t, launchSummary } = useOnboardingWizard();
return (
<>
<OnboardingHero
step={step}
currentLocale={currentLocale}
t={t}
/>
<OnboardingHero step={step} currentLocale={currentLocale} t={t} />
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
@@ -77,6 +82,8 @@ export function OnboardingStepPanel({
{step === "company" ? (
<CompanyStep
initialValues={companyValues}
onPickDirectory={onPickDirectory}
onContinue={onContinueFromCompany}
onBack={() => onBack("providers")}
/>
@@ -91,6 +98,7 @@ export function OnboardingStepPanel({
{step === "executionmode" ? (
<ExecutionModeStep
initialValue={executionMode}
onContinue={onContinueFromExecutionMode}
onBack={() => onBack("modelpool")}
/>
@@ -98,6 +106,7 @@ export function OnboardingStepPanel({
{step === "mission" ? (
<MissionStep
initialValues={missionValues}
onLaunch={onLaunchCompany}
onBack={() => onBack("executionmode")}
/>
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ConnectorId, ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
import type {
ConnectorId,
ExecutionMode,
OnboardingBootstrapResult,
ProfileSnapshot,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { useT } from "../../i18n";
import { unwrap } from "../../lib/desktop";
@@ -8,7 +13,22 @@ import { WelcomeStep } from "./WelcomeStep";
import { OnboardingStepPanel } from "./OnboardingStepPanel";
import { useConnectorCallbacks } from "./useConnectorCallbacks";
import { useConnectorModelSelection } from "./useConnectorModelSelection";
import { OnboardingProvider, type OnboardingContextValue } from "./OnboardingContext";
import {
OnboardingConnectorsProvider,
OnboardingWizardProvider,
type OnboardingConnectorsContextValue,
type OnboardingWizardContextValue,
} from "./OnboardingContext";
import type { CompanyStepValues } from "./CompanyStep";
import type { MissionStepValues } from "./MissionStep";
const EMPTY_COMPANY: CompanyStepValues = {
companyName: "",
companyDescription: "",
workspacePath: "",
};
const EMPTY_MISSION: MissionStepValues = { goalTitle: "", goalDescription: "" };
export function CompanyOnboarding({
connectors,
@@ -32,13 +52,24 @@ export function CompanyOnboarding({
const [step, setStep] = useState<OnboardingStep>("welcome");
const [nodeStatus, setNodeStatus] = useState<{ installed: boolean; version: string | null } | null>(null);
const [nodeChecking, setNodeChecking] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
// Finalized step-local snapshots. Written only on step "commit" (continue).
const [companyValues, setCompanyValues] = useState<CompanyStepValues>(EMPTY_COMPANY);
const [executionMode, setExecutionMode] = useState<ExecutionMode>("ai_ceo");
const [missionValues, setMissionValues] = useState<MissionStepValues>(EMPTY_MISSION);
const checkNode = useCallback(async () => {
setNodeChecking(true);
try {
const result = unwrap(await window.agentCompany.checkNodeInstalled());
setNodeStatus(result);
} catch {
} catch (error) {
console.warn(
`[onboarding/OnboardingWizard] checkNodeInstalled failed; assuming uninstalled: ${error instanceof Error ? error.message : String(error)}`,
);
setNodeStatus({ installed: false, version: null });
} finally {
setNodeChecking(false);
@@ -54,15 +85,7 @@ export function CompanyOnboarding({
const { handleTestConnector, handleSaveConnector, handleLoginConnector, handleInstallCommand } =
useConnectorCallbacks(connectors);
const [companyName, setCompanyName] = useState("");
const [companyDescription, setCompanyDescription] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
const [executionMode, setExecutionMode] = useState<ExecutionMode>("ai_ceo");
const [goalTitle, setGoalTitle] = useState("");
const [goalDescription, setGoalDescription] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
const {
connectorModelPools,
executionReadyConnectors,
@@ -79,70 +102,73 @@ export function CompanyOnboarding({
toggleConnectorModel,
} = useConnectorModelSelection(connectors, currentLocale, t, setError);
const pickDirectory = useCallback(async () => {
const pickDirectory = useCallback(async (): Promise<string | null> => {
setError(null);
const result = await window.agentCompany.pickDirectory();
if (!result.ok) {
setError(result.error.message);
return;
}
if (result.data) {
setWorkspacePath(result.data);
return null;
}
return result.data ?? null;
}, []);
const bootstrap = useCallback(async () => {
const result = unwrap(await window.agentCompany.bootstrapOnboarding({
companyName: companyName.trim(),
companyDescription: companyDescription.trim(),
workspacePath: workspacePath.trim(),
autoApproveHires: executionMode === "ai_ceo",
reviewDeliverables: executionMode === "ai_cofounder",
executionMode,
goalTitle: goalTitle.trim(),
goalDescription: goalDescription.trim(),
connectorId: selectedConnector?.id as ConnectorId | undefined,
connectorModel: selectedModel.trim(),
}));
setLaunchSummary(result);
setStep("launch");
}, [
executionMode,
companyDescription,
companyName,
goalDescription,
goalTitle,
selectedConnector?.id,
selectedModel,
workspacePath,
]);
const handleContinueFromCompany = useCallback(
(values: CompanyStepValues) => {
if (loading) return;
if (!values.companyName.trim()) {
setError(t("onboarding.enterCompanyName"));
return;
}
setCompanyValues(values);
setError(null);
setStep("modelpool");
},
[loading, t],
);
const continueToMission = useCallback(() => {
if (loading) return;
if (!companyName.trim()) {
setError(t("onboarding.enterCompanyName"));
return;
}
const handleContinueFromExecutionMode = useCallback((mode: ExecutionMode) => {
setExecutionMode(mode);
setError(null);
setStep("modelpool");
}, [companyName, loading, t]);
setStep("mission");
}, []);
const launchCompany = useCallback(async () => {
if (loading) return;
if (!goalTitle.trim()) {
setError(t("onboarding.describeFirstGoal"));
return;
}
setLoading(true);
setError(null);
try {
await bootstrap();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t("onboarding.unableToBootstrap"));
} finally {
setLoading(false);
}
}, [bootstrap, goalTitle, loading, t]);
const launchCompany = useCallback(
async (values: MissionStepValues) => {
if (loading) return;
if (!values.goalTitle.trim()) {
setError(t("onboarding.describeFirstGoal"));
return;
}
setMissionValues(values);
setLoading(true);
setError(null);
try {
const result = unwrap(
await window.agentCompany.bootstrapOnboarding({
companyName: companyValues.companyName.trim(),
companyDescription: companyValues.companyDescription.trim(),
workspacePath: companyValues.workspacePath.trim(),
autoApproveHires: executionMode === "ai_ceo",
reviewDeliverables: executionMode === "ai_cofounder",
executionMode,
goalTitle: values.goalTitle.trim(),
goalDescription: values.goalDescription.trim(),
connectorId: selectedConnector?.id as ConnectorId | undefined,
connectorModel: selectedModel.trim(),
}),
);
setLaunchSummary(result);
setStep("launch");
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : t("onboarding.unableToBootstrap"),
);
} finally {
setLoading(false);
}
},
[companyValues, executionMode, loading, selectedConnector?.id, selectedModel, t],
);
const finishLaunch = useCallback(async () => {
if (!launchSummary) return;
@@ -151,34 +177,38 @@ export function CompanyOnboarding({
try {
await onDone(launchSummary);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t("onboarding.unableToOpenControlCenter"));
setError(
submitError instanceof Error
? submitError.message
: t("onboarding.unableToOpenControlCenter"),
);
} finally {
setLoading(false);
}
}, [launchSummary, onDone, t]);
const isWelcome = step === "welcome";
const heroStep: OnboardingStep = step as OnboardingStep;
const contextValue = useMemo<OnboardingContextValue>(
const wizardValue = useMemo<OnboardingWizardContextValue>(
() => ({
currentLocale,
t,
error,
loading,
launchSummary,
nodeStatus,
nodeChecking,
onCheckNode: () => void checkNode(),
onInstallCommand: (commandKey) => void handleInstallCommand(commandKey),
}),
[currentLocale, t, error, loading, launchSummary, nodeStatus, nodeChecking, checkNode, handleInstallCommand],
);
const connectorsValue = useMemo<OnboardingConnectorsContextValue>(
() => ({
connectors,
hasAtLeastOneReady,
onTestConnector: (id) => void handleTestConnector(id),
onSaveConnector: (id, env, model, notes) => void handleSaveConnector(id, env, model, notes),
onLoginConnector: (id) => void handleLoginConnector(id),
companyName,
setCompanyName,
companyDescription,
setCompanyDescription,
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
@@ -190,34 +220,15 @@ export function CompanyOnboarding({
loadingModels,
totalEnabledModels,
selectedConnectorEnabledModels,
onToggleConnectorModel: (connectorId, modelId) => void toggleConnectorModel(connectorId, modelId),
workspacePath,
setWorkspacePath,
onPickDirectory: () => void pickDirectory(),
executionMode,
setExecutionMode,
goalTitle,
setGoalTitle,
goalDescription,
setGoalDescription,
launchSummary,
onToggleConnectorModel: (connectorId, modelId) =>
void toggleConnectorModel(connectorId, modelId),
}),
[
currentLocale,
t,
error,
loading,
nodeStatus,
nodeChecking,
checkNode,
handleInstallCommand,
connectors,
hasAtLeastOneReady,
handleTestConnector,
handleSaveConnector,
handleLoginConnector,
companyName,
companyDescription,
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
@@ -230,24 +241,18 @@ export function CompanyOnboarding({
totalEnabledModels,
selectedConnectorEnabledModels,
toggleConnectorModel,
workspacePath,
pickDirectory,
executionMode,
goalTitle,
goalDescription,
launchSummary,
],
);
const isWelcome = step === "welcome";
const heroStep: OnboardingStep = step as OnboardingStep;
return (
<div
className={`relative h-[100dvh] overflow-hidden bg-background ${
isWelcome
? "flex items-stretch"
: "grid md:grid-cols-[minmax(0,1fr)_540px]"
isWelcome ? "flex items-stretch" : "grid md:grid-cols-[minmax(0,1fr)_540px]"
}`}
>
{/* Drag region for frameless title bar */}
<div className="sidebar-drag absolute inset-x-0 top-0 z-10 h-[38px]" />
{isWelcome ? (
@@ -258,22 +263,37 @@ export function CompanyOnboarding({
t={t}
/>
) : (
<OnboardingProvider value={contextValue}>
<OnboardingStepPanel
step={heroStep}
canCancel={canCancel}
onCancel={onCancel}
isFirstLaunch={isFirstLaunch}
onBack={setStep}
onContinueFromNodeCheck={() => { setError(null); setStep("providers"); }}
onContinueFromProviders={() => { setError(null); setStep("company"); }}
onContinueFromCompany={() => void continueToMission()}
onContinueFromModelPool={() => { setError(null); setStep("executionmode"); }}
onContinueFromExecutionMode={() => { setError(null); setStep("mission"); }}
onLaunchCompany={() => void launchCompany()}
onFinishLaunch={() => void finishLaunch()}
/>
</OnboardingProvider>
<OnboardingWizardProvider value={wizardValue}>
<OnboardingConnectorsProvider value={connectorsValue}>
<OnboardingStepPanel
step={heroStep}
canCancel={canCancel}
onCancel={onCancel}
isFirstLaunch={isFirstLaunch}
onBack={setStep}
onContinueFromNodeCheck={() => {
setError(null);
setStep("providers");
}}
onContinueFromProviders={() => {
setError(null);
setStep("company");
}}
onContinueFromCompany={handleContinueFromCompany}
onContinueFromModelPool={() => {
setError(null);
setStep("executionmode");
}}
onContinueFromExecutionMode={handleContinueFromExecutionMode}
onLaunchCompany={(values) => void launchCompany(values)}
onFinishLaunch={() => void finishLaunch()}
onPickDirectory={pickDirectory}
companyValues={companyValues}
missionValues={missionValues}
executionMode={executionMode}
/>
</OnboardingConnectorsProvider>
</OnboardingWizardProvider>
)}
</div>
);
@@ -7,7 +7,7 @@ import {
listFeaturedOnboardingConnectors,
} from "@shared/connector-catalog";
import { OnboardingStep } from "./OnboardingStep";
import { useOnboarding } from "./OnboardingContext";
import { useOnboardingWizard } from "./OnboardingContext";
import { OnboardingProviderDispatch } from "./provider-cards/ProviderCard";
interface ProvidersStepProps {
@@ -20,7 +20,7 @@ const FEATURED_IDS = new Set(FEATURED.map((entry) => entry.id));
const ADVANCED = CONNECTOR_CATALOG.filter((entry) => !FEATURED_IDS.has(entry.id));
export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
const { t, error } = useOnboarding();
const { t, error } = useOnboardingWizard();
const [credentialsMap, setCredentialsMap] = useState<Map<string, CredentialSummary>>(
new Map(),
);
@@ -4,12 +4,12 @@ import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
import {
INPUT_CLS,
InlineFeedback,
OnboardingProviderCard,
OnboardingStatusBadge,
type OnboardingCardStatus,
} from "./shared";
import { Input } from "../../ui";
interface ApiKeyCardProps {
entry: ConnectorCatalogEntry;
@@ -96,14 +96,11 @@ export function ApiKeyProviderCard({
{t("onboarding.providers.apiKeyLabel")}{" "}
<span className="font-mono text-[11px] text-muted-foreground/70">({envKey})</span>
</label>
<input
<Input
type="password"
className={INPUT_CLS}
value={value}
onChange={(event) => setValue(event.target.value)}
onChange={setValue}
placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""}
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</div>
@@ -2,9 +2,7 @@ import { CaretDown, CaretRight } from "../../ui/icons";
import type { ReactNode } from "react";
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
import type { TranslationFn } from "../../../i18n";
export const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
import { Card } from "../../ui";
export type OnboardingCardStatus = CredentialSummary["status"] | "checking" | "not_configured";
@@ -24,7 +22,7 @@ export function OnboardingProviderCard({
children: ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<Card padding="none" className="overflow-hidden">
<button
type="button"
onClick={onToggle}
@@ -46,7 +44,7 @@ export function OnboardingProviderCard({
<div className="flex-shrink-0 pt-0.5">{badge}</div>
</button>
{expanded ? <div className="space-y-3 px-4 pb-4">{children}</div> : null}
</div>
</Card>
);
}
@@ -9,8 +9,10 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"])
const handleTestConnector = useCallback(async (connectorId: string) => {
try {
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch {
// Health check result is stored in DB and reflected via domain-changed
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, []);
@@ -27,16 +29,20 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"])
}));
// Run health check AFTER save completes so the probe uses the new config
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch {
// Error is non-fatal; result reflected via domain-changed
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] saveConnector+testConnector(${connectorId}) failed (result reflected via domain-changed): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [connectors]);
const handleLoginConnector = useCallback(async (connectorId: string) => {
try {
unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId));
} catch {
// Terminal open failure is non-fatal
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] openConnectorAuthTerminal(${connectorId}) failed: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
// Poll connector health after login terminal opens (same pattern as ConnectorEditor)
@@ -46,15 +52,21 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"])
if (attempts > 12) { clearInterval(poll); return; }
try {
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch { /* health check result reflected via domain-changed */ }
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] post-login testConnector(${connectorId}) failed (attempt ${attempts}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}, 5000);
}, []);
const handleInstallCommand = useCallback(async (commandKey: z.infer<typeof openTerminalCommandSchema>["commandKey"]) => {
try {
unwrap(await window.agentCompany.openTerminalCommand({ commandKey }));
} catch {
// Terminal open failure is non-fatal
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] openTerminalCommand(${commandKey}) failed: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
// Poll all connectors after install (install may make a new CLI available)
@@ -64,7 +76,13 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"])
if (attempts > 12) { clearInterval(poll); return; }
for (const c of connectors) {
if (c.status !== "ready") {
try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ }
try {
unwrap(await window.agentCompany.testConnector(c.id as ConnectorId));
} catch (error) {
console.warn(
`[onboarding/useConnectorCallbacks] post-install testConnector(${c.id}) failed (attempt ${attempts}): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}, 5000);
@@ -136,8 +136,11 @@ export function useConnectorModelSelection(
setConnectorModelsById((previous) =>
connectorCatalogsEqual(previous, next) ? previous : next,
);
} catch {
} catch (error) {
if (cancelled) return;
console.warn(
`[onboarding/useConnectorModelSelection] connector model fetch failed; clearing catalog: ${error instanceof Error ? error.message : String(error)}`,
);
setConnectorModelsById((previous) => (Object.keys(previous).length === 0 ? previous : {}));
setSelectedModel("");
} finally {
@@ -1,4 +0,0 @@
export { DocumentCenter } from "./company-operations/DocumentCenter";
export { KnowledgeBaseManager } from "./company-operations/KnowledgeBaseManager";
export { MeetingsManager } from "./company-operations/MeetingsManager";
export { SprintManager } from "./company-operations/SprintManager";
-5
View File
@@ -1,5 +0,0 @@
export { Dashboard } from "./dashboard/Dashboard";
export type { DashboardProps } from "./dashboard/Dashboard";
export { KanbanBoard } from "./dashboard/KanbanBoard";
export { MetricCard } from "./dashboard/MetricsGrid";
export { ActivityFeed } from "./dashboard/ActivityFeed";
@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
import { useReducedMotion } from "../ui";
import { Eyebrow, useReducedMotion } from "../ui";
import { useT } from "../../i18n";
import {
aggregateRunsByDay,
aggregateCostsByDay,
@@ -22,6 +23,7 @@ interface ChartSectionProps {
}
export function ChartSection({ runs, costs, agents, companyId }: ChartSectionProps) {
const t = useT();
const reducedMotion = useReducedMotion();
const dailyRuns = useMemo(() => aggregateRunsByDay(runs, 14), [runs]);
@@ -34,39 +36,39 @@ export function ChartSection({ runs, costs, agents, companyId }: ChartSectionPro
const hasRunData = dailyRuns.some((d) => d.total > 0);
const hasCostData = dailyCosts.some((d) => d.total > 0);
const totalRuns = dailyRuns.reduce((s, d) => s + d.total, 0);
const content = (
<div className="space-y-6">
{/* Section header */}
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Performance
</div>
<Eyebrow>{t("chart.performance")}</Eyebrow>
{/* 2-column chart grid */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<ChartCard
title="Run Activity"
ariaLabel={`Run activity chart showing ${dailyRuns.reduce((s, d) => s + d.total, 0)} total runs over 14 days`}
title={t("chart.runActivity")}
ariaLabel={t("chart.runActivityAria").replace("{count}", String(totalRuns))}
isEmpty={!hasRunData}
>
<RunActivityChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Success Rate"
ariaLabel="Success rate chart showing percentage of succeeded runs over 14 days"
title={t("chart.successRate")}
ariaLabel={t("chart.successRateAria")}
isEmpty={!hasRunData}
>
<SuccessRateChart data={dailyRuns} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Cost Trend"
ariaLabel="Cost trend chart showing daily spend over 14 days"
title={t("chart.costTrend")}
ariaLabel={t("chart.costTrendAria")}
isEmpty={!hasCostData}
>
<CostTrendChart data={dailyCosts} reducedMotion={reducedMotion} />
</ChartCard>
<ChartCard
title="Agent Workload"
ariaLabel="Agent workload chart showing run distribution across agents"
title={t("chart.agentWorkload")}
ariaLabel={t("chart.agentWorkloadAria")}
isEmpty={agentWorkload.length === 0}
>
<AgentWorkloadChart data={agentWorkload} reducedMotion={reducedMotion} />
@@ -74,9 +76,7 @@ export function ChartSection({ runs, costs, agents, companyId }: ChartSectionPro
</div>
{/* Budget Utilization section */}
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Budget Utilization
</div>
<Eyebrow>{t("chart.budgetUtilization")}</Eyebrow>
<BudgetUtilization companyId={companyId} agents={agents} />
</div>
);
@@ -27,7 +27,7 @@ import { ActivityFeed } from "./ActivityFeed";
import { RecoveryBanner } from "./RecoveryBanner";
import { BudgetAlertBanner } from "./BudgetAlertBanner";
import { ChartSection } from "./ChartSection";
import { AttentionRow } from "../ui";
import { AttentionRow, Eyebrow } from "../ui";
export interface DashboardProps {
companyName?: string;
@@ -142,10 +142,10 @@ export function Dashboard(props: DashboardProps) {
{/* Needs Attention — moved to top, only shows when items exist */}
{hasAttentionItems ? (
<section>
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">
<Eyebrow tone="warning" className="mb-3 flex items-center gap-2">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
{t("dash.needsAttentionSection")}
</div>
</Eyebrow>
<div className="space-y-1.5">
{pendingApprovals.slice(0, 5).map((approval, i) => {
const requester = approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) : undefined;
@@ -201,9 +201,7 @@ export function Dashboard(props: DashboardProps) {
{/* Active Work — what's running right now */}
<section>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("dash.activeWork")}
</div>
<Eyebrow>{t("dash.activeWork")}</Eyebrow>
<button type="button" onClick={() => onNavigate("runs")} className="flex items-center gap-1 text-[11px] font-medium text-primary transition hover:underline">
{t("dash.allRuns")} <ArrowRight size={12} />
</button>
@@ -250,9 +248,7 @@ export function Dashboard(props: DashboardProps) {
{/* Recent Activity */}
<section>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("dash.recentActivity")}
</div>
<Eyebrow>{t("dash.recentActivity")}</Eyebrow>
</div>
<div className="max-h-[360px] overflow-y-auto rounded-lg border border-border bg-card py-2">
<ActivityFeed activity={activity} agents={agents} limit={10} />
@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { Briefcase, Robot } from "../ui/icons";
import type { AgentRecord, RunRecord, TaskRecord } from "@shared/types";
import { useT } from "../../i18n";
import { Eyebrow } from "../ui";
interface KanbanProps {
tasks: TaskRecord[];
@@ -9,13 +11,13 @@ interface KanbanProps {
onSelectTask: (taskId: string) => void;
}
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [
{ status: "backlog", label: "Backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" },
{ status: "todo", label: "Todo", tone: "text-warning", dotColor: "bg-warning" },
{ status: "in_progress", label: "In Progress", tone: "text-primary", dotColor: "bg-primary" },
{ status: "in_review", label: "In Review", tone: "text-warning", dotColor: "bg-warning" },
{ status: "done", label: "Done", tone: "text-success", dotColor: "bg-success" },
{ status: "blocked", label: "Blocked", tone: "text-destructive", dotColor: "bg-destructive" },
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; labelKey: string; tone: string; dotColor: string }> = [
{ status: "backlog", labelKey: "kanban.backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" },
{ status: "todo", labelKey: "kanban.todo", tone: "text-warning", dotColor: "bg-warning" },
{ status: "in_progress", labelKey: "kanban.inProgress", tone: "text-primary", dotColor: "bg-primary" },
{ status: "in_review", labelKey: "kanban.inReview", tone: "text-warning", dotColor: "bg-warning" },
{ status: "done", labelKey: "kanban.done", tone: "text-success", dotColor: "bg-success" },
{ status: "blocked", labelKey: "kanban.blocked", tone: "text-destructive", dotColor: "bg-destructive" },
];
const priorityColors: Record<string, string> = {
@@ -26,6 +28,7 @@ const priorityColors: Record<string, string> = {
};
export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps) {
const t = useT();
const tasksByStatus = useMemo(() => {
const grouped: Record<string, TaskRecord[]> = {};
for (const col of KANBAN_COLUMNS) grouped[col.status] = [];
@@ -46,8 +49,8 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
<Briefcase size={32} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No tasks yet</div>
<div className="mt-1 text-[12px] text-muted-foreground">Tasks created by agents will appear here on the board</div>
<div className="text-[14px] font-medium text-muted-foreground">{t("kanban.noTasks")}</div>
<div className="mt-1 text-[12px] text-muted-foreground">{t("kanban.noTasksDetail")}</div>
</div>
);
}
@@ -60,7 +63,7 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
<div key={col.status} className="flex w-[220px] min-w-[160px] shrink-0 flex-col lg:w-[240px]">
<div className="mb-2 flex items-center gap-2 px-1">
<span className={`h-2.5 w-2.5 rounded-full ${col.dotColor}`} aria-hidden="true" />
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
<Eyebrow className={col.tone}>{t(col.labelKey)}</Eyebrow>
<span className="ml-auto rounded-full bg-muted px-2 py-0.5 text-[10px] font-bold tabular-nums text-muted-foreground">
{colTasks.length}
</span>
@@ -85,18 +88,18 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{assignee.name}
</span>
) : (
<span className="text-[10px] italic text-muted-foreground">Unassigned</span>
<span className="text-[10px] italic text-muted-foreground">{t("kanban.unassigned")}</span>
)}
{isRunning ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-success">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-success" aria-hidden="true" />
Running
{t("kanban.running")}
</span>
) : null}
</div>
{task.priority === "critical" || task.priority === "high" ? (
<div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-destructive" : "text-warning"}`}>
{task.priority}
{task.priority === "critical" ? t("kanban.priority.critical") : t("kanban.priority.high")}
</div>
) : null}
</button>
@@ -1,4 +1,5 @@
import type React from "react";
import { Eyebrow } from "../ui";
export function MetricCard({
label,
@@ -48,7 +49,7 @@ export function MetricCard({
<Icon size={24} weight="fill" className={toneMap[tone]} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{label}</div>
<Eyebrow>{label}</Eyebrow>
<div className={`text-[28px] font-bold tracking-[-0.02em] leading-none ${toneMap[tone]}`} style={{ fontVariantNumeric: "tabular-nums" }}>{value}</div>
{subtitle ? <div className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</div> : null}
</div>
@@ -8,6 +8,7 @@ interface InterruptedRun {
taskTitle: string;
agentId: string;
agentName: string;
companyId: string;
connectorId: string;
interruptedAt: string;
}
@@ -30,12 +31,12 @@ export function RecoveryBanner() {
if (runs.length === 0 || dismissed) return null;
const handleRetry = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, "");
await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, run.companyId);
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
const handleFail = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryFail(run.taskId);
await window.agentCompany?.recoveryFail(run.taskId, run.companyId);
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
@@ -1,4 +1,5 @@
import { ChartLine } from "../../ui/icons";
import { useT } from "../../../i18n";
interface ChartCardProps {
title: string;
@@ -14,11 +15,14 @@ export function ChartCard({
title,
period = "14d",
isEmpty = false,
emptyHeading = "No activity yet",
emptyBody = "Charts will appear once your company starts running tasks.",
emptyHeading,
emptyBody,
ariaLabel,
children,
}: ChartCardProps) {
const t = useT();
const resolvedHeading = emptyHeading ?? t("chart.emptyHeading");
const resolvedBody = emptyBody ?? t("chart.emptyBody");
return (
<div
className="rounded-lg border border-border bg-card px-4 py-4"
@@ -40,10 +44,10 @@ export function ChartCard({
className="text-muted-foreground opacity-50"
/>
<span className="text-[13px] font-semibold text-muted-foreground">
{emptyHeading}
{resolvedHeading}
</span>
<span className="max-w-[200px] text-center text-[11px] text-muted-foreground">
{emptyBody}
{resolvedBody}
</span>
</div>
) : (
-17
View File
@@ -1,17 +0,0 @@
// Backward-compatible re-exports from decomposed execution components
export { TaskQueue } from "./execution/TaskQueue"
export { TaskInspector } from "./execution/TaskInspector"
export { TaskCreator } from "./execution/TaskCreator"
export { ApprovalList } from "./execution/ApprovalList"
export { ApprovalInspector } from "./execution/approval-inspector"
export { ActivityList } from "./execution/ActivityList"
export { CostList } from "./execution/CostList"
export { BudgetSummary } from "./execution/BudgetSummary"
export { WorkspaceList } from "./execution/WorkspaceList"
export { WorkspaceInspector } from "./execution/WorkspaceInspector"
export { WorkspaceCreator } from "./execution/WorkspaceCreator"
export { SettingsPane } from "./execution/SettingsPane"
export { PendingReviewList } from "./execution/PendingReviewList"
export { PolicyList } from "./execution/PolicyList"
export { PolicyFormModal } from "./execution/PolicyFormModal"
export { IncidentList } from "./execution/IncidentList"
@@ -1,12 +1,13 @@
import { CaretDown, CaretRight } from "../ui/icons";
import type { ProfileSnapshot } from "@shared/types";
import { Input } from "../ui";
import { useT } from "../../i18n";
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
export const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
export { Input };
export interface ModelEntry {
id: string;
@@ -25,12 +26,13 @@ export function parseEnvValue(text: string, key: string): string {
/* StatusBadge */
/* ------------------------------------------------------------------ */
export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
export function StatusBadge({ status }: { status: ConnectorStatus }) {
const t = useT();
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{zh ? "就绪" : "Ready"}
{t("models.ready")}
</span>
);
}
@@ -38,7 +40,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{zh ? "未安装" : "Not installed"}
{t("models.notInstalled")}
</span>
);
}
@@ -46,7 +48,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{zh ? "需登录" : "Auth required"}
{t("models.authRequired")}
</span>
);
}
@@ -54,7 +56,7 @@ export function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boole
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{zh ? "检测中" : "Checking"}
{t("models.checking")}
</span>
);
}
@@ -137,18 +139,17 @@ export function ModelToggleList({
onToggle,
onRemove,
canRemove,
zh,
}: {
models: ModelEntry[];
onToggle: (id: string) => void;
onRemove?: (id: string) => void;
canRemove: boolean;
zh: boolean;
}) {
const t = useT();
if (models.length === 0) {
return (
<div className="text-[12px] text-muted-foreground">
{zh ? "暂无模型" : "No models available"}
{t("models.noModels")}
</div>
);
}
@@ -156,7 +157,7 @@ export function ModelToggleList({
return (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
{t("models.label")} ({t("models.enabledCount").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length))})
</div>
<div className="space-y-1">
{models.map((m) => (
@@ -168,7 +169,7 @@ export function ModelToggleList({
<ToggleSwitch
checked={m.enabled}
onChange={() => onToggle(m.id)}
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
label={(m.enabled ? t("models.disableToggle") : t("models.enableToggle")).replace("{name}", m.label)}
/>
<span
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
@@ -181,7 +182,7 @@ export function ModelToggleList({
type="button"
onClick={() => onRemove(m.id)}
className="text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m.label}`}
aria-label={t("models.removeModel").replace("{name}", m.label)}
>
<XIcon size={12} />
</button>
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import type { BudgetPolicyRecord } from "@shared/types";
import { ActionButton, Input, Modal, Select } from "../ui";
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../lib/budget-policy-input";
import { useT } from "../../i18n";
interface PolicyFormModalProps {
open: boolean;
@@ -14,6 +15,7 @@ interface PolicyFormModalProps {
}
export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPolicy, agents, projects }: PolicyFormModalProps) {
const t = useT();
const [scopeType, setScopeType] = useState<"company" | "agent" | "project">("company");
const [scopeId, setScopeId] = useState(companyId);
const [amountDollars, setAmountDollars] = useState("100");
@@ -79,23 +81,23 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
<Modal
open={open}
onClose={onClose}
title={editingPolicy ? "Edit Budget Policy" : "Add Budget Policy"}
title={editingPolicy ? t("policy.editTitle") : t("policy.addTitle")}
>
<div className="grid gap-4">
<Select
label="Scope"
label={t("policy.scope")}
value={scopeType}
onChange={(v) => setScopeType(v as "company" | "agent" | "project")}
options={[
{ value: "company", label: "Company" },
{ value: "agent", label: "Agent" },
{ value: "project", label: "Project" },
{ value: "company", label: t("policy.scopeCompany") },
{ value: "agent", label: t("policy.scopeAgent") },
{ value: "project", label: t("policy.scopeProject") },
]}
/>
{scopeType === "agent" ? (
<Select
label="Agent"
label={t("policy.scopeAgent")}
value={scopeId}
onChange={setScopeId}
options={agents.map((a) => ({ value: a.id, label: a.name }))}
@@ -104,7 +106,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
{scopeType === "project" ? (
<Select
label="Project"
label={t("policy.scopeProject")}
value={scopeId}
onChange={setScopeId}
options={projects.map((p) => ({ value: p.id, label: p.name }))}
@@ -112,7 +114,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
) : null}
<Input
label="Monthly Budget (USD)"
label={t("policy.monthlyBudget")}
value={amountDollars}
onChange={setAmountDollars}
type="number"
@@ -120,7 +122,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
/>
<Input
label="Warning Threshold (%)"
label={t("policy.warningThreshold")}
value={warnPercent}
onChange={setWarnPercent}
type="number"
@@ -134,7 +136,7 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
onChange={(e) => setHardStopEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">Enable hard stop (block new runs at 100%)</span>
<span className="text-[13px] text-foreground">{t("policy.hardStop")}</span>
</label>
<label className="flex items-center gap-2.5">
@@ -144,19 +146,13 @@ export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPoli
onChange={(e) => setNotifyEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<span className="text-[13px] text-foreground">Send notifications</span>
<span className="text-[13px] text-foreground">{t("policy.sendNotifications")}</span>
</label>
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
className="rounded-lg px-4 py-2 text-[13px] font-medium text-muted-foreground transition hover:text-foreground"
onClick={onClose}
>
Cancel
</button>
<ActionButton label={t("policy.cancel")} onClick={onClose} />
<ActionButton
label={saving ? "Saving..." : "Save Policy"}
label={saving ? t("policy.saving") : t("policy.savePolicy")}
tone="accent"
disabled={saving}
onClick={() => void handleSave()}
@@ -7,7 +7,7 @@ import { useLocale, useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { isMac } from "../../lib/platform"
import { ActionButton, ConfirmDialog, EmptyState, InlineNotice, Select } from "../ui"
import { ActionButton, ConfirmDialog, EmptyState, Eyebrow, InlineNotice, Select } from "../ui"
import { describeActionError } from "./execution-utils"
import { useAppStore } from "../../state/store"
@@ -19,25 +19,25 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
const secretToDelete = secrets.find((s) => s.id === confirmDeleteSecretId) ?? null
useEffect(() => { setTheme(snapshot.theme); setUpdatesEnabled(snapshot.updatesEnabled); if (snapshot.locale) setSelectedLocale(snapshot.locale) }, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale])
async function handleDeleteSecret(id: string) { setDeleteSecretError(null); setActionError(null); setActionNotice(null); try { unwrap(await window.agentCompany.deleteSecret({ id, companyId })); setConfirmDeleteSecretId(null); setActionNotice("Secret deleted."); addToast("Secret deleted", "success"); await onSaved() } catch (e) { setDeleteSecretError(describeActionError(e)) } }
async function handleSaveSettings() { setActionError(null); setActionNotice(null); setLoadingAction("save"); try { unwrap(await window.agentCompany.updateSettings({ theme, locale: selectedLocale, updatesEnabled })); await onSaved(); setActionNotice("Settings saved."); addToast("Settings saved", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleCheckForUpdates() { setActionError(null); setActionNotice(null); setLoadingAction("check"); try { const started = unwrap(await window.agentCompany.checkForUpdates()); setActionNotice(started ? "Update check started." : "Update check skipped because no release update feed is configured.") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleBackupProfile() { setActionError(null); setActionNotice(null); setLoadingAction("backup"); try { const backupPath = unwrap(await window.agentCompany.backupProfile()); if (backupPath) { setActionNotice(`Profile backup created at ${backupPath}.`); addToast("Profile backup created", "success") } } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleRestoreProfile() { setActionError(null); setActionNotice(null); setLoadingAction("restore"); try { const restored = unwrap(await window.agentCompany.restoreProfile()); if (!restored) return; await onSaved(); setActionNotice("Profile restored."); addToast("Profile restored", "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleDeleteSecret(id: string) { setDeleteSecretError(null); setActionError(null); setActionNotice(null); try { unwrap(await window.agentCompany.deleteSecret({ id, companyId })); setConfirmDeleteSecretId(null); setActionNotice(t("settings.secretDeleted")); addToast(t("settings.secretDeleted"), "success"); await onSaved() } catch (e) { setDeleteSecretError(describeActionError(e)) } }
async function handleSaveSettings() { setActionError(null); setActionNotice(null); setLoadingAction("save"); try { unwrap(await window.agentCompany.updateSettings({ theme, locale: selectedLocale, updatesEnabled })); await onSaved(); setActionNotice(t("settings.settingsSaved")); addToast(t("settings.settingsSaved"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleCheckForUpdates() { setActionError(null); setActionNotice(null); setLoadingAction("check"); try { const started = unwrap(await window.agentCompany.checkForUpdates()); setActionNotice(started ? t("settings.updateCheckStarted") : t("settings.updateCheckSkipped")) } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleBackupProfile() { setActionError(null); setActionNotice(null); setLoadingAction("backup"); try { const backupPath = unwrap(await window.agentCompany.backupProfile()); if (backupPath) { setActionNotice(t("settings.profileBackupAt").replace("{path}", backupPath)); addToast(t("settings.profileBackupCreated"), "success") } } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
async function handleRestoreProfile() { setActionError(null); setActionNotice(null); setLoadingAction("restore"); try { const restored = unwrap(await window.agentCompany.restoreProfile()); if (!restored) return; await onSaved(); setActionNotice(t("settings.profileRestored")); addToast(t("settings.profileRestored"), "success") } catch (e) { setActionError(describeActionError(e)) } finally { setLoadingAction(null) } }
return (
<div className="space-y-6">
<ConfirmDialog open={confirmDeleteSecretId !== null} title="Delete secret" message={`Permanently delete secret "${secretToDelete?.name ?? ""}"? Any connectors referencing it will lose access.`} errorMessage={deleteSecretError} onConfirm={() => confirmDeleteSecretId && void handleDeleteSecret(confirmDeleteSecretId)} onCancel={() => { setDeleteSecretError(null); setConfirmDeleteSecretId(null) }} />
<ConfirmDialog open={confirmDeleteSecretId !== null} title={t("settings.deleteSecretTitle")} message={t("settings.deleteSecretConfirm").replace("{name}", secretToDelete?.name ?? "")} errorMessage={deleteSecretError} onConfirm={() => confirmDeleteSecretId && void handleDeleteSecret(confirmDeleteSecretId)} onCancel={() => { setDeleteSecretError(null); setConfirmDeleteSecretId(null) }} />
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.profile")}</div><div className="border-y border-border py-4"><div className="mb-2 text-[11px] text-muted-foreground">{t("exec.profilePath")}</div><div className="font-mono truncate text-[11px] text-muted-foreground">{snapshot.profilePath}</div></div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.preferences")}</div>
<div><Eyebrow className="mb-3">{t("exec.profile")}</Eyebrow><div className="border-y border-border py-4"><div className="mb-2 text-[11px] text-muted-foreground">{t("exec.profilePath")}</div><div className="font-mono truncate text-[11px] text-muted-foreground">{snapshot.profilePath}</div></div></div>
<div><Eyebrow className="mb-3">{t("exec.preferences")}</Eyebrow>
<Select label={t("settings.theme")} value={theme} onChange={(v) => { const next = v as ProfileSnapshot["theme"]; setTheme(next); const root = document.documentElement; root.className = next; root.style.colorScheme = next === "system" ? "light dark" : next; void window.agentCompany.updateSettings({ theme: next }).then(() => onSaved()) }} options={[{ value: "system", label: t("settings.system") }, { value: "dark", label: t("settings.dark") }, { value: "light", label: t("settings.light") }]} />
<div className="mt-3"><Select label={t("settings.language")} value={selectedLocale} onChange={(v) => setSelectedLocale(v as AppLocale)} options={APP_LOCALES.map((loc) => ({ value: loc, label: LOCALE_LABELS[loc] }))} /></div>
<div className="border-y border-border py-4 mt-3"><label className="flex items-center justify-between text-[13px] text-muted-foreground"><span>{t("exec.enableUpdater")}</span><input type="checkbox" checked={updatesEnabled} onChange={(e) => setUpdatesEnabled(e.target.checked)} /></label></div>
</div>
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; const isCeo = company.executionMode !== "ai_cofounder"; return (
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.executionMode")}</div>
<div><Eyebrow className="mb-3">{t("exec.executionMode")}</Eyebrow>
<div className="space-y-3">
<button type="button" onClick={async () => { if (isCeo) return; setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: true, reviewDeliverables: false, executionMode: "ai_ceo" })); await onSaved(); addToast(t("exec.modeSwitched"), "success") } catch (err) { setActionError(describeActionError(err)) } }} className={`w-full rounded-lg border-2 px-4 py-3 text-left transition ${isCeo ? "border-[color:var(--primary)] bg-accent" : "border-border bg-muted hover:border-[color:var(--muted-foreground)] cursor-pointer"}`}>
<div className="text-[13px] font-semibold text-foreground">AI CEO <span className="ml-1 text-[10px] font-normal text-muted-foreground">{t("exec.modeCeoTag")}</span></div>
@@ -48,15 +48,15 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
<div className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{t("exec.modeCofounderDesc")}</div>
</button>
</div></div>) })()}
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
<div><Eyebrow className="mb-3">{t("settings.keyboardShortcuts")}</Eyebrow>
<div className="space-y-1 border-y border-border py-3">
{([
[isMac() ? "⌘K" : "Ctrl+K", "Open command palette"],
[isMac() ? "⌘Enter" : "Ctrl+Enter", "Send message"],
["Escape", "Close modal / palette"],
["↑ / ↓", "Navigate palette results"],
["Enter", "Select palette result"],
["Tab", "Move focus between elements"],
[isMac() ? "⌘K" : "Ctrl+K", t("settings.shortcut.commandPalette")],
[isMac() ? "⌘Enter" : "Ctrl+Enter", t("settings.shortcut.sendMessage")],
["Escape", t("settings.shortcut.closeModal")],
["↑ / ↓", t("settings.shortcut.navigatePalette")],
["Enter", t("settings.shortcut.selectPaletteResult")],
["Tab", t("settings.shortcut.moveFocus")],
] as const).map(([key, desc]) => (
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
<span className="text-muted-foreground">{desc}</span>
@@ -65,9 +65,9 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
))}
</div>
</div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
<div><Eyebrow className="mb-3">{t("exec.actions")}</Eyebrow><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
<div><Eyebrow className="mb-3">{t("exec.secretBackend")}</Eyebrow><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
<div><Eyebrow className="mb-3">{t("exec.secrets")} ({secrets.length})</Eyebrow>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label={t("settings.delete")} onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
</div>
)
}
@@ -4,7 +4,7 @@ import type { CommentRecord, GoalRecord, ProfileSnapshot, ProjectRecord, RunReco
import { useT } from "../../i18n"
import { formatMoney, formatTime } from "../../lib/formatters"
import { unwrap } from "../../lib/desktop"
import { ActionButton, ConfirmDialog, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui"
import { ActionButton, ConfirmDialog, Eyebrow, InlineNotice, Input, Select, StatusPill, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
import { TaskComments } from "./TaskComments"
import { useAppStore } from "../../state/store"
@@ -80,7 +80,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
)}
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
<div className="space-y-2">
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-success">{t("exec.doneCount").replace("{0}", String(subtasks.filter((st) => st.status === "done").length)).replace("{1}", String(subtasks.length))}</span></div>
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><Eyebrow>{t("exec.subtasks")} ({subtasks.length})</Eyebrow><span className="text-[10px] text-success">{t("exec.doneCount").replace("{0}", String(subtasks.filter((st) => st.status === "done").length)).replace("{1}", String(subtasks.length))}</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
@@ -89,7 +89,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
</div>) })}</div></div>) })()}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
{runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
{runs.length > 0 && <div className="space-y-2"><Eyebrow>{t("exec.runHistory")}</Eyebrow><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? t("exec.runningEllipsis") : t("exec.completedRun"))}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
@@ -4,7 +4,8 @@ import type { ApprovalRecord, DocumentRecord } from "@shared/types"
import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents"
import { unwrap } from "../../../lib/desktop"
import { useAppStore } from "../../../state/store"
import { ActionButton, InlineNotice, Input, TextArea } from "../../ui"
import { ActionButton, Card, Eyebrow, InlineNotice, Input, TextArea } from "../../ui"
import { useT } from "../../../i18n"
import { describeActionError } from "../execution-utils"
function ApprovalDocumentEditor({
@@ -16,6 +17,7 @@ function ApprovalDocumentEditor({
document: DocumentRecord
fallback?: ApprovalDocumentReference
}) {
const t = useT()
const addToast = useAppStore((s) => s.addToast)
const [title, setTitle] = useState(document.title)
const [content, setContent] = useState(document.content ?? "")
@@ -24,7 +26,7 @@ function ApprovalDocumentEditor({
async function handleSave() {
if (!title.trim()) {
setSaveError("Document title is required.")
setSaveError(t("approval.documentTitleRequired"))
return
}
setSaving(true)
@@ -47,7 +49,7 @@ function ApprovalDocumentEditor({
tagsJson: document.tagsJson,
}),
)
addToast(`Saved document: ${title.trim()}`, "success")
addToast(t("approval.documentSavedToast").replace("{title}", title.trim()), "success")
} catch (error) {
setSaveError(describeActionError(error))
} finally {
@@ -56,7 +58,7 @@ function ApprovalDocumentEditor({
}
return (
<div className="space-y-3 rounded-lg border border-border px-4 py-3">
<Card padding="sm" className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-foreground">{fallback?.title ?? document.title}</div>
@@ -65,7 +67,7 @@ function ApprovalDocumentEditor({
</div>
</div>
<ActionButton
label={saving ? "Saving..." : "Save changes"}
label={saving ? t("approval.saving") : t("approval.saveChanges")}
icon={CheckCircle}
tone="accent"
onClick={() => void handleSave()}
@@ -73,9 +75,9 @@ function ApprovalDocumentEditor({
/>
</div>
{saveError ? <InlineNotice message={saveError} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Document title" />
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Document content" />
</div>
<Input label={t("approval.docTitle")} value={title} onChange={setTitle} placeholder={t("approval.docTitlePlaceholder")} />
<TextArea label={t("approval.docContent")} value={content} onChange={setContent} rows={12} placeholder={t("approval.docContentPlaceholder")} />
</Card>
)
}
@@ -88,6 +90,7 @@ export function ApprovalDocumentsPanel({
documents?: DocumentRecord[]
companyId: string
}) {
const t = useT()
const references = useMemo(
() => extractApprovalDocumentReferences(approval.payloadJson),
[approval.payloadJson],
@@ -109,7 +112,7 @@ export function ApprovalDocumentsPanel({
return (
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Linked Documents</div>
<Eyebrow tone="primary">{t("approval.linkedDocuments")}</Eyebrow>
{fullDocs.length > 0
? fullDocs.map(({ reference, document }) => (
<ApprovalDocumentEditor
@@ -120,15 +123,15 @@ export function ApprovalDocumentsPanel({
/>
))
: references.map((reference) => (
<div key={reference.id} className="rounded-lg border border-border bg-muted px-4 py-3">
<Card key={reference.id} padding="sm" className="bg-muted">
<div className="text-[13px] font-medium text-foreground">{reference.title ?? reference.id}</div>
<div className="text-[11px] text-muted-foreground">
{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}
</div>
<div className="mt-2 text-[12px] text-muted-foreground">
The linked document is not loaded in the current snapshot yet. Reopen the approval after the next refresh.
{t("approval.documentNotLoaded")}
</div>
</div>
</Card>
))}
{approval.impactSummary ? (
<div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div>
@@ -3,7 +3,7 @@ import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@s
import { extractApprovalDocumentReferences } from "@shared/approval-documents"
import { useT } from "../../../i18n"
import { unwrap } from "../../../lib/desktop"
import { ConfirmDialog, InlineNotice } from "../../ui"
import { Card, ConfirmDialog, Eyebrow, InlineNotice } from "../../ui"
import { useAppStore } from "../../../state/store"
import { describeActionError } from "../execution-utils"
import { SocialDraftPreview } from "../SocialDraftPreview"
@@ -69,12 +69,12 @@ export function ApprovalInspector({
if (isSocialPost && state === "approved" && scheduleMode === "scheduled") {
if (!scheduledAt) {
setScheduleError("Please select a date and time for scheduling.")
setScheduleError(t("approval.scheduleSelectTime"))
return
}
const chosenTime = new Date(scheduledAt).getTime()
if (chosenTime <= Date.now()) {
setScheduleError("Scheduled time must be in the future.")
setScheduleError(t("approval.scheduleFuture"))
return
}
}
@@ -87,9 +87,9 @@ export function ApprovalInspector({
noteForDecision = noteForDecision ? `${noteForDecision} ${scheduleInfo}` : scheduleInfo
}
await onDecide(state, noteForDecision)
if (state === "approved") addToast("Approval granted — agent is moving forward", "success")
else if (state === "rejected") addToast("Approval rejected", "warn")
else if (state === "revision_requested") addToast("Revision requested — agent will revise and resubmit", "warn")
if (state === "approved") addToast(t("approval.granted"), "success")
else if (state === "rejected") addToast(t("approval.rejectedToast"), "warn")
else if (state === "revision_requested") addToast(t("approval.revisionRequested"), "warn")
} catch (error) {
setActionError(describeActionError(error))
} finally {
@@ -115,14 +115,17 @@ export function ApprovalInspector({
<ApprovalSummary approval={approval} requestingAgent={requestingAgent} relatedTask={relatedTask} />
{approval.type === "hire_agent" ? <ProposedHireCard approval={approval} agents={agents} /> : null}
{approval.type === "approve_ceo_strategy" ? (
<div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">
Organization Strategy
</div>
<Card
padding="sm"
className="border-[color:var(--destructive)] border-opacity-30 bg-warning-muted"
>
<Eyebrow tone="warning" className="mb-2">
{t("approval.orgStrategy")}
</Eyebrow>
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
{approval.payloadSummary}
</div>
</div>
</Card>
) : null}
<ApprovalDocumentsPanel approval={approval} documents={documents} companyId={companyId} />
{approval.type === "deliverable_review" && !hasDocumentReferences ? (
@@ -160,7 +163,7 @@ export function ApprovalInspector({
{approval.decisionNote ? (
<div className="border-t border-border pt-4 text-[13px] text-muted-foreground">
Last note: {approval.decisionNote}
{t("approval.lastNote")} {approval.decisionNote}
</div>
) : null}
</div>
@@ -2,6 +2,7 @@ import { Briefcase, Robot } from "../../ui/icons"
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
import { formatTime } from "../../../lib/formatters"
import { StatusPill } from "../../ui"
import { useT } from "../../../i18n"
import { TypeBadge } from "./TypeBadge"
export function ApprovalSummary({
@@ -13,6 +14,7 @@ export function ApprovalSummary({
requestingAgent: AgentRecord | null
relatedTask: TaskRecord | null
}) {
const t = useT()
return (
<>
<div className="flex items-center gap-3">
@@ -26,8 +28,8 @@ export function ApprovalSummary({
<div className="grid gap-2 text-[13px] md:grid-cols-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Robot size={14} />
Requested by:{" "}
<span className="font-medium text-foreground">{requestingAgent?.name ?? "Unknown"}</span>
{t("approval.requestedBy")}{" "}
<span className="font-medium text-foreground">{requestingAgent?.name ?? t("approval.unknownRequester")}</span>
{requestingAgent?.role ? (
<span className="text-[10px] text-muted-foreground">({requestingAgent.role})</span>
) : null}
@@ -35,15 +37,15 @@ export function ApprovalSummary({
{relatedTask ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Briefcase size={14} />
Task: <span className="font-medium text-foreground">{relatedTask.title}</span>
{t("approval.task")} <span className="font-medium text-foreground">{relatedTask.title}</span>
</div>
) : null}
<div className="text-muted-foreground">
Created: <span className="font-mono text-foreground">{formatTime(approval.createdAt)}</span>
{t("approval.created")} <span className="font-mono text-foreground">{formatTime(approval.createdAt)}</span>
</div>
{approval.updatedAt !== approval.createdAt ? (
<div className="text-muted-foreground">
Updated: <span className="font-mono text-foreground">{formatTime(approval.updatedAt)}</span>
{t("approval.updated")} <span className="font-mono text-foreground">{formatTime(approval.updatedAt)}</span>
</div>
) : null}
</div>
@@ -1,5 +1,5 @@
import { CheckCircle, ClockCounterClockwise, Trash, XCircle } from "../../ui/icons"
import { ActionButton, TextArea } from "../../ui"
import { ActionButton, Eyebrow, TextArea } from "../../ui"
import { useT } from "../../../i18n"
export type DecisionState = "approved" | "rejected" | "revision_requested"
@@ -28,13 +28,13 @@ export function DecisionForm({
<>
{!isDecided ? (
<div>
<label className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Decision note
</label>
<Eyebrow className="mb-1">
{t("approval.decisionNote")}
</Eyebrow>
<TextArea
value={decisionNote}
onChange={onDecisionNoteChange}
placeholder="Add a note (optional)"
placeholder={t("approval.decisionNotePlaceholder")}
rows={3}
/>
</div>
@@ -43,7 +43,7 @@ export function DecisionForm({
{!isDecided ? (
<>
<ActionButton
label={isSocialPost ? "Approve & Post" : t("exec.approve")}
label={isSocialPost ? t("approval.approvePost") : t("exec.approve")}
tone="accent"
onClick={() => onDecide("approved")}
icon={CheckCircle}
@@ -66,7 +66,7 @@ export function DecisionForm({
) : null}
{isDecided ? (
<ActionButton
label="Delete"
label={t("approval.delete")}
onClick={onRequestDelete}
icon={Trash}
tone="danger"
@@ -1,6 +1,8 @@
import { useMemo, useState } from "react"
import { CaretDown, CaretUp } from "../../ui/icons"
import type { AgentRecord, ApprovalRecord, DocumentRecord } from "@shared/types"
import { Card, Eyebrow } from "../../ui"
import { useT } from "../../../i18n"
import { useApprovalPayload } from "./useApprovalPayload"
interface DocRef {
@@ -36,6 +38,7 @@ export function DeliverableReviewPanel({
documents?: DocumentRecord[]
agents: AgentRecord[]
}) {
const t = useT()
const payload = useApprovalPayload(approval)
const docRefs = useMemo(() => toDocRefs(payload?.documents), [payload])
const docIds = useMemo(() => toDocIds(payload?.documentIds, docRefs), [payload, docRefs])
@@ -52,13 +55,13 @@ export function DeliverableReviewPanel({
return (
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Deliverable Review</div>
<Eyebrow tone="primary">{t("approval.deliverableReview")}</Eyebrow>
{fullDocs.length > 0 ? (
fullDocs.map((doc) => {
const isExpanded = expandedDocId === doc.id
const author = doc.authorAgentId ? agents.find((a) => a.id === doc.authorAgentId) : null
return (
<div key={doc.id} className="rounded-lg border border-border overflow-hidden">
<Card key={doc.id} padding="none" className="overflow-hidden">
<button
type="button"
onClick={() => setExpandedDocId(isExpanded ? null : doc.id)}
@@ -78,28 +81,29 @@ export function DeliverableReviewPanel({
<div className="border-t border-border px-4 py-3">
{author ? (
<div className="mb-2 text-[11px] text-muted-foreground">
Author: <span className="font-medium text-foreground">{author.name}</span> ({author.role})
{t("approval.hireAuthor")} <span className="font-medium text-foreground">{author.name}</span> ({author.role})
</div>
) : null}
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
{doc.content || "No content."}
{doc.content || t("approval.docEmpty")}
</div>
</div>
) : null}
</div>
</Card>
)
})
) : docRefs.length > 0 ? (
docRefs.map((doc) => (
<div
<Card
key={doc.id}
className="flex items-center gap-2 rounded-lg border border-border bg-muted px-4 py-3 text-[12px]"
padding="sm"
className="flex items-center gap-2 bg-muted text-[12px]"
>
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">
{doc.type.replaceAll("_", " ")}
</span>
<span className="font-medium text-foreground">{doc.title}</span>
</div>
</Card>
))
) : (
<div className="text-[12px] text-muted-foreground">{approval.payloadSummary}</div>
@@ -1,6 +1,8 @@
import { useMemo } from "react"
import type { AgentRecord, ApprovalRecord } from "@shared/types"
import { formatMoney } from "../../../lib/formatters"
import { Card, Eyebrow } from "../../ui"
import { useT } from "../../../i18n"
import { useApprovalPayload } from "./useApprovalPayload"
interface HireSnapshot {
@@ -25,6 +27,7 @@ export function ProposedHireCard({
approval: ApprovalRecord
agents: AgentRecord[]
}) {
const t = useT()
const payload = useApprovalPayload(approval)
const { proposedAgent, reqSnap, mgr } = useMemo(() => {
@@ -42,52 +45,52 @@ export function ProposedHireCard({
if (!proposedAgent && !reqSnap) return null
const name = proposedAgent?.name ?? reqSnap?.name ?? "Unspecified"
const role = proposedAgent?.role ?? reqSnap?.role ?? "Unspecified"
const name = proposedAgent?.name ?? reqSnap?.name ?? t("approval.proposedUnspecified")
const role = proposedAgent?.role ?? reqSnap?.role ?? t("approval.proposedUnspecified")
const title = proposedAgent?.title || reqSnap?.title
const capabilities = proposedAgent?.capabilities || reqSnap?.capabilities
const connectorId = proposedAgent?.connectorId ?? reqSnap?.connectorId ?? "Unknown"
const connectorId = proposedAgent?.connectorId ?? reqSnap?.connectorId ?? t("approval.proposedUnknown")
const budget = proposedAgent?.budgetMonthlyUsd ?? reqSnap?.budgetMonthlyUsd ?? 0
return (
<div className="rounded-lg border border-[color:var(--primary)] border-opacity-30 bg-accent px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Proposed Hire</div>
<Card padding="sm" className="border-[color:var(--primary)] border-opacity-30 bg-accent">
<Eyebrow tone="primary" className="mb-2">{t("approval.proposedHire")}</Eyebrow>
<div className="grid gap-1.5 text-[12px]">
<div>
<span className="text-muted-foreground">Name:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedName")}</span>{" "}
<span className="font-medium text-foreground">{name}</span>
</div>
<div>
<span className="text-muted-foreground">Role:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedRole")}</span>{" "}
<span className="text-foreground">{role}</span>
</div>
{title ? (
<div>
<span className="text-muted-foreground">Title:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedTitle")}</span>{" "}
<span className="text-foreground">{title}</span>
</div>
) : null}
{capabilities ? (
<div>
<span className="text-muted-foreground">Capabilities:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedCapabilities")}</span>{" "}
<span className="text-foreground">{capabilities}</span>
</div>
) : null}
<div>
<span className="text-muted-foreground">Connector:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedConnector")}</span>{" "}
<span className="font-mono text-foreground">{connectorId}</span>
</div>
<div>
<span className="text-muted-foreground">Budget:</span>{" "}
<span className="font-mono text-foreground">{formatMoney(budget)}/mo</span>
<span className="text-muted-foreground">{t("approval.proposedBudget")}</span>{" "}
<span className="font-mono text-foreground">{formatMoney(budget)}{t("approval.budgetSuffix")}</span>
</div>
{mgr ? (
<div>
<span className="text-muted-foreground">Reports to:</span>{" "}
<span className="text-muted-foreground">{t("approval.proposedReportsTo")}</span>{" "}
<span className="text-foreground">{mgr.name}</span>
</div>
) : null}
</div>
</div>
</Card>
)
}
@@ -1,6 +1,7 @@
import { useMemo } from "react"
import { Clock } from "../../ui/icons"
import { InlineNotice } from "../../ui"
import { useT } from "../../../i18n"
export type ScheduleMode = "immediate" | "scheduled"
@@ -25,11 +26,12 @@ export function SchedulePicker({
onScheduledAtChange: (value: string) => void
onClearError: () => void
}) {
const t = useT()
const minDatetime = useMemo(() => computeMinDatetime(), [])
return (
<div className="space-y-3">
<div role="radiogroup" aria-label="Post timing" className="space-y-2">
<div role="radiogroup" aria-label={t("approval.scheduleTimingAria")} className="space-y-2">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
@@ -42,7 +44,7 @@ export function SchedulePicker({
}}
className="h-4 w-4 shrink-0 cursor-pointer accent-[color:var(--primary)]"
/>
<span className="text-[13px] font-medium text-foreground">Post immediately</span>
<span className="text-[13px] font-medium text-foreground">{t("approval.schedulePostNow")}</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
@@ -53,7 +55,7 @@ export function SchedulePicker({
onChange={() => onModeChange("scheduled")}
className="h-4 w-4 shrink-0 cursor-pointer accent-[color:var(--primary)]"
/>
<span className="text-[13px] font-medium text-foreground">Schedule for later</span>
<span className="text-[13px] font-medium text-foreground">{t("approval.scheduleLater")}</span>
</label>
</div>
{mode === "scheduled" ? (
-2
View File
@@ -1,2 +0,0 @@
// Backward-compatible re-exports from decomposed hiring components
export { HiringPipeline } from "./hiring/HiringPipeline";
@@ -10,7 +10,7 @@ import { useT } from "../../i18n";
import { getAgentInitials } from "../../lib/formatters";
import { unwrap } from "../../lib/desktop";
import { safeJson } from "../../lib/safe-json";
import { ActionButton, EmptyState, Panel, StatusPill } from "../ui";
import { ActionButton, Card, EmptyState, Panel, StatusPill } from "../ui";
import {
type HiringStage,
type HiringCandidate,
@@ -157,7 +157,7 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
companyId: a.companyId,
approvalId: a.id,
state: "approved",
decisionNote: "Hire approved via hiring pipeline",
decisionNote: t("hiring.approvedNote"),
}));
await onRefresh();
}}
@@ -171,7 +171,7 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
companyId: a.companyId,
approvalId: a.id,
state: "rejected",
decisionNote: "Hire rejected via hiring pipeline",
decisionNote: t("hiring.rejectedNote"),
}));
await onRefresh();
}}
@@ -244,26 +244,26 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
</Panel>
<div className="grid grid-cols-4 gap-3">
<div className="rounded-lg px-4 py-3 border border-border bg-card">
<Card padding="sm">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.totalAgents")}</div>
<div className="text-2xl font-bold text-foreground mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
</div>
<div className="rounded-lg px-4 py-3 border border-border bg-card">
</Card>
<Card padding="sm">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.pendingHires")}</div>
<div className="text-2xl font-bold text-warning mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div>
<div className="rounded-lg px-4 py-3 border border-border bg-card">
</Card>
<Card padding="sm">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.departments")}</div>
<div className="text-2xl font-bold text-foreground mt-1">
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
</div>
</div>
<div className="rounded-lg px-4 py-3 border border-border bg-card">
</Card>
<Card padding="sm">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.monthlyPayroll")}</div>
<div className="text-2xl font-bold text-foreground mt-1">
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
</div>
</div>
</Card>
</div>
<Panel title={t("hiring.departmentHeadcount")}>
-4
View File
@@ -1,4 +0,0 @@
// Backward-compatible re-exports from decomposed inbox components
export { Inbox } from "./inbox/Inbox";
export { StandupView } from "./inbox/StandupView";
export { PerformanceView } from "./inbox/PerformanceView";
+8 -8
View File
@@ -48,13 +48,13 @@ export function Inbox({ items, onNavigate }: InboxProps) {
};
const filterButtons: Array<{ key: InboxItemType | "all"; label: string; count: number }> = [
{ key: "all", label: "All", count: counts.all },
{ key: "pending_approval", label: "Approvals", count: counts.pending_approval },
{ key: "review_needed", label: "Reviews", count: counts.review_needed },
{ key: "failed_run", label: "Failed", count: counts.failed_run },
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task },
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
{ key: "agent_message", label: "Messages", count: counts.agent_message },
{ key: "all", label: t("inbox.filter.all"), count: counts.all },
{ key: "pending_approval", label: t("inbox.filter.approvals"), count: counts.pending_approval },
{ key: "review_needed", label: t("inbox.filter.reviews"), count: counts.review_needed },
{ key: "failed_run", label: t("inbox.filter.failed"), count: counts.failed_run },
{ key: "blocked_task", label: t("inbox.filter.blocked"), count: counts.blocked_task },
{ key: "budget_warning", label: t("inbox.filter.budget"), count: counts.budget_warning },
{ key: "agent_message", label: t("inbox.filter.messages"), count: counts.agent_message },
];
return (
@@ -67,7 +67,7 @@ export function Inbox({ items, onNavigate }: InboxProps) {
<div>
<h2 className="text-[18px] font-bold tracking-[-0.02em] text-foreground">{t("inbox.title")}</h2>
<p className="text-[12px] text-muted-foreground">
{counts.all} item{counts.all !== 1 ? "s" : ""} need{counts.all === 1 ? "s" : ""} your attention
{counts.all} {t("inbox.needsAttention")}
</p>
</div>
</div>
@@ -3,6 +3,7 @@ import type { CompanyMetrics, SectionId } from "@shared/types";
import { formatDuration, formatMoney, getAgentInitials } from "../../lib/formatters";
import { useT } from "../../i18n";
import { CompactMetric, RunProgressBar } from "./inbox-helpers";
import { Card, Eyebrow } from "../ui";
interface PerformanceProps {
metrics: CompanyMetrics | null;
@@ -48,8 +49,8 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
/>
</div>
<div className="rounded-lg border border-border bg-card p-5">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Run Activity (30d)</div>
<Card padding="lg">
<Eyebrow className="mb-3">{t("perf.runActivity30d")}</Eyebrow>
<div className="flex items-center gap-6">
<div className="flex-1">
<RunProgressBar
@@ -61,28 +62,28 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<div className="flex gap-4 text-[12px]">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-success" />
<span className="text-muted-foreground">{metrics.successfulRuns} succeeded</span>
<span className="text-muted-foreground">{t("perf.runsSucceeded").replace("{n}", String(metrics.successfulRuns))}</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-destructive" />
<span className="text-muted-foreground">{metrics.failedRuns} failed</span>
<span className="text-muted-foreground">{t("perf.runsFailed").replace("{n}", String(metrics.failedRuns))}</span>
</span>
</div>
</div>
</div>
</Card>
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Agent Performance</div>
<Eyebrow className="mb-3">{t("perf.agentPerformance")}</Eyebrow>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left">
<thead>
<tr className="border-b border-border bg-muted">
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">Agent</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">Tasks Done</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">Success %</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">Runs</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">Avg Duration</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">Cost</th>
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{t("perf.agent")}</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">{t("perf.tasksDoneCol")}</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">{t("perf.successPct")}</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">{t("perf.runsCol")}</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">{t("perf.avgDuration")}</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground text-right">{t("perf.costCol")}</th>
</tr>
</thead>
<tbody>
+10 -9
View File
@@ -8,6 +8,7 @@ import {
import type { SectionId, StandupReport } from "@shared/types";
import { getAgentInitials } from "../../lib/formatters";
import { useT } from "../../i18n";
import { Card, Eyebrow } from "../ui";
interface StandupProps {
report: StandupReport | null;
@@ -28,7 +29,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
return (
<div className="space-y-6">
<div className="rounded-lg border border-border bg-card p-5">
<Card padding="lg">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent">
<Heartbeat size={20} weight="fill" className="text-primary" />
@@ -36,7 +37,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div>
<h2 className="text-[16px] font-bold text-foreground">{t("standup.dailyStandup")}</h2>
<p className="text-[12px] text-muted-foreground">
Generated {new Date(report.generatedAt).toLocaleString()}
{t("standup.generatedAt").replace("{time}", new Date(report.generatedAt).toLocaleString())}
</p>
</div>
</div>
@@ -64,11 +65,11 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="text-[10px] text-muted-foreground">{t("standup.budgetUsed")}</div>
</div>
</div>
</div>
</Card>
{report.companyHighlights.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("standup.highlights")}</div>
<Eyebrow className="mb-2">{t("standup.highlights")}</Eyebrow>
<div className="space-y-1.5">
{report.companyHighlights.map((h, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-success-muted px-3 py-2 text-[12px] text-success">
@@ -82,7 +83,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
{report.blockers.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-destructive">Blockers</div>
<Eyebrow tone="destructive" className="mb-2">{t("standup.blockers")}</Eyebrow>
<div className="space-y-1.5">
{report.blockers.map((b, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-destructive-muted px-3 py-2 text-[12px] text-destructive">
@@ -95,7 +96,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
) : null}
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("standup.agentStatus")}</div>
<Eyebrow className="mb-2">{t("standup.agentStatus")}</Eyebrow>
<div className="space-y-2">
{report.agentReports.map((ar) => (
<button
@@ -125,9 +126,9 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<span className="text-[10px] text-muted-foreground">{ar.role}</span>
</div>
<div className="mt-1 flex flex-wrap gap-3 text-[11px] text-muted-foreground">
{ar.tasksInProgress > 0 ? <span className="text-primary">{ar.tasksInProgress} in progress</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-success">{ar.tasksCompleted24h} done</span> : null}
{ar.tasksBlocked > 0 ? <span className="text-destructive">{ar.tasksBlocked} blocked</span> : null}
{ar.tasksInProgress > 0 ? <span className="text-primary">{t("standup.runHighlight.tasksInProgress").replace("{n}", String(ar.tasksInProgress))}</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-success">{t("standup.runHighlight.tasksDone").replace("{n}", String(ar.tasksCompleted24h))}</span> : null}
{ar.tasksBlocked > 0 ? <span className="text-destructive">{t("standup.runHighlight.tasksBlocked").replace("{n}", String(ar.tasksBlocked))}</span> : null}
{ar.costLast24h > 0 ? <span className="tabular-nums">${ar.costLast24h.toFixed(2)}</span> : null}
</div>
{ar.highlights.length > 0 ? (
@@ -8,6 +8,7 @@ import {
ChatCircle,
} from "../ui/icons";
import type { InboxItemType } from "@shared/types";
import { Card } from "../ui";
export const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
pending_approval: { icon: ShieldCheck, color: "var(--destructive)", bg: "var(--muted-foreground)", label: "Approval" },
@@ -45,11 +46,11 @@ export function CompactMetric({ label, value, tone = "default", subtitle }: {
};
return (
<div className="rounded-lg border border-border bg-card px-4 py-3">
<Card padding="sm">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{label}</div>
<div className={`text-[22px] font-bold ${toneMap[tone]}`}>{value}</div>
{subtitle ? <div className="text-[10px] text-muted-foreground">{subtitle}</div> : null}
</div>
</Card>
);
}
-10
View File
@@ -1,10 +0,0 @@
export { AgentRoster } from "./organization/AgentRoster";
export { AgentCreator } from "./organization/AgentCreator";
export { AgentEditor } from "./organization/AgentEditor";
export { GoalList } from "./organization/GoalList";
export { GoalCreator } from "./organization/GoalCreator";
export { GoalEditor } from "./organization/GoalEditor";
export { ProjectList } from "./organization/ProjectList";
export { ProjectCreator } from "./organization/ProjectCreator";
export { ProjectEditor } from "./organization/ProjectEditor";
export { OrgChart } from "./organization/OrgChart";
@@ -75,7 +75,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
return (
<div ref={containerRef} className="relative min-h-[400px] h-[clamp(400px,60vh,720px)] w-full overflow-hidden rounded-lg border border-border bg-background" style={{ cursor: dragging ? "grabbing" : "grab" }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
<div className="absolute right-3 top-3 z-10 flex flex-col gap-1">
{[{ label: "+", ariaLabel: "Zoom in", action: zoomIn }, { label: "\u2212", ariaLabel: "Zoom out", action: zoomOut }, { label: "Fit", ariaLabel: "Fit chart to screen", action: fitToScreen }].map((btn) => (
{[{ label: "+", ariaLabel: t("org.zoomIn"), action: zoomIn }, { label: "\u2212", ariaLabel: t("org.zoomOut"), action: zoomOut }, { label: t("org.fit"), ariaLabel: t("org.fitChart"), action: fitToScreen }].map((btn) => (
<button key={btn.ariaLabel} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-[12px] text-foreground transition hover:bg-muted" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button>
))}
</div>
@@ -91,7 +91,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
</svg>
<div className="absolute inset-0" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "0 0" }}>
{allNodes.map((node) => {
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
const dotColor = statusDotColor[node.agent.status] ?? "var(--muted-foreground)";
const connectorRecord = connectors?.find((c) => c.id === node.agent.connectorId);
const connectorLabel = (connectorRecord?.id.startsWith("claude_compat") && connectorRecord.notes)
? connectorRecord.notes
@@ -103,7 +103,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
const isPending = node.agent.status === "pending_approval";
const isCEO = node.children.length > 0 && !node.agent.reportsTo;
return (
<div key={node.agent.id} data-org-card role="button" tabIndex={0} aria-label={`${node.agent.name}, ${node.agent.title || node.agent.role}, ${node.agent.status}`} className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
<div key={node.agent.id} data-org-card role="button" tabIndex={0} aria-label={t("org.agentAria").replace("{name}", node.agent.name).replace("{title}", node.agent.title || node.agent.role).replace("{status}", node.agent.status)} className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_color-mix(in_oklch,var(--success)_15%,transparent)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
<div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
<div className="flex items-center gap-3 px-4 py-3">
<div className="relative shrink-0">
@@ -116,7 +116,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
) : (
<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-[color:var(--card)]" style={{ backgroundColor: dotColor }} aria-hidden="true" />
)}
<span className="sr-only">{`Status: ${node.agent.status}`}</span>
<span className="sr-only">{t("org.statusAria").replace("{status}", node.agent.status)}</span>
</div>
<div className="flex min-w-0 flex-1 flex-col items-start">
<span className="truncate text-[14px] font-semibold leading-tight text-foreground">{node.agent.name}</span>
@@ -112,21 +112,21 @@ export function CreateFormBody({
value={name}
onChange={setName}
placeholder={t("org.agentName")}
hint="Display name shown in org chart and messages"
hint={t("agent.form.nameHint")}
/>
<Input
label={t("agent.role")}
value={role}
onChange={setRole}
placeholder="e.g. Engineer, PM, CEO"
hint="Determines which tasks the agent can handle"
placeholder={t("agent.form.rolePlaceholder")}
hint={t("agent.form.roleHint")}
/>
<Input
label={t("agent.title")}
value={title}
onChange={setTitle}
placeholder={t("agent.title")}
hint="Job title shown under the name"
hint={t("agent.form.titleHint")}
/>
<Select
label={t("org.connector")}
@@ -152,9 +152,9 @@ export function CreateFormBody({
onChange={setThinkingEffort}
options={[
{ value: "", label: t("org.default") },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "low", label: t("agent.form.effortLow") },
{ value: "medium", label: t("agent.form.effortMedium") },
{ value: "high", label: t("agent.form.effortHigh") },
]}
/>
) : null}
@@ -224,16 +224,16 @@ export function CreateFormBody({
label={t("org.monthlyBudget")}
value={budget}
onChange={setBudget}
placeholder="120"
placeholder={t("agent.form.budgetPlaceholder")}
type="number"
hint="Spending cap per month; agent pauses when exceeded"
hint={t("agent.form.budgetHint")}
/>
<TextArea
label={t("agent.capabilities")}
value={capabilities}
onChange={setCapabilities}
placeholder="What this agent does"
hint="Skills and domain knowledge injected into the agent's system prompt"
placeholder={t("agent.form.capabilitiesPlaceholder")}
hint={t("agent.form.capabilitiesHint")}
/>
</motion.div>
) : null}
@@ -128,7 +128,7 @@ export function EditFormBody({
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
});
if (!payload) {
throw new Error("Agent budget policy is invalid.");
throw new Error(t("agent.form.budgetPolicyInvalid"));
}
unwrap(await window.agentCompany.saveBudgetPolicy(payload));
}
@@ -147,7 +147,7 @@ export function EditFormBody({
);
await onSaved();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Failed to wake agent.");
setSaveError(error instanceof Error ? error.message : t("agent.form.wakeFailed"));
}
}
@@ -157,7 +157,7 @@ export function EditFormBody({
await persistAgentDraft();
await onSaved();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
setSaveError(error instanceof Error ? error.message : t("agent.form.saveFailed"));
}
}
@@ -206,12 +206,12 @@ export function EditFormBody({
pendingApproval
? [{ value: "pending_approval", label: t("agent.pendingApproval") }]
: [
{ value: "idle", label: "Idle" },
{ value: "active", label: "Active" },
{ value: "running", label: "Running" },
{ value: "paused", label: "Paused" },
{ value: "error", label: "Error" },
{ value: "terminated", label: "Terminated" },
{ value: "idle", label: t("agent.form.statusIdle") },
{ value: "active", label: t("agent.form.statusActive") },
{ value: "running", label: t("agent.form.statusRunning") },
{ value: "paused", label: t("agent.form.statusPaused") },
{ value: "error", label: t("agent.form.statusError") },
{ value: "terminated", label: t("agent.form.statusTerminated") },
]
}
/>
@@ -244,10 +244,10 @@ export function EditFormBody({
onChange={setThinkingEffort}
disabled={pendingApproval}
options={[
{ value: "", label: "Default" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "", label: t("agent.form.effortDefault") },
{ value: "low", label: t("agent.form.effortLow") },
{ value: "medium", label: t("agent.form.effortMedium") },
{ value: "high", label: t("agent.form.effortHigh") },
]}
/>
) : null}
@@ -275,7 +275,7 @@ export function EditFormBody({
label={t("agent.budget")}
value={budget}
onChange={setBudget}
placeholder="120"
placeholder={t("agent.form.budgetPlaceholder")}
type="number"
readOnly={pendingApproval}
disabled={pendingApproval}
@@ -331,7 +331,7 @@ export function EditFormBody({
/>
) : (
<ActionButton
label="Delete"
label={t("agent.form.delete")}
tone="danger"
icon={Trash}
disabled={pendingApproval}
@@ -341,8 +341,7 @@ export function EditFormBody({
</div>
{pendingApproval ? (
<p className="text-[12px] text-muted-foreground">
Pending hires are read-only here. Use Hiring or Approvals to approve or reject
the request before editing the agent profile.
{t("agent.form.pendingReadOnly")}
</p>
) : null}
{saveError ? <p className="text-[12px] text-destructive">{saveError}</p> : null}
-2
View File
@@ -1,2 +0,0 @@
// Backward-compatible re-exports from decomposed run components
export { RunCenter } from "./runs/RunCenter";
+3 -3
View File
@@ -7,7 +7,7 @@ import {
} from "../ui/icons";
import type { AgentRecord, RunRecord, TaskRecord, WorkspaceRecord } from "@shared/types";
import { formatMoney, formatTime } from "../../lib/formatters";
import { ActionButton, Panel, StatusPill } from "../ui";
import { ActionButton, Card, Panel, StatusPill } from "../ui";
import type { TranslationFn } from "../../i18n";
import { isActiveStatus } from "./run-helpers";
import type { RunLogState } from "./run-helpers";
@@ -125,11 +125,11 @@ export function RunDetail({
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>{logState.loading ? "Refreshing\u2026" : logState.eof ? "At latest tail" : "Tail window loaded"}</span>
</div>
<div className="max-h-[420px] overflow-auto rounded-lg border border-border bg-card px-4 py-3">
<Card padding="sm" className="max-h-[420px] overflow-auto">
<pre className="font-mono whitespace-pre-wrap text-[12px] leading-relaxed text-muted-foreground">
{logState.content || "No log output captured yet."}
</pre>
</div>
</Card>
</Panel>
</div>
);
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { DesktopEvent } from "@shared/types";
import { ActionButton, Modal } from "../../ui";
import { useT } from "../../../i18n";
const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as
| (Window["agentCompany"] | null);
@@ -28,6 +29,7 @@ type PendingPrompt =
* and replies via `submitOAuthPrompt` / `submitOAuthManualCode`.
*/
export function OAuthPromptDialog() {
const t = useT();
const [pending, setPending] = useState<PendingPrompt | null>(null);
const [value, setValue] = useState("");
@@ -88,7 +90,7 @@ export function OAuthPromptDialog() {
};
const isManualCode = pending?.kind === "manual-code";
const title = isManualCode ? "Finish signing in" : "Provider needs more info";
const title = isManualCode ? t("oauth.finishSignIn") : t("oauth.providerNeedsInfo");
const submitDisabled = !pending
? true
: value.trim().length === 0 && !(pending.kind === "prompt" && pending.allowEmpty);
@@ -107,23 +109,23 @@ export function OAuthPromptDialog() {
<li>
{pending.authUrl ? (
<>
Open{" "}
{t("oauth.openAuthPage")}{" "}
<a
href={pending.authUrl}
target="_blank"
rel="noreferrer"
className="text-foreground underline decoration-dotted underline-offset-4 hover:text-accent-foreground"
>
the authorization page
{t("oauth.theAuthPage")}
</a>{" "}
and click <strong className="text-foreground">Authorize</strong>.
{t("oauth.authAction")} <strong className="text-foreground">{t("oauth.authorizeButton")}</strong>.
</>
) : (
<>Complete authorization in the browser window that opened.</>
<>{t("oauth.fallbackInstruction")}</>
)}
</li>
<li>Copy the authorization code shown on the confirmation page.</li>
<li>Paste it below and press <strong className="text-foreground">Submit</strong>.</li>
<li>{t("oauth.copyCode")}</li>
<li>{t("oauth.pasteAndSubmit")} <strong className="text-foreground">{t("oauth.submit")}</strong>.</li>
</ol>
</div>
) : (
@@ -137,8 +139,8 @@ export function OAuthPromptDialog() {
type={isManualCode ? "password" : "text"}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={isManualCode ? "Authorization code" : pending.placeholder}
aria-label={isManualCode ? "Authorization code" : (pending as { message: string }).message}
placeholder={isManualCode ? t("oauth.authCodePlaceholder") : pending.placeholder}
aria-label={isManualCode ? t("oauth.authCodeAria") : (pending as { message: string }).message}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSubmit();
}}
@@ -148,8 +150,8 @@ export function OAuthPromptDialog() {
/>
<div className="mt-5 flex items-center justify-end gap-2">
<ActionButton label="Cancel" onClick={() => void handleCancel()} />
<ActionButton label="Submit" tone="accent" onClick={() => void handleSubmit()} disabled={submitDisabled} />
<ActionButton label={t("oauth.cancel")} onClick={() => void handleCancel()} />
<ActionButton label={t("oauth.submit")} tone="accent" onClick={() => void handleSubmit()} disabled={submitDisabled} />
</div>
</>
) : null}
@@ -1,4 +1,6 @@
import { useState } from "react";
import { ActionButton, Input } from "../../ui";
import { useT } from "../../../i18n";
const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as
| (Window["agentCompany"] | null);
@@ -10,6 +12,7 @@ interface Props {
}
export function ApiKeyForm({ connectorId, defaultEnvVar, onSaved }: Props) {
const t = useT();
const [source, setSource] = useState<"literal" | "env">("literal");
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
@@ -52,21 +55,17 @@ export function ApiKeyForm({ connectorId, defaultEnvVar, onSaved }: Props) {
}}
className="space-y-3"
>
<input
<Input
type={isEnv ? "text" : "password"}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={isEnv ? (defaultEnvVar ?? "PROVIDER_API_KEY") : "Paste your API key"}
autoComplete="off"
spellCheck={false}
onChange={setValue}
placeholder={isEnv ? (defaultEnvVar ?? t("form.envVarPlaceholder")) : t("form.apiKeyPlaceholder")}
disabled={busy}
autoFocus
aria-label={isEnv ? "Environment variable name" : "API key"}
className="block w-full rounded-md border border-border bg-background px-3 py-2.5 font-mono text-[12px] tracking-tight text-foreground outline-none transition-colors placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground/50 focus:border-foreground/50 disabled:opacity-50"
ariaLabel={isEnv ? t("form.envVarAriaLabel") : t("form.apiKeyAriaLabel")}
/>
{error ? (
<div role="alert" className="text-[11px] text-red-400">
<div role="alert" className="text-[11px] text-destructive">
{error}
</div>
) : null}
@@ -78,15 +77,15 @@ export function ApiKeyForm({ connectorId, defaultEnvVar, onSaved }: Props) {
disabled={busy}
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
{isEnv ? "Paste key directly" : "Use environment variable instead"}
{isEnv ? t("form.pasteKey") : t("form.useEnvVar")}
</button>
<button
<ActionButton
type="submit"
label={busy ? t("form.saving") : t("form.save")}
tone="accent"
disabled={!canSave}
className="rounded px-3 py-1.5 text-[11px] font-medium bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30"
>
{busy ? "Saving…" : "Save"}
</button>
loading={busy}
/>
</div>
</form>
);
@@ -1,12 +1,6 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FIELD_SELECT,
FORM_BUTTON,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
import { ActionButton, Input, Select } from "../../ui";
import { useT } from "../../../i18n";
interface Props {
connectorId: string;
@@ -16,6 +10,7 @@ interface Props {
type AuthMode = "profile" | "iam" | "bearer";
export function AwsForm({ connectorId, onSaved }: Props) {
const t = useT();
const [authMode, setAuthMode] = useState<AuthMode>("profile");
const [region, setRegion] = useState("us-east-1");
const [awsProfile, setAwsProfile] = useState("");
@@ -34,18 +29,18 @@ export function AwsForm({ connectorId, onSaved }: Props) {
const desktop = window.agentCompany;
if (!desktop) return;
if (authMode === "profile" && awsProfile.trim().length === 0) {
setError("AWS profile name required.");
setError(t("form.aws.errProfileRequired"));
return;
}
if (
authMode === "iam" &&
(accessKeyId.trim().length === 0 || secretAccessKey.trim().length === 0)
) {
setError("Access key id and secret access key required for IAM auth.");
setError(t("form.aws.errIamRequired"));
return;
}
if (authMode === "bearer" && bearerToken.trim().length === 0) {
setError("Bearer token required.");
setError(t("form.aws.errBearerRequired"));
return;
}
setBusy(true);
@@ -84,97 +79,35 @@ export function AwsForm({ connectorId, onSaved }: Props) {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
className="space-y-3"
>
<div>
<label className={FIELD_LABEL}>Auth mode</label>
<select
value={authMode}
onChange={(event) => setAuthMode(event.target.value as AuthMode)}
disabled={busy}
className={FIELD_SELECT}
>
<option value="profile">AWS profile (~/.aws/credentials)</option>
<option value="iam">IAM access key + secret</option>
<option value="bearer">Bedrock bearer token</option>
</select>
</div>
<div>
<label className={FIELD_LABEL}>AWS region</label>
<input
type="text"
value={region}
onChange={(event) => setRegion(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="us-east-1"
/>
</div>
<Select
label={t("form.aws.authMode")}
value={authMode}
onChange={(v) => setAuthMode(v as AuthMode)}
disabled={busy}
options={[
{ value: "profile", label: t("form.aws.authProfile") },
{ value: "iam", label: t("form.aws.authIam") },
{ value: "bearer", label: t("form.aws.authBearer") },
]}
/>
<Input label={t("form.aws.region")} value={region} onChange={setRegion} disabled={busy} placeholder="us-east-1" />
{authMode === "profile" ? (
<div>
<label className={FIELD_LABEL}>Profile name</label>
<input
type="text"
value={awsProfile}
onChange={(event) => setAwsProfile(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="default"
/>
</div>
<Input label={t("form.aws.profileName")} value={awsProfile} onChange={setAwsProfile} disabled={busy} placeholder="default" />
) : null}
{authMode === "iam" ? (
<>
<div>
<label className={FIELD_LABEL}>Access key id</label>
<input
type="text"
value={accessKeyId}
onChange={(event) => setAccessKeyId(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Secret access key</label>
<input
type="password"
value={secretAccessKey}
onChange={(event) => setSecretAccessKey(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Session token (optional)</label>
<input
type="password"
value={sessionToken}
onChange={(event) => setSessionToken(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<Input label={t("form.aws.accessKeyId")} value={accessKeyId} onChange={setAccessKeyId} disabled={busy} />
<Input label={t("form.aws.secretAccessKey")} type="password" value={secretAccessKey} onChange={setSecretAccessKey} disabled={busy} />
<Input label={t("form.aws.sessionToken")} type="password" value={sessionToken} onChange={setSessionToken} disabled={busy} />
</>
) : null}
{authMode === "bearer" ? (
<div>
<label className={FIELD_LABEL}>Bearer token</label>
<input
type="password"
value={bearerToken}
onChange={(event) => setBearerToken(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<Input label={t("form.aws.bearerToken")} type="password" value={bearerToken} onChange={setBearerToken} disabled={busy} />
) : null}
<details className="rounded-md border border-border/50 px-3 py-2 text-[12px]">
<summary className="cursor-pointer select-none text-muted-foreground">Advanced</summary>
<summary className="cursor-pointer select-none text-muted-foreground">{t("form.aws.advanced")}</summary>
<div className="mt-3 space-y-2">
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
@@ -183,19 +116,16 @@ export function AwsForm({ connectorId, onSaved }: Props) {
onChange={(event) => setForceCache(event.target.checked)}
disabled={busy}
/>
Force prompt cache (AWS_BEDROCK_FORCE_CACHE)
{t("form.aws.forceCache")}
</label>
<div>
<label className={FIELD_LABEL}>Endpoint URL (optional)</label>
<input
type="url"
value={endpointUrl}
onChange={(event) => setEndpointUrl(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="https://bedrock-runtime.your-region.amazonaws.com"
/>
</div>
<Input
label={t("form.aws.endpointUrl")}
type="url"
value={endpointUrl}
onChange={setEndpointUrl}
disabled={busy}
placeholder="https://bedrock-runtime.your-region.amazonaws.com"
/>
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
type="checkbox"
@@ -203,7 +133,7 @@ export function AwsForm({ connectorId, onSaved }: Props) {
onChange={(event) => setSkipAuth(event.target.checked)}
disabled={busy}
/>
Skip auth (AWS_BEDROCK_SKIP_AUTH)
{t("form.aws.skipAuth")}
</label>
<label className="flex items-center gap-2 text-[12px] text-foreground">
<input
@@ -212,14 +142,22 @@ export function AwsForm({ connectorId, onSaved }: Props) {
onChange={(event) => setForceHttp1(event.target.checked)}
disabled={busy}
/>
Force HTTP/1.1 (AWS_BEDROCK_FORCE_HTTP1)
{t("form.aws.forceHttp1")}
</label>
</div>
</details>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
{error ? (
<div role="alert" className="rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
<ActionButton
type="submit"
label={busy ? t("form.saving") : t("form.save")}
tone="accent"
disabled={busy}
loading={busy}
/>
</form>
);
}
@@ -1,12 +1,6 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FORM_BUTTON,
FORM_BUTTON_SECONDARY,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
import { ActionButton, Input } from "../../ui";
import { useT } from "../../../i18n";
interface Props {
connectorId: string;
@@ -19,6 +13,7 @@ interface DeploymentEntry {
}
export function AzureForm({ connectorId, onSaved }: Props) {
const t = useT();
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [resourceName, setResourceName] = useState("");
@@ -33,7 +28,7 @@ export function AzureForm({ connectorId, onSaved }: Props) {
const desktop = window.agentCompany;
if (!desktop) return;
if (apiKey.trim().length === 0) {
setError("API key required.");
setError(t("form.azure.errApiKeyRequired"));
return;
}
setBusy(true);
@@ -70,112 +65,71 @@ export function AzureForm({ connectorId, onSaved }: Props) {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
className="space-y-3"
>
<div>
<label className={FIELD_LABEL}>API key</label>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
autoComplete="off"
/>
</div>
<div>
<label className={FIELD_LABEL}>Base URL (optional)</label>
<input
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="https://YOUR-RESOURCE.openai.azure.com/"
/>
</div>
<div>
<label className={FIELD_LABEL}>Resource name (optional)</label>
<input
type="text"
value={resourceName}
onChange={(event) => setResourceName(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
/>
</div>
<div>
<label className={FIELD_LABEL}>API version (optional)</label>
<input
type="text"
value={apiVersion}
onChange={(event) => setApiVersion(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="2024-08-01-preview"
/>
</div>
<Input label={t("form.azure.apiKey")} type="password" value={apiKey} onChange={setApiKey} disabled={busy} />
<Input label={t("form.azure.baseUrl")} type="url" value={baseUrl} onChange={setBaseUrl} disabled={busy} placeholder="https://YOUR-RESOURCE.openai.azure.com/" />
<Input label={t("form.azure.resourceName")} value={resourceName} onChange={setResourceName} disabled={busy} />
<Input label={t("form.azure.apiVersion")} value={apiVersion} onChange={setApiVersion} disabled={busy} placeholder="2024-08-01-preview" />
<fieldset className="rounded-md border border-border/50 px-3 py-2">
<legend className="px-1 text-[11px] font-medium text-muted-foreground">
Deployment map (model id → deployment name)
{t("form.azure.deploymentMap")}
</legend>
<div className="space-y-2">
{deployments.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={entry.modelId}
placeholder="gpt-4o"
<div className="flex-1">
<Input
value={entry.modelId}
placeholder="gpt-4o"
disabled={busy}
onChange={(newVal) =>
setDeployments((prev) =>
prev.map((p, i) => (i === index ? { ...p, modelId: newVal } : p)),
)
}
/>
</div>
<div className="flex-1">
<Input
value={entry.deploymentName}
placeholder="my-gpt4-deployment"
disabled={busy}
onChange={(newVal) =>
setDeployments((prev) =>
prev.map((p, i) =>
i === index ? { ...p, deploymentName: newVal } : p,
),
)
}
/>
</div>
<ActionButton
label={t("form.azure.remove")}
disabled={busy}
className={FIELD_INPUT}
onChange={(event) =>
setDeployments((prev) =>
prev.map((p, i) => (i === index ? { ...p, modelId: event.target.value } : p)),
)
}
onClick={() => setDeployments((prev) => prev.filter((_, i) => i !== index))}
/>
<input
type="text"
value={entry.deploymentName}
placeholder="my-gpt4-deployment"
disabled={busy}
className={FIELD_INPUT}
onChange={(event) =>
setDeployments((prev) =>
prev.map((p, i) =>
i === index ? { ...p, deploymentName: event.target.value } : p,
),
)
}
/>
<button
type="button"
disabled={busy}
onClick={() =>
setDeployments((prev) => prev.filter((_, i) => i !== index))
}
className={FORM_BUTTON_SECONDARY}
>
Remove
</button>
</div>
))}
<button
type="button"
<ActionButton
label={t("form.azure.addMapping")}
disabled={busy}
onClick={() =>
setDeployments((prev) => [...prev, { modelId: "", deploymentName: "" }])
}
className={FORM_BUTTON_SECONDARY}
>
Add mapping
</button>
onClick={() => setDeployments((prev) => [...prev, { modelId: "", deploymentName: "" }])}
/>
</div>
</fieldset>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
{error ? (
<div role="alert" className="rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
<ActionButton
type="submit"
label={busy ? t("form.saving") : t("form.save")}
tone="accent"
disabled={busy}
loading={busy}
/>
</form>
);
}
@@ -1,12 +1,6 @@
import { useState } from "react";
import {
FIELD_INPUT,
FIELD_LABEL,
FORM_BUTTON,
FORM_BUTTON_SECONDARY,
FORM_ERROR,
FORM_FIELDSET,
} from "./form-shared";
import { ActionButton, Input } from "../../ui";
import { useT } from "../../../i18n";
interface Props {
connectorId: string;
@@ -14,6 +8,7 @@ interface Props {
}
export function VertexForm({ connectorId, onSaved }: Props) {
const t = useT();
const [project, setProject] = useState("");
const [location, setLocation] = useState("");
const [credentialsPath, setCredentialsPath] = useState("");
@@ -24,7 +19,7 @@ export function VertexForm({ connectorId, onSaved }: Props) {
const desktop = window.agentCompany;
if (!desktop) return;
if (project.trim().length === 0 || location.trim().length === 0) {
setError("Project and location are required.");
setError(t("form.vertex.errProjectLocationRequired"));
return;
}
setBusy(true);
@@ -61,55 +56,36 @@ export function VertexForm({ connectorId, onSaved }: Props) {
event.preventDefault();
void handleSubmit();
}}
className={FORM_FIELDSET}
className="space-y-3"
>
<Input label={t("form.vertex.project")} value={project} onChange={setProject} disabled={busy} placeholder="my-gcp-project" />
<Input label={t("form.vertex.location")} value={location} onChange={setLocation} disabled={busy} placeholder="us-central1" />
<div>
<label className={FIELD_LABEL}>GCP project id</label>
<input
type="text"
value={project}
onChange={(event) => setProject(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="my-gcp-project"
/>
</div>
<div>
<label className={FIELD_LABEL}>Region / location</label>
<input
type="text"
value={location}
onChange={(event) => setLocation(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="us-central1"
/>
</div>
<div>
<label className={FIELD_LABEL}>Credentials JSON path (optional — leave blank for ADC)</label>
<div className="mt-1 flex gap-2">
<input
type="text"
value={credentialsPath}
onChange={(event) => setCredentialsPath(event.target.value)}
disabled={busy}
className={FIELD_INPUT}
placeholder="/path/to/service-account.json"
/>
<button
type="button"
onClick={() => void pickCredentialsFile()}
disabled={busy}
className={FORM_BUTTON_SECONDARY}
>
Browse
</button>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{t("form.vertex.credentialsPath")}</div>
<div className="flex gap-2">
<div className="flex-1">
<Input
value={credentialsPath}
onChange={setCredentialsPath}
disabled={busy}
placeholder="/path/to/service-account.json"
/>
</div>
<ActionButton label={t("form.vertex.browse")} disabled={busy} onClick={() => void pickCredentialsFile()} />
</div>
</div>
{error ? <div className={FORM_ERROR}>{error}</div> : null}
<button type="submit" disabled={busy} className={FORM_BUTTON}>
{busy ? "Saving…" : "Save"}
</button>
{error ? (
<div role="alert" className="rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
<ActionButton
type="submit"
label={busy ? t("form.saving") : t("form.save")}
tone="accent"
disabled={busy}
loading={busy}
/>
</form>
);
}
@@ -1,18 +0,0 @@
export const FIELD_LABEL =
"block text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground";
export const FIELD_INPUT =
"mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-foreground/40";
export const FIELD_SELECT = FIELD_INPUT + " appearance-none";
export const FORM_BUTTON =
"rounded px-3 py-1.5 text-[11px] font-medium bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30";
export const FORM_BUTTON_SECONDARY =
"rounded px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40";
export const FORM_FIELDSET = "space-y-3";
export const FORM_ERROR =
"rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive";
@@ -5,48 +5,65 @@ import { ApiKeyForm } from "../forms/ApiKeyForm";
import { AzureForm } from "../forms/AzureForm";
import { AwsForm } from "../forms/AwsForm";
import { VertexForm } from "../forms/VertexForm";
import { ActionButton } from "../../ui";
import { useT, type TranslationFn } from "../../../i18n";
interface Props {
entry: ConnectorCatalogEntry;
credential: CredentialSummary | null;
}
const KIND_LABEL: Record<ConnectorCatalogEntry["auth"]["type"], string> = {
oauth: "OAuth",
api_key: "API key",
azure: "Azure",
aws: "AWS",
gcp_adc: "Vertex (ADC)",
};
function kindLabel(
auth: ConnectorCatalogEntry["auth"]["type"],
t: TranslationFn,
): string {
switch (auth) {
case "oauth":
return t("providers.kindOAuth");
case "api_key":
return t("providers.kindApiKey");
case "azure":
return t("providers.kindAzure");
case "aws":
return t("providers.kindAws");
case "gcp_adc":
return t("providers.kindGcp");
}
}
const STATUS_STYLE: Record<string, { dot: string; text: string }> = {
active: { dot: "bg-emerald-400", text: "text-emerald-400" },
expired: { dot: "bg-amber-400", text: "text-amber-400" },
error: { dot: "bg-red-400", text: "text-red-400" },
active: { dot: "bg-success", text: "text-success" },
expired: { dot: "bg-warning", text: "text-warning" },
error: { dot: "bg-destructive", text: "text-destructive" },
not_configured: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" },
revoked: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" },
};
const STATUS_LABEL: Record<string, string> = {
active: "Connected",
expired: "Expired",
error: "Error",
not_configured: "Not configured",
revoked: "Revoked",
};
const ACTION_BASE =
"rounded px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40";
const ACTION_PRIMARY = ACTION_BASE + " bg-foreground text-background hover:opacity-90";
const ACTION_SECONDARY = ACTION_BASE + " text-muted-foreground hover:text-foreground";
function statusLabel(status: string, t: TranslationFn): string {
switch (status) {
case "active":
return t("providers.statusConnected");
case "expired":
return t("providers.statusExpired");
case "error":
return t("providers.statusError");
case "not_configured":
return t("providers.statusNotConfigured");
case "revoked":
return t("providers.statusRevoked");
default:
return status;
}
}
export function ProviderRow({ entry, credential }: Props) {
const t = useT();
const [busy, setBusy] = useState<"login" | "remove" | "test" | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const status = credential?.status ?? "not_configured";
const statusStyle = STATUS_STYLE[status] ?? STATUS_STYLE.not_configured;
const statusLabel = STATUS_LABEL[status] ?? status;
const statusLabelText = statusLabel(status, t);
const hasCredential = credential !== null;
const handleOAuthLogin = async () => {
@@ -80,7 +97,9 @@ export function ProviderRow({ entry, credential }: Props) {
return;
}
setFeedback(
result.data.ok ? `OK — ${result.data.message}` : `Failed — ${result.data.message}`,
result.data.ok
? t("providers.testOk").replace("{message}", result.data.message)
: t("providers.testFailed").replace("{message}", result.data.message),
);
};
@@ -88,7 +107,7 @@ export function ProviderRow({ entry, credential }: Props) {
if (!editing || entry.auth.type === "oauth") return null;
const onSaved = () => {
setEditing(false);
setFeedback("Saved.");
setFeedback(t("providers.saved"));
};
const apiKeyEnv = entry.auth.type === "api_key" ? entry.auth.envKey : undefined;
if (entry.auth.type === "api_key")
@@ -109,7 +128,7 @@ export function ProviderRow({ entry, credential }: Props) {
<li className="px-4 py-3 transition-colors hover:bg-muted/30">
<div className="flex items-center gap-4">
<span
aria-label={`status: ${statusLabel}`}
aria-label={t("providers.statusAria").replace("{label}", statusLabelText)}
className={`block h-2 w-2 flex-shrink-0 rounded-full ${statusStyle.dot}`}
/>
<div className="min-w-0 flex-1">
@@ -118,52 +137,54 @@ export function ProviderRow({ entry, credential }: Props) {
{entry.label}
</div>
<div className="text-[10px] uppercase tracking-[0.1em] text-muted-foreground/70">
{KIND_LABEL[entry.auth.type]}
{kindLabel(entry.auth.type, t)}
</div>
</div>
<div className={`truncate text-[11px] ${statusStyle.text}`}>
{hasCredential ? (credential.displayHint ?? statusLabel) : statusLabel}
{hasCredential ? (credential.displayHint ?? statusLabelText) : statusLabelText}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{isOAuth ? (
<button
type="button"
<ActionButton
label={
busy === "login"
? t("providers.opening")
: hasCredential
? t("providers.reLogin")
: t("providers.login")
}
disabled={disabled}
onClick={() => void handleOAuthLogin()}
className={hasCredential ? ACTION_SECONDARY : ACTION_PRIMARY}
>
{busy === "login" ? "Opening…" : hasCredential ? "Re-login" : "Login"}
</button>
tone={hasCredential ? "default" : "accent"}
/>
) : (
<button
type="button"
<ActionButton
label={
editing
? t("providers.close")
: hasCredential
? t("providers.edit")
: t("providers.configure")
}
disabled={disabled}
onClick={() => setEditing((current) => !current)}
className={hasCredential || editing ? ACTION_SECONDARY : ACTION_PRIMARY}
>
{editing ? "Close" : hasCredential ? "Edit" : "Configure"}
</button>
tone={hasCredential || editing ? "default" : "accent"}
/>
)}
{hasCredential ? (
<button
type="button"
<ActionButton
label={busy === "test" ? t("providers.testing") : t("providers.test")}
disabled={disabled}
onClick={() => void handleTest()}
className={ACTION_SECONDARY}
>
{busy === "test" ? "Testing…" : "Test"}
</button>
/>
) : null}
{hasCredential ? (
<button
type="button"
<ActionButton
label={busy === "remove" ? t("providers.removing") : t("providers.remove")}
disabled={disabled}
onClick={() => void handleRemove()}
className={ACTION_SECONDARY}
>
{busy === "remove" ? "Removing…" : "Remove"}
</button>
/>
) : null}
</div>
</div>
@@ -7,26 +7,28 @@ import {
type ConnectorCatalogEntry,
} from "@shared/connector-catalog";
import { ProviderRow } from "./ProviderRow";
const FILTERS: Array<{ id: "all" | "configured" | "available"; label: string }> = [
{ id: "all", label: "All" },
{ id: "configured", label: "Connected" },
{ id: "available", label: "Available" },
];
const GROUP_ORDER: Array<{ id: ConnectorAuthKind; label: string }> = [
{ id: "oauth", label: "Sign in" },
{ id: "api_key", label: "API key" },
{ id: "azure", label: "Azure" },
{ id: "aws", label: "AWS" },
{ id: "gcp_adc", label: "Google Cloud" },
];
import { useT } from "../../../i18n";
interface ProvidersPanelProps {
catalog?: ConnectorCatalogEntry[];
}
export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
const t = useT();
const FILTERS: Array<{ id: "all" | "configured" | "available"; label: string }> = [
{ id: "all", label: t("providers.filterAll") },
{ id: "configured", label: t("providers.filterConfigured") },
{ id: "available", label: t("providers.filterAvailable") },
];
const GROUP_ORDER: Array<{ id: ConnectorAuthKind; label: string }> = [
{ id: "oauth", label: t("providers.groupOAuth") },
{ id: "api_key", label: t("providers.groupApiKey") },
{ id: "azure", label: t("providers.groupAzure") },
{ id: "aws", label: t("providers.groupAws") },
{ id: "gcp_adc", label: t("providers.groupGcp") },
];
const entries = catalog ?? CONNECTOR_CATALOG;
const [credentials, setCredentials] = useState<Map<string, CredentialSummary>>(new Map());
const [filter, setFilter] = useState<"all" | "configured" | "available">("all");
@@ -78,7 +80,8 @@ export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
...group,
items: byKind.get(group.id) ?? [],
})).filter((group) => group.items.length > 0);
}, [filtered]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtered, t]);
const configuredCount = useMemo(
() => entries.filter((entry) => credentials.has(entry.id)).length,
@@ -86,7 +89,7 @@ export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
);
return (
<section aria-label="Providers" className="space-y-4">
<section aria-label={t("providers.ariaLabel")} className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<MagnifyingGlass
@@ -97,21 +100,21 @@ export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
/>
<input
type="search"
placeholder="Search providers"
placeholder={t("providers.search")}
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label="Search providers"
aria-label={t("providers.search")}
className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-foreground/40"
/>
</div>
<div className="flex items-center gap-3">
<div className="hidden text-[11px] font-medium text-muted-foreground sm:block">
{configuredCount} / {entries.length} connected
{t("providers.connectedCount").replace("{n}", String(configuredCount)).replace("{total}", String(entries.length))}
</div>
<div
role="tablist"
aria-label="Filter providers"
aria-label={t("providers.filterAriaLabel")}
className="inline-flex rounded-md border border-border bg-background p-0.5"
>
{FILTERS.map((item) => {
@@ -140,7 +143,7 @@ export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) {
{grouped.length === 0 ? (
<div className="rounded-md border border-dashed border-border py-10 text-center text-[12px] text-muted-foreground">
No providers match the current filter.
{t("providers.noMatch")}
</div>
) : (
<div className="space-y-6">
+56 -40
View File
@@ -10,9 +10,10 @@ import {
} from "../ui/icons";
import type { AgentRecord, SkillRecord, SkillUpdateStatus } from "@shared/types";
import { useAppStore } from "../../state/store";
import { ActionButton, StatusPill } from "../ui";
import { ActionButton, StatusPill, Eyebrow } from "../ui";
import { SkillAgentToggles } from "./SkillAgentToggles";
import { buildTree, SkillFileTree } from "./SkillFileTree";
import { useT, type TranslationFn } from "../../i18n";
const sourceIconMap: Record<string, typeof GithubLogo> = {
github: GithubLogo,
@@ -22,13 +23,22 @@ const sourceIconMap: Record<string, typeof GithubLogo> = {
managed: PuzzlePiece,
};
const sourceLabel: Record<string, string> = {
github: "GitHub",
local_path: "Local Path",
url: "URL",
skills_sh: "skills.sh",
managed: "Managed",
};
function sourceLabel(kind: string, t: TranslationFn): string {
switch (kind) {
case "github":
return t("skill.source.github");
case "local_path":
return t("skill.source.localPath");
case "url":
return t("skill.source.url");
case "skills_sh":
return t("skill.source.skillsSh");
case "managed":
return t("skill.source.managed");
default:
return kind;
}
}
const trustToneMap: Record<string, string> = {
markdown_only: "ready",
@@ -36,11 +46,18 @@ const trustToneMap: Record<string, string> = {
scripts_executables: "error",
};
const trustLabelMap: Record<string, string> = {
markdown_only: "Markdown only",
assets: "Has assets",
scripts_executables: "Contains scripts",
};
function trustLabel(level: string, t: TranslationFn): string {
switch (level) {
case "markdown_only":
return t("skill.trust.markdownOnly");
case "assets":
return t("skill.trust.assets");
case "scripts_executables":
return t("skill.trust.scripts");
default:
return level;
}
}
function stripFrontmatter(markdown: string): string {
const normalized = markdown.replace(/\r\n/g, "\n");
@@ -50,8 +67,8 @@ function stripFrontmatter(markdown: string): string {
return normalized.slice(closing + 5).trim();
}
function shortRef(ref: string | null | undefined): string {
if (!ref) return "untracked";
function shortRef(ref: string | null | undefined, t: TranslationFn): string {
if (!ref) return t("skill.untracked");
return ref.length > 7 ? ref.slice(0, 7) : ref;
}
@@ -68,6 +85,7 @@ export function SkillDetail({
onDeleted: () => void;
onRefresh: () => void;
}) {
const t = useT();
const addToast = useAppStore((s) => s.addToast);
const selectedSkillFilePath = useAppStore((s) => s.selectedSkillFilePath);
const setSelectedSkillFilePath = useAppStore((s) => s.setSelectedSkillFilePath);
@@ -116,12 +134,12 @@ export function SkillDetail({
const handleDelete = useCallback(async () => {
const result = await window.agentCompany.deleteSkill(companyId, skillId);
if (result.ok) {
addToast("Skill deleted", "success");
addToast(t("skill.deleted"), "success");
onDeleted();
} else {
addToast(result.error.message, "danger");
}
}, [companyId, skillId, addToast, onDeleted]);
}, [companyId, skillId, addToast, onDeleted, t]);
const handleToggleDir = useCallback((path: string) => {
setExpandedDirs((prev) => {
@@ -135,7 +153,7 @@ export function SkillDetail({
if (loading) {
return (
<div className="flex flex-1 items-center justify-center p-8 text-muted-foreground">
<div className="text-[13px]">Loading skill...</div>
<div className="text-[13px]">{t("skill.loading")}</div>
</div>
);
}
@@ -143,7 +161,7 @@ export function SkillDetail({
if (!detail) {
return (
<div className="flex flex-1 items-center justify-center p-8 text-muted-foreground">
<div className="text-[13px]">Skill not found</div>
<div className="text-[13px]">{t("skill.notFound")}</div>
</div>
);
}
@@ -186,31 +204,31 @@ export function SkillDetail({
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px]">
{/* Source */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Source</span>
<Eyebrow>{t("skill.source")}</Eyebrow>
<span className="flex items-center gap-1.5">
<SourceIcon size={14} className="text-muted-foreground" />
<span className="text-foreground">{sourceLabel[detail.sourceType] ?? detail.sourceType}</span>
<span className="text-foreground">{sourceLabel(detail.sourceType, t)}</span>
</span>
</div>
{/* Trust level */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Trust</span>
<Eyebrow>{t("skill.trust")}</Eyebrow>
<StatusPill status={trustToneMap[detail.trustLevel] ?? "default"} />
<span className="text-foreground">{trustLabelMap[detail.trustLevel] ?? detail.trustLevel}</span>
<span className="text-foreground">{trustLabel(detail.trustLevel, t)}</span>
</div>
{/* Compatibility */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Compat</span>
<Eyebrow>{t("skill.compat")}</Eyebrow>
<span className="text-foreground">{detail.compatibility || "all"}</span>
</div>
{/* Ref (for GitHub sources) */}
{isGitHubSource ? (
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Ref</span>
<span className="font-mono text-[12px] text-foreground">{shortRef(detail.sourceRef)}</span>
<Eyebrow>{t("skill.ref")}</Eyebrow>
<span className="font-mono text-[12px] text-foreground">{shortRef(detail.sourceRef, t)}</span>
</div>
) : null}
</div>
@@ -219,17 +237,17 @@ export function SkillDetail({
{isGitHubSource ? (
<div className="flex flex-wrap items-center gap-2 text-[13px]">
<ActionButton
label="Check for updates"
label={t("skill.checkForUpdates")}
icon={ArrowsClockwise}
loading={checkingUpdate}
onClick={() => void handleCheckUpdate()}
/>
{updateStatus?.hasUpdate ? (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2.5 py-0.5 text-[12px] font-medium text-primary">
Update available: {shortRef(updateStatus.currentRef)} &rarr; {shortRef(updateStatus.latestRef)}
{t("skill.updateAvailable")}: {shortRef(updateStatus.currentRef, t)} &rarr; {shortRef(updateStatus.latestRef, t)}
</span>
) : updateStatus && !updateStatus.hasUpdate ? (
<span className="text-[12px] text-muted-foreground">Up to date</span>
<span className="text-[12px] text-muted-foreground">{t("skill.upToDate")}</span>
) : null}
</div>
) : null}
@@ -250,7 +268,7 @@ export function SkillDetail({
onSelectPath={setSelectedSkillFilePath}
/>
) : (
<p className="px-3 py-2 text-[12px] text-muted-foreground">No files</p>
<p className="px-3 py-2 text-[12px] text-muted-foreground">{t("skill.noFiles")}</p>
)}
</div>
@@ -276,11 +294,11 @@ export function SkillDetail({
) : selectedFileEntry ? (
<div className="rounded-lg bg-card p-4 text-[13px] text-muted-foreground">
<p className="font-medium text-foreground">{selectedFileEntry.path}</p>
<p className="mt-1">Kind: {selectedFileEntry.kind}</p>
<p className="mt-1 text-[12px] italic">File content preview is not available for non-SKILL.md files.</p>
<p className="mt-1">{t("skill.kind")}: {selectedFileEntry.kind}</p>
<p className="mt-1 text-[12px] italic">{t("skill.previewUnavailable")}</p>
</div>
) : (
<p className="text-[13px] text-muted-foreground">Select a file to view its details.</p>
<p className="text-[13px] text-muted-foreground">{t("skill.selectFile")}</p>
)}
</div>
</div>
@@ -288,9 +306,7 @@ export function SkillDetail({
{/* Agent assignments section */}
<div className="border-t border-border px-5 py-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">
Agent Assignments
</h3>
<Eyebrow tone="primary" className="mb-3">{t("skill.agentAssignments")}</Eyebrow>
<SkillAgentToggles mode="skill" companyId={companyId} skillId={skillId} agents={agents} />
</div>
@@ -299,21 +315,21 @@ export function SkillDetail({
{confirmingDelete ? (
<div className="flex items-center gap-3">
<p className="text-[13px] text-destructive">
Delete &ldquo;{detail.name}&rdquo;? This cannot be undone.
{t("skill.deleteConfirm").replace("{name}", detail.name)}
</p>
<ActionButton
label="Confirm Delete"
label={t("skill.confirmDelete")}
tone="danger"
onClick={() => void handleDelete()}
/>
<ActionButton
label="Cancel"
label={t("skill.cancel")}
onClick={() => setConfirmingDelete(false)}
/>
</div>
) : (
<ActionButton
label="Delete Skill"
label={t("skill.deleteSkill")}
tone="danger"
icon={Trash}
onClick={() => setConfirmingDelete(true)}
@@ -7,16 +7,10 @@ import {
} from "../ui/icons";
import { Modal, ActionButton, Input } from "../ui";
import { useAppStore } from "../../state/store";
import { useT } from "../../i18n";
type TabId = "github" | "local" | "url" | "skills_sh";
const tabs: { id: TabId; label: string; icon: typeof GithubLogo }[] = [
{ id: "github", label: "GitHub", icon: GithubLogo },
{ id: "local", label: "Local Path", icon: FolderSimple },
{ id: "url", label: "HTTP URL", icon: Link },
{ id: "skills_sh", label: "skills.sh", icon: Globe },
];
function isValidGithubSource(value: string): boolean {
return /^(https:\/\/github\.com\/[\w.-]+\/[\w.-]+|[\w.-]+\/[\w.-]+)/.test(value.trim());
}
@@ -41,6 +35,13 @@ export function SkillImportModal({
companyId: string;
onImported: () => void;
}) {
const t = useT();
const tabs: { id: TabId; label: string; icon: typeof GithubLogo }[] = [
{ id: "github", label: t("skillImport.tabGithub"), icon: GithubLogo },
{ id: "local", label: t("skillImport.tabLocal"), icon: FolderSimple },
{ id: "url", label: t("skillImport.tabUrl"), icon: Link },
{ id: "skills_sh", label: t("skillImport.tabSkillsSh"), icon: Globe },
];
const addToast = useAppStore((s) => s.addToast);
const [activeTab, setActiveTab] = useState<TabId>("github");
const [source, setSource] = useState("");
@@ -90,28 +91,28 @@ export function SkillImportModal({
const result = await window.agentCompany.importSkill(companyId, trimmed);
if (result.ok) {
const count = result.data.length;
addToast(`Imported ${count} skill${count !== 1 ? "s" : ""} successfully`);
addToast(t("skillImport.imported").replace("{n}", String(count)));
onImported();
handleClose();
} else {
addToast(result.error.message, "danger");
}
} catch (error) {
addToast(error instanceof Error ? error.message : "Import failed", "danger");
addToast(error instanceof Error ? error.message : t("skillImport.failed"), "danger");
} finally {
setIsImporting(false);
}
}, [source, companyId, addToast, onImported, handleClose]);
}, [source, companyId, addToast, onImported, handleClose, t]);
const placeholder: Record<TabId, string> = {
github: "https://github.com/owner/repo or owner/repo",
local: "Enter or browse local path",
url: "https://example.com/path/to/skill",
skills_sh: "skills.sh install command or URL",
github: t("skillImport.placeholderGithub"),
local: t("skillImport.placeholderLocal"),
url: t("skillImport.placeholderUrl"),
skills_sh: t("skillImport.placeholderSkillsSh"),
};
return (
<Modal open={open} title="Import Skill" onClose={handleClose} width="560px">
<Modal open={open} title={t("skillImport.title")} onClose={handleClose} width="560px">
<div className="space-y-5">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border">
@@ -146,7 +147,7 @@ export function SkillImportModal({
<div className="flex-1">
<Input value={source} onChange={setSource} placeholder={placeholder.local} />
</div>
<ActionButton label="Browse" onClick={() => void handleBrowse()} />
<ActionButton label={t("skillImport.browse")} onClick={() => void handleBrowse()} />
</div>
) : (
<Input value={source} onChange={setSource} placeholder={placeholder[activeTab]} />
@@ -154,16 +155,16 @@ export function SkillImportModal({
{activeTab === "skills_sh" && (
<p className="text-[12px] text-muted-foreground">
Enter a skills.sh install command (e.g., <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">npx skills.sh install skill-name</code>) or a direct skills.sh URL.
{t("skillImport.skillsShHelp")}
</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<ActionButton label="Cancel" onClick={handleClose} />
<ActionButton label={t("skillImport.cancel")} onClick={handleClose} />
<ActionButton
label="Import"
label={t("skillImport.import")}
tone="accent"
onClick={() => void handleImport()}
loading={isImporting}
-2
View File
@@ -1,2 +0,0 @@
// Backward-compatible re-exports from decomposed social components
export { SocialAccountsPanel } from "./social/SocialPanel";
@@ -1,6 +1,7 @@
import { ShieldCheck, SignIn, Trash } from "../ui/icons";
import type { SocialAccountRecord } from "@shared/types";
import { timeAgo } from "../../lib/formatters";
import { Card } from "../ui";
import { platformConfig } from "./social-config";
interface AccountCardProps {
@@ -39,7 +40,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
);
return (
<div className="group flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3.5 transition duration-150 hover:border-border hover:shadow-md hover:-translate-y-px">
<Card padding="none" className="group flex items-center gap-3 px-4 py-3.5 transition duration-150 hover:border-border hover:shadow-md hover:-translate-y-px">
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: platform.bgColor, color: platform.color }}
@@ -94,6 +95,6 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
<Trash size={14} />
</button>
</div>
</div>
</Card>
);
}
@@ -2,6 +2,7 @@ import { useState } from "react";
import { SignIn } from "../ui/icons";
import type { SocialPlatform } from "@shared/types";
import { ActionButton, Modal } from "../ui";
import { useT } from "../../i18n";
import { platformConfig } from "./social-config";
interface AddAccountDialogProps {
@@ -10,14 +11,15 @@ interface AddAccountDialogProps {
}
export function AddAccountDialog({ onAddAndLogin, onClose }: AddAccountDialogProps) {
const t = useT();
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
const [requireApproval, setRequireApproval] = useState(true);
return (
<Modal open onClose={onClose} title="Add Social Account" width="420px">
<Modal open onClose={onClose} title={t("social.addAccountTitle")} width="420px">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Platform</label>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("social.platformLabel")}</label>
<div className="max-h-[240px] overflow-y-auto rounded-lg border border-border p-1.5">
<div className="grid grid-cols-3 gap-1.5">
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
@@ -50,23 +52,23 @@ export function AddAccountDialog({ onAddAndLogin, onClose }: AddAccountDialogPro
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
/>
<div>
<div className="text-[12px] font-medium text-foreground">Require Board Approval for Posts</div>
<div className="text-[12px] font-medium text-foreground">{t("social.requireApprovalTitle")}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
Agents must get approval before publishing content to this account
{t("social.requireApprovalBody")}
</div>
</div>
</label>
</div>
<div className="rounded-lg bg-accent px-4 py-3 text-[11px] text-muted-foreground">
A browser window will open for you to sign in. Your username and profile info will be detected automatically after login.
{t("social.signInHint")}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<ActionButton label="Cancel" onClick={onClose} />
<ActionButton label={t("social.cancel")} onClick={onClose} />
<ActionButton
label="Login"
label={t("social.loginButton")}
onClick={() => onAddAndLogin(platform, requireApproval)}
icon={SignIn}
tone="accent"
+12 -2
View File
@@ -57,16 +57,26 @@ export function Card({
children,
className = "",
padded = true,
padding,
as: Tag = "div",
}: {
children: ReactNodeType;
className?: string;
padded?: boolean;
padding?: "none" | "sm" | "md" | "lg";
as?: "div" | "section" | "article";
}) {
const padding = padded ? "p-4" : "";
const resolvedPadding = padding ?? (padded ? "md" : "none");
const paddingClass =
resolvedPadding === "lg"
? "p-5"
: resolvedPadding === "sm"
? "px-4 py-3"
: resolvedPadding === "none"
? ""
: "p-4";
return (
<Tag className={`rounded-lg border border-border bg-card ${padding} ${className}`}>
<Tag className={`rounded-lg border border-border bg-card ${paddingClass} ${className}`}>
{children}
</Tag>
);