update
This commit is contained in:
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";
|
||||
@@ -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 +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";
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-11
@@ -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}
|
||||
|
||||
+16
-17
@@ -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}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Backward-compatible re-exports from decomposed run components
|
||||
export { RunCenter } from "./runs/RunCenter";
|
||||
@@ -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">
|
||||
|
||||
@@ -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)} → {shortRef(updateStatus.latestRef)}
|
||||
{t("skill.updateAvailable")}: {shortRef(updateStatus.currentRef, t)} → {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 “{detail.name}”? 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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user