diff --git a/DEVLOG.md b/DEVLOG.md index f6843521..483d9fb0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,24 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-08-26 — Dashboard operator-home redesign + +- Replaced the team dashboard's independent masonry-style stacks with a + deterministic operator home: one recommended action and mutually exclusive + Now, Next, and Waiting lanes, plus a compact attention rail for decisions, + agent exceptions, and blocked work. +- Consolidated Pipeline, Throughput, Standup, and What's New into one + collapsible Workspace health drawer. Only the current What's New release can + be cleared; operational health stays visible and the drawer preference + persists per user. +- Preserved the personal dashboard and existing Forge design primitives, + limited agent-access onboarding to workspace admins, and added responsive + mobile rows plus compact loading feedback. +- Added deterministic lane unit coverage and desktop/mobile visual QA evidence + under `docs/audits/2026-08-26-dashboard-operator-home/`, plus production-build + Playwright coverage for responsive hierarchy, deduplication, drawer behavior, + and overflow. + ## 2026-08-25 — v0.33.0 release preparation - Squash-merged AXI-180 / PR #96, AXI-181 / PR #97, and AXI-182 / PR #98 to diff --git a/design-qa.md b/design-qa.md index 6ec65776..c5095286 100644 --- a/design-qa.md +++ b/design-qa.md @@ -73,3 +73,52 @@ No actionable P0, P1, or P2 differences remain. - [x] Same-state source/implementation comparison final result: passed + +--- + +# Design QA — AXI-107 dashboard operator home + +## Reference + +`docs/audits/2026-08-26-dashboard-operator-home/selected-dashboard-operator-home.png` + +## Rendered evidence + +- Desktop, 1440 × 1024: + `docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-desktop.png` +- Mobile, 390 × 844: + `docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-mobile.png` +- Side-by-side: + `docs/audits/2026-08-26-dashboard-operator-home/source-vs-implementation.png` + +## Visual review + +- Layout follows the selected operator-home hierarchy: recommended work and + trace-linked lanes on the left, attention and live context on the right, and + one full-width health drawer below. +- Independent masonry stacks and their dead-space failure mode are removed. +- Existing Forge type, spacing, border, badge, and semantic color tokens are + preserved. +- The responsive render has no horizontal overflow. Work remains first, then + attention and health context, with compact mobile issue rows. +- Loading uses a compact row skeleton, avoiding the large blank placeholder + visible during the first render pass. + +## Interaction review + +- Attention tabs expose correct selected state. +- Workspace-health collapse persists after the preference mutation settles. +- Clearing What's New hides only the current release notes; operational metrics + and the drawer remain available. +- Browser console review found no warnings or errors attributable to this view. + +## Verification + +- `pnpm lint` — passed with pre-existing repository warnings only. +- `pnpm typecheck` — passed. +- Focused Vitest dashboard suites — 9/9 passed. +- Focused production-build Playwright dashboard suite — passed at desktop, + tablet, and mobile breakpoints. +- Desktop and mobile in-app browser passes — passed. + +final result: passed diff --git a/docs/audits/2026-08-26-dashboard-operator-home/README.md b/docs/audits/2026-08-26-dashboard-operator-home/README.md new file mode 100644 index 00000000..1ae9cd68 --- /dev/null +++ b/docs/audits/2026-08-26-dashboard-operator-home/README.md @@ -0,0 +1,30 @@ +# Dashboard operator-home audit + +## Problem + +The previous dashboard used independently flowing card stacks. At common desktop +widths that made the left column end early while the right column continued, +creating large empty regions and obscuring the page's primary question: what +needs the operator now? + +## Implemented direction + +- One deterministic work surface assigns each issue to exactly one of `Now`, + `Next`, or `Waiting`, with one recommended action above the trace. +- A fixed attention rail separates decisions, agent exceptions, and blocked + work instead of mixing every alert into one feed. +- Agents and the current week remain compact secondary context. +- Workspace health is a single collapsible drawer. Pipeline, Throughput, + Standup, and What's New stay visible together when expanded. +- Only What's New can be cleared; the drawer and operational metrics remain. +- Mobile keeps the same semantic order and uses dense list rows instead of a + compressed desktop grid. + +## Evidence + +- `selected-dashboard-operator-home.png` — approved visual direction. +- `before-dashboard-overview.png` and `before-workspace-flow-gap.png` — source + layout evidence. +- `after-dashboard-desktop.png` and `after-dashboard-mobile.png` — rendered + local implementation. +- `source-vs-implementation.png` — side-by-side visual comparison. diff --git a/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-desktop.png b/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-desktop.png new file mode 100644 index 00000000..c2f7920d Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-desktop.png differ diff --git a/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-mobile.png b/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-mobile.png new file mode 100644 index 00000000..ff41be77 Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/after-dashboard-mobile.png differ diff --git a/docs/audits/2026-08-26-dashboard-operator-home/before-dashboard-overview.png b/docs/audits/2026-08-26-dashboard-operator-home/before-dashboard-overview.png new file mode 100644 index 00000000..ae9f7b0d Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/before-dashboard-overview.png differ diff --git a/docs/audits/2026-08-26-dashboard-operator-home/before-workspace-flow-gap.png b/docs/audits/2026-08-26-dashboard-operator-home/before-workspace-flow-gap.png new file mode 100644 index 00000000..4fd2927f Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/before-workspace-flow-gap.png differ diff --git a/docs/audits/2026-08-26-dashboard-operator-home/selected-dashboard-operator-home.png b/docs/audits/2026-08-26-dashboard-operator-home/selected-dashboard-operator-home.png new file mode 100644 index 00000000..9a3b3cd5 Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/selected-dashboard-operator-home.png differ diff --git a/docs/audits/2026-08-26-dashboard-operator-home/source-vs-implementation.png b/docs/audits/2026-08-26-dashboard-operator-home/source-vs-implementation.png new file mode 100644 index 00000000..2bf276af Binary files /dev/null and b/docs/audits/2026-08-26-dashboard-operator-home/source-vs-implementation.png differ diff --git a/src/app/(app)/w/[slug]/dashboard/page.tsx b/src/app/(app)/w/[slug]/dashboard/page.tsx index bd7b605d..080158f0 100644 --- a/src/app/(app)/w/[slug]/dashboard/page.tsx +++ b/src/app/(app)/w/[slug]/dashboard/page.tsx @@ -1,87 +1,14 @@ "use client"; + import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; +import { ArrowRight, Check, ChevronRight, Circle, Inbox, Rocket } from "lucide-react"; import { toast } from "sonner"; -import { - ArrowRight, - Check, - ChevronRight, - Globe, - KeyRound, - LayoutGrid, - List as ListIcon, - Mail, - Plus, - Rocket, - RotateCcw, - SlidersHorizontal, - Sparkles, - UserPlus, - X, -} from "lucide-react"; import { Topbar } from "@/components/topbar"; -import { Badge } from "@/components/ui/badge"; +import { OperatorHome } from "@/components/dashboard/operator-home"; +import { PersonalDashboard } from "@/components/dashboard/personal-dashboard"; import { Button } from "@/components/ui/button"; -import { CycleChip } from "@/components/cycle-chip"; -import { QuickNotesWidget } from "@/components/quick-notes-widget"; -import { TodayWidget } from "@/components/dashboard/today-widget"; -import { IdeasTile } from "@/components/dashboard/ideas-tile"; -import { WhatsNewTile } from "@/components/dashboard/whats-new-tile"; -import { AgentActivityTile } from "@/components/dashboard/agent-activity-tile"; -import { NeedsYouTile } from "@/components/dashboard/needs-you-tile"; -import { PulseTile } from "@/components/dashboard/pulse-tile"; -import { DeliveryWorkTile } from "@/components/dashboard/delivery-work-tile"; -import { IssueCard, type DashboardWorkCard } from "@/components/dashboard/issue-card"; -import { AgentAttentionPanel } from "@/components/agent-attention-panel"; -import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline"; -import { - DashboardStack, - priorityColumnsForWorkCount, - type DashboardLayout, - type DashboardWidget, -} from "@/components/dashboard/dashboard-stack"; -import { trpc } from "@/lib/trpc"; -import { cn, formatIssueId, relativeTime } from "@/lib/utils"; -import { useTimePrefs } from "@/lib/time-prefs"; -import { useCountUp } from "@/lib/use-count-up"; import { useWorkspace } from "@/hooks/use-workspace"; -import { PersonalDashboard } from "@/components/dashboard/personal-dashboard"; - -/** - * M6 (design spec): a metric that counts up from 0 once it scrolls into - * view. rAF, no animation library; jumps straight to the value under - * reduced-motion / motion-off, and tweens a given value only once per - * mount (see {@link useCountUp}). - */ -function CountUp({ value, className }: { value: number; className?: string }) { - const { value: n, ref } = useCountUp(value); - return ( - - {n} - - ); -} - -const PRIORITY_GLYPH: Record = { - URGENT: "!!!", - HIGH: "!!", - MEDIUM: "!", - LOW: "·", - NONE: "—", -}; -const ONBOARDING_KEY = "forge:onboarding:dismissed"; -const ONBOARDING_DONE_TOAST = "forge:onboarding:done-toast"; -const DASHBOARD_LAYOUT_VERSION = 2; -type DashboardFlowBreakpoint = "mobile" | "tablet" | "desktop"; -const FLOW_ORDER: Record = { - // Three tracks: pair every wide module with a compact third-column tile. - desktop: ["pipeline", "whats-new", "suggestions", "quick-notes", "workspace-activity", "ideas"], - // Two tracks: wide modules take complete rows; compact modules pair up. - tablet: ["pipeline", "suggestions", "whats-new", "ideas", "quick-notes", "workspace-activity"], - // One track: preserve the operational reading order. - mobile: ["pipeline", "whats-new", "suggestions", "quick-notes", "workspace-activity", "ideas"], -}; +import { trpc } from "@/lib/trpc"; export default function DashboardPage() { const workspace = useWorkspace(); @@ -90,368 +17,32 @@ export default function DashboardPage() { function TeamDashboard() { const workspace = useWorkspace(); - const slug = workspace.slug; - const { data: me } = trpc.workspace.me.useQuery(); - const { data: account } = trpc.user.me.useQuery(); - const { data: ws } = trpc.workspace.current.useQuery(); - const { data: members } = trpc.workspace.members.useQuery(); - const { data: statuses } = trpc.status.list.useQuery(); - const { data: projects } = trpc.project.list.useQuery({ archived: false, limit: 100 }); - const { data: access } = trpc.access.list.useQuery(); - const active = trpc.issue.list.useQuery({ includeDone: false, limit: 100 }); - const anyIssue = trpc.issue.list.useQuery({ includeDone: true, limit: 1 }); - // "You" zone — Focus + Pick-up, enriched server-side into rich cards - // (people / progress / activity / context). Replaces the old client-side - // filter of the 100-issue `issue.list`. - const myWork = trpc.dashboard.myWork.useQuery(); - - const isAdmin = me?.role === "OWNER" || me?.role === "ADMIN"; - const prefs = useTimePrefs(); - - const workspaceKey = ws?.key ?? "—"; - const firstName = (me?.user.name ?? me?.user.email ?? "").split(/[\s@]/)[0] || "there"; - const greeting = useGreeting(); - const focusCards = myWork.data?.focus ?? []; - const resumeCards = myWork.data?.resume ?? []; - const myWorkLoading = myWork.isLoading || !me; - const flowBreakpoint = useDashboardFlowBreakpoint(); - const priorityColumnCount = priorityColumnsForWorkCount(focusCards.length + resumeCards.length); - const showPrimarySuggestions = - focusCards.length === 0 && resumeCards.length === 0 && !myWorkLoading; - - const statusRows = useMemo(() => { - const map = new Map(); - for (const i of active.data?.items ?? []) map.set(i.statusId, (map.get(i.statusId) ?? 0) + 1); - return (statuses ?? []) - .filter((s) => s.category !== "DONE" && s.category !== "CANCELED") - .map((s) => ({ status: s, count: map.get(s.id) ?? 0 })); - }, [active.data, statuses]); - - const statusMax = useMemo(() => Math.max(1, ...statusRows.map((r) => r.count)), [statusRows]); - - // ---- Customizable widget stack ------------------------------------- - // Layout (order + hidden) persists on the user via `dashboardPrefs`. - // Seeded once `user.me` lands; thereafter local state is authoritative - // for the session and each change is pushed to the server. - const setPrefsMut = trpc.user.setDashboardPrefs.useMutation(); - const [editing, setEditing] = useState(false); - const [layout, setLayout] = useState(null); - const layoutSeeded = useRef(false); - useEffect(() => { - if (layoutSeeded.current || !account) return; - layoutSeeded.current = true; - const p = account.dashboardPrefs as { - version?: number; - order?: string[]; - hidden?: string[]; - widths?: Record; - } | null; - const savedOrder = p?.order ?? []; - // Layout v2 moves Pulse + Schedule into the priority cockpit and gives - // What's New the compact right-hand slot in Workspace flow. Reset order - // and width overrides once so older preferences cannot recreate the - // previous composition; hidden choices remain intentional and survive. - const currentVersion = p?.version === DASHBOARD_LAYOUT_VERSION; - const order = - currentVersion && - savedOrder.length > 0 && - (!savedOrder.includes("pipeline") || !savedOrder.includes("suggestions")) - ? [] - : currentVersion - ? savedOrder - : []; - setLayout({ - order, - hidden: p?.hidden ?? [], - widths: currentVersion ? (p?.widths ?? {}) : {}, - }); - }, [account]); - const effLayout: DashboardLayout = layout ?? { order: [], hidden: [], widths: {} }; - const persistLayout = useCallback( - (next: DashboardLayout) => { - setLayout(next); - setPrefsMut.mutate({ - version: DASHBOARD_LAYOUT_VERSION, - order: next.order, - hidden: next.hidden, - collapsed: [], - widths: next.widths ?? {}, - }); - }, - [setPrefsMut], - ); - - // The top cockpit is deliberately bounded: personal work on the left, - // live operational signals on the right. These widgets stay customizable, - // but do not drag the ambient feeds into a permanently reserved rail. - const priorityWidgets: DashboardWidget[] = useMemo( - () => [ - { - id: "agent-attention", - title: "Agent attention", - defaultWidth: "full", - node: , - }, - { - id: "delivery-work", - title: "Delivery work", - defaultWidth: "full", - node: , - }, - { - id: "agent-activity", - title: "Agent activity", - defaultWidth: "half", - node: , - }, - { id: "standup", title: "Standup", defaultWidth: "half", node: }, - { id: "pulse", title: "Pulse", defaultWidth: "half", node: }, - { - id: "today", - title: "Schedule", - defaultWidth: "half", - node: , - }, - ], - [slug, workspaceKey], - ); - - // Everything that can flex, fold, or recede lives in one shared board. - // At wide viewports it has three columns: Pipeline + What's New form the - // bounded 2+1 lead row, then substantial flow modules reclaim all three - // tracks. DOM order remains the priority/keyboard order. - const flowWidgets: DashboardWidget[] = useMemo(() => { - const registry: DashboardWidget[] = [ - { - id: "pipeline", - title: "Pipeline", - defaultWidth: "full", - node: ( - - ), - }, - { - id: "whats-new", - title: "What's new", - defaultWidth: "half", - node: ( - - ), - }, - ...(!showPrimarySuggestions - ? [ - { - id: "suggestions", - title: "Suggestions", - defaultWidth: "full" as const, - fullBleedAtDesktop: true, - node: ( - - ), - }, - ] - : []), - { - id: "quick-notes", - title: "Notes & journal", - defaultWidth: "full", - fullBleedAtDesktop: true, - node: , - }, - { - id: "workspace-activity", - title: "Workspace activity", - defaultWidth: "full", - fullBleedAtDesktop: true, - node: , - }, - { id: "ideas", title: "Ideas", defaultWidth: "half", node: }, - ]; - const byId = new Map(registry.map((widget) => [widget.id, widget] as const)); - return FLOW_ORDER[flowBreakpoint].flatMap((id) => { - const widget = byId.get(id); - return widget ? [widget] : []; - }); - }, [ - account?.changelogSeenAt, - account?.changelogSeenRelease, - flowBreakpoint, - showPrimarySuggestions, - slug, - statusMax, - statusRows, - workspaceKey, - ]); return ( <> - - {editing && ( - - )} - - - - Inbox - - - + + + Inbox + + } />
- {/* Ambient background now lives once in the app shell
- (.forge-page-bg, driven by the per-user data-bg pref). */}
- 0} - /> - - - - - -
- {/* The desktop cockpit uses two independently flowing rails. Workspace - flow starts directly beneath personal work instead of waiting for - the taller operations rail to finish. Mobile keeps the intentional - Focus → Operations → Workspace reading order. */} -
-
- {/* Focus (assigned, priority-first) + Pick-up (recent). Both - render rich auto-height cards. If there's no personal work - at all, Suggestions takes the slot as the primary handoff. */} - {showPrimarySuggestions ? ( - - ) : ( - <> - {(focusCards.length > 0 || myWorkLoading) && ( -
- - -
- )} - {resumeCards.length > 0 && ( -
- - -
- )} - - )} -
- -
- - -
- -
- - -
-
-
+ +
@@ -459,1005 +50,102 @@ function TeamDashboard() { ); } -// --------------------------------------------------------------------------- -// Greeting -// --------------------------------------------------------------------------- - -function GreetingBar({ - greeting, - name, - slug, - isAdmin, - hasProjects, -}: { - greeting: string; - name: string; - slug: string; - isAdmin: boolean; - hasProjects: boolean; -}) { - return ( -
-
-
- {greeting}, {name}. -
-
- Decide what to move forward today. Small steps count. -
-
-
- - {/* Templates matter when you're starting out; once the workspace - has projects, the more useful CTA is a fresh project. */} - {hasProjects ? ( - - - - ) : ( - - - - )} - {/* Only admins/owners can actually invite — hide the dead button - for everyone else instead of showing it unconditionally. */} - {isAdmin && ( - - - - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Zone helpers — rich work-card grid + zone divider -// --------------------------------------------------------------------------- - -function WorkCardGrid({ - cards, - isLoading, - workspaceKey, - tz, - slug, -}: { - cards: DashboardWorkCard[]; - isLoading: boolean; - workspaceKey: string; - tz: string | null; - slug: string; -}) { - if (isLoading) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- ); - } - if (cards.length === 0) return null; - // `items-start` so each card sizes to its own content instead of - // stretching to the tallest sibling in the row — that stretch was the - // source of the empty space between sparse cards. - return ( -
    - {cards.map((card) => ( -
  • - -
  • - ))} -
- ); -} - -/** Active-work pipeline as proportional bars in a single card — replaces - * the old two-column status link-list with something that reads at a - * glance and doesn't leave a ragged column bottom next to Focus/Pick-up. */ -function PipelineCard({ - statusRows, - statusMax, - href, -}: { - statusRows: { status: { id: string; name: string; color: string }; count: number }[]; - statusMax: number; - href: string; -}) { - if (statusRows.length === 0) return null; - return ( -
- -
    - {statusRows.map(({ status, count }) => ( -
  • - - - {status.name} - -
    -
    -
    - - -
  • - ))} -
-
- ); -} - -/** Labeled hairline divider that opens a dashboard zone. */ -function ZoneDivider({ label }: { label: string }) { - return ( -
- - {label} - - -
- ); -} - -// --------------------------------------------------------------------------- -// Onboarding -// --------------------------------------------------------------------------- - -type OnboardingStep = { - id: string; - label: string; - hint: string; - done: boolean; - icon: ComponentType<{ className?: string }>; - href?: string; - action?: "quick-create"; -}; - -/** - * Subscribe to the dismissed-flag in localStorage. The `forge:onboarding-dismissed` - * window event lets the OnboardingCard and the Topbar pill stay in sync without - * lifting state up to the page (which would force a re-render of every section). - */ -function useOnboardingDismissed(): [boolean, (next: boolean) => void] { - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - try { - setDismissed(localStorage.getItem(ONBOARDING_KEY) === "1"); - } catch { - /* ignore */ - } - const onChange = (e: Event) => { - const detail = (e as CustomEvent).detail; - setDismissed(!!detail); - }; - window.addEventListener("forge:onboarding-dismissed", onChange); - return () => window.removeEventListener("forge:onboarding-dismissed", onChange); - }, []); - - const update = useCallback((next: boolean) => { - setDismissed(next); - try { - if (next) localStorage.setItem(ONBOARDING_KEY, "1"); - else localStorage.removeItem(ONBOARDING_KEY); - } catch { - /* ignore */ - } - window.dispatchEvent(new CustomEvent("forge:onboarding-dismissed", { detail: next })); - }, []); - - return [dismissed, update]; -} - -function OnboardingCard({ - projectsCount, - issuesCount, - membersCount, - apiKeysCount, - hasTimezone, - ready, - slug, - serverDismissedAt, - skippedSteps, -}: { - projectsCount: number; - issuesCount: number; - membersCount: number; - apiKeysCount: number; - hasTimezone: boolean; - ready: boolean; - slug: string; - serverDismissedAt: Date | string | null; - skippedSteps: string[]; -}) { - const [dismissed, setDismissed] = useOnboardingDismissed(); +function DashboardOnboarding({ slug }: { slug: string }) { const utils = trpc.useUtils(); - - const dismissPermanently = trpc.user.dismissOnboarding.useMutation({ - onMutate: async () => { - await utils.user.me.cancel(); - const prev = utils.user.me.getData(); - utils.user.me.setData(undefined, (cur) => - cur ? { ...cur, onboardingDismissedAt: new Date() } : cur, - ); - return { prev }; - }, - onError: (e, _v, ctx) => { - if (ctx?.prev) utils.user.me.setData(undefined, ctx.prev); - toast.error(e.message); - }, - onSettled: () => utils.user.me.invalidate(), + const { data: account } = trpc.user.me.useQuery(); + const { data: me } = trpc.workspace.me.useQuery(); + const projects = trpc.project.list.useQuery({ archived: false, limit: 1 }); + const issues = trpc.issue.list.useQuery({ includeDone: true, limit: 1 }); + const members = trpc.workspace.members.useQuery(); + const canManageAccess = me?.role === "OWNER" || me?.role === "ADMIN"; + const access = trpc.access.list.useQuery(undefined, { enabled: canManageAccess }); + const dismiss = trpc.user.dismissOnboarding.useMutation({ + onSuccess: () => utils.user.me.invalidate(), + onError: (error) => toast.error(error.message), }); - const skipStep = trpc.user.skipOnboardingStep.useMutation({ - onMutate: async ({ stepId }) => { - await utils.user.me.cancel(); - const prev = utils.user.me.getData(); - utils.user.me.setData(undefined, (cur) => - cur && !cur.onboardingSkippedSteps.includes(stepId) - ? { ...cur, onboardingSkippedSteps: [...cur.onboardingSkippedSteps, stepId] } - : cur, - ); - return { prev }; + const ready = Boolean( + account && + me && + projects.data && + issues.data && + members.data && + (!canManageAccess || access.data), + ); + if (!ready || account?.onboardingDismissedAt) return null; + + const steps = [ + { + label: "Project", + hint: "Create a home for related work", + done: (projects.data?.items.length ?? 0) > 0, + href: `/w/${slug}/projects?new=1`, }, - onError: (e, _v, ctx) => { - if (ctx?.prev) utils.user.me.setData(undefined, ctx.prev); - toast.error(e.message); + { + label: "Issue", + hint: "Capture your first actionable item", + done: (issues.data?.items.length ?? 0) > 0, + href: `/w/${slug}/issues?new=1`, }, - onSettled: () => utils.user.me.invalidate(), - }); - - const allSteps: OnboardingStep[] = useMemo( - () => [ - { - id: "project", - label: "Create your first project", - hint: "Group related issues.", - done: projectsCount > 0, - href: `/w/${slug}/projects`, - icon: Rocket, - }, - { - id: "issue", - label: "Create an issue", - hint: "Capture work. Press C anywhere.", - done: issuesCount > 0, - action: "quick-create", - icon: Plus, - }, - { - id: "member", - label: "Invite a teammate", - hint: "Work is better with others.", - done: membersCount > 1, - href: `/w/${slug}/settings/members`, - icon: Mail, - }, - { - id: "api", - label: "Create an API key", - hint: "Wire Forge into Hermes, Claude, or Codex.", - done: apiKeysCount > 0, - href: `/w/${slug}/settings/access`, - icon: KeyRound, - }, - { - id: "tz", - label: "Set your timezone", - hint: "Makes due dates sane.", - done: hasTimezone, - href: `/w/${slug}/settings/account`, - icon: Globe, - }, - ], - [projectsCount, issuesCount, membersCount, apiKeysCount, hasTimezone, slug], - ); - - const steps = useMemo( - () => allSteps.filter((s) => !skippedSteps.includes(s.id)), - [allSteps, skippedSteps], - ); - - const total = steps.length; - const completed = steps.filter((s) => s.done).length; - const allDone = ready && total > 0 && completed === total; - - useEffect(() => { - if (!allDone) return; - try { - if (localStorage.getItem(ONBOARDING_DONE_TOAST) === "1") return; - localStorage.setItem(ONBOARDING_DONE_TOAST, "1"); - toast.success("Nice — your workspace is set up."); - } catch { - /* ignore */ - } - }, [allDone]); - - if (!ready) return null; - if (serverDismissedAt) return null; - if (dismissed || allDone) return null; - - function handleSkipPermanently() { - dismissPermanently.mutate(); - // Mirror to localStorage so the Resume pill stays consistent within - // this session even before the server roundtrip lands. - setDismissed(true); - toast.success("Onboarding skipped — resume from Settings → Account if you change your mind."); - } - - function handleSkipStep(stepId: "member") { - skipStep.mutate({ stepId }); - if (stepId === "member") { - toast.success("Skipped — you can invite later from Settings → Members."); - } - } - - return ( -
-
-
-
Getting started
-
- {completed} of {total} steps complete. -
+ { + label: "Teammate", + hint: "Invite another workspace member", + done: (members.data?.length ?? 0) > 1 || account?.onboardingSkippedSteps.includes("member"), + href: `/w/${slug}/settings/members`, + }, + ...(canManageAccess + ? [ + { + label: "Agent access", + hint: "Create an API key or MCP client", + done: (access.data?.length ?? 0) > 0, + href: `/w/${slug}/settings/access`, + }, + ] + : []), + ]; + const remaining = steps.filter((step) => !step.done); + if (remaining.length === 0) return null; + + return ( +
+
+ + + +
+

Finish setting up Forge

+

+ {remaining.length} {remaining.length === 1 ? "step" : "steps"} left before the dashboard + is fully connected. +

- - -
-
-
-
    - {steps.map((s) => ( -
  • - handleSkipStep("member") : undefined} - /> -
  • +
    + {steps.map((step) => ( + + {step.done ? ( + + ) : ( + + )} + {step.label} + ))} -
-
- ); -} - -/** - * Tiny pill rendered in the dashboard topbar when the user has dismissed - * the OnboardingCard but onboarding isn't actually complete. Clicking it - * clears the dismissed flag so the full card re-appears on the same page - * load. - */ -function ResumeSetupPill({ - projectsCount, - issuesCount, - membersCount, - apiKeysCount, - hasTimezone, - ready, - serverDismissedAt, - skippedSteps, -}: { - projectsCount: number; - issuesCount: number; - membersCount: number; - apiKeysCount: number; - hasTimezone: boolean; - ready: boolean; - serverDismissedAt: Date | string | null; - skippedSteps: string[]; -}) { - const [dismissed, setDismissed] = useOnboardingDismissed(); - const memberDone = membersCount > 1 || skippedSteps.includes("member"); - const allDone = - ready && projectsCount > 0 && issuesCount > 0 && memberDone && apiKeysCount > 0 && hasTimezone; - if (!ready || serverDismissedAt || !dismissed || allDone) return null; - return ( - - ); -} - -function OnboardingRow({ step, onSkip }: { step: OnboardingStep; onSkip?: () => void }) { - const Icon = step.icon; - const inner = ( -
-
- {step.done ? : } -
-
-
{step.label}
-
{step.hint}
-
- {!step.done && } -
- ); - const body = step.done ? ( - inner - ) : step.action === "quick-create" ? ( - - ) : ( - {inner} - ); - if (!onSkip || step.done) return body; - return ( -
- {body} - -
- ); -} - -// --------------------------------------------------------------------------- -// Primitives -// --------------------------------------------------------------------------- - -function SectionHeader({ title, hint }: { title: string; hint?: string }) { - return ( -
-

- {title} -

- {hint && {hint}} -
- ); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function useGreeting() { - const [greeting, setGreeting] = useState("Hello"); - useEffect(() => { - const h = new Date().getHours(); - if (h < 5) setGreeting("Still up"); - else if (h < 12) setGreeting("Good morning"); - else if (h < 18) setGreeting("Good afternoon"); - else setGreeting("Good evening"); - }, []); - return greeting; -} - -/** Match the same breakpoints used by the shared CSS grid so its registry - * order packs wide and compact widgets without dense visual reordering. The - * DOM therefore remains the visual and keyboard order at every viewport. */ -function useDashboardFlowBreakpoint(): DashboardFlowBreakpoint { - const [breakpoint, setBreakpoint] = useState("mobile"); - - useEffect(() => { - const desktop = window.matchMedia("(min-width: 1536px)"); - const tablet = window.matchMedia("(min-width: 768px)"); - const update = () => - setBreakpoint(desktop.matches ? "desktop" : tablet.matches ? "tablet" : "mobile"); - update(); - desktop.addEventListener("change", update); - tablet.addEventListener("change", update); - return () => { - desktop.removeEventListener("change", update); - tablet.removeEventListener("change", update); - }; - }, []); - - return breakpoint; -} - -// --------------------------------------------------------------------------- -// Standup tile -// --------------------------------------------------------------------------- - -/** - * Compact dashboard tile for the standup draft. Replaces the dedicated - * sidebar entry — the full page (`/standup`) still exists for editing - * + copy-paste, but most of the time a glance + a copy button is all - * the operator needs. - */ -function StandupTile({ slug }: { slug: string }) { - const { data, isLoading } = trpc.standup.draft.useQuery({ sinceHours: 24 }); - const empty = - !!data && - !data.groups.closed.length && - !data.groups.opened.length && - !data.groups.inProgress.length && - !data.groups.blocked.length; - - const total = data - ? data.counts.closed + data.counts.opened + data.counts.inProgress + data.counts.blocked - : 0; - - return ( -
-
- Standup — last 24h - {data && total > 0 && ( - - {total} - - )} - dismiss.mutate()} + className="text-meta h-7 gap-1 text-muted-foreground" > - Open → - -
-
- {isLoading || !data ? ( -
Composing…
- ) : empty ? ( -
- Quiet 24 hours. Close issues, leave comments, or move tickets and it'll fill in. -
- ) : ( -
- - - - -
- )} -
-
- ); -} - -function StandupCount({ - label, - n, - tone, -}: { - label: string; - n: number; - tone: "success" | "info" | "warning" | "danger"; -}) { - const dot = { - success: "bg-success", - info: "bg-sky-400", - warning: "bg-warning", - danger: "bg-danger", - }[tone]; - return ( -
- - {label} - {n} -
- ); -} - -// --------------------------------------------------------------------------- -// Suggestions strip (Phase 1C) -// --------------------------------------------------------------------------- - -/** - * "What should I do next" — three buckets surfaced from - * `dashboard.suggestions`: current sprint slice, unassigned-in-my-projects, - * and stalled. - * - * Two visual modes: - * - `primary` — Focus Today is empty, Suggestions takes the top slot. - * Larger header, normal card tint, top of page. - * - `secondary` — Focus Today has items, Suggestions stays below as a - * discoverability strip. Smaller header, dimmer cards, - * wrapped in a `
` so a busy operator can collapse - * it. Defaults to open so it actually surfaces work. - * - * Empty groups render nothing (no "no items" stub clutter). All three - * buckets empty + `primary` → a single "you're caught up" CTA pointing at - * the project create flow. - * - * The fetch limit comes from the server defaults (top-N per bucket) — no - * `take`/`limit` magic number passed from the client. This way bumping the - * server default scales every consumer. - */ -function SuggestionsStrip({ - variant, - workspaceKey, - slug, -}: { - variant: "primary" | "secondary"; - workspaceKey: string; - slug: string; -}) { - const { data, isLoading } = trpc.dashboard.suggestions.useQuery(); - - if (isLoading || !data) { - if (variant === "secondary") return null; - return ( -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
-
- ); - } - - const sprint = data.currentSprintSlice; - const unassigned = data.unassignedInMyProjects; - const stalled = data.stalled; - const allEmpty = sprint.length === 0 && unassigned.length === 0 && stalled.length === 0; - - if (allEmpty) { - if (variant === "secondary") return null; - return ( -
-
- -
-
You're caught up.
-

- No assigned work, no orphan issues in your projects, nothing stalled. Start a new project - to keep momentum. -

-
- - - - -
-
- ); - } - - if (variant === "secondary") { - // Dimmer, collapsible. Subordinate to Focus Today. - return ( -
- - - Suggestions - - sprint · projects · stalled - - - -
- {sprint.length > 0 && ( - - ) : ( - - ) - } - items={sprint} - workspaceKey={workspaceKey} - slug={slug} - dim - /> - )} - {unassigned.length > 0 && ( - } - items={unassigned} - workspaceKey={workspaceKey} - slug={slug} - showProjectChip - dim - /> - )} - {stalled.length > 0 && ( - ${data.stalledThresholdDays}d quiet` : undefined - } - /> - } - items={stalled} - workspaceKey={workspaceKey} - slug={slug} - showStalledTimestamp - dim - /> - )} -
-
- ); - } - - // Primary mode — Focus is empty, Suggestions IS the page's top content. - return ( -
- -
- {sprint.length > 0 && ( - - ) : ( - - ) - } - items={sprint} - workspaceKey={workspaceKey} - slug={slug} - /> - )} - {unassigned.length > 0 && ( - } - items={unassigned} - workspaceKey={workspaceKey} - slug={slug} - showProjectChip - /> - )} - {stalled.length > 0 && ( - ${data.stalledThresholdDays}d quiet` : undefined - } - /> - } - items={stalled} - workspaceKey={workspaceKey} - slug={slug} - showStalledTimestamp - /> - )} + Hide +
); } - -type SuggestionItem = { - id: string; - number: number; - title: string; - priority: string; - updatedAt: Date | string; - status: { id: string; name: string; category: string; color: string }; - project: { - id: string; - key: string; - name: string; - color: string | null; - icon: string | null; - } | null; -}; - -function FallbackHeader({ label, hint }: { label: string; hint?: string }) { - return ( -
- {label} - {hint && {hint}} -
- ); -} - -function SuggestionsCard({ - header, - items, - workspaceKey, - slug, - showProjectChip = false, - showStalledTimestamp = false, - dim = false, -}: { - header: React.ReactNode; - items: SuggestionItem[]; - workspaceKey: string; - slug: string; - showProjectChip?: boolean; - showStalledTimestamp?: boolean; - dim?: boolean; -}) { - return ( -
-
{header}
-
    - {items.map((issue) => ( -
  • - - - {PRIORITY_GLYPH[issue.priority] ?? "—"} - - {showProjectChip && issue.project ? ( - - {issue.project.key} - - ) : null} - - {formatIssueId(workspaceKey, issue.number)} - - {issue.title} - {showStalledTimestamp ? ( - - {relativeTime(issue.updatedAt)} - - ) : ( - - {issue.status.name} - - )} - -
  • - ))} -
-
- ); -} - -function DashboardViewToggle({ slug }: { slug: string }) { - const router = useRouter(); - const personalCanvas = trpc.user.personalCanvas.useQuery(undefined, { - staleTime: 5 * 60_000, - }); - const setView = trpc.user.setDashboardView.useMutation({ - onError: () => { - /* silent — view toggle is best-effort persistence */ - }, - }); - const onCanvas = useCallback(() => { - const id = personalCanvas.data?.id; - if (!id) { - toast.message("Preparing your canvas…"); - return; - } - setView.mutate({ view: "canvas" }); - router.push(`/w/${slug}/canvas/${id}`); - }, [personalCanvas.data?.id, router, setView, slug]); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key !== "\\") return; - const target = e.target as HTMLElement | null; - if ( - target && - (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) - ) { - return; - } - e.preventDefault(); - onCanvas(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onCanvas]); - return ( -
- - -
- ); -} diff --git a/src/components/dashboard/operator-home.tsx b/src/components/dashboard/operator-home.tsx new file mode 100644 index 00000000..a649e2af --- /dev/null +++ b/src/components/dashboard/operator-home.tsx @@ -0,0 +1,825 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { + Activity, + AlertTriangle, + ArrowRight, + CheckCircle2, + ChevronDown, + ChevronUp, + CircleDot, + Clock3, + Inbox, + Sparkles, +} from "lucide-react"; +import { Avatar } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { AgentActivityTile } from "@/components/dashboard/agent-activity-tile"; +import { TodayWidget } from "@/components/dashboard/today-widget"; +import type { DashboardWorkCard } from "@/components/dashboard/issue-card"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { hasUnseenChangelog } from "@/lib/changelog"; +import { formatDueDate } from "@/lib/issue-display"; +import { trpc } from "@/lib/trpc"; +import { cn, formatIssueId } from "@/lib/utils"; +import { useTimePrefs } from "@/lib/time-prefs"; + +const DASHBOARD_LAYOUT_VERSION = 3; +const HEALTH_DRAWER_ID = "workspace-health"; + +const PRIORITY_GLYPH: Record = { + URGENT: "!!!", + HIGH: "!!", + MEDIUM: "!", + LOW: "·", + NONE: "—", +}; + +export type OperatorLanes = { + recommended: DashboardWorkCard | null; + now: DashboardWorkCard[]; + next: DashboardWorkCard[]; + waiting: DashboardWorkCard[]; +}; + +function waitingOnOthers(card: DashboardWorkCard): boolean { + return ( + card.latestRun?.status === "WAITING" || + card.latestRun?.status === "STALLED" || + card.status.category === "IN_REVIEW" + ); +} + +function belongsNow(card: DashboardWorkCard, nowMs: number): boolean { + const dueMs = card.dueDate ? new Date(card.dueDate).getTime() : Number.POSITIVE_INFINITY; + return ( + card.status.category === "IN_PROGRESS" || + card.priority === "URGENT" || + card.priority === "HIGH" || + dueMs <= nowMs + 48 * 60 * 60 * 1000 + ); +} + +/** One deterministic pass: every card appears in at most one lane. */ +export function buildOperatorLanes( + focus: DashboardWorkCard[], + resume: DashboardWorkCard[], + nowMs = Date.now(), +): OperatorLanes { + const seen = new Set(); + const cards = [...focus, ...resume].filter((card) => { + if (seen.has(card.id)) return false; + seen.add(card.id); + return true; + }); + + const waiting = cards.filter(waitingOnOthers); + const available = cards.filter((card) => !waitingOnOthers(card)); + const urgent = available.filter((card) => belongsNow(card, nowMs)); + const ordinary = available.filter((card) => !belongsNow(card, nowMs)); + const ranked = urgent.length > 0 ? [...urgent, ...ordinary] : ordinary; + const recommended = ranked[0] ?? waiting[0] ?? null; + const recommendedId = recommended?.id ?? null; + const remainingRanked = ranked.filter((card) => card.id !== recommendedId); + const now = remainingRanked.slice(0, 5); + const nowIds = new Set(now.map((card) => card.id)); + const next = remainingRanked.filter((card) => !nowIds.has(card.id)).slice(0, 4); + + return { + recommended, + now, + next, + waiting: waiting.filter((card) => card.id !== recommendedId).slice(0, 3), + }; +} + +export function OperatorHome() { + const workspace = useWorkspace(); + const prefs = useTimePrefs(); + const { data: ws } = trpc.workspace.current.useQuery(); + const { data: me } = trpc.workspace.me.useQuery(); + const myWork = trpc.dashboard.myWork.useQuery(); + + const lanes = useMemo( + () => buildOperatorLanes(myWork.data?.focus ?? [], myWork.data?.resume ?? []), + [myWork.data?.focus, myWork.data?.resume], + ); + + const firstName = (me?.user.name ?? me?.user.email ?? "").split(/[\s@]/)[0] || "there"; + const waitingCount = lanes.waiting.length; + const activeCount = lanes.now.filter((card) => card.status.category === "IN_PROGRESS").length; + + return ( +
+
+

Good day, {firstName}.

+ + + Focus today: {activeCount} in progress + {waitingCount > 0 ? ` · ${waitingCount} waiting on others` : ""} + +
+ +
+
+ +
+ + +
+ + +
+ ); +} + +function WorkLanes({ + lanes, + loading, + slug, + workspaceKey, + timezone, +}: { + lanes: OperatorLanes; + loading: boolean; + slug: string; + workspaceKey: string; + timezone: string | null; +}) { + if (loading) { + return ( +
+
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ + + + +
+ ))} +
+
+ ); + } + + const empty = + !lanes.recommended && !lanes.now.length && !lanes.next.length && !lanes.waiting.length; + if (empty) { + return ( +
+ +

Nothing needs you right now

+

+ Open an issue or pick from Suggestions. +

+
+ ); + } + + return ( +
+
+

+ What needs me now? +

+
+ + {lanes.recommended && ( + + + Recommended next + + {formatIssueId(workspaceKey, lanes.recommended.number)} + + + {lanes.recommended.title} + + + {lanes.recommended.status.name} + + + + )} + +
+
+ + + +
+
+ ); +} + +function Lane({ + label, + cards, + tone, + slug, + workspaceKey, + timezone, +}: { + label: string; + cards: DashboardWorkCard[]; + tone: "active" | "neutral" | "waiting"; + slug: string; + workspaceKey: string; + timezone: string | null; +}) { + if (cards.length === 0) return null; + const nodeTone = + tone === "active" + ? "text-ember" + : tone === "waiting" + ? "text-sky-400" + : "text-muted-foreground"; + return ( +
+ +
+

{label}

+ + {cards.length} + +
+
    + {cards.map((card) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function WorkRow({ + card, + slug, + workspaceKey, + timezone, +}: { + card: DashboardWorkCard; + slug: string; + workspaceKey: string; + timezone: string | null; +}) { + const owner = card.assignees[0]?.user ?? null; + return ( + + + {PRIORITY_GLYPH[card.priority] ?? "—"} + + + {formatIssueId(workspaceKey, card.number)} + + + {card.title} + + + {card.project ? ( + {card.project.key} + ) : ( + — + )} + + + {card.status.name} + + + {card.dueDate ? ( + + {formatDueDate(card.dueDate, timezone)} + + ) : owner ? ( + + + {owner.name ?? "Assignee"} + + ) : card.assignedAgent ? ( + @{card.assignedAgent.profileKey} + ) : ( + "—" + )} + + + ); +} + +type AttentionTab = "decisions" | "exceptions" | "blocked"; + +function AttentionRail({ slug }: { slug: string }) { + const { data, isLoading } = trpc.commandCenter.summary.useQuery( + { limit: 8 }, + { refetchOnWindowFocus: true, staleTime: 30_000 }, + ); + const [tab, setTab] = useState("decisions"); + + const decisions = useMemo(() => { + if (!data) return []; + return [ + ...data.actionRequests.map((request) => ({ + id: `request-${request.id}`, + title: request.issue?.title ?? request.title, + detail: request.title, + href: request.issue + ? `/w/${request.issue.workspace.slug}/issues/${request.issue.id}` + : `/w/${slug}/command-center`, + })), + ...data.reviewGates.map((gate) => ({ + id: `gate-${gate.id}`, + title: gate.prompt, + detail: "Review required", + href: `/w/${slug}/command-center`, + })), + ...data.runtimeApprovals.map((run) => ({ + id: `approval-${run.id}`, + title: run.issue.title, + detail: "Runtime approval required", + href: `/w/${slug}/issues/${run.issue.id}`, + })), + ]; + }, [data, slug]); + + const exceptions = useMemo(() => { + if (!data) return []; + return [ + ...data.stalledRuns.map((run) => ({ + id: `stalled-${run.id}`, + title: run.issue.title, + detail: run.recoveryTitle ?? "Agent run needs recovery", + href: `/w/${slug}/issues/${run.issue.id}`, + })), + ...data.activeRuns + .filter((run) => run.status === "WAITING") + .map((run) => ({ + id: `waiting-${run.id}`, + title: run.issue.title, + detail: "Agent is waiting", + href: `/w/${slug}/issues/${run.issue.id}`, + })), + ]; + }, [data, slug]); + + const blocked = + data?.stalledRuns.map((run) => ({ + id: `blocked-${run.id}`, + title: run.issue.title, + detail: run.recoveryDetail ?? "Work is blocked", + href: `/w/${slug}/issues/${run.issue.id}`, + })) ?? []; + const activeItems = tab === "decisions" ? decisions : tab === "exceptions" ? exceptions : blocked; + const total = decisions.length + exceptions.length + blocked.length; + + return ( +
+
+ +

Needs attention

+ {total > 0 && ( + + {total} + + )} +
+
+ setTab("decisions")} + > + Decisions + + setTab("exceptions")} + > + Agent exceptions + + setTab("blocked")} + > + Blocked + +
+
+ {isLoading ? ( +
+ ) : activeItems.length === 0 ? ( +
+ + All clear in this view. +
+ ) : ( +
    + {activeItems.slice(0, 3).map((item) => ( +
  • + +
    {item.title}
    +
    + + {item.detail} + +
    + +
  • + ))} +
+ )} + {activeItems.length > 0 && ( + + View all + + )} +
+
+ ); +} + +function AttentionTabButton({ + active, + count, + onClick, + children, +}: { + active: boolean; + count: number; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function WorkspaceHealthDrawer({ slug }: { slug: string }) { + const { data: account } = trpc.user.me.useQuery(); + const active = trpc.issue.list.useQuery({ includeDone: false, limit: 200 }); + const done = trpc.issue.list.useQuery({ includeDone: true, limit: 200 }); + const statuses = trpc.status.list.useQuery(); + const standup = trpc.standup.draft.useQuery({ sinceHours: 24 }); + const changelog = trpc.system.changelog.useQuery({ limit: 2 }); + const utils = trpc.useUtils(); + const savePrefs = trpc.user.setDashboardPrefs.useMutation({ + onSettled: () => utils.user.me.invalidate(), + }); + const markSeen = trpc.user.markChangelogSeen.useMutation({ + onSuccess: () => utils.user.me.invalidate(), + }); + const [collapsed, setCollapsed] = useState(false); + const [clearedRelease, setClearedRelease] = useState(null); + + const dashboardPrefs = account?.dashboardPrefs as { + version?: number; + order?: string[]; + hidden?: string[]; + collapsed?: string[]; + widths?: Record; + } | null; + + useEffect(() => { + setCollapsed(Boolean(dashboardPrefs?.collapsed?.includes(HEALTH_DRAWER_ID))); + }, [dashboardPrefs?.collapsed]); + + const latest = changelog.data?.entries.find((entry) => entry.date) ?? null; + const unseen = + !!latest && + latest.id !== clearedRelease && + hasUnseenChangelog( + latest, + account?.changelogSeenRelease ?? null, + account?.changelogSeenAt ?? null, + ); + + function toggleDrawer() { + const next = !collapsed; + setCollapsed(next); + const current = new Set(dashboardPrefs?.collapsed ?? []); + if (next) current.add(HEALTH_DRAWER_ID); + else current.delete(HEALTH_DRAWER_ID); + savePrefs.mutate({ + version: DASHBOARD_LAYOUT_VERSION, + order: dashboardPrefs?.order ?? [], + hidden: dashboardPrefs?.hidden ?? [], + widths: dashboardPrefs?.widths ?? {}, + collapsed: [...current], + }); + } + + function clearWhatsNew() { + if (!latest) return; + setClearedRelease(latest.id); + markSeen.mutate({ releaseId: latest.id }); + } + + const activeItems = active.data?.items ?? []; + const counts = new Map(); + for (const issue of activeItems) + counts.set(issue.statusId, (counts.get(issue.statusId) ?? 0) + 1); + const visibleStatuses = (statuses.data ?? []).filter( + (status) => status.category !== "DONE" && status.category !== "CANCELED", + ); + const totalActive = Math.max(1, activeItems.length); + const inProgress = activeItems.filter((issue) => issue.status.category === "IN_PROGRESS").length; + const weekAgo = Date.now() - 7 * 86_400_000; + const doneThisWeek = + done.data?.items.filter( + (issue) => issue.status.category === "DONE" && new Date(issue.updatedAt).getTime() >= weekAgo, + ).length ?? 0; + const blocked = standup.data?.counts.blocked ?? 0; + + return ( +
+ + + {!collapsed && ( +
+
+ +
+ {visibleStatuses.map((status) => { + const count = counts.get(status.id) ?? 0; + return ( + + ); + })} +
+
+ {visibleStatuses.map((status) => ( +
+ + {status.name} + + {counts.get(status.id) ?? 0} + +
+ ))} +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+
+ + {unseen && ( + + )} + {unseen && ( + + )} +
+ {unseen && latest ? ( + <> +
{latest.heading}
+
    + {latest.items.slice(0, 3).map((item, index) => ( +
  • + + {item.type} + + {item.text} +
  • + ))} +
+ + ) : ( +
+ You're caught up. New release notes will appear here automatically. +
+ )} + + View all changes + +
+
+ )} +
+ ); +} + +function HealthHeading({ + id, + label, + hint, + href, +}: { + id: string; + label: string; + hint: string; + href?: string; +}) { + const content = ( +
+

+ {label} +

+

{hint}

+
+ ); + return href ? ( + + {content} + + ) : ( + content + ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function StandupMetric({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+ + {label} + {value} +
+ ); +} diff --git a/tests/e2e/dashboard-layout.spec.ts b/tests/e2e/dashboard-layout.spec.ts index e07e7b1c..d1baba4c 100644 --- a/tests/e2e/dashboard-layout.spec.ts +++ b/tests/e2e/dashboard-layout.spec.ts @@ -1,33 +1,12 @@ import { expect, test } from "@playwright/test"; const STATES = [ - { - name: "desktop", - width: 1600, - height: 2600, - columns: 2, - priorityColumns: 2, - firstWidgets: ["pipeline", "whats-new", "suggestions", "quick-notes"], - }, - { - name: "tablet", - width: 1024, - height: 2600, - columns: 2, - priorityColumns: 2, - firstWidgets: ["pipeline", "suggestions", "whats-new", "ideas"], - }, - { - name: "mobile", - width: 390, - height: 3000, - columns: 1, - priorityColumns: 1, - firstWidgets: ["pipeline", "whats-new", "suggestions", "quick-notes"], - }, + { name: "desktop", width: 1600, height: 1800, split: true }, + { name: "tablet", width: 1024, height: 2200, split: false }, + { name: "mobile", width: 390, height: 2600, split: false }, ] as const; -test("dashboard keeps priority work bounded and reflows secondary modules", async ({ +test("dashboard preserves operator-home hierarchy without gaps or duplicate work", async ({ page, }, testInfo) => { await page.addInitScript(() => localStorage.setItem("theme", "dark")); @@ -36,81 +15,55 @@ test("dashboard keeps priority work bounded and reflows secondary modules", asyn await page.setViewportSize({ width: state.width, height: state.height }); await page.goto("/w/forge/dashboard"); - const cockpit = page.getByTestId("dashboard-priority-cockpit"); - const flow = page.getByTestId("dashboard-flow-board"); - await expect(cockpit).toBeVisible(); - await expect(flow).toBeVisible(); - await expect(flow.getByText("Pipeline", { exact: true }).first()).toBeVisible(); + const home = page.getByTestId("dashboard-operator-home"); + const work = page.getByTestId("dashboard-work-lanes"); + const operations = page.getByTestId("dashboard-live-operations"); + const attention = page.getByTestId("dashboard-attention-rail"); + const health = page.getByTestId("dashboard-health-drawer"); - const priorityGrid = cockpit.locator(`[data-dashboard-columns]`).first(); - await expect(priorityGrid).toBeVisible(); - const configuredPriorityColumns = Number( - await priorityGrid.getAttribute("data-dashboard-columns"), - ); - expect([1, 2]).toContain(configuredPriorityColumns); - await expect - .poll(() => - priorityGrid - .locator(":scope > [data-widget-id]") - .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-widget-id"))), - ) - .toEqual(["agent-attention", "delivery-work", "agent-activity", "standup", "pulse", "today"]); - const priorityColumns = await priorityGrid.evaluate((node) => { - const tracks = getComputedStyle(node).gridTemplateColumns; - return tracks === "none" ? 1 : tracks.split(" ").length; - }); - expect(priorityColumns).toBe( - state.name === "desktop" ? configuredPriorityColumns : state.priorityColumns, - ); + await expect(home).toBeVisible(); + await expect(work).toBeVisible(); + await expect(page.getByTestId("dashboard-recommended-next")).toBeVisible(); + await expect(attention).toBeVisible(); + await expect(health).toBeVisible(); + await expect(health.getByText("Pipeline", { exact: true })).toBeVisible(); + await expect(health.getByText("Throughput", { exact: true })).toBeVisible(); + await expect(health.getByText("Standup", { exact: true })).toBeVisible(); + await expect(health.getByText("What's new", { exact: true })).toBeVisible(); - await expect(cockpit.getByTestId("dashboard-pulse")).toBeVisible(); - await expect(cockpit.getByTestId("dashboard-schedule")).toBeVisible(); - await expect(flow.locator('[data-widget-id="pulse"]')).toHaveCount(0); - await expect(flow.locator('[data-widget-id="today"]')).toHaveCount(0); - await expect(flow.getByTestId("dashboard-whats-new")).toBeVisible(); - await expect( - page.getByTestId("dashboard-operations-column").locator('[data-widget-id="whats-new"]'), - ).toHaveCount(0); + const issueIds = await work + .locator("[data-dashboard-issue-id]") + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-dashboard-issue-id"))); + expect(issueIds).toEqual([...new Set(issueIds)]); - expect(await cockpit.locator("[data-schedule-due-item]").count()).toBeLessThanOrEqual(3); - expect(await flow.locator("[data-whats-new-item]").count()).toBeLessThanOrEqual(4); - expect(await flow.locator("[data-whats-new-history]").count()).toBeLessThanOrEqual(3); - - const grid = flow.locator(`[data-dashboard-columns="2"]`); - await expect(grid).toBeVisible(); - await expect - .poll(() => - grid - .locator(":scope > [data-widget-id]") - .evaluateAll( - (nodes, count) => - nodes.slice(0, count).map((node) => node.getAttribute("data-widget-id")), - state.firstWidgets.length, - ), - ) - .toEqual(state.firstWidgets); - const renderedColumns = await grid.evaluate((node) => { - const tracks = getComputedStyle(node).gridTemplateColumns; - return tracks === "none" ? 1 : tracks.split(" ").length; - }); - expect(renderedColumns).toBe(state.columns); + const [workBox, operationsBox, healthBox, homeBox] = await Promise.all([ + work.boundingBox(), + operations.boundingBox(), + health.boundingBox(), + home.boundingBox(), + ]); + expect(workBox).not.toBeNull(); + expect(operationsBox).not.toBeNull(); + expect(healthBox).not.toBeNull(); + expect(homeBox).not.toBeNull(); - if (state.name === "desktop") { - const [boardBox, suggestionsBox, flowBox, operationsBox] = await Promise.all([ - grid.boundingBox(), - grid.locator('[data-widget-id="suggestions"]').boundingBox(), - flow.boundingBox(), - page.getByTestId("dashboard-operations-column").boundingBox(), - ]); - expect(boardBox).not.toBeNull(); - expect(suggestionsBox).not.toBeNull(); - expect(flowBox).not.toBeNull(); - expect(operationsBox).not.toBeNull(); - expect(Math.abs((boardBox?.width ?? 0) - (suggestionsBox?.width ?? 0))).toBeLessThanOrEqual( - 1, - ); - expect(flowBox!.y).toBeLessThan(operationsBox!.y + operationsBox!.height); + if (state.split) { + expect(workBox!.x).toBeLessThan(operationsBox!.x); + expect(Math.abs(workBox!.y - operationsBox!.y)).toBeLessThanOrEqual(1); + } else { + expect(operationsBox!.y).toBeGreaterThan(workBox!.y + workBox!.height - 1); } + expect(healthBox!.y).toBeGreaterThan( + Math.max(workBox!.y + workBox!.height, operationsBox!.y + operationsBox!.height) - 1, + ); + expect(Math.abs(healthBox!.width - homeBox!.width)).toBeLessThanOrEqual(1); + + const drawerButton = health.getByRole("button", { name: /Workspace health/ }); + await expect(drawerButton).toHaveAttribute("aria-expanded", "true"); + await drawerButton.click(); + await expect(drawerButton).toHaveAttribute("aria-expanded", "false"); + await drawerButton.click(); + await expect(drawerButton).toHaveAttribute("aria-expanded", "true"); const overflow = await page.evaluate(() => { const doc = document.documentElement; @@ -119,24 +72,10 @@ test("dashboard keeps priority work bounded and reflows secondary modules", asyn }); expect(overflow).toBeLessThanOrEqual(1); - const overflowingWidgets = await page - .locator("[data-widget-id]") - .evaluateAll((nodes) => - nodes - .filter( - (node) => - getComputedStyle(node).display !== "none" && node.scrollWidth - node.clientWidth > 1, - ) - .map((node) => node.getAttribute("data-widget-id")), - ); - expect(overflowingWidgets).toEqual([]); - await page.screenshot({ path: testInfo.outputPath(`dashboard-${state.name}.png`), fullPage: true, }); - await page.getByTestId("dashboard-layout").screenshot({ - path: testInfo.outputPath(`dashboard-${state.name}-layout.png`), - }); + await home.screenshot({ path: testInfo.outputPath(`dashboard-${state.name}-layout.png`) }); } }); diff --git a/tests/unit/dashboard-operator-home.test.ts b/tests/unit/dashboard-operator-home.test.ts new file mode 100644 index 00000000..bb341228 --- /dev/null +++ b/tests/unit/dashboard-operator-home.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { buildOperatorLanes } from "@/components/dashboard/operator-home"; +import type { DashboardWorkCard } from "@/components/dashboard/issue-card"; + +function card(id: string, overrides: Partial = {}): DashboardWorkCard { + return { + id, + number: Number(id.replace(/\D/g, "")) || 1, + title: `Issue ${id}`, + description: null, + priority: "NONE", + dueDate: null, + slaMinutes: null, + activityAt: new Date("2026-08-26T12:00:00Z"), + childTotal: 0, + childDone: 0, + status: { + id: `status-${id}`, + name: "Todo", + category: "TODO", + color: "#78716c", + }, + project: null, + labels: [], + assignees: [], + assignedAgent: null, + latestRun: null, + ...overrides, + } as DashboardWorkCard; +} + +describe("buildOperatorLanes", () => { + const now = new Date("2026-08-26T12:00:00Z").getTime(); + + it("deduplicates cards and assigns each one to exactly one semantic lane", () => { + const active = card("1", { + priority: "HIGH", + status: { id: "active", name: "In Progress", category: "IN_PROGRESS", color: "#d97706" }, + }); + const waiting = card("2", { + latestRun: { status: "WAITING" } as DashboardWorkCard["latestRun"], + }); + const later = card("3"); + + const lanes = buildOperatorLanes([active, waiting], [active, later], now); + const ids = [lanes.recommended, ...lanes.now, ...lanes.next, ...lanes.waiting] + .filter((item): item is DashboardWorkCard => Boolean(item)) + .map((item) => item.id); + + expect(ids).toHaveLength(new Set(ids).size); + expect(lanes.now.map((item) => item.id)).toEqual(["3"]); + expect(lanes.waiting.map((item) => item.id)).toEqual(["2"]); + expect(lanes.next).toEqual([]); + expect(lanes.recommended?.id).toBe("1"); + }); + + it("promotes due-soon work and keeps review work in Waiting", () => { + const dueSoon = card("4", { dueDate: new Date(now + 24 * 60 * 60 * 1000) }); + const review = card("5", { + status: { id: "review", name: "In Review", category: "IN_REVIEW", color: "#ca8a04" }, + }); + + const lanes = buildOperatorLanes([dueSoon, review], [], now); + + expect(lanes.recommended?.id).toBe("4"); + expect(lanes.now).toEqual([]); + expect(lanes.waiting.map((item) => item.id)).toEqual(["5"]); + }); + + it("caps lane density while preserving the highest-ranked source order", () => { + const active = Array.from({ length: 8 }, (_, index) => + card(String(index + 10), { + priority: index < 2 ? "URGENT" : "HIGH", + }), + ); + + const lanes = buildOperatorLanes(active, [], now); + + expect(lanes.now).toHaveLength(5); + expect(lanes.recommended?.id).toBe("10"); + expect(lanes.now.map((item) => item.id)).toEqual(["11", "12", "13", "14", "15"]); + expect(lanes.next.map((item) => item.id)).toEqual(["16", "17"]); + }); +});