This commit is contained in:
ZacharyZhang-NY committed 2026-04-05 20:14:48 -04:00
1 parent 09971cabc2
commit 9145572422
163 files changed
+3348 -2874

No files matched your search

@@ -1,2 +1,2 @@
// Backward-compatible re-exports from decomposed onboarding components
export { CompanyOnboarding } from "./CompanyOnboarding/OnboardingWizard";
export { CompanyOnboarding } from "./company-onboarding/OnboardingWizard";
-5
View File
@@ -1,6 +1 @@
// Backward-compatible re-exports from decomposed automation components
export { AutomationPanel } from "./automation/AutomationPanel"
export { RuleCard } from "./automation/RuleCard"
export { RuleFormModal } from "./automation/RuleEditor"
export { WorkflowCard, StepTimeline } from "./automation/WorkflowList"
export { WorkflowFormModal } from "./automation/WorkflowEditor"
@@ -1,9 +1 @@
// Backward-compatible re-exports from decomposed communication components
export { CommunicationHub } from "./communication/CommunicationHub"
export { ChannelSidebar, FilterBar } from "./communication/ChannelSidebar"
export { MessageList } from "./communication/MessageList"
export { MessageBubble, TaskCommentBubble, AgentAvatar } from "./communication/MessageItem"
export { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./communication/MessageComposer"
export { ChannelHeader } from "./communication/ChannelHeader"
export type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./communication/ChannelHeader"
export type { FilterState } from "./communication/ChannelSidebar"
@@ -114,7 +114,7 @@ function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
function MentionDropdown({ agents, query, onSelect, position }: {
agents: AgentRecord[];
query: string;
onSelect: (agent: AgentRecord) => void;
onSelect: (agent: AgentRecord | null) => void;
position: { top: number; left: number };
}) {
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -136,7 +136,7 @@ function MentionDropdown({ agents, query, onSelect, position }: {
else if (e.key === "Enter" || e.key === "Tab") {
if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) }
}
else if (e.key === "Escape") { onSelect(null as unknown as AgentRecord) }
else if (e.key === "Escape") { onSelect(null) }
}
document.addEventListener("keydown", handler, true)
return () => document.removeEventListener("keydown", handler, true)
@@ -51,7 +51,7 @@ export default function WaitStateIndicator({ waitState, onReply, onNavigateAppro
</div>
{isApproval && onNavigateApproval ? (
<button
onClick={() => onNavigateApproval(waitState.relatedApprovalId!)}
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"
>
<CheckCircle size={14} />
@@ -7,27 +7,28 @@ import {
Terminal,
} from "@phosphor-icons/react";
import { ActionButton } from "../ui";
import type { TerminalCommandKey } from "@shared/contracts";
interface NodeCheckStepProps {
nodeStatus: { installed: boolean; version: string | null } | null;
checking: boolean;
onCheck: () => void;
onInstall: (command: string) => void;
onInstall: (commandKey: TerminalCommandKey) => void;
onContinue: () => void;
onBack: () => void;
t: (key: string) => string;
}
const INSTALL_COMMANDS: Record<string, { label: string; code: string; command: string }> = {
const INSTALL_COMMANDS: Record<string, { label: string; code: string; commandKey: TerminalCommandKey }> = {
darwin: {
label: "macOS",
code: "brew install node@22",
command: "brew install node@22",
commandKey: "install_node_darwin",
},
win32: {
label: "Windows",
code: "winget install -e --id OpenJS.NodeJS.LTS",
command: "winget install -e --id OpenJS.NodeJS.LTS",
commandKey: "install_node_win32",
},
linux: {
label: "Linux",
@@ -42,7 +43,7 @@ nvm install 22
# Verify the Node.js version:
node -v`,
command: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
commandKey: "install_node_linux",
},
};
@@ -109,7 +110,7 @@ export function NodeCheckStep({
</pre>
<ActionButton
label={t("onboarding.installNodeTerminal")}
onClick={() => onInstall(platformInfo.command)}
onClick={() => onInstall(platformInfo.commandKey)}
icon={Terminal}
/>
</div>
@@ -1,6 +1,7 @@
import type { AppLocale } from "@shared/locale";
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
import type { ConnectorModelEntry } from "@shared/connector-models";
import type { TerminalCommandKey } from "@shared/contracts";
import { AnimatePresence } from "framer-motion";
import type { TranslationFn } from "../../i18n";
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
@@ -22,7 +23,7 @@ interface OnboardingStepPanelProps {
nodeStatus: { installed: boolean; version: string | null } | null;
nodeChecking: boolean;
onCheckNode: () => void;
onInstallCommand: (title: string, command: string) => void;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
connectors: ProfileSnapshot["connectors"];
hasAtLeastOneReady: boolean;
onTestConnector: (connectorId: string) => void;
@@ -148,7 +149,7 @@ export function OnboardingStepPanel({
nodeStatus={nodeStatus}
checking={nodeChecking}
onCheck={onCheckNode}
onInstall={(command) => onInstallCommand("Install Node.js", command)}
onInstall={(commandKey) => onInstallCommand(commandKey)}
onContinue={onContinueFromNodeCheck}
onBack={() => onBack("welcome")}
t={t}
@@ -189,7 +189,7 @@ export function CompanyOnboarding({
nodeStatus={nodeStatus}
nodeChecking={nodeChecking}
onCheckNode={() => void checkNode()}
onInstallCommand={(title, command) => void handleInstallCommand(title, command)}
onInstallCommand={(commandKey) => void handleInstallCommand(commandKey)}
connectors={connectors}
hasAtLeastOneReady={hasAtLeastOneReady}
onTestConnector={(id) => void handleTestConnector(id)}
@@ -2,6 +2,7 @@ import { useState } from "react";
import { motion } from "framer-motion";
import { ArrowRight, FloppyDisk, SignIn } from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import type { TerminalCommandKey } from "@shared/contracts";
import { ActionButton } from "../ui";
import {
CompatProviderCard,
@@ -20,7 +21,7 @@ interface ProvidersStepProps {
onTestConnector: (connectorId: string) => void;
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
onLoginConnector: (connectorId: string) => void;
onInstallCommand: (title: string, command: string) => void;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
hasAtLeastOneReady: boolean;
onContinue: () => void;
onBack: () => void;
@@ -125,8 +126,9 @@ export function ProvidersStep({
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
commandKeyUnix="install_claude_unix"
commandKeyWin32="install_claude_win32"
onInstall={onInstallCommand}
title="Install Claude Code"
t={t}
/>
)}
@@ -171,8 +173,9 @@ export function ProvidersStep({
<InstallBlock
macLinuxCmd="npm i -g @openai/codex"
windowsCmd={null}
commandKeyUnix="install_codex"
commandKeyWin32={null}
onInstall={onInstallCommand}
title="Install Codex"
t={t}
/>
)}
@@ -217,8 +220,9 @@ export function ProvidersStep({
<InstallBlock
macLinuxCmd="npm install -g @google/gemini-cli"
windowsCmd={null}
commandKeyUnix="install_gemini"
commandKeyWin32={null}
onInstall={onInstallCommand}
title="Install Gemini CLI"
t={t}
/>
)}
@@ -7,6 +7,7 @@ import {
Terminal,
} from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import type { TerminalCommandKey } from "@shared/contracts";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets */
@@ -15,8 +16,6 @@ import type { ProfileSnapshot } from "@shared/types";
export const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support#macos-%E5%92%8C-linux-1" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
@@ -135,19 +134,21 @@ export function ProviderCard({
export function InstallBlock({
macLinuxCmd,
windowsCmd,
commandKeyUnix,
commandKeyWin32,
onInstall,
title,
t,
}: {
macLinuxCmd: string;
windowsCmd: string | null;
onInstall: (title: string, command: string) => void;
title: string;
commandKeyUnix: TerminalCommandKey;
commandKeyWin32: TerminalCommandKey | null;
onInstall: (commandKey: TerminalCommandKey) => void;
t: (key: string) => string;
}) {
const platform = window.agentCompany?.platform ?? "linux";
const isWindows = platform === "win32";
const cmd = isWindows && windowsCmd ? windowsCmd : macLinuxCmd;
const commandKey = isWindows && commandKeyWin32 ? commandKeyWin32 : commandKeyUnix;
return (
<div className="space-y-2">
@@ -164,7 +165,7 @@ export function InstallBlock({
)}
<button
type="button"
onClick={() => onInstall(title, cmd)}
onClick={() => onInstall(commandKey)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<Terminal size={14} />
@@ -192,7 +193,7 @@ export function CompatProviderCard({
connector: ProfileSnapshot["connectors"][number] | null;
claudeInstalled: boolean;
onSaveConnector: ProvidersStepSaveConnector;
onInstallCommand: (title: string, command: string) => void;
onInstallCommand: (commandKey: TerminalCommandKey) => void;
expanded: boolean;
onToggle: () => void;
t: (key: string) => string;
@@ -266,8 +267,9 @@ export function CompatProviderCard({
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
commandKeyUnix="install_claude_unix"
commandKeyWin32="install_claude_win32"
onInstall={onInstallCommand}
title="Install Claude Code"
t={t}
/>
)}
@@ -1,6 +1,8 @@
import { useCallback } from "react";
import type { ConnectorId } from "@shared/types";
import type { ProfileSnapshot } from "@shared/types";
import type { openTerminalCommandSchema } from "@shared/contracts";
import type { z } from "zod";
import { unwrap } from "../../lib/desktop";
export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"]) {
@@ -48,9 +50,9 @@ export function useConnectorCallbacks(connectors: ProfileSnapshot["connectors"])
}, 5000);
}, []);
const handleInstallCommand = useCallback(async (title: string, command: string) => {
const handleInstallCommand = useCallback(async (commandKey: z.infer<typeof openTerminalCommandSchema>["commandKey"]) => {
try {
unwrap(await window.agentCompany.openTerminalCommand({ title, command }));
unwrap(await window.agentCompany.openTerminalCommand({ commandKey }));
} catch {
// Terminal open failure is non-fatal
return;
@@ -5,6 +5,7 @@ import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, M
import { useT } from "../../i18n";
import { documentTypeOptions, documentStatusOptions, splitTags, parseTags } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function DocumentEditor({ companyId, agents, projects, goals, document, onSaved }: {
companyId: string; agents: AgentRecord[]; projects: ProjectRecord[]; goals: GoalRecord[];
@@ -75,7 +76,7 @@ export function DocumentCenter({ companyId, documents, agents, projects, goals,
return (
<div>
<SectionHeader title={t("ops.documentCenter")} count={documents.length} action={() => setEditingId("__new__")} />
<SectionHeader title={t("ops.documentCenter")} count={documents.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
@@ -97,7 +98,7 @@ export function DocumentCenter({ companyId, documents, agents, projects, goals,
))}
</ListFrame>
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
<DocumentEditor companyId={companyId} agents={agents} projects={projects} goals={goals} document={editingId === "__new__" ? null : activeDocument} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
<DocumentEditor companyId={companyId} agents={agents} projects={projects} goals={goals} document={editingId === NEW_ENTITY_ID ? null : activeDocument} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete document" message="This removes the document from the company knowledge base." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteDocument({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
@@ -5,6 +5,7 @@ import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, M
import { useT } from "../../i18n";
import { knowledgeCategoryOptions, importanceOptions, splitTags, parseTags } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function KnowledgeEditor({ companyId, entry, onSaved }: {
companyId: string; entry: KnowledgeEntryRecord | null; onSaved: () => Promise<void>;
@@ -63,7 +64,7 @@ export function KnowledgeBaseManager({ companyId, entries, onRefresh }: {
return (
<div>
<SectionHeader title={t("ops.knowledgeBase")} count={entries.length} action={() => setEditingId("__new__")} />
<SectionHeader title={t("ops.knowledgeBase")} count={entries.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{entries.map((entry) => (
<ListRow key={entry.id}>
@@ -85,7 +86,7 @@ export function KnowledgeBaseManager({ companyId, entries, onRefresh }: {
))}
</ListFrame>
<Modal open={editingId !== null} title={activeEntry ? activeEntry.topic : "New knowledge entry"} onClose={() => setEditingId(null)} width="760px">
<KnowledgeEditor companyId={companyId} entry={editingId === "__new__" ? null : activeEntry} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
<KnowledgeEditor companyId={companyId} entry={editingId === NEW_ENTITY_ID ? null : activeEntry} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete knowledge entry" message="This will permanently delete this entry." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteKnowledgeEntry({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
@@ -5,6 +5,7 @@ import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, M
import { useT } from "../../i18n";
import { meetingTypeOptions, meetingStatusOptions, splitTags, parseTags, parseParticipantIds, formatParticipantIds } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function MeetingEditor({ companyId, agents, meeting, onSaved }: {
companyId: string; agents: AgentRecord[]; meeting: MeetingRecord | null; onSaved: () => Promise<void>;
@@ -77,7 +78,7 @@ export function MeetingsManager({ companyId, meetings, agents, onRefresh }: {
return (
<div>
<SectionHeader title={t("ops.meetings")} count={meetings.length} action={() => setEditingId("__new__")} />
<SectionHeader title={t("ops.meetings")} count={meetings.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{meetings.map((meeting) => (
<ListRow key={meeting.id}>
@@ -99,7 +100,7 @@ export function MeetingsManager({ companyId, meetings, agents, onRefresh }: {
))}
</ListFrame>
<Modal open={editingId !== null} title={activeMeeting ? activeMeeting.title : "New meeting"} onClose={() => setEditingId(null)} width="760px">
<MeetingEditor companyId={companyId} agents={agents} meeting={editingId === "__new__" ? null : activeMeeting} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
<MeetingEditor companyId={companyId} agents={agents} meeting={editingId === NEW_ENTITY_ID ? null : activeMeeting} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete meeting" message="This removes the meeting and its operational context from the company timeline." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteMeeting({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
@@ -5,6 +5,7 @@ import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, M
import { useT } from "../../i18n";
import { sprintStatusOptions } from "./helpers";
import { SectionHeader } from "./SectionHeader";
import { NEW_ENTITY_ID } from "../../lib/constants";
function SprintEditor({ companyId, sprint, onSaved }: {
companyId: string; sprint: SprintRecord | null; onSaved: () => Promise<void>;
@@ -69,7 +70,7 @@ export function SprintManager({ companyId, sprints, onRefresh }: {
return (
<div>
<SectionHeader title={t("ops.sprintBoard")} count={sprints.length} action={() => setEditingId("__new__")} />
<SectionHeader title={t("ops.sprintBoard")} count={sprints.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{sprints.map((sprint) => (
<ListRow key={sprint.id}>
@@ -91,7 +92,7 @@ export function SprintManager({ companyId, sprints, onRefresh }: {
))}
</ListFrame>
<Modal open={editingId !== null} title={activeSprint ? activeSprint.name : "New sprint"} onClose={() => setEditingId(null)} width="720px">
<SprintEditor companyId={companyId} sprint={editingId === "__new__" ? null : activeSprint} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
<SprintEditor companyId={companyId} sprint={editingId === NEW_ENTITY_ID ? null : activeSprint} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
<ConfirmDialog open={deleteId !== null} title="Delete sprint" message="This removes the sprint board entry. Historical run and task records remain." onCancel={() => setDeleteId(null)} onConfirm={async () => { if (!deleteId) return; const result = await window.agentCompany.deleteSprint({ id: deleteId, companyId }); setDeleteId(null); if (result.ok) await onRefresh(); }} />
</div>
@@ -85,15 +85,15 @@ export function Dashboard(props: DashboardProps) {
<div className="mt-2 flex items-center gap-3 text-[13px] tracking-[0.005em] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{activeAgents.length} {activeAgents.length === 1 ? "agent" : "agents"} active
{activeAgents.length} {activeAgents.length === 1 ? t("dash.agentActive") : t("dash.agentsActive")}
</span>
<span className="h-3 w-px bg-[color:var(--border)]" />
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
{activeTasks.length} {activeTasks.length === 1 ? "task" : "tasks"} in progress
{activeTasks.length} {activeTasks.length === 1 ? t("dash.taskInProgress") : t("dash.tasksInProgress")}
</span>
<span className="h-3 w-px bg-[color:var(--border)]" />
<span>{tasks.filter((tk) => tk.status === "done").length} completed</span>
<span>{tasks.filter((tk) => tk.status === "done").length} {t("dash.completedCount")}</span>
</div>
</div>
) : null}
@@ -121,12 +121,12 @@ export function Dashboard(props: DashboardProps) {
subtitle={pendingApprovals.length > 0 ? t("dash.needsAttention") : t("dash.allClear")}
/>
<MetricCard
label="Agents"
label={t("common.agents")}
value={activeAgents.length}
icon={Robot}
tone={activeAgents.filter((a) => a.status === "running").length > 0 ? "success" : "default"}
onClick={() => onNavigate("agents")}
subtitle={`${activeAgents.filter((a) => a.status === "running").length} running`}
subtitle={`${activeAgents.filter((a) => a.status === "running").length} ${t("dash.running")}`}
/>
<MetricCard
label={t("dash.totalSpend")}
@@ -225,10 +225,10 @@ export function Dashboard(props: DashboardProps) {
<section>
<div className="mb-3 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
Active Work
{t("dash.activeWork")}
</div>
<button type="button" onClick={() => onNavigate("runs")} className="flex items-center gap-1 text-[11px] font-medium text-primary transition hover:underline">
All runs <ArrowRight size={12} />
{t("dash.allRuns")} <ArrowRight size={12} />
</button>
</div>
{activeWork.length > 0 ? (
@@ -263,7 +263,7 @@ export function Dashboard(props: DashboardProps) {
) : (
<div className="rounded-lg border border-dashed border-border py-8 text-center">
<div className="text-[13px] text-muted-foreground">{t("dash.allQuiet")}</div>
<div className="mt-1 text-[11px] text-muted-foreground">No tasks currently running</div>
<div className="mt-1 text-[11px] text-muted-foreground">{t("dash.noTasksRunning")}</div>
</div>
)}
</section>
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { Warning, ArrowClockwise, X, Trash } from "@phosphor-icons/react";
import type { DesktopEvent } from "@shared/types/events";
interface InterruptedRun {
runId: string;
@@ -16,7 +17,7 @@ export function RecoveryBanner() {
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const unsubscribe = window.agentCompany?.subscribe?.((event: any) => {
const unsubscribe = window.agentCompany?.subscribe?.((event: DesktopEvent) => {
if (event.type === "recovery-needed" && event.interruptedRuns) {
setRuns(event.interruptedRuns);
}
-1
View File
@@ -10,7 +10,6 @@ export { BudgetSummary } from "./execution/BudgetSummary"
export { WorkspaceList } from "./execution/WorkspaceList"
export { WorkspaceInspector } from "./execution/WorkspaceInspector"
export { WorkspaceCreator } from "./execution/WorkspaceCreator"
export { SecretCreator } from "./execution/SecretCreator"
export { SettingsPane } from "./execution/SettingsPane"
export { ModelManagement } from "./execution/ModelManagement"
export { PendingReviewList } from "./execution/PendingReviewList"
@@ -104,7 +104,7 @@ function ApprovalDocumentsPanel({
reference,
document: documents.find((entry) => entry.id === reference.id) ?? null,
}))
.filter((entry) => entry.document)
.filter((entry): entry is typeof entry & { document: NonNullable<typeof entry.document> } => entry.document != null)
: []
return (
@@ -112,9 +112,9 @@ function ApprovalDocumentsPanel({
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Linked Documents</div>
{fullDocs.length > 0 ? fullDocs.map(({ reference, document }) => (
<ApprovalDocumentEditor
key={document!.id}
key={document.id}
companyId={companyId}
document={document!}
document={document}
fallback={reference}
/>
)) : references.map((reference) => (
@@ -1,28 +1,8 @@
import { CaretDown, CaretRight } from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import { COMPATIBLE_PROVIDERS } from "../company-onboarding/ProvidersStepShared";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets (mirrors ProvidersStep) */
/* ------------------------------------------------------------------ */
export const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
export { COMPATIBLE_PROVIDERS };
export const COMPAT_SLOTS = [
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
@@ -1,31 +0,0 @@
import { useState } from "react"
import { ShieldCheck } from "@phosphor-icons/react"
import { useT } from "../../i18n"
import { unwrap } from "../../lib/desktop"
import { ActionButton, InlineNotice, Input, TextArea } from "../ui"
import { describeActionError } from "./execution-utils"
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
const t = useT()
const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [value, setValue] = useState("")
const [errors, setErrors] = useState<{ name?: string; value?: string }>({}); const [submitError, setSubmitError] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false)
function validate(): boolean {
const next: typeof errors = {}
if (!name.trim()) next.name = "Secret name is required."
else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name.trim())) next.name = "Name must be alphanumeric with underscores, starting with a letter or underscore."
if (!value.trim()) next.value = "Secret value cannot be empty."
setErrors(next); return Object.keys(next).length === 0
}
return (
<div className="grid gap-4">
<div><Input label={t("exec.secretNameLabel")} value={name} onChange={(v) => { setName(v); if (errors.name) setErrors((e) => ({ ...e, name: undefined })) }} placeholder="e.g. ANTHROPIC_API_KEY" />{errors.name ? <div className="mt-1 text-[12px] text-destructive" role="alert">{errors.name}</div> : null}</div>
<Input label="Description" value={description} onChange={setDescription} placeholder="What this secret is used for" />
<div><TextArea label="Secret value" value={value} onChange={(v) => { setValue(v); if (errors.value) setErrors((e) => ({ ...e, value: undefined })) }} rows={5} placeholder="Paste the secret value" />{errors.value ? <div className="mt-1 text-[12px] text-destructive" role="alert">{errors.value}</div> : null}</div>
{submitError ? <InlineNotice message={submitError} /> : null}
<ActionButton label={t("exec.storeSecret")} tone="accent" onClick={() => void (async () => { if (!validate()) return; setSubmitError(null); setSubmitting(true); try { unwrap(await window.agentCompany.saveSecret({ companyId, name: name.trim(), description, value })); setName(""); setDescription(""); setValue(""); setErrors({}); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } })()} icon={ShieldCheck} loading={submitting} />
<div className="border-t border-border pt-4 text-[12px] leading-relaxed text-muted-foreground">Connector secret bindings use `ENV_NAME=secret-name` lines. Values stay outside renderer state after submission.</div>
</div>
)
}
@@ -27,7 +27,7 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
<div className="md:col-span-2"><TextArea label={t("exec.taskDescription")} value={description} onChange={setDescription} placeholder={t("exec.describeTask")} rows={5} /></div>
<div className="md:col-span-2">
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
return (
<div className="space-y-6">
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} {t("exec.budgetUsed").replace("{0}", String(Math.round(budgetWarning * 100)))} ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
@@ -56,15 +56,15 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
) : (
<>
<div className="grid gap-x-6 gap-y-2 text-[13px] md:grid-cols-2">
{assignee && <div className="text-muted-foreground">Assignee: <span className="text-foreground">{assignee.name}</span></div>}
{project && <div className="text-muted-foreground">Project: <span className="text-foreground">{project.name}</span></div>}
{goal && <div className="text-muted-foreground">Goal: <span className="text-foreground">{goal.title}</span></div>}
{task.parentId && (() => { const parent = tasks.find((t) => t.id === task.parentId); return parent ? <div className="text-muted-foreground">Parent: <span className="text-foreground">{parent.title}</span></div> : null })()}
{workspace && <div className="text-muted-foreground">Workspace: <span className="text-foreground">{workspace.name}</span></div>}
{task.costUsd > 0 && <div className="text-muted-foreground">Cost: <span className="font-mono tabular-nums text-foreground">{formatMoney(task.costUsd)}</span></div>}
{assignee && <div className="text-muted-foreground">{t("exec.assigneeLabel")} <span className="text-foreground">{assignee.name}</span></div>}
{project && <div className="text-muted-foreground">{t("exec.projectLabel")} <span className="text-foreground">{project.name}</span></div>}
{goal && <div className="text-muted-foreground">{t("exec.goalLabel")} <span className="text-foreground">{goal.title}</span></div>}
{task.parentId && (() => { const parent = tasks.find((t) => t.id === task.parentId); return parent ? <div className="text-muted-foreground">{t("exec.parentLabel")} <span className="text-foreground">{parent.title}</span></div> : null })()}
{workspace && <div className="text-muted-foreground">{t("exec.workspaceLabel")} <span className="text-foreground">{workspace.name}</span></div>}
{task.costUsd > 0 && <div className="text-muted-foreground">{t("exec.costLabel")} <span className="font-mono tabular-nums text-foreground">{formatMoney(task.costUsd)}</span></div>}
</div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div>
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />{t("exec.beingReviewedBy").replace("{0}", reviewer.name + (reviewer.title ? ` (${reviewer.title})` : ""))}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{t("exec.submittedForReview").replace("{0}", assignee?.name ?? "").replace("{1}", reviewer.name)}</div></div>
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
<div className="flex items-center gap-3">
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
@@ -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">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<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="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">
@@ -91,9 +91,9 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
<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 (
<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 ? "Running..." : "Completed run")}</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>
<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}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">{t("exec.moreRuns").replace("{0}", String(runs.length - 8))}</div>}</div></div>}
</div>
)
}
@@ -200,8 +200,9 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
const ag = candidate.agent;
if (!ag) return null;
const manager = agents.find(a => a.id === ag.reportsTo);
const requestedBy = candidate.approval?.requestedByAgentId
? agents.find(a => a.id === candidate.approval!.requestedByAgentId)
const approvalRequestedBy = candidate.approval?.requestedByAgentId ?? null;
const requestedBy = approvalRequestedBy
? agents.find(a => a.id === approvalRequestedBy)
: null;
return (
<div
@@ -5,6 +5,7 @@ import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/type
import { isConnectorExecutionReady } from "@shared/connector-policy";
import { unwrap } from "../../lib/desktop";
import { ActionButton, Input, Select, TextArea } from "../ui";
import { useT } from "../../i18n";
export function AgentCreator({
companyId,
@@ -66,27 +67,28 @@ export function AgentCreator({
}
}, [executionReadyConnectors, connectorId]);
const t = useT();
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div className="grid min-w-0 gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" hint="Display name shown in org chart and messages" />
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" hint="Determines which tasks the agent can handle" />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" hint="Job title shown under the name" />
<Input label={t("org.agentName")} value={name} onChange={setName} placeholder={t("org.agentName")} hint="Display name shown in org chart and messages" />
<Input label={t("agent.role")} value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" hint="Determines which tasks the agent can handle" />
<Input label={t("agent.title")} value={title} onChange={setTitle} placeholder={t("agent.title")} hint="Job title shown under the name" />
<Select
label="Connector"
label={t("org.connector")}
value={connectorId}
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))}
/>
{executionReadyConnectors.length === 0 ? (
<p className="text-[12px] text-warning">
No execution-ready connector is available. Fix connector health before requesting hires.
{t("org.noConnectorAvailable")}
</p>
) : null}
{availableModels.length > 0 ? (
<Select
label="Model"
label={t("org.model")}
value={model}
onChange={setModel}
options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
@@ -94,11 +96,11 @@ export function AgentCreator({
) : null}
{supportsEffort ? (
<Select
label="Thinking effort"
label={t("org.thinkingEffort")}
value={thinkingEffort}
onChange={setThinkingEffort}
options={[
{ value: "", label: "Default" },
{ value: "", label: t("org.default") },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
@@ -107,32 +109,32 @@ export function AgentCreator({
) : null}
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
Advanced settings
{t("org.advancedSettings")}
</button>
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden">
<Select
label="Workspace"
label={t("agent.workspace")}
value={workspaceId}
onChange={setWorkspaceId}
options={[
{ value: "", label: "No workspace" },
{ value: "", label: t("agent.noWorkspace") },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
]}
/>
<Select
label="Reports to"
label={t("agent.reportsTo")}
value={reportsTo}
onChange={setReportsTo}
options={[
{ value: "", label: "No manager" },
{ value: "", label: t("agent.noManager") },
...agents.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" hint="Spending cap per month; agent pauses when exceeded" />
<Input label={t("org.monthlyBudget")} value={budget} onChange={setBudget} placeholder="120" type="number" hint="Spending cap per month; agent pauses when exceeded" />
<TextArea
label="Capabilities"
label={t("agent.capabilities")}
value={capabilities}
onChange={setCapabilities}
placeholder="What this agent does"
@@ -142,17 +144,17 @@ export function AgentCreator({
) : null}
</AnimatePresence>
<p className="text-[12px] text-muted-foreground">
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
{t("org.hireRequestNote")}
</p>
<ActionButton
label="Submit hire request"
label={t("org.submitHireRequest")}
tone="accent"
onClick={() =>
void (async () => {
try {
setRequestError(null);
if (!selectedConnector) {
setRequestError("Select an execution-ready connector before requesting a hire.");
setRequestError(t("org.selectConnectorFirst"));
return;
}
const meta: Record<string, string> = {};
@@ -180,7 +182,7 @@ export function AgentCreator({
setRequestError(null);
await onSaved();
} catch (error) {
setRequestError(error instanceof Error ? error.message : "Hire request failed.");
setRequestError(error instanceof Error ? error.message : t("org.hireRequestFailed"));
}
})()
}
@@ -89,15 +89,15 @@ export function AgentRoster({
<div>
<div className={`truncate text-[14px] font-semibold text-foreground`}>{agent.name}</div>
<div className="truncate text-[11px] text-muted-foreground">
{agent.role} · {agent.title || "No title"}
{agent.role} · {agent.title || t("org.noTitle")}
</div>
</div>
</div>
<StatusPill status={agent.status} />
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>Connector: {connector?.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector?.label ?? agent.connectorId}</span>
{workspace && <span>Workspace: {workspace.name}</span>}
<span>{t("org.connectorLabel")} {connector?.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector?.label ?? agent.connectorId}</span>
{workspace && <span>{t("org.workspaceLabel")} {workspace.name}</span>}
</div>
<div className="mt-1.5">
<BudgetBar spent={agent.spentMonthlyUsd} budget={agent.budgetMonthlyUsd} />
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Target } from "@phosphor-icons/react";
import type { AgentRecord, GoalRecord } from "@shared/types";
import { ActionButton, Input, Select, TextArea } from "../ui";
import { useT } from "../../i18n";
export function GoalCreator({
companyId,
@@ -14,39 +15,40 @@ export function GoalCreator({
goals: GoalRecord[];
onSaved: () => Promise<void>;
}) {
const t = useT();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [ownerAgentId, setOwnerAgentId] = useState(agents[0]?.id ?? "");
const [parentId, setParentId] = useState("");
return (
<div className="grid gap-4">
<Input label="Goal title" value={title} onChange={setTitle} placeholder="Goal title" />
<Input label={t("org.goalTitle")} value={title} onChange={setTitle} placeholder={t("org.goalTitle")} />
<TextArea
label="Success criteria"
label={t("org.successCriteria")}
value={description}
onChange={setDescription}
placeholder="Define the acceptance criteria"
placeholder={t("org.successCriteria")}
/>
<Select
label="Owner agent"
label={t("org.ownerAgent")}
value={ownerAgentId}
onChange={setOwnerAgentId}
options={[
{ value: "", label: "No owner" },
{ value: "", label: t("org.noOwner") },
...agents.map((agent) => ({ value: agent.id, label: agent.name })),
]}
/>
<Select
label="Parent goal"
label={t("org.parentGoal")}
value={parentId}
onChange={setParentId}
options={[
{ value: "", label: "No parent goal" },
{ value: "", label: t("org.noParentGoal") },
...goals.map((g) => ({ value: g.id, label: g.title })),
]}
/>
<ActionButton
label="Create goal"
label={t("org.createGoal")}
tone="accent"
onClick={() =>
void (async () => {
@@ -3,6 +3,7 @@ import { Pencil, Trash } from "@phosphor-icons/react";
import type { AgentRecord, GoalRecord } from "@shared/types";
import { ActionButton, Input, Select, TextArea } from "../ui";
import { ConfirmDelete } from "./helpers";
import { useT } from "../../i18n";
export function GoalEditor({
goal,
@@ -24,47 +25,48 @@ export function GoalEditor({
const [status, setStatus] = useState<GoalRecord["status"]>(goal.status);
const [parentId, setParentId] = useState(goal.parentId ?? "");
const [ownerAgentId, setOwnerAgentId] = useState(goal.ownerAgentId ?? "");
const t = useT();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const parentCandidates = goals.filter((g) => g.id !== goal.id);
return (
<div className="grid gap-4">
<Input label="Goal title" value={title} onChange={setTitle} placeholder="Goal title" />
<TextArea label="Description" value={description} onChange={setDescription} placeholder="Description" />
<Input label={t("org.goalTitle")} value={title} onChange={setTitle} placeholder={t("org.goalTitle")} />
<TextArea label={t("org.description")} value={description} onChange={setDescription} placeholder={t("org.description")} />
<Select
label="Status"
label={t("org.status")}
value={status}
onChange={(v) => setStatus(v as GoalRecord["status"])}
options={[
{ value: "planned", label: "Planned" },
{ value: "active", label: "Active" },
{ value: "achieved", label: "Achieved" },
{ value: "cancelled", label: "Cancelled" },
{ value: "planned", label: t("org.statusPlanned") },
{ value: "active", label: t("org.statusActive") },
{ value: "achieved", label: t("org.statusAchieved") },
{ value: "cancelled", label: t("org.statusCancelled") },
]}
/>
<Select
label="Parent goal"
label={t("org.parentGoal")}
value={parentId}
onChange={setParentId}
options={[
{ value: "", label: "No parent goal" },
{ value: "", label: t("org.noParentGoal") },
...parentCandidates.map((g) => ({ value: g.id, label: g.title })),
]}
/>
<Select
label="Owner agent"
label={t("org.ownerAgent")}
value={ownerAgentId}
onChange={setOwnerAgentId}
options={[
{ value: "", label: "No owner" },
{ value: "", label: t("org.noOwner") },
...agents.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<div className="flex items-center gap-3 flex-wrap">
<ActionButton
label="Save changes"
label={t("org.saveChanges")}
tone="accent"
icon={Pencil}
onClick={() =>
@@ -99,7 +101,7 @@ export function GoalEditor({
/>
) : (
<ActionButton
label="Delete"
label={t("common.delete")}
tone="danger"
icon={Trash}
onClick={() => setConfirmingDelete(true)}
@@ -45,15 +45,15 @@ export function ProjectList({
<div className="mb-2 text-[13px] text-muted-foreground">{project.description}</div>
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
<span>
Goal: {goals.find((g) => g.id === project.goalId)?.title ?? "None"}
{t("org.goalLabel")} {goals.find((g) => g.id === project.goalId)?.title ?? t("exec.noGoal")}
</span>
<span>
Lead: {agents.find((a) => a.id === project.leadAgentId)?.name ?? "Unassigned"}
{t("org.leadLabel")} {agents.find((a) => a.id === project.leadAgentId)?.name ?? t("exec.unassigned")}
</span>
{project.targetDate && <span>Target: {project.targetDate}</span>}
{project.targetDate && <span>{t("org.targetLabel")} {project.targetDate}</span>}
{wsCount > 0 && (
<span>
{wsCount} workspace{wsCount !== 1 ? "s" : ""}
{wsCount !== 1 ? t("org.workspacesCount").replace("{0}", String(wsCount)) : t("org.workspaceCount").replace("{0}", String(wsCount))}
</span>
)}
</div>
+1 -1
View File
@@ -91,7 +91,7 @@ export function RunDetail({
<ActionButton
label={t("runs.openLogFile")}
icon={FolderOpen}
onClick={() => void onOpenLogPath(selectedRun.logPath!)}
onClick={() => { const p = selectedRun.logPath; if (p) void onOpenLogPath(p); }}
/>
</div>
) : null}
@@ -65,7 +65,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
{action.taskId ? (
<button
type="button"
onClick={() => onNavigate("tasks", action.taskId!)}
onClick={() => { const id = action.taskId; if (id) onNavigate("tasks", id); }}
className="rounded-[5px] bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground transition hover:text-foreground"
>
Open task
@@ -74,7 +74,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
{action.approvalId ? (
<button
type="button"
onClick={() => onNavigate("approvals", action.approvalId!)}
onClick={() => { const id = action.approvalId; if (id) onNavigate("approvals", id); }}
className="rounded-[5px] bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground transition hover:text-foreground"
>
Open approval
@@ -83,7 +83,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
{action.screenshotPath ? (
<button
type="button"
onClick={() => void onOpenPath(action.screenshotPath!)}
onClick={() => { const p = action.screenshotPath; if (p) void onOpenPath(p); }}
className="inline-flex items-center gap-1 rounded-[5px] bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground transition hover:text-foreground"
>
<ArrowSquareOut size={10} />
@@ -57,7 +57,7 @@ export function SocialAccountsPanel({
</div>
</div>
<ActionButton
label="Add Account"
label={t("social.addAccount")}
onClick={() => setShowAddDialog(true)}
icon={Plus}
tone="accent"
@@ -68,11 +68,9 @@ export function SocialAccountsPanel({
<div className="flex items-start gap-2 text-[12px] text-primary">
<Browser size={16} className="mt-0.5 shrink-0" />
<div>
<div className="font-medium">How it works</div>
<div className="font-medium">{t("social.howItWorks")}</div>
<div className="mt-1 text-[11px] opacity-80">
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
using browser automation. All actions are logged and can require Board approval.
{t("social.howItWorksDesc")}
</div>
</div>
</div>
@@ -88,7 +86,7 @@ export function SocialAccountsPanel({
: "text-muted-foreground hover:text-foreground"
}`}
>
Accounts ({accounts.length})
{t("social.accounts")} ({accounts.length})
</button>
<button
type="button"
@@ -99,7 +97,7 @@ export function SocialAccountsPanel({
: "text-muted-foreground hover:text-foreground"
}`}
>
Action History ({actions.length})
{t("social.actionHistory")} ({actions.length})
</button>
</div>
@@ -113,7 +111,7 @@ export function SocialAccountsPanel({
</div>
<div className="mt-4">
<ActionButton
label="Add Account"
label={t("social.addAccount")}
onClick={() => setShowAddDialog(true)}
icon={Plus}
tone="accent"
+178 -358
View File
@@ -1,389 +1,209 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from "react";
import type {
CompanyRecord,
OnboardingBootstrapResult,
SectionId,
} from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { createTranslator } from "../i18n";
import { useCallback, useMemo } from "react";
import { useAppStore } from "../state/store";
import { unwrap } from "../lib/desktop";
import { useFocusTrap, useReducedMotion } from "../components/ui";
import {
BookOpen,
Gear,
Tray,
SquaresFour,
UsersThree,
ListChecks,
ChatCircle,
PuzzlePiece,
FileText,
ShieldCheck,
Megaphone,
FolderOpen,
} from "@phosphor-icons/react";
import { useDesktopEvents } from "./useDesktopEvents";
import { useDataLoader } from "./useDataLoader";
import { useCompanyData } from "./useCompanyData";
import { useUIController } from "./useUIController";
import { useCompanyController } from "./useCompanyController";
import { useExecutionController } from "./useExecutionController";
import { useOrganizationController } from "./useOrganizationController";
import { useCommunicationController } from "./useCommunicationController";
import { useCommandPaletteHotkeys } from "./useCommandPaletteHotkeys";
import { unwrap } from "../lib/desktop";
type NavItemDef = { id: SectionId; labelKey: string; icon: typeof SquaresFour };
export type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
export type PaletteAction = { key: string; section: SectionId; entityId?: string };
const sidebarNavDefs: NavItemDef[] = [
{ id: "overview", labelKey: "nav.overview", icon: SquaresFour },
{ id: "inbox", labelKey: "nav.inbox", icon: Tray },
{ id: "communication", labelKey: "nav.communication", icon: ChatCircle },
{ id: "tasks", labelKey: "nav.work", icon: ListChecks },
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
{ id: "social", labelKey: "nav.social", icon: Megaphone },
{ id: "documents", labelKey: "nav.documents", icon: FileText },
];
const paletteOnlyNavDefs: NavItemDef[] = [
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
];
// Re-export types consumed by other modules
export type { NavItem, PaletteAction } from "./useUIController";
export function useAppController() {
const reducedMotion = useReducedMotion();
const noMotion = { duration: 0 };
const pageTransition = reducedMotion ? noMotion : { duration: 0.32, ease: [0.16, 1, 0.3, 1] as const };
const panelTransition = reducedMotion ? noMotion : { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
const ui = useUIController();
const company = useCompanyController();
const execution = useExecutionController();
const organization = useOrganizationController();
const communication = useCommunicationController();
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const t = useMemo(() => createTranslator(locale), [locale]);
const sidebarNav: NavItem[] = useMemo(() => sidebarNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })), [t]);
const navItems: NavItem[] = useMemo(
() =>
sidebarNav
.concat(
paletteOnlyNavDefs.map((item) => ({
id: item.id,
label: t(item.labelKey),
icon: item.icon,
})),
)
.concat({ id: "settings", label: t("nav.settings"), icon: Gear }),
[sidebarNav, t],
);
const snapshot = useAppStore((s) => s.snapshot);
const setSnapshot = useAppStore((s) => s.setSnapshot);
const section = useAppStore((s) => s.section);
const setSection = useAppStore((s) => s.setSection);
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const consoleOpen = useAppStore((s) => s.consoleOpen);
const setConsoleOpen = useAppStore((s) => s.setConsoleOpen);
const sidebarCollapsed = useAppStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useAppStore((s) => s.setSidebarCollapsed);
const companySwitcherOpen = useAppStore((s) => s.companySwitcherOpen);
const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen);
const confirmDeleteCompanyId = useAppStore((s) => s.confirmDeleteCompanyId);
const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId);
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId);
const selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId);
const selectedRunId = useAppStore((s) => s.selectedRunId);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId);
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId);
const setSelectedWorkspaceId = useAppStore((s) => s.setSelectedWorkspaceId);
const selectedCommunicationEntityId = useAppStore((s) => s.selectedCommunicationEntityId);
const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId);
const taskViewMode = useAppStore((s) => s.taskViewMode);
const setTaskViewMode = useAppStore((s) => s.setTaskViewMode);
const logBufferRef = useRef<Record<string, string>>({});
const incrementLogRevision = useAppStore((s) => s.incrementLogRevision);
const commandPaletteSearch = useAppStore((s) => s.commandPaletteSearch);
const setCommandPaletteSearch = useAppStore((s) => s.setCommandPaletteSearch);
const selectedPaletteIndex = useAppStore((s) => s.selectedPaletteIndex);
const setSelectedPaletteIndex = useAppStore((s) => s.setSelectedPaletteIndex);
const showOnboarding = useAppStore((s) => s.showOnboarding);
const setShowOnboarding = useAppStore((s) => s.setShowOnboarding);
const initialOnboardingLocked = useAppStore((s) => s.initialOnboardingLocked);
const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked);
const inboxItems = useAppStore((s) => s.inboxItems);
const setInboxItems = useAppStore((s) => s.setInboxItems);
const companyMetrics = useAppStore((s) => s.companyMetrics);
const setCompanyMetrics = useAppStore((s) => s.setCompanyMetrics);
const standupReport = useAppStore((s) => s.standupReport);
const setStandupReport = useAppStore((s) => s.setStandupReport);
const socialAccounts = useAppStore((s) => s.socialAccounts);
const setSocialAccounts = useAppStore((s) => s.setSocialAccounts);
const browserActions = useAppStore((s) => s.browserActions);
const setBrowserActions = useAppStore((s) => s.setBrowserActions);
const setBadges = useAppStore((s) => s.setBadges);
const currentCompany = useMemo<CompanyRecord | null>(() => {
if (!snapshot) return null;
return snapshot.companies.find((company) => company.id === snapshot.currentCompanyId) ?? snapshot.companies[0] ?? null;
}, [snapshot]);
const companyData = useCompanyData(snapshot, currentCompany);
const { tasks, runs, approvals, pendingApprovals, companyAgents, companyGoals, companyProjects,
companyWorkspaces, companyCosts, filteredActivity, companySecrets, companyDocuments,
companyKnowledge, companyMeetings, companySprints, selectedTask, selectedApproval,
selectedRun, selectedAgent, selectedGoal, selectedProject, selectedWorkspace, navBadges } = companyData;
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
const clearCompanyScopedUiState = useCallback(() => {
const store = useAppStore.getState();
store.clearCompanyScopedSelections();
store.setInboxItems([]);
store.setCompanyMetrics(null);
store.setStandupReport(null);
store.setSocialAccounts([]);
store.setBrowserActions([]);
store.setBadges(null);
store.setSkills([]);
store.setSelectedSkillId(null);
store.setSelectedSkillFilePath("SKILL.md");
store.setSkillImportModalOpen(false);
}, []);
const { refresh } = useDataLoader(
snapshot, section, selectedConnectorId, selectedRunId,
setSnapshot, setError, setInitialOnboardingLocked,
setSelectedConnectorId, setSelectedRunId,
setBadges, setInboxItems, setSocialAccounts, setBrowserActions,
setCompanyMetrics, setStandupReport,
);
// Stable ref so the subscribe callback always calls the latest refresh
// without tearing down the subscription when refresh identity changes.
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useDesktopEvents(refreshRef, logBufferRef, incrementLogRevision);
useEffect(() => {
if (!snapshot) return;
const root = document.documentElement;
const applyTheme = () => {
const isDark = snapshot.theme === "dark" ||
(snapshot.theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", isDark);
root.style.colorScheme = isDark ? "dark" : "light";
};
applyTheme();
if (snapshot.locale && snapshot.locale !== locale) setLocale(snapshot.locale);
if (snapshot.theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", applyTheme);
return () => mq.removeEventListener("change", applyTheme);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
}, [snapshot, locale]);
useEffect(() => {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
}, [locale]);
useEffect(() => {
if (selectedRunId && !runs.some((run) => run.id === selectedRunId)) {
setSelectedRunId(runs[0]?.id ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectedRunId is a stable Zustand setter
}, [runs, selectedRunId]);
const activeRunId = selectedRun?.id ?? null;
useEffect(() => {
if (!activeRunId) return;
void (async () => {
const activeRun = runs.find((run) => run.id === activeRunId);
if (!activeRun) return;
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
if (result.ok) {
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
incrementLogRevision();
return;
}
setError(result.error.message);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- incrementLogRevision and setError are stable Zustand setters
}, [activeRunId, runs]);
const handleLocaleChange = useCallback(async (newLocale: AppLocale) => {
setLocale(newLocale);
try { await window.agentCompany.updateSettings({ locale: newLocale }); } catch { /* local state already updated */ }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
}, []);
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase());
const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery
? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
: navItems;
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedTasks = paletteQuery ? tasks.filter((t) => t.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedRuns = paletteQuery ? runs.filter((run) => {
const matchingTask = tasks.find((task) => task.id === run.taskId);
return [run.id, run.summary ?? "", run.errorMessage ?? "", matchingTask?.title ?? "", run.connectorId]
.some((value) => value.toLowerCase().includes(paletteQuery));
}).slice(0, 5) : [];
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
}, [paletteQuery, companyAgents, companyGoals, companyProjects, runs, tasks, navItems]);
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
const paletteActions = useMemo<PaletteAction[]>(() => ([
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
]), [filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects]);
const paletteActionIndex = useMemo(() => new Map(paletteActions.map((action, index) => [action.key, index])), [paletteActions]);
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
else if (targetSection === "runs") { setSelectedRunId(entityId); setConsoleOpen(true); }
else if (targetSection === "tasks") {
const matchingTask = tasks.find((task) => task.id === entityId) ?? null;
if (matchingTask) { setSelectedTaskId(matchingTask.id); }
else {
const matchingRun = runs.find((run) => run.id === entityId) ?? null;
if (matchingRun) { setSelectedRunId(matchingRun.id); setSelectedTaskId(matchingRun.taskId); setConsoleOpen(true); }
}
}
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [runs, tasks]);
useCommandPaletteHotkeys({
commandPaletteOpen,
setCommandPaletteOpen,
setCommandPaletteSearch,
setSelectedPaletteIndex,
paletteActions,
selectedPaletteIndex,
navigateTo,
commandPaletteSearch,
});
const setCompany = useCallback(async (companyId: string) => {
if (companyId === "__new__") { setShowOnboarding(true); return; }
try {
unwrap(await window.agentCompany.setCurrentCompany(companyId));
clearCompanyScopedUiState();
await refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError and setShowOnboarding are stable Zustand setters
}, [clearCompanyScopedUiState, refresh]);
const deleteCompany = useCallback(async (companyId: string) => {
try {
unwrap(await window.agentCompany.deleteCompany(companyId));
setConfirmDeleteCompanyId(null);
setCompanySwitcherOpen(false);
await refresh();
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
// --- Cross-domain actions composed here ---
const startRun = useCallback(async (taskId: string) => {
if (!currentCompany) { setError("Select a company before starting a run."); return; }
const result = await window.agentCompany.startTaskRun({ companyId: currentCompany.id, taskId });
if (!company.currentCompany) { setError("Select a company before starting a run."); return; }
const result = await window.agentCompany.startTaskRun({ companyId: company.currentCompany.id, taskId });
if (!result.ok) { setError(result.error.message); return; }
setSelectedRunId(result.data);
setConsoleOpen(true);
await refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [currentCompany, refresh]);
execution.setSelectedRunId(result.data);
ui.setConsoleOpen(true);
await company.refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters and company.refresh are stable
}, [company.currentCompany, company.refresh]);
const cancelRun = useCallback(async (runId: string) => {
try {
const run = runs.find((entry) => entry.id === runId);
const run = company.runs.find((entry) => entry.id === runId);
if (!run) throw new Error("Selected run is no longer available.");
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
await refresh();
await company.refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, [refresh, runs]);
}, [company.refresh, company.runs]);
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
setError(null);
setSection("overview");
if (summary.runId) setSelectedRunId(summary.runId);
if (summary.leadAgentId) setSelectedAgentId(summary.leadAgentId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
// --- Command palette results (needs entity lists from company domain) ---
const openPathWithFeedback = useCallback(async (path: string) => {
try { unwrap(await window.agentCompany.openPath(path)); setError(null); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, []);
const { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults } =
useMemo(() => ui.buildPaletteResults(
ui.navItems,
company.companyAgents,
company.tasks,
company.runs,
company.companyGoals,
company.companyProjects,
), [
ui.navItems,
ui.paletteQuery,
company.companyAgents,
company.tasks,
company.runs,
company.companyGoals,
company.companyProjects,
]);
const paletteActions = useMemo(
() => ui.buildPaletteActions(filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects),
[filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects],
);
const paletteActionIndex = useMemo(
() => new Map(paletteActions.map((action, index) => [action.key, index])),
[paletteActions],
);
// navigateTo needs entity lists for task/run cross-lookup
const navigateTo = useCallback((targetSection: Parameters<typeof ui.navigateTo>[0], entityId?: string) => {
ui.navigateTo(targetSection, entityId, company.runs, company.tasks);
}, [ui.navigateTo, company.runs, company.tasks]);
// Wire command palette hotkeys
useCommandPaletteHotkeys({
commandPaletteOpen: ui.commandPaletteOpen,
setCommandPaletteOpen: ui.setCommandPaletteOpen,
setCommandPaletteSearch: ui.setCommandPaletteSearch,
setSelectedPaletteIndex: ui.setSelectedPaletteIndex,
paletteActions,
selectedPaletteIndex: ui.selectedPaletteIndex,
navigateTo,
commandPaletteSearch: ui.commandPaletteSearch,
});
return {
pageTransition, panelTransition,
locale, t, handleLocaleChange,
sidebarNav, navItems, navBadges,
snapshot, currentCompany,
showOnboarding, initialOnboardingLocked, handleOnboardingDone, setShowOnboarding, setInitialOnboardingLocked, setError,
sidebarCollapsed, setSidebarCollapsed,
companySwitcherOpen, setCompanySwitcherOpen,
confirmDeleteCompanyId, setConfirmDeleteCompanyId,
setCompany, deleteCompany,
section, setSection, error, consoleOpen, setConsoleOpen,
tasks, runs, approvals, pendingApprovals,
companyAgents, companyGoals, companyProjects, companyWorkspaces,
companyCosts, filteredActivity, companySecrets, companyDocuments,
companyKnowledge, companyMeetings, companySprints,
socialAccounts, browserActions, inboxItems, companyMetrics, standupReport,
selectedTask, selectedTaskId, setSelectedTaskId,
selectedConnector, setSelectedConnectorId,
selectedApproval, selectedApprovalId, setSelectedApprovalId,
selectedAgent, selectedAgentId, setSelectedAgentId,
selectedGoal, selectedGoalId, setSelectedGoalId,
selectedProject, selectedProjectId, setSelectedProjectId,
selectedWorkspace, setSelectedWorkspaceId,
selectedCommunicationEntityId, setSelectedCommunicationEntityId,
selectedRun, selectedRunId, setSelectedRunId,
taskViewMode, setTaskViewMode,
logBufferRef,
navigateTo, refresh, startRun, cancelRun, openPathWithFeedback,
commandPaletteOpen, setCommandPaletteOpen,
commandPaletteSearch, setCommandPaletteSearch,
selectedPaletteIndex, setSelectedPaletteIndex,
paletteTrapRef,
filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults,
paletteActions, paletteActionIndex,
// UI / navigation
pageTransition: ui.pageTransition,
panelTransition: ui.panelTransition,
locale: ui.locale,
t: ui.t,
handleLocaleChange: ui.handleLocaleChange,
sidebarNav: ui.sidebarNav,
navItems: ui.navItems,
section: ui.section,
setSection: ui.setSection,
consoleOpen: ui.consoleOpen,
setConsoleOpen: ui.setConsoleOpen,
sidebarCollapsed: ui.sidebarCollapsed,
setSidebarCollapsed: ui.setSidebarCollapsed,
companySwitcherOpen: ui.companySwitcherOpen,
setCompanySwitcherOpen: ui.setCompanySwitcherOpen,
confirmDeleteCompanyId: ui.confirmDeleteCompanyId,
setConfirmDeleteCompanyId: ui.setConfirmDeleteCompanyId,
taskViewMode: ui.taskViewMode,
setTaskViewMode: ui.setTaskViewMode,
showOnboarding: ui.showOnboarding,
setShowOnboarding: ui.setShowOnboarding,
initialOnboardingLocked: ui.initialOnboardingLocked,
setInitialOnboardingLocked: ui.setInitialOnboardingLocked,
commandPaletteOpen: ui.commandPaletteOpen,
setCommandPaletteOpen: ui.setCommandPaletteOpen,
commandPaletteSearch: ui.commandPaletteSearch,
setCommandPaletteSearch: ui.setCommandPaletteSearch,
selectedPaletteIndex: ui.selectedPaletteIndex,
setSelectedPaletteIndex: ui.setSelectedPaletteIndex,
paletteTrapRef: ui.paletteTrapRef,
filteredNavItems,
matchedAgents,
matchedTasks,
matchedRuns,
matchedGoals,
matchedProjects,
hasEntityResults,
paletteActions,
paletteActionIndex,
navigateTo,
// Company / core
snapshot: company.snapshot,
currentCompany: company.currentCompany,
selectedConnector: company.selectedConnector,
logBufferRef: company.logBufferRef,
refresh: company.refresh,
setCompany: company.setCompany,
deleteCompany: company.deleteCompany,
handleOnboardingDone: company.handleOnboardingDone,
openPathWithFeedback: company.openPathWithFeedback,
tasks: company.tasks,
runs: company.runs,
approvals: company.approvals,
pendingApprovals: company.pendingApprovals,
companyAgents: company.companyAgents,
companyGoals: company.companyGoals,
companyProjects: company.companyProjects,
companyWorkspaces: company.companyWorkspaces,
companyCosts: company.companyCosts,
filteredActivity: company.filteredActivity,
companySecrets: company.companySecrets,
companyDocuments: company.companyDocuments,
companyKnowledge: company.companyKnowledge,
companyMeetings: company.companyMeetings,
companySprints: company.companySprints,
selectedTask: company.selectedTask,
selectedApproval: company.selectedApproval,
selectedRun: company.selectedRun,
selectedAgent: company.selectedAgent,
selectedGoal: company.selectedGoal,
selectedProject: company.selectedProject,
selectedWorkspace: company.selectedWorkspace,
navBadges: company.navBadges,
// Execution
selectedTaskId: execution.selectedTaskId,
setSelectedTaskId: execution.setSelectedTaskId,
setSelectedConnectorId: execution.setSelectedConnectorId,
selectedApprovalId: execution.selectedApprovalId,
setSelectedApprovalId: execution.setSelectedApprovalId,
selectedRunId: execution.selectedRunId,
setSelectedRunId: execution.setSelectedRunId,
startRun,
cancelRun,
// Organization
selectedAgentId: organization.selectedAgentId,
setSelectedAgentId: organization.setSelectedAgentId,
selectedGoalId: organization.selectedGoalId,
setSelectedGoalId: organization.setSelectedGoalId,
selectedProjectId: organization.selectedProjectId,
setSelectedProjectId: organization.setSelectedProjectId,
setSelectedWorkspaceId: organization.setSelectedWorkspaceId,
// Communication
selectedCommunicationEntityId: communication.selectedCommunicationEntityId,
setSelectedCommunicationEntityId: communication.setSelectedCommunicationEntityId,
// Shared cross-domain state
error,
setError,
inboxItems,
companyMetrics,
standupReport,
socialAccounts,
browserActions,
};
}
@@ -0,0 +1,37 @@
import { useAppStore } from "../state/store";
/**
* Fine-grained subscriptions for communication domain:
* messages, channels, threads, search state.
*/
export function useCommunicationController() {
const selectedCommunicationEntityId = useAppStore((s) => s.selectedCommunicationEntityId);
const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId);
const searchQuery = useAppStore((s) => s.searchQuery);
const setSearchQuery = useAppStore((s) => s.setSearchQuery);
const searchScope = useAppStore((s) => s.searchScope);
const setSearchScope = useAppStore((s) => s.setSearchScope);
const searchResults = useAppStore((s) => s.searchResults);
const setSearchResults = useAppStore((s) => s.setSearchResults);
const searchLoading = useAppStore((s) => s.searchLoading);
const setSearchLoading = useAppStore((s) => s.setSearchLoading);
const clearSearch = useAppStore((s) => s.clearSearch);
const activeThreadId = useAppStore((s) => s.activeThreadId);
const setActiveThreadId = useAppStore((s) => s.setActiveThreadId);
return {
selectedCommunicationEntityId,
setSelectedCommunicationEntityId,
searchQuery,
setSearchQuery,
searchScope,
setSearchScope,
searchResults,
setSearchResults,
searchLoading,
setSearchLoading,
clearSearch,
activeThreadId,
setActiveThreadId,
};
}
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { CompanyRecord, OnboardingBootstrapResult } from "@shared/types";
import { useAppStore } from "../state/store";
import { NEW_ENTITY_ID } from "../lib/constants";
import { unwrap } from "../lib/desktop";
import { useDesktopEvents } from "./useDesktopEvents";
import { useDataLoader } from "./useDataLoader";
import { useCompanyData } from "./useCompanyData";
export function useCompanyController() {
const snapshot = useAppStore((s) => s.snapshot);
const setSnapshot = useAppStore((s) => s.setSnapshot);
const section = useAppStore((s) => s.section);
const setError = useAppStore((s) => s.setError);
const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked);
const setSection = useAppStore((s) => s.setSection);
const setShowOnboarding = useAppStore((s) => s.setShowOnboarding);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
const selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
const selectedRunId = useAppStore((s) => s.selectedRunId);
const setBadges = useAppStore((s) => s.setBadges);
const setInboxItems = useAppStore((s) => s.setInboxItems);
const setSocialAccounts = useAppStore((s) => s.setSocialAccounts);
const setBrowserActions = useAppStore((s) => s.setBrowserActions);
const setCompanyMetrics = useAppStore((s) => s.setCompanyMetrics);
const setStandupReport = useAppStore((s) => s.setStandupReport);
const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId);
const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen);
const locale = useAppStore((s) => s.locale);
const setLocale = useAppStore((s) => s.setLocale);
const incrementLogRevision = useAppStore((s) => s.incrementLogRevision);
const currentCompany = useMemo<CompanyRecord | null>(() => {
if (!snapshot) return null;
return snapshot.companies.find((company) => company.id === snapshot.currentCompanyId) ?? snapshot.companies[0] ?? null;
}, [snapshot]);
const companyData = useCompanyData(snapshot, currentCompany);
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
const clearCompanyScopedUiState = useCallback(() => {
const store = useAppStore.getState();
store.clearCompanyScopedSelections();
store.setInboxItems([]);
store.setCompanyMetrics(null);
store.setStandupReport(null);
store.setSocialAccounts([]);
store.setBrowserActions([]);
store.setBadges(null);
store.setSkills([]);
store.setSelectedSkillId(null);
store.setSelectedSkillFilePath("SKILL.md");
store.setSkillImportModalOpen(false);
}, []);
const { refresh } = useDataLoader(
snapshot, section, selectedConnectorId, selectedRunId,
setSnapshot, setError, setInitialOnboardingLocked,
setSelectedConnectorId, setSelectedRunId,
setBadges, setInboxItems, setSocialAccounts, setBrowserActions,
setCompanyMetrics, setStandupReport,
);
const logBufferRef = useRef<Record<string, string>>({});
// Stable ref so the subscribe callback always calls the latest refresh
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useDesktopEvents(refreshRef, logBufferRef, incrementLogRevision);
// Theme / locale sync
useEffect(() => {
if (!snapshot) return;
const root = document.documentElement;
const applyTheme = () => {
const isDark = snapshot.theme === "dark" ||
(snapshot.theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", isDark);
root.style.colorScheme = isDark ? "dark" : "light";
};
applyTheme();
if (snapshot.locale && snapshot.locale !== locale) setLocale(snapshot.locale);
if (snapshot.theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", applyTheme);
return () => mq.removeEventListener("change", applyTheme);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setLocale is a stable Zustand setter
}, [snapshot, locale]);
useEffect(() => {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
}, [locale]);
// Keep selectedRunId in sync when run list changes
useEffect(() => {
if (selectedRunId && !companyData.runs.some((run) => run.id === selectedRunId)) {
setSelectedRunId(companyData.runs[0]?.id ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectedRunId is a stable Zustand setter
}, [companyData.runs, selectedRunId]);
// Pre-load run log when active run changes
const activeRunId = companyData.selectedRun?.id ?? null;
useEffect(() => {
if (!activeRunId) return;
void (async () => {
const activeRun = companyData.runs.find((run) => run.id === activeRunId);
if (!activeRun) return;
const result = await window.agentCompany.getRunLog({ companyId: activeRun.companyId, runId: activeRunId });
if (result.ok) {
logBufferRef.current = { ...logBufferRef.current, [activeRunId]: result.data };
incrementLogRevision();
return;
}
setError(result.error.message);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- incrementLogRevision and setError are stable Zustand setters
}, [activeRunId, companyData.runs]);
const setCompany = useCallback(async (companyId: string) => {
if (companyId === NEW_ENTITY_ID) { setShowOnboarding(true); return; }
try {
unwrap(await window.agentCompany.setCurrentCompany(companyId));
clearCompanyScopedUiState();
await refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError and setShowOnboarding are stable Zustand setters
}, [clearCompanyScopedUiState, refresh]);
const deleteCompany = useCallback(async (companyId: string) => {
try {
unwrap(await window.agentCompany.deleteCompany(companyId));
setConfirmDeleteCompanyId(null);
setCompanySwitcherOpen(false);
await refresh();
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
setError(null);
setSection("overview");
if (summary.runId) setSelectedRunId(summary.runId);
if (summary.leadAgentId) setSelectedAgentId(summary.leadAgentId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, [refresh]);
const openPathWithFeedback = useCallback(async (path: string) => {
try { unwrap(await window.agentCompany.openPath(path)); setError(null); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, []);
return {
snapshot,
currentCompany,
selectedConnector,
logBufferRef,
refresh,
setCompany,
deleteCompany,
handleOnboardingDone,
openPathWithFeedback,
...companyData,
};
}
@@ -0,0 +1,30 @@
import { useAppStore } from "../state/store";
/**
* Fine-grained subscriptions for execution domain:
* runs, approvals, costs, budgets, selected run/approval/task/connector.
*
* Cross-domain actions (startRun, cancelRun) are composed in useAppController
* because they need refresh() from useCompanyController.
*/
export function useExecutionController() {
const selectedTaskId = useAppStore((s) => s.selectedTaskId);
const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId);
const selectedConnectorId = useAppStore((s) => s.selectedConnectorId);
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
const selectedApprovalId = useAppStore((s) => s.selectedApprovalId);
const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId);
const selectedRunId = useAppStore((s) => s.selectedRunId);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
return {
selectedTaskId,
setSelectedTaskId,
selectedConnectorId,
setSelectedConnectorId,
selectedApprovalId,
setSelectedApprovalId,
selectedRunId,
setSelectedRunId,
};
}
@@ -0,0 +1,27 @@
import { useAppStore } from "../state/store";
/**
* Fine-grained subscriptions for organization domain:
* goals, projects, sprints, departments, workspaces, agents.
*/
export function useOrganizationController() {
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const selectedGoalId = useAppStore((s) => s.selectedGoalId);
const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId);
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId);
const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId);
const setSelectedWorkspaceId = useAppStore((s) => s.setSelectedWorkspaceId);
return {
selectedAgentId,
setSelectedAgentId,
selectedGoalId,
setSelectedGoalId,
selectedProjectId,
setSelectedProjectId,
selectedWorkspaceId,
setSelectedWorkspaceId,
};
}
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useDeferredValue, useMemo } from "react";
import type { AgentRecord, GoalRecord, ProjectRecord, RunRecord, SectionId, TaskRecord } from "@shared/types";
import { createTranslator } from "../i18n";
import { useAppStore } from "../state/store";
import { useFocusTrap, useReducedMotion } from "../components/ui";
import {
BookOpen,
Gear,
Tray,
SquaresFour,
UsersThree,
ListChecks,
ChatCircle,
PuzzlePiece,
FileText,
ShieldCheck,
Megaphone,
FolderOpen,
} from "@phosphor-icons/react";
import type { AppLocale } from "@shared/locale";
type NavItemDef = { id: SectionId; labelKey: string; icon: typeof SquaresFour };
export type NavItem = { id: SectionId; label: string; icon: typeof SquaresFour };
export type PaletteAction = { key: string; section: SectionId; entityId?: string };
const sidebarNavDefs: NavItemDef[] = [
{ id: "overview", labelKey: "nav.overview", icon: SquaresFour },
{ id: "inbox", labelKey: "nav.inbox", icon: Tray },
{ id: "communication", labelKey: "nav.communication", icon: ChatCircle },
{ id: "tasks", labelKey: "nav.work", icon: ListChecks },
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
{ id: "social", labelKey: "nav.social", icon: Megaphone },
{ id: "documents", labelKey: "nav.documents", icon: FileText },
];
const paletteOnlyNavDefs: NavItemDef[] = [
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
];
export function useUIController() {
const reducedMotion = useReducedMotion();
const noMotion = { duration: 0 };
const pageTransition = reducedMotion ? noMotion : { duration: 0.32, ease: [0.16, 1, 0.3, 1] as const };
const panelTransition = reducedMotion ? noMotion : { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
const locale = useAppStore((s) => s.locale);
const t = useMemo(() => createTranslator(locale), [locale]);
const sidebarNav: NavItem[] = useMemo(
() => sidebarNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })),
[t],
);
const navItems: NavItem[] = useMemo(
() =>
sidebarNav
.concat(paletteOnlyNavDefs.map((item) => ({ id: item.id, label: t(item.labelKey), icon: item.icon })))
.concat({ id: "settings", label: t("nav.settings"), icon: Gear }),
[sidebarNav, t],
);
const section = useAppStore((s) => s.section);
const setSection = useAppStore((s) => s.setSection);
const consoleOpen = useAppStore((s) => s.consoleOpen);
const setConsoleOpen = useAppStore((s) => s.setConsoleOpen);
const sidebarCollapsed = useAppStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useAppStore((s) => s.setSidebarCollapsed);
const companySwitcherOpen = useAppStore((s) => s.companySwitcherOpen);
const setCompanySwitcherOpen = useAppStore((s) => s.setCompanySwitcherOpen);
const confirmDeleteCompanyId = useAppStore((s) => s.confirmDeleteCompanyId);
const setConfirmDeleteCompanyId = useAppStore((s) => s.setConfirmDeleteCompanyId);
const taskViewMode = useAppStore((s) => s.taskViewMode);
const setTaskViewMode = useAppStore((s) => s.setTaskViewMode);
const showOnboarding = useAppStore((s) => s.showOnboarding);
const setShowOnboarding = useAppStore((s) => s.setShowOnboarding);
const initialOnboardingLocked = useAppStore((s) => s.initialOnboardingLocked);
const setInitialOnboardingLocked = useAppStore((s) => s.setInitialOnboardingLocked);
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const commandPaletteSearch = useAppStore((s) => s.commandPaletteSearch);
const setCommandPaletteSearch = useAppStore((s) => s.setCommandPaletteSearch);
const selectedPaletteIndex = useAppStore((s) => s.selectedPaletteIndex);
const setSelectedPaletteIndex = useAppStore((s) => s.setSelectedPaletteIndex);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const setSelectedConnectorId = useAppStore((s) => s.setSelectedConnectorId);
const setSelectedRunId = useAppStore((s) => s.setSelectedRunId);
const setSelectedTaskId = useAppStore((s) => s.setSelectedTaskId);
const setSelectedApprovalId = useAppStore((s) => s.setSelectedApprovalId);
const setSelectedGoalId = useAppStore((s) => s.setSelectedGoalId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId);
const setSelectedCommunicationEntityId = useAppStore((s) => s.setSelectedCommunicationEntityId);
const handleLocaleChange = useCallback(async (newLocale: AppLocale) => {
useAppStore.getState().setLocale(newLocale);
try { await window.agentCompany.updateSettings({ locale: newLocale }); } catch { /* local state already updated */ }
}, []);
const paletteTrapRef = useFocusTrap(commandPaletteOpen);
// These are passed in from useAppController which has access to entity lists
// For standalone palette search, we expose the query and let consumers compute results
const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase());
const navigateTo = useCallback((targetSection: SectionId, entityId?: string, runs?: RunRecord[], tasks?: TaskRecord[]) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "connectors") setSelectedConnectorId(entityId);
else if (targetSection === "runs") { setSelectedRunId(entityId); setConsoleOpen(true); }
else if (targetSection === "tasks") {
const matchingTask = tasks?.find((task) => task.id === entityId) ?? null;
if (matchingTask) { setSelectedTaskId(matchingTask.id); }
else {
const matchingRun = runs?.find((run) => run.id === entityId) ?? null;
if (matchingRun) { setSelectedRunId(matchingRun.id); setSelectedTaskId(matchingRun.taskId); setConsoleOpen(true); }
}
}
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters are stable references
}, []);
const buildPaletteResults = useCallback((
navItemsList: NavItem[],
companyAgents: AgentRecord[],
tasks: TaskRecord[],
runs: RunRecord[],
companyGoals: GoalRecord[],
companyProjects: ProjectRecord[],
) => {
const filteredNavItems = paletteQuery
? navItemsList.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
: navItemsList;
const matchedAgents = paletteQuery ? companyAgents.filter((a) => a.name.toLowerCase().includes(paletteQuery) || a.role.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedTasks = paletteQuery ? tasks.filter((task) => task.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedRuns = paletteQuery ? runs.filter((run) => {
const matchingTask = tasks.find((task) => task.id === run.taskId);
return [run.id, run.summary ?? "", run.errorMessage ?? "", matchingTask?.title ?? "", run.connectorId]
.some((value) => value.toLowerCase().includes(paletteQuery));
}).slice(0, 5) : [];
const matchedGoals = paletteQuery ? companyGoals.filter((g) => g.title.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const matchedProjects = paletteQuery ? companyProjects.filter((p) => p.name.toLowerCase().includes(paletteQuery)).slice(0, 5) : [];
const hasEntityResults = matchedAgents.length > 0 || matchedTasks.length > 0 || matchedRuns.length > 0 || matchedGoals.length > 0 || matchedProjects.length > 0;
return { filteredNavItems, matchedAgents, matchedTasks, matchedRuns, matchedGoals, matchedProjects, hasEntityResults };
}, [paletteQuery]);
const buildPaletteActions = useCallback((
filteredNavItems: NavItem[],
matchedAgents: AgentRecord[],
matchedTasks: TaskRecord[],
matchedRuns: RunRecord[],
matchedGoals: GoalRecord[],
matchedProjects: ProjectRecord[],
): PaletteAction[] => ([
...filteredNavItems.map((item) => ({ key: `section:${item.id}`, section: item.id })),
...matchedAgents.map((agent) => ({ key: `agent:${agent.id}`, section: "agents" as const, entityId: agent.id })),
...matchedTasks.map((task) => ({ key: `task:${task.id}`, section: "tasks" as const, entityId: task.id })),
...matchedRuns.map((run) => ({ key: `run:${run.id}`, section: "runs" as const, entityId: run.id })),
...matchedGoals.map((goal) => ({ key: `goal:${goal.id}`, section: "goals" as const, entityId: goal.id })),
...matchedProjects.map((project) => ({ key: `project:${project.id}`, section: "projects" as const, entityId: project.id })),
]), []);
return {
pageTransition,
panelTransition,
locale,
t,
handleLocaleChange,
sidebarNav,
navItems,
section,
setSection,
consoleOpen,
setConsoleOpen,
sidebarCollapsed,
setSidebarCollapsed,
companySwitcherOpen,
setCompanySwitcherOpen,
confirmDeleteCompanyId,
setConfirmDeleteCompanyId,
taskViewMode,
setTaskViewMode,
showOnboarding,
setShowOnboarding,
initialOnboardingLocked,
setInitialOnboardingLocked,
commandPaletteOpen,
setCommandPaletteOpen,
commandPaletteSearch,
setCommandPaletteSearch,
selectedPaletteIndex,
setSelectedPaletteIndex,
paletteQuery,
paletteTrapRef,
navigateTo,
buildPaletteResults,
buildPaletteActions,
};
}
+35
View File
@@ -703,6 +703,41 @@ export const en: Record<string, string> = {
"org.model": "Model",
"org.interval": "Interval",
"org.fit": "Fit",
"org.description": "Description",
"org.status": "Status",
"org.statusPlanned": "Planned",
"org.statusActive": "Active",
"org.statusAchieved": "Achieved",
"org.statusCancelled": "Cancelled",
"org.connector": "Connector",
"org.advancedSettings": "Advanced settings",
"org.hireRequestNote": "New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.",
"org.connectorLabel": "Connector:",
"org.workspaceLabel": "Workspace:",
"org.goalLabel": "Goal:",
"org.leadLabel": "Lead:",
"org.targetLabel": "Target:",
"org.workspaceCount": "{0} workspace",
"org.workspacesCount": "{0} workspaces",
"org.noConnectorAvailable": "No execution-ready connector is available. Fix connector health before requesting hires.",
"org.selectConnectorFirst": "Select an execution-ready connector before requesting a hire.",
"org.hireRequestFailed": "Hire request failed.",
"dash.activeWork": "Active Work",
"dash.allRuns": "All runs",
"dash.noTasksRunning": "No tasks currently running",
"dash.agentActive": "agent active",
"dash.agentsActive": "agents active",
"dash.taskInProgress": "task in progress",
"dash.tasksInProgress": "tasks in progress",
"dash.completedCount": "completed",
"exec.taskDescription": "Description",
"exec.describeTask": "Describe the task",
"social.addAccountLabel": "Add Account",
"social.howItWorksTitle": "How it works",
"social.accountsTab": "Accounts ({0})",
"social.actionHistoryTab": "Action History ({0})",
"social.accountsConnectedCount": "{0} account connected",
"social.accountsConnectedCountPlural": "{0} accounts connected",
// ── Runs ──
"runs.noRunsYet": "No agent runs yet",
+35
View File
@@ -703,6 +703,41 @@ export const zh: Record<string, string> = {
"org.model": "模型",
"org.interval": "间隔",
"org.fit": "适配",
"org.description": "描述",
"org.status": "状态",
"org.statusPlanned": "已规划",
"org.statusActive": "活跃",
"org.statusAchieved": "已达成",
"org.statusCancelled": "已取消",
"org.connector": "连接器",
"org.advancedSettings": "高级设置",
"org.hireRequestNote": "新智能体先进招聘管线。审批通过后激活,入职、预算和组织历史记录全部挂在同一条记录下。",
"org.connectorLabel": "连接器:",
"org.workspaceLabel": "工作区:",
"org.goalLabel": "目标:",
"org.leadLabel": "负责人:",
"org.targetLabel": "目标日期:",
"org.workspaceCount": "{0} 个工作区",
"org.workspacesCount": "{0} 个工作区",
"org.noConnectorAvailable": "暂无可用的执行连接器,请先修复连接器状态再发起招聘。",
"org.selectConnectorFirst": "请先选择一个可用的执行连接器再发起招聘。",
"org.hireRequestFailed": "招聘申请失败。",
"dash.activeWork": "当前工作",
"dash.allRuns": "全部运行",
"dash.noTasksRunning": "当前没有任务在运行",
"dash.agentActive": "个智能体活跃",
"dash.agentsActive": "个智能体活跃",
"dash.taskInProgress": "个任务进行中",
"dash.tasksInProgress": "个任务进行中",
"dash.completedCount": "已完成",
"exec.taskDescription": "描述",
"exec.describeTask": "描述任务",
"social.addAccountLabel": "添加账号",
"social.howItWorksTitle": "使用说明",
"social.accountsTab": "账号({0})",
"social.actionHistoryTab": "操作历史({0})",
"social.accountsConnectedCount": "已连接 {0} 个账号",
"social.accountsConnectedCountPlural": "已连接 {0} 个账号",
// ── 运行 ──
"runs.noRunsYet": "暂无智能体运行记录",
+8
View File
@@ -2,6 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
CSP: 'unsafe-inline' in style-src is required because:
1. Framer Motion injects <style> elements at runtime (AnimatePresence/PopChild)
2. OnboardingHero.tsx uses inline <style> for @keyframes
3. Tailwind CSS v4 Vite plugin injects <style> during dev mode (HMR)
Removing it breaks animations and dev workflow. Nonce-based CSP is not feasible
because Framer Motion's runtime style injection does not support nonces.
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';"
+2
View File
@@ -0,0 +1,2 @@
/** Sentinel value used as entity ID to indicate "create new" in UI state. */
export const NEW_ENTITY_ID = "__new__" as const;
+1 -1
View File
@@ -86,7 +86,7 @@ export function ConsoleView({
</div>
{selectedRun.logPath ? (
<div className="pt-1">
<ActionButton label={t("common.openLog")} onClick={() => void openPathWithFeedback(selectedRun.logPath!)} icon={FolderOpen} />
<ActionButton label={t("common.openLog")} onClick={() => { const p = selectedRun.logPath; if (p) void openPathWithFeedback(p); }} icon={FolderOpen} />
</div>
) : null}
</div>
+3 -2
View File
@@ -9,6 +9,7 @@ import {
} from "@phosphor-icons/react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
import { SectionButton } from "../components/ui";
import { NEW_ENTITY_ID } from "../lib/constants";
type NavItem = { id: SectionId; label: string; icon: typeof Gear };
@@ -129,7 +130,7 @@ export function SidebarView({
<button
type="button"
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px] text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => { void setCompany("__new__"); setCompanySwitcherOpen(false); }}
onClick={() => { void setCompany(NEW_ENTITY_ID); setCompanySwitcherOpen(false); }}
>
<Plus size={14} />
<span>{t("app.newCompany")}</span>
@@ -168,7 +169,7 @@ export function SidebarView({
<div className={`border-t border-border py-2 ${sidebarCollapsed ? "px-1.5" : "px-3"}`}>
<SectionButton
active={section === "settings"}
label="Settings"
label={t("nav.settings")}
icon={Gear}
onClick={() => setSection("settings")}
collapsed={sidebarCollapsed}
@@ -1,4 +1,5 @@
import { useState } from "react";
import { NEW_ENTITY_ID } from "../../lib/constants";
import type {
AgentRecord,
ApprovalRecord,
@@ -123,6 +124,8 @@ export function ExecutionSections({
const [costsTab, setCostsTab] = useState<"costs" | "policies" | "incidents">("costs");
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
if (!currentCompany) return null;
return (
<>
{section === "runs" ? (
@@ -179,11 +182,11 @@ export function ExecutionSections({
{section === "tasks" ? (
<>
<Panel title="Goals" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
<Panel title="Goals" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId(NEW_ENTITY_ID)} />}>
<GoalList goals={companyGoals} agents={companyAgents} selectedGoalId={selectedGoalId} onSelect={setSelectedGoalId} />
</Panel>
<Modal open={selectedGoalId === "__new__" && section === "tasks"} title="New goal" onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany!.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
<Modal open={selectedGoalId === NEW_ENTITY_ID && section === "tasks"} title="New goal" onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedGoal && section === "tasks"} title={selectedGoal ? `Edit: ${selectedGoal.title}` : ""} onClose={() => setSelectedGoalId(null)}>
{selectedGoal ? (
@@ -192,7 +195,7 @@ export function ExecutionSections({
goal={selectedGoal}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
companyId={currentCompany.id}
onSaved={async () => { setSelectedGoalId(null); await refresh(); }}
onDeleted={async () => { setSelectedGoalId(null); await refresh(); }}
/>
@@ -219,7 +222,7 @@ export function ExecutionSections({
<Kanban size={14} />
</button>
</div>
<ActionButton label="New task" icon={ListChecks} tone="accent" onClick={() => setSelectedTaskId("__new__")} />
<ActionButton label="New task" icon={ListChecks} tone="accent" onClick={() => setSelectedTaskId(NEW_ENTITY_ID)} />
</div>
}
>
@@ -229,8 +232,8 @@ export function ExecutionSections({
<TaskQueue tasks={tasks} runs={runs} onSelect={setSelectedTaskId} selectedTaskId={selectedTask?.id ?? null} />
)}
</Panel>
<Modal open={selectedTaskId === "__new__"} title="New task" onClose={() => setSelectedTaskId(null)}>
<TaskCreator companyId={currentCompany!.id} goals={companyGoals} projects={companyProjects} agents={companyAgents} workspaces={companyWorkspaces} onSaved={async () => { setSelectedTaskId(null); await refresh(); }} />
<Modal open={selectedTaskId === NEW_ENTITY_ID} title="New task" onClose={() => setSelectedTaskId(null)}>
<TaskCreator companyId={currentCompany.id} goals={companyGoals} projects={companyProjects} agents={companyAgents} workspaces={companyWorkspaces} onSaved={async () => { setSelectedTaskId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedTask} title={selectedTask?.title ?? ""} onClose={() => setSelectedTaskId(null)} width="720px">
{selectedTask ? (
@@ -242,7 +245,7 @@ export function ExecutionSections({
goals={companyGoals}
projects={companyProjects}
workspaces={companyWorkspaces}
companyId={currentCompany!.id}
companyId={currentCompany.id}
comments={(snapshot.comments ?? []).filter((comment) => comment.taskId === selectedTask.id)}
onRefresh={refresh}
onRun={startRun}
@@ -250,7 +253,7 @@ export function ExecutionSections({
onCreateApproval={async () => {
const isBlocked = selectedTask.status === "blocked";
const approvalId = unwrap(await window.agentCompany.requestApproval({
companyId: currentCompany!.id,
companyId: currentCompany.id,
relatedTaskId: selectedTask.id,
requestedByAgentId: selectedTask.assigneeAgentId,
type: isBlocked ? "secret_access" : "approve_ceo_strategy",
@@ -291,9 +294,9 @@ export function ExecutionSections({
agents={companyAgents}
tasks={tasks}
documents={companyDocuments}
companyId={currentCompany!.id}
companyId={currentCompany.id}
onDecide={async (state, note) => {
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
await refresh();
}}
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
@@ -338,13 +341,13 @@ export function ExecutionSections({
/>
) : costsTab === "policies" ? (
<PolicyList
companyId={currentCompany!.id}
companyId={currentCompany.id}
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
/>
) : (
<IncidentList
companyId={currentCompany!.id}
companyId={currentCompany.id}
agents={companyAgents.map((a) => ({ id: a.id, name: a.name }))}
projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))}
/>
@@ -284,7 +284,7 @@ export function OperationsSections({
<WorkspaceList
workspaces={companyWorkspaces}
projects={companyProjects}
companyId={currentCompany!.id}
companyId={currentCompany.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId}
onOpen={openPathWithFeedback}
@@ -306,7 +306,7 @@ export function OperationsSections({
/>
</Panel>
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
<WorkspaceCreator companyId={currentCompany.id} projects={companyProjects} onSaved={refresh} />
</Panel>
</div>
}
@@ -316,7 +316,7 @@ export function OperationsSections({
{section === "settings" && currentCompany ? (
<div className="space-y-10">
<Panel title={locale === "zh" ? "配置" : "Profile and release"}>
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany!.id} onSaved={refresh} />
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany.id} onSaved={refresh} />
</Panel>
<Panel title={locale === "zh" ? "模型管理" : "Model Management"}>
<ModelManagement connectors={snapshot.connectors} locale={locale} onSaved={refresh} />
@@ -7,6 +7,7 @@ import type {
ProfileSnapshot,
SectionId,
} from "@shared/types";
import { NEW_ENTITY_ID } from "../../lib/constants";
import {
Target,
UserPlus,
@@ -26,6 +27,7 @@ import {
} from "../../components/organization";
import { HiringPipeline } from "../../components/hiring";
import { ActionButton, Modal, Panel } from "../../components/ui";
import { useT } from "../../i18n";
interface OrganizationSectionsProps {
section: SectionId;
@@ -62,16 +64,19 @@ export function OrganizationSections({
setSection,
refresh,
}: OrganizationSectionsProps) {
const t = useT();
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
const selectedGoal = companyGoals.find((g) => g.id === selectedGoalId) ?? null;
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
if (!currentCompany) return null;
return (
<>
{section === "agents" ? (
<>
<OrgChart agents={companyAgents} connectors={snapshot.connectors} onSelectAgent={(id) => setSelectedAgentId(id)} />
<Panel title="Agent roster" action={<ActionButton label="New hire request" icon={UserPlus} tone="accent" onClick={() => setSelectedAgentId("__new__")} />}>
<Panel title={t("org.agentRoster")} action={<ActionButton label={t("org.newHireRequest")} icon={UserPlus} tone="accent" onClick={() => setSelectedAgentId(NEW_ENTITY_ID)} />}>
<AgentRoster
agents={companyAgents}
connectors={snapshot.connectors}
@@ -80,9 +85,9 @@ export function OrganizationSections({
onSelect={setSelectedAgentId}
/>
</Panel>
<Modal open={selectedAgentId === "__new__"} title="Request new hire" onClose={() => setSelectedAgentId(null)}>
<Modal open={selectedAgentId === NEW_ENTITY_ID} title={t("org.requestNewHire")} onClose={() => setSelectedAgentId(null)}>
<AgentCreator
companyId={currentCompany!.id}
companyId={currentCompany.id}
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
agents={companyAgents}
@@ -101,7 +106,7 @@ export function OrganizationSections({
connectors={snapshot.connectors}
workspaces={companyWorkspaces}
agents={companyAgents}
companyId={currentCompany!.id}
companyId={currentCompany.id}
onSaved={refresh}
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
/>
@@ -117,7 +122,7 @@ export function OrganizationSections({
{section === "hiring" && currentCompany ? (
<HiringPipeline
agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany.id)}
onRefresh={refresh}
onNavigateToAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }}
/>
@@ -125,11 +130,11 @@ export function OrganizationSections({
{section === "goals" ? (
<>
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
<Panel title={t("org.goalStack")} action={<ActionButton label={t("org.newGoal")} icon={Target} tone="accent" onClick={() => setSelectedGoalId(NEW_ENTITY_ID)} />}>
<GoalList goals={companyGoals} agents={companyAgents} selectedGoalId={selectedGoalId} onSelect={setSelectedGoalId} />
</Panel>
<Modal open={selectedGoalId === "__new__"} title="New goal" onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany!.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
<Modal open={selectedGoalId === NEW_ENTITY_ID} title={t("org.newGoal")} onClose={() => setSelectedGoalId(null)}>
<GoalCreator companyId={currentCompany.id} agents={companyAgents} goals={companyGoals} onSaved={async () => { setSelectedGoalId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedGoal} title={selectedGoal ? `Edit: ${selectedGoal.title}` : ""} onClose={() => setSelectedGoalId(null)}>
{selectedGoal ? (
@@ -138,7 +143,7 @@ export function OrganizationSections({
goal={selectedGoal}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
companyId={currentCompany.id}
onSaved={async () => { setSelectedGoalId(null); await refresh(); }}
onDeleted={async () => { setSelectedGoalId(null); await refresh(); }}
/>
@@ -149,11 +154,11 @@ export function OrganizationSections({
{section === "projects" ? (
<>
<Panel title="Projects" action={<ActionButton label="New project" icon={Kanban} tone="accent" onClick={() => setSelectedProjectId("__new__")} />}>
<Panel title={t("nav.projects")} action={<ActionButton label={t("org.newProject")} icon={Kanban} tone="accent" onClick={() => setSelectedProjectId(NEW_ENTITY_ID)} />}>
<ProjectList projects={companyProjects} goals={companyGoals} agents={companyAgents} workspaces={companyWorkspaces} selectedProjectId={selectedProjectId} onSelect={setSelectedProjectId} />
</Panel>
<Modal open={selectedProjectId === "__new__"} title="New project" onClose={() => setSelectedProjectId(null)}>
<ProjectCreator companyId={currentCompany!.id} goals={companyGoals} agents={companyAgents} onSaved={async () => { setSelectedProjectId(null); await refresh(); }} />
<Modal open={selectedProjectId === NEW_ENTITY_ID} title={t("org.newProject")} onClose={() => setSelectedProjectId(null)}>
<ProjectCreator companyId={currentCompany.id} goals={companyGoals} agents={companyAgents} onSaved={async () => { setSelectedProjectId(null); await refresh(); }} />
</Modal>
<Modal open={!!selectedProject} title={selectedProject ? `Edit: ${selectedProject.name}` : ""} onClose={() => setSelectedProjectId(null)}>
{selectedProject ? (
@@ -162,7 +167,7 @@ export function OrganizationSections({
project={selectedProject}
goals={companyGoals}
agents={companyAgents}
companyId={currentCompany!.id}
companyId={currentCompany.id}
onSaved={async () => { setSelectedProjectId(null); await refresh(); }}
onDeleted={async () => { setSelectedProjectId(null); await refresh(); }}
/>