Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out. - Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language. - OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol. - Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar. - Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used. - Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors. - Per-agent model picker lists the provider's whole model list, live when connected. - Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id. - The stored "clock" theme reads back instead of falling to "system". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
import { XpBar } from "../shared/XpBar";
|
|
import { runTriggerLabel } from "../i18n/labels";
|
|
import { goalProgress, type GoalPace } from "./home/goal-progress";
|
|
import { nextUp } from "./home/next-up";
|
|
import { NextUpCard } from "./home/NextUpCard";
|
|
import { newestRuns } from "./runs/newest-runs";
|
|
import { isBusinessTask } from "./work/task-helpers";
|
|
import { useMemo, useState, type CSSProperties } from "react";
|
|
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
|
import { TopbarActions } from "../shared/TopbarActions";
|
|
import type {
|
|
AgentRecord,
|
|
ChatMessageRecord,
|
|
EmployeeRecord,
|
|
GoalRecord,
|
|
ProviderRecord,
|
|
TaskRecord,
|
|
TaskRunRecord,
|
|
} from "../../../shared/domain";
|
|
import { NewTaskDialog } from "./NewTaskDialog";
|
|
import { AcceptanceChart, AgentWorkloadChart, CostTrendChart, RunActivityChart } from "./home/DashboardCharts";
|
|
import type { PageProps } from "./types";
|
|
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types";
|
|
import { t } from "../i18n/i18n";
|
|
|
|
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
|
const METRICS: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
|
const ACTIVITY: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 16 };
|
|
|
|
export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
|
const orgId = activeOrgId(snapshot);
|
|
const organization = requireActiveOrganization(snapshot);
|
|
const employee = requireActiveEmployee(snapshot);
|
|
const tasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
|
|
// Progress and workload count real work only; run titles still resolve against every task.
|
|
const businessTasks = useMemo(() => tasks.filter(isBusinessTask), [tasks]);
|
|
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
|
|
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
|
|
const goals = useMemo(() => snapshot.goals.filter((item) => item.orgId === orgId), [snapshot.goals, orgId]);
|
|
const providers = useMemo(() => snapshot.providers.filter((provider) => provider.orgId === orgId), [snapshot.providers, orgId]);
|
|
const budgetCapCents = useMemo(
|
|
() => snapshot.hiringPolicies.filter((policy) => policy.orgId === orgId).reduce((sum, policy) => sum + policy.monthlyBudgetLimitCents, 0),
|
|
[snapshot.hiringPolicies, orgId],
|
|
);
|
|
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
|
|
const chatMessages = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
|
|
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((s) => s.orgId === orgId), [snapshot.chatSpaces, orgId]);
|
|
const usageStats = requireUsageStats(snapshot.usageStats, orgId);
|
|
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
|
|
|
const { needsYou, next } = nextUp(approvals, businessTasks);
|
|
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
|
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
|
|
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
|
|
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
|
|
const greeting = greetingFor(new Date().getHours());
|
|
|
|
return (
|
|
<div className="page-wide" style={{ maxWidth: 1240 }}>
|
|
<TopbarActions>
|
|
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" /> {t("New task")}</Button>
|
|
</TopbarActions>
|
|
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
|
<p className="page-sub">{renderSummary(needsYou.length, agents.length)}</p>
|
|
|
|
<div style={STRIP}>
|
|
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
|
|
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
|
<NextUpCard
|
|
needsYou={needsYou}
|
|
next={next}
|
|
agents={agents}
|
|
employees={[employee]}
|
|
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))}
|
|
onOpenApprovals={() => navigate("approvals")}
|
|
onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })}
|
|
/>
|
|
</div>
|
|
|
|
<div style={METRICS}>
|
|
<AgentWorkloadChart agents={agents} tasks={businessTasks} />
|
|
<RunActivityChart days={usageStats.days} />
|
|
<AcceptanceChart days={usageStats.days} />
|
|
<CostTrendChart days={usageStats.days} />
|
|
</div>
|
|
|
|
<div style={ACTIVITY}>
|
|
<RunActivityCard taskRuns={taskRuns} tasks={tasks} agents={agents} onViewAll={() => navigate("runs")} />
|
|
<RecentMessagesCard messages={chatMessages} agents={agents} employees={[employee]} spaces={chatSpaces} onViewAll={() => navigate("chat")} />
|
|
</div>
|
|
|
|
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GoalCard({
|
|
goal,
|
|
progress,
|
|
organizationName,
|
|
actions,
|
|
}: {
|
|
goal: GoalRecord | null;
|
|
progress: { percent: number; pace: GoalPace | null } | null;
|
|
organizationName: string;
|
|
actions: PageProps["actions"];
|
|
}) {
|
|
const percent = progress?.percent ?? 0;
|
|
const [resumeError, setResumeError] = useState<string | null>(null);
|
|
const resume = (stalled: GoalRecord) => {
|
|
setResumeError(null);
|
|
actions.run(() => window.agentCompany.resumeGoal({ orgId: stalled.orgId, goalId: stalled.id }))
|
|
.catch((error: unknown) => setResumeError(error instanceof Error ? error.message : String(error)));
|
|
};
|
|
return (
|
|
<section className="card">
|
|
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
|
|
<div style={{ fontSize: "var(--fs-body)", lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: "2.8em" }}>
|
|
{goal ? goal.title : t("Set the first goal for {organizationName}.", { organizationName })}
|
|
</div>
|
|
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
|
|
<span className="num" style={{ fontSize: "var(--fs-display)" }}>{progress ? `${percent}%` : "—"}</span>
|
|
<span style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{progress?.pace ? statusLabel(progress.pace) : t("no target date")}</span>
|
|
{goal && <TargetDate goal={goal} actions={actions} />}
|
|
</div>
|
|
<div style={{ marginTop: 12 }}><XpBar percent={percent} label={t("Goal progress")} /></div>
|
|
{goal?.status === "active" && goal.stalledReason && (
|
|
<div className="row" style={{ marginTop: 12, gap: 8, alignItems: "center" }}>
|
|
<span className="form-error" role="status" style={{ flex: 1, marginTop: 0 }}>{goal.stalledReason}</span>
|
|
<Button size="small" onClick={() => resume(goal)}>{t("Resume")}</Button>
|
|
</div>
|
|
)}
|
|
{resumeError && <div className="form-error" role="alert">{resumeError}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SpendCard({
|
|
providers,
|
|
totalBudgetCents,
|
|
capCents,
|
|
agents,
|
|
}: {
|
|
providers: ProviderRecord[];
|
|
totalBudgetCents: number;
|
|
capCents: number;
|
|
agents: AgentRecord[];
|
|
}) {
|
|
const connected = providers.filter((provider) => provider.enabled);
|
|
const fillPct = capCents > 0 ? Math.min(100, Math.round((totalBudgetCents / capCents) * 100)) : 0;
|
|
return (
|
|
<section className="card">
|
|
<div className="card-label" style={{ marginBottom: 12 }}>{t("Monthly budget")}</div>
|
|
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
|
|
<span className="num" style={{ fontSize: "var(--fs-display)" }}>{money(totalBudgetCents)}</span>
|
|
<span style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : t("allocated")}</span>
|
|
</div>
|
|
<div style={{ marginTop: 12 }}><XpBar percent={fillPct} label={t("Budget allocated")} /></div>
|
|
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{connected.slice(0, 3).map((provider) => {
|
|
const allocatedCents = agents.filter((agent) => agent.modelProviderId === provider.id).reduce((sum, agent) => sum + agent.budgetCents, 0);
|
|
return (
|
|
<div key={provider.id} className="row" style={{ fontSize: "var(--fs-body)" }}>
|
|
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</span>
|
|
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)" }}>{money(allocatedCents)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{connected.length === 0 && <span style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{t("No providers connected")}</span>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RunActivityCard({
|
|
taskRuns,
|
|
tasks,
|
|
agents,
|
|
onViewAll,
|
|
}: {
|
|
taskRuns: TaskRunRecord[];
|
|
tasks: TaskRecord[];
|
|
agents: AgentRecord[];
|
|
onViewAll: () => void;
|
|
}) {
|
|
const recent = newestRuns(taskRuns, 6);
|
|
return (
|
|
<section className="card">
|
|
<div className="row" style={{ marginBottom: 12 }}>
|
|
<span className="card-label">{t("Agent runs")}</span>
|
|
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: "var(--fs-body)" }} onClick={onViewAll}>{t("All runs")} →</button>
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
{recent.map((run) => {
|
|
const task = tasks.find((t) => t.id === run.taskId);
|
|
const agent = agents.find((a) => a.id === run.agentId);
|
|
return (
|
|
<div key={run.id} className="row" style={{ gap: 10 }}>
|
|
{agent && <Avatar name={agent.name} size="sm" bot />}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: "var(--fs-body)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task?.title ?? run.taskId}</div>
|
|
<div style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{agent?.name ?? t("Agent")} · {runTriggerLabel(run.trigger)}</div>
|
|
</div>
|
|
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
|
|
<span className="mono" style={{ fontSize: "var(--fs-body)", color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{recent.length === 0 && <div style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{t("No runs yet")}</div>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RecentMessagesCard({
|
|
messages,
|
|
agents,
|
|
employees,
|
|
spaces,
|
|
onViewAll,
|
|
}: {
|
|
messages: ChatMessageRecord[];
|
|
agents: AgentRecord[];
|
|
employees: EmployeeRecord[];
|
|
spaces: Array<{ id: string; name: string }>;
|
|
onViewAll: () => void;
|
|
}) {
|
|
const recent = messages.slice(-6).reverse();
|
|
return (
|
|
<section className="card">
|
|
<div className="row" style={{ marginBottom: 12 }}>
|
|
<span className="card-label">{t("Recent messages")}</span>
|
|
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: "var(--fs-body)" }} onClick={onViewAll}>{t("Open chat")} →</button>
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
{recent.map((msg) => {
|
|
const senderAgent = msg.senderType === "agent" ? agents.find((a) => a.id === msg.senderId) : null;
|
|
const senderEmployee = msg.senderType === "employee" ? employees.find((e) => e.id === msg.senderId) : null;
|
|
const name = senderAgent?.name ?? senderEmployee?.displayName ?? msg.senderType;
|
|
const space = spaces.find((s) => s.id === msg.spaceId);
|
|
const preview = msg.bodyMd.length > 80 ? `${msg.bodyMd.slice(0, 77)}...` : msg.bodyMd;
|
|
return (
|
|
<div key={msg.id} className="row" style={{ gap: 10, alignItems: "flex-start" }}>
|
|
<Avatar name={name} size="sm" bot={msg.senderType === "agent"} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="row" style={{ gap: 6 }}>
|
|
<span style={{ fontSize: "var(--fs-body)" }}>{name}</span>
|
|
{space && <span style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>#{space.name}</span>}
|
|
<span className="mono" style={{ marginLeft: "auto", fontSize: "var(--fs-body)", color: "var(--text-3)", flexShrink: 0 }}>{formatTime(msg.createdAt)}</span>
|
|
</div>
|
|
<div style={{ fontSize: "var(--fs-body)", color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1.4 }}>{preview}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{recent.length === 0 && <div style={{ fontSize: "var(--fs-body)", color: "var(--text-3)" }}>{t("No messages yet")}</div>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function statusLabel(pace: GoalPace): string {
|
|
if (pace === "ahead") return t("ahead");
|
|
if (pace === "behind") return t("behind");
|
|
if (pace === "overdue") return t("overdue");
|
|
return t("on track");
|
|
}
|
|
|
|
function greetingFor(hour: number): string {
|
|
if (hour < 12) return t("Good morning");
|
|
if (hour < 17) return t("Good afternoon");
|
|
return t("Good evening");
|
|
}
|
|
|
|
function renderSummary(pending: number, agents: number): string {
|
|
const things = pending === 1
|
|
? t("{n} thing waiting on you", { n: pending })
|
|
: t("{n} things waiting on you", { n: pending });
|
|
const onDeck = agents === 1
|
|
? t("{n} agent on deck", { n: agents })
|
|
: t("{n} agents on deck", { n: agents });
|
|
return `${things} · ${onDeck}`;
|
|
}
|
|
|
|
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
|
if (state === "failed") return "red";
|
|
if (state === "running") return "violet";
|
|
if (state === "queued") return "amber";
|
|
if (state === "completed") return "green";
|
|
return undefined;
|
|
}
|
|
|
|
function runStateLabel(state: string): string {
|
|
if (state === "queued") return t("Queued");
|
|
if (state === "running") return t("Running");
|
|
if (state === "completed") return t("Done");
|
|
if (state === "failed") return t("Failed");
|
|
if (state === "superseded") return t("Superseded");
|
|
if (state === "stopped") return t("Stopped");
|
|
return state;
|
|
}
|
|
|
|
/** Sets or clears when the goal should be reached, which is what its pace is measured against. */
|
|
function TargetDate({ goal, actions }: { goal: GoalRecord; actions: PageProps["actions"] }) {
|
|
const [error, setError] = useState<string | null>(null);
|
|
const value = goal.dueAt ? localDate(new Date(goal.dueAt)) : "";
|
|
const change = (date: string) => {
|
|
setError(null);
|
|
// A date means the end of that day where the operator is.
|
|
const dueAt = date ? new Date(`${date}T23:59:59`).toISOString() : null;
|
|
actions.run(() => window.agentCompany.setGoalDueDate({ orgId: goal.orgId, goalId: goal.id, dueAt }))
|
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
|
|
};
|
|
return (
|
|
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
|
|
<input className="input" type="date" aria-label={t("Target date")} value={value} onChange={(event) => change(event.target.value)} style={{ height: 26, padding: "0 6px", fontSize: "var(--fs-body)" }} />
|
|
{error && <span className="form-error" role="alert">{error}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** The calendar day of a time where the operator is, as a date input expects it. */
|
|
function localDate(time: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
|
|
}
|