diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 51834e2f..3032fb2c 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -600,7 +600,10 @@ export const zh: Record = { "Version": "版本", "Version (semver)": "版本(semver)", "Visibility": "可见性", - "Waiting on you": "等你处理", + "Needs you": "需要你处理", + "Next in line": "下一项工作", + "Stopped: {reason}": "已停下:{reason}", + "{n} more after these": "之后还有 {n} 项", "We never embed the key in the build. Credentials encrypt locally via OS-protected storage.": "密钥绝不打包进构建。凭证经操作系统保护的存储在本地加密。", "Welcome": "欢迎", "Work": "工作", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index ddf09069..0d0d9a6e 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -1,5 +1,7 @@ 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"; @@ -7,7 +9,6 @@ import { Avatar, Button, Icon, Pill } from "../shared/ui"; import { TopbarActions } from "../shared/TopbarActions"; import type { AgentRecord, - ApprovalRecord, ChatMessageRecord, EmployeeRecord, GoalRecord, @@ -46,7 +47,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) { const usageStats = requireUsageStats(snapshot.usageStats, orgId); const [newTaskOpen, setNewTaskOpen] = useState(false); - const pendingApprovals = approvals.filter((approval) => approval.state === "pending"); + 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; @@ -59,17 +60,19 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {

{greeting}, {firstName}

-

{renderSummary(pendingApprovals.length, agents.length)}

+

{renderSummary(needsYou.length, agents.length)}

- void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))} - onOpen={() => navigate("approvals")} + onOpenApprovals={() => navigate("approvals")} + onOpenTask={(taskId) => navigate("work", { kind: "task", id: taskId })} />
@@ -172,49 +175,6 @@ function SpendCard({ ); } -function WaitingCard({ - approvals, - agents, - employees, - onApprove, - onOpen, -}: { - approvals: ApprovalRecord[]; - agents: AgentRecord[]; - employees: EmployeeRecord[]; - onApprove: (approvalId: string) => void; - onOpen: () => void; -}) { - return ( -
-
- {t("Waiting on you")} - {approvals.length > 0 && {approvals.length}} -
- {approvals.length === 0 ? ( -
- {t("You're all caught up")} -
- ) : ( -
- {approvals.slice(0, 2).map((approval) => ( -
-
{approval.title}
-
- {t("{kind} · {risk} risk · from {requester}", { kind: humanizeLabel(approval.kind), risk: humanizeLabel(approval.risk), requester: resolveRequester(approval, employees, agents) })} -
-
- - -
-
- ))} -
- )} -
- ); -} - function RunActivityCard({ taskRuns, tasks, @@ -325,24 +285,6 @@ function renderSummary(pending: number, agents: number): string { return `${things} · ${onDeck}`; } -function humanizeLabel(value: string): string { - if (!value) return value; - const spaced = value.replace(/_/g, " "); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); -} - -function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string { - if (approval.requestedByType === "employee" && approval.requestedById) { - const employee = employees.find((item) => item.id === approval.requestedById); - if (employee) return employee.displayName; - } - if (approval.requestedByType === "agent" && approval.requestedById) { - const agent = agents.find((item) => item.id === approval.requestedById); - if (agent) return agent.name; - } - return t("System"); -} - function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined { if (state === "failed") return "red"; if (state === "running") return "violet"; diff --git a/src/renderer/src/pages/home/NextUpCard.tsx b/src/renderer/src/pages/home/NextUpCard.tsx new file mode 100644 index 00000000..77f3d80c --- /dev/null +++ b/src/renderer/src/pages/home/NextUpCard.tsx @@ -0,0 +1,88 @@ +import type { AgentRecord, ApprovalRecord, EmployeeRecord } from "../../../../shared/domain"; +import { approvalKindLabel, riskLabel } from "../../i18n/labels"; +import { t } from "../../i18n/i18n"; +import { Button, Icon, Pill } from "../../shared/ui"; +import type { NextUpItem } from "./next-up"; + +const SHOWN = 3; +const TITLE = { fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } as const; +const META = { fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" } as const; + +/** Home's one place for what to look at next: decisions and stopped work in priority order, or else the next task in line. */ +export function NextUpCard({ + needsYou, + next, + agents, + employees, + onApprove, + onOpenApprovals, + onOpenTask, +}: { + needsYou: NextUpItem[]; + next: NextUpItem | null; + agents: AgentRecord[]; + employees: EmployeeRecord[]; + onApprove: (approvalId: string) => void; + onOpenApprovals: () => void; + onOpenTask: (taskId: string) => void; +}) { + const more = needsYou.length - SHOWN; + return ( +
+
+ {t("Needs you")} + {needsYou.length > 0 && {needsYou.length}} +
+ {needsYou.length === 0 ? ( + <> +
+ {t("You're all caught up")} +
+ {next?.kind === "next" && ( +
+
{t("Next in line")}
+
{next.task.title}
+
+ +
+
+ )} + + ) : ( +
    + {needsYou.slice(0, SHOWN).map((item) => item.kind === "approval" ? ( +
  1. +
    {item.approval.title}
    +
    + {t("{kind} · {risk} risk · from {requester}", { kind: approvalKindLabel(item.approval.kind), risk: riskLabel(item.approval.risk), requester: requesterName(item.approval, employees, agents) })} +
    +
    + + +
    +
  2. + ) : item.kind === "held" ? ( +
  3. +
    {item.task.title}
    +
    {t("Stopped: {reason}", { reason: item.reason })}
    + +
  4. + ) : null)} + {more > 0 &&
  5. {t("{n} more after these", { n: more })}
  6. } +
+ )} +
+ ); +} + +function requesterName(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string { + if (approval.requestedByType === "employee" && approval.requestedById) { + const employee = employees.find((item) => item.id === approval.requestedById); + if (employee) return employee.displayName; + } + if (approval.requestedByType === "agent" && approval.requestedById) { + const agent = agents.find((item) => item.id === approval.requestedById); + if (agent) return agent.name; + } + return t("System"); +} diff --git a/src/renderer/src/pages/home/next-up.ts b/src/renderer/src/pages/home/next-up.ts new file mode 100644 index 00000000..3a9beee0 --- /dev/null +++ b/src/renderer/src/pages/home/next-up.ts @@ -0,0 +1,29 @@ +import type { ApprovalRecord, TaskPriority, TaskRecord } from "../../../../shared/domain"; + +/** One thing to look at next, in the single order Home shows them: decisions waiting on you, then work stopped until you act, then the next task in line. */ +export type NextUpItem = + | { kind: "approval"; approval: ApprovalRecord } + | { kind: "held"; task: TaskRecord; reason: string } + | { kind: "next"; task: TaskRecord }; + +const PRIORITY_RANK: Record = { high: 0, normal: 1, low: 2 }; + +const byPriorityThenOldest = (a: TaskRecord, b: TaskRecord) => + PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || a.createdAt.localeCompare(b.createdAt); + +export function nextUp(approvals: ApprovalRecord[], tasks: TaskRecord[]): { needsYou: NextUpItem[]; next: NextUpItem | null } { + const waiting: NextUpItem[] = approvals + .filter((approval) => approval.state === "pending") + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .map((approval) => ({ kind: "approval", approval })); + const open = tasks.filter((task) => task.status !== "done"); + const held: NextUpItem[] = open + .filter((task) => task.hold?.waitingOn === "operator") + .sort(byPriorityThenOldest) + .map((task) => ({ kind: "held", task, reason: task.hold!.reason })); + // Work already under way comes before work not yet started. + const inLine = open + .filter((task) => task.hold === null && task.status !== "review") + .sort((a, b) => Number(a.status !== "in_progress") - Number(b.status !== "in_progress") || byPriorityThenOldest(a, b))[0]; + return { needsYou: [...waiting, ...held], next: inLine ? { kind: "next", task: inLine } : null }; +} diff --git a/tests/e2e/next-up.spec.ts b/tests/e2e/next-up.spec.ts new file mode 100644 index 00000000..c0916684 --- /dev/null +++ b/tests/e2e/next-up.spec.ts @@ -0,0 +1,58 @@ +import { _electron as electron, expect, test } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture"; +import { closeApp, onboard } from "./onboard"; + +test("Home shows one ordered list of what needs you, with why work stopped, and else the next task in line", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-nextup-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-nextup-builtin-${randomUUID()}`)); + const app = await electron.launch({ + args: [resolve("out/main/index.js")], + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir }, + }); + try { + const page = await app.firstWindow(); + await onboard(page, "Next Up Co"); + const blocked = await page.evaluate(async () => { + type Api = Record Promise<{ ok: boolean; data: { activeOrgId: string; tasks: Array<{ id: string; title: string }> } }>>; + const api = (window as unknown as { agentCompany: Api }).agentCompany; + const seeded = (await api.loadSnapshot()).data; + const orgId = seeded.activeOrgId; + // Onboarding's kickoff task is finished first, so only this test's tasks are open. + for (const task of seeded.tasks) await api.updateTaskStatus({ orgId, taskId: task.id, status: "done" }); + const base = { orgId, description: "d", status: "backlog", assigneeAgentId: null, risk: "low", projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false }; + await api.createTask({ ...base, title: "Ship pricing page", priority: "normal" }); + await api.createTask({ ...base, title: "Fix login", priority: "high" }); + const id = (await api.loadSnapshot()).data.tasks.find((task) => task.title === "Fix login")!.id; + await api.setTaskBlocked({ orgId, taskId: id, reason: "Needs the staging key" }); + return { orgId, id }; + }); + + await page.getByRole("button", { name: "Home" }).click(); + const card = page.getByRole("region", { name: "Needs you" }); + const stopped = card.getByRole("listitem").filter({ hasText: "Fix login" }); + await expect(stopped).toContainText("Stopped: Blocked: Needs the staging key"); + await expect(card.getByText("Ship pricing page")).toHaveCount(0); + await expect(page.getByText(/1 thing waiting on you/)).toBeVisible(); + await stopped.getByRole("button", { name: "Open" }).click(); + await expect(page.getByRole("dialog", { name: /Fix login/ })).toBeVisible(); + await page.keyboard.press("Escape"); + + await page.evaluate(async ({ orgId, id }) => { + const api = (window as unknown as { agentCompany: Record Promise> }).agentCompany; + await api.setTaskBlocked({ orgId, taskId: id, reason: null }); + }, blocked); + await page.getByRole("button", { name: "Home" }).click(); + await expect(card.getByText("You're all caught up")).toBeVisible(); + // With nothing waiting, the most urgent open task is next. + await expect(card.getByText("Next in line")).toBeVisible(); + await expect(card.getByText("Fix login")).toBeVisible(); + await expect(card.getByText("Ship pricing page")).toHaveCount(0); + } finally { + await closeApp(app); + } +}); diff --git a/tests/unit/next-up.test.ts b/tests/unit/next-up.test.ts new file mode 100644 index 00000000..50751594 --- /dev/null +++ b/tests/unit/next-up.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { nextUp } from "../../src/renderer/src/pages/home/next-up"; +import type { ApprovalRecord, TaskRecord } from "../../src/shared/domain"; + +const approval = (id: string, createdAt: string, state = "pending") => ({ id, state, createdAt }) as ApprovalRecord; +const task = (id: string, fields: Partial) => + ({ id, status: "backlog", priority: "normal", hold: null, createdAt: "2026-01-01T00:00:00.000Z", ...fields }) as TaskRecord; +const ids = (items: ReturnType["needsYou"]) => + items.map((item) => (item.kind === "approval" ? item.approval.id : item.task.id)); + +test("decisions come first, oldest first, then work stopped until you act, most urgent first", () => { + const result = nextUp( + [approval("apr_new", "2026-01-03T00:00:00.000Z"), approval("apr_old", "2026-01-02T00:00:00.000Z"), approval("apr_done", "2026-01-01T00:00:00.000Z", "approved")], + [ + task("tsk_low", { priority: "low", hold: { reason: "Blocked: needs a key", waitingOn: "operator", resumesAt: null } }), + task("tsk_high", { priority: "high", hold: { reason: "Ran out of runs", waitingOn: "operator", resumesAt: null } }), + task("tsk_cooldown", { hold: { reason: "Cooling down", waitingOn: "provider", resumesAt: "2026-01-04T00:00:00.000Z" } }), + task("tsk_closed", { status: "done", hold: { reason: "Old", waitingOn: "operator", resumesAt: null } }), + ], + ); + expect(ids(result.needsYou)).toEqual(["apr_old", "apr_new", "tsk_high", "tsk_low"]); + const held = result.needsYou[2]; + expect(held.kind === "held" && held.reason).toBe("Ran out of runs"); +}); + +test("the next task in line is work under way before work not started, then by priority; held, waiting and finished work is not next", () => { + const backlogHigh = task("tsk_backlog_high", { priority: "high" }); + const runningLow = task("tsk_running_low", { status: "in_progress", priority: "low" }); + const runningHigh = task("tsk_running_high", { status: "in_progress", priority: "high", createdAt: "2026-01-05T00:00:00.000Z" }); + const review = task("tsk_review", { status: "review", priority: "high" }); + const cooling = task("tsk_cooling", { status: "in_progress", priority: "high", hold: { reason: "Cooling", waitingOn: "provider", resumesAt: "2026-01-04T00:00:00.000Z" } }); + expect(nextUp([], [backlogHigh, runningLow, review, cooling]).next).toEqual({ kind: "next", task: runningLow }); + expect(nextUp([], [backlogHigh, runningLow, runningHigh]).next).toEqual({ kind: "next", task: runningHigh }); + expect(nextUp([], [backlogHigh]).next).toEqual({ kind: "next", task: backlogHigh }); + expect(nextUp([], [review, cooling, task("tsk_done", { status: "done" })]).next).toBeNull(); +});