From 981ef16e062742e2574b773260f3bd6c2b8504af Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 16 Jul 2026 09:50:29 -0400 Subject: [PATCH 1/2] feat(axi-114): add personal workspace experience --- DEVLOG.md | 29 + design-qa.md | 122 ++-- .../migration.sql | 6 + prisma/schema.prisma | 9 + src/app/(app)/settings/workspaces/page.tsx | 55 +- src/app/(app)/w/[slug]/dashboard/page.tsx | 6 + src/app/(app)/w/[slug]/layout.tsx | 2 + .../w/[slug]/settings/workspace/page.tsx | 72 ++- .../dashboard/personal-dashboard.tsx | 522 ++++++++++++++++++ src/components/sidebar-nav.ts | 27 + src/components/sidebar.tsx | 17 +- src/hooks/use-workspace.ts | 3 +- src/server/routers/workspace.ts | 59 +- tests/e2e/personal-workspace.spec.ts | 54 ++ tests/unit/sidebar-nav.test.ts | 30 +- 15 files changed, 918 insertions(+), 95 deletions(-) create mode 100644 prisma/migrations/20260716130000_workspace_experience_profile/migration.sql create mode 100644 src/components/dashboard/personal-dashboard.tsx create mode 100644 tests/e2e/personal-workspace.spec.ts diff --git a/DEVLOG.md b/DEVLOG.md index 388f3057..b3227fff 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13619,3 +13619,32 @@ PR #49 passed GitHub's lint, typecheck, unit, and both Playwright shards. Bumped the application version to 0.24.0 and curated the in-app changelog around the Mission Control attention lifecycle, inspectable cross-workspace surfaces, concurrent run capacity, agent-page scrolling, and semantic navigation. + +--- + +## 2026-07-16 — Personal workspace experience + +Added a settings-driven `WorkspaceExperienceProfile` so one tenant model can +present either the full project/team system or a calmer personal task system. +Personal creation seeds home/work/errand/waiting labels, assigns new tasks to +their creator by default, and skips the initial sprint; team creation retains +the existing project defaults and now consistently names the first iteration +Sprint 1. Owners and admins can switch profiles later without migrating or +losing projects, issues, notes, routines, agents, or delivery history. + +Personal workspaces now open on a responsive Today dashboard with fast task +capture, Today/overdue/upcoming grouping, completion, personal notes, planning +handoff to agent chat, and a narrow collapsible agent companion. The companion +uses real assigned-task state and update times instead of invented progress. +Navigation becomes Today, Inbox, Tasks, Upcoming, Notes, Chat, Routines, and +Agents while preserving full agent management and all underlying routes. + +Verification: Prisma generation and migration deployment passed; lint passed +with existing repository warnings; typecheck and the production Next build +passed; the personal navigation unit suite passed 11 tests; and the new +Playwright journey passed workspace creation, task add/complete, note capture, +companion collapse, and settled-route console checks. The broad Vitest run +passed 1,322 of 1,323 runnable tests with one intentional live skip; its single +parallel stale-work failure passed immediately in isolation (9/9), confirming +test interference rather than a feature regression. Desktop and 390px mobile +captures passed design QA against the selected mockup; see `design-qa.md`. diff --git a/design-qa.md b/design-qa.md index da4c494d..6ec65776 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,73 +1,75 @@ -# Mission Control operations — design QA +# Personal workspace — design QA -## Source and implementation evidence +## Evidence -### Workspace Operations Shelf +- Source visual truth: `/home/bailey/.codex/generated_images/019f684f-8ec0-7312-8d0f-026f4ced54b3/exec-de9fe713-1cdf-4721-8d57-366afeef5114.png` +- Browser-rendered implementation: `/home/bailey/.codex/visualizations/2026/07/16/019f684f-8ec0-7312-8d0f-026f4ced54b3/personal-dashboard-implemented-2.png` +- Full-view comparison: `/home/bailey/.codex/visualizations/2026/07/16/019f684f-8ec0-7312-8d0f-026f4ced54b3/personal-dashboard-comparison.png` +- Mobile implementation: `/home/bailey/.codex/visualizations/2026/07/16/019f684f-8ec0-7312-8d0f-026f4ced54b3/personal-dashboard-mobile.png` +- Desktop viewport: 1440 × 1024, light theme, authenticated personal workspace, agent companion expanded, realistic tasks/notes/delegated work. +- Mobile viewport: 390 × 844, light theme, authenticated personal workspace. -- Source visual truth: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\11-direction-3.png` -- Desktop implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\21-workspace-operations-desktop.png` -- Mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\22-workspace-operations-mobile.png` -- Full-view comparison: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\24-workspace-qa-comparison.png` -- Viewports: 1536 x 1024 desktop; 390 x 844 mobile -- State: Forge workspace Dashboard, Mission Control expanded, Queue selected, seeded local data loaded +The comparison image normalizes the 1487 × 1058 generated source to the +implementation viewport. It was inspected at its original 2880 × 1024 output, +where navigation, task rows, capture controls, note cards, agent states, and +icons remained readable. A separate focused crop was not needed because the +full-resolution side-by-side preserved those details without downsampling. -The full-view comparison preserves each 1536 x 1024 frame at native scale. It shows the selected bottom operations-shelf composition beside the browser-rendered implementation. Both reflow the dashboard above a bottom-attached shelf, use summary / queue / agent-presence zones, keep Queue selected, expose one ember primary action, and retain Forge's warm-earthy dark tokens and compact typography. - -The implementation intentionally omits the mock's speculative filter, sort, and inline assignee controls because the current quick-access query is read-only and deep-links to the durable issue surface. It uses honest dispatch-state copy and canonical issue links instead. This is an expected product constraint rather than unresolved visual drift. - -### Global Mission Control - -- Source desktop baseline: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\01-global-desktop-accepted.png` -- Source mobile baseline: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\02-global-mobile-accepted.jpg` -- Desktop implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\19-global-operations-desktop.png` -- Mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\20-global-operations-mobile.png` -- Lower mobile implementation: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\20b-global-operations-mobile-lower.png` -- Full-view comparison: `C:\Users\Bailey\Documents\Codex\2026-07-13\forge-mission-control-audit\outputs\forge-mission-control-audit\23-global-qa-comparison.png` -- Viewports: 1536 x 1024 desktop; 390 x 844 mobile -- State: cross-workspace overview with seeded Forge workspace, zero registered runtimes, 19 open issues, no assigned attention, three offline agents, and recent activity - -The baseline and implementation comparison confirms the redesign preserves the global shell, real data, existing tokens, density, navigation destinations, and read-only contract while replacing the undifferentiated card grid with the selected direction's operator hierarchy. Runtime and dispatch posture now leads; workspace queue and assigned attention are the primary work plane; agent presence, runtime coverage, and activity follow as supporting evidence. - -## Required fidelity surfaces - -- **Fonts and typography:** Both surfaces use Forge's existing font stack and density-aware scale. Section labels, headings, metrics, issue keys, and row copy establish the same compact operational hierarchy without a parallel type system. -- **Spacing and layout:** The workspace shelf attaches to the bottom of the content flow and never covers the dashboard. The global surface uses one posture banner, a 2:1 primary work grid, then three supporting columns. Mobile collapses both surfaces into one readable vertical sequence with no clipped controls or horizontal overflow. -- **Colors and tokens:** Existing `background`, `card`, `border`, `ember`, `warning`, `danger`, `success`, `foreground`, and `muted-foreground` tokens are used throughout. No hard-coded palette, gradients, or new design system were introduced. -- **Images and icons:** Neither target requires raster imagery. All interface icons use Forge's existing Lucide dependency; no placeholder asset, handcrafted SVG, CSS drawing, or generated substitute was added. -- **Copy and content:** Mission Control terminology remains consistent. Workspace issue keys use canonical `FRG-*` references. The global runtime CTA appears only when setup is required, Queue remains distinct from assigned Attention, and Sprint terminology elsewhere remains unchanged. - -## States, interaction, and accessibility +## Findings -- Live, Queue, Agents, and Chat expose `tablist`, `tab`, `aria-selected`, `aria-controls`, and `tabpanel` semantics. -- Numeric shortcuts 1–4 switch workspace tabs and Escape collapses the shelf. The primary Assign next action opens the oldest unassigned issue; issue and agent rows deep-link to their durable surfaces. -- The global workspace row, issue-queue action, inbox, activity, agent settings, and runtime settings navigation were exercised from the rendered page. -- Workspace Queue and Agent Presence provide explicit loading, empty, and error states. Every global query region has a bounded loading state, empty state, and retry action without hiding the rest of Mission Control. -- Mobile workspace tabs, Settings, and Collapse meet a 44 px target. Global mobile interactive rows and actions meet at least 40 px, preserve visible focus styles from existing components, and wrap long content without collision. -- Desktop and mobile document widths match their viewport widths. Browser log review found no warnings or errors attributable to the implementation. +No actionable P0, P1, or P2 differences remain. + +- Typography: the implementation preserves Forge's sans/mono system, close + display hierarchy, compact metadata, and identifier treatment. Dynamic + workspace/task text wraps or truncates without collision. +- Spacing and layout: the task-first main column, persistent workspace rail, + narrow companion rail, capture bar, task groups, and notes retain the source + hierarchy. The narrower agent rail is the explicitly selected refinement. +- Colors and tokens: all surfaces use Forge's warm background, graphite text, + ember action/state color, and existing border/card tokens; no ad-hoc palette + or gradient was introduced. +- Image and icon fidelity: the source contains no raster product imagery. All + visible UI symbols use the repository's existing Lucide and AgentAvatar + components; there are no placeholder images, custom SVGs, emoji stand-ins, + or CSS illustrations in the implementation. +- Copy and content: personal terminology is consistent (`Today`, `Tasks`, + `Upcoming`, `Notes`, `Routines`) while `Agents` remains directly available. + The companion reports real assignment state and update time rather than the + mock's illustrative progress timeline or percentage. +- Interaction and accessibility: browser coverage passed workspace creation, + task add/complete, note capture, companion collapse/reopen, accessible labels, + keyboard-capable controls, and a settled-route console check. At 390px the + desktop sidebar becomes the existing bottom navigation, task content remains + readable, and there is no horizontal clipping. ## Comparison history -### Iteration 1 - -- **[P2] Workspace mobile controls were 24–40 px.** Settings, tabs, and Collapse did not meet the intended touch target. Fixed by increasing mobile controls to 44 px while retaining compact desktop sizing. -- **[P1] The collapsed workspace pill could sit behind mobile navigation.** Fixed by applying bottom-navigation safe clearance to bottom-corner pill and glance states. -- **[P1] Global runtime health was buried below workspace volume.** Fixed by replacing the equal-weight card grid with a derived runtime and dispatch posture banner, keeping registration failure visible before queue triage. -- **[P2] Queue, attention, presence, and activity competed at the same level globally.** Fixed by separating actionable workspace queue and personal attention from supporting coverage and recency evidence. - -### Iteration 2 - -- Post-fix mobile measurement confirms the workspace controls meet 44 px and neither surface overflows at 390 x 844. -- Post-fix desktop captures confirm the workspace shelf remains in layout and the global scan begins with runtime posture, queue, and attention. -- Combined comparisons contain no actionable P0, P1, or P2 differences against the approved direction, captured product baseline, and existing Forge conventions. - -## Findings - -No actionable P0, P1, or P2 findings remain. +1. The first implementation capture contained transient black compositor + blocks while the page was still settling. No source-code visual change was + made; the same state was recaptured after the heading and companion loaded + and a five-second settle. The stable post-capture evidence is + `personal-dashboard-implemented-2.png`. +2. Full-view comparison found no P0/P1/P2 design mismatch. The visible + deviations are intentional product constraints: existing Forge component + vocabulary, the user-requested narrower companion, and honest activity + state instead of invented progress. ## Follow-up polish -- **P3:** If Forge later exposes safe quick-assignment mutations and queue sort/filter contracts, the workspace shelf can adopt inline controls without changing its structure. - -## Final result +- [P3] The generated source uses nearly borderless task rows, while Forge's + implementation uses its established rounded group cards. This is acceptable + system consistency; it can be revisited if the whole task surface adopts a + flatter row treatment later. +- [P3] The source includes more quick-date chips beneath capture. The working + implementation keeps the core Today/Tomorrow/Someday picker to reduce density. + +## Implementation checklist + +- [x] Profile-aware workspace creation and defaults +- [x] Personal navigation and Today dashboard +- [x] Persisted task and note interactions +- [x] Real delegated-agent activity with collapsible rail +- [x] Desktop and mobile browser verification +- [x] Same-state source/implementation comparison final result: passed diff --git a/prisma/migrations/20260716130000_workspace_experience_profile/migration.sql b/prisma/migrations/20260716130000_workspace_experience_profile/migration.sql new file mode 100644 index 00000000..3ec2c146 --- /dev/null +++ b/prisma/migrations/20260716130000_workspace_experience_profile/migration.sql @@ -0,0 +1,6 @@ +-- Add a presentation/defaults profile without changing workspace tenancy or +-- capability. Existing workspaces remain TEAM; new PERSONAL workspaces opt in. +CREATE TYPE "WorkspaceExperienceProfile" AS ENUM ('TEAM', 'PERSONAL'); + +ALTER TABLE "Workspace" +ADD COLUMN "experienceProfile" "WorkspaceExperienceProfile" NOT NULL DEFAULT 'TEAM'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55eaca4a..755fc5b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,14 @@ enum Role { GUEST } +/// Presentation and default-provisioning profile for a workspace. This does +/// not change tenancy, permissions, or agent capability: PERSONAL keeps the +/// same underlying work model while simplifying the default experience. +enum WorkspaceExperienceProfile { + TEAM + PERSONAL +} + /// Instance-wide role, distinct from the per-workspace {@link Role}. An /// INSTANCE_ADMIN administers the whole self-hosted instance (tenants, /// users, instance-wide runtimes, audit, system info) and can create @@ -912,6 +920,7 @@ model Workspace { /// Short key used for issue identifiers: e.g. `FRG-123`. key String @unique avatarUrl String? + experienceProfile WorkspaceExperienceProfile @default(TEAM) /// Default cycle length (days). Overridable per cycle at create time. cycleLengthDays Int @default(7) /// Default rest/cooldown days between cycles (0 = back-to-back). diff --git a/src/app/(app)/settings/workspaces/page.tsx b/src/app/(app)/settings/workspaces/page.tsx index b5314b6d..e05c3c58 100644 --- a/src/app/(app)/settings/workspaces/page.tsx +++ b/src/app/(app)/settings/workspaces/page.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Plus, ArrowRight, AlertTriangle } from "lucide-react"; +import { Plus, ArrowRight, AlertTriangle, UserRound, UsersRound } from "lucide-react"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,8 +20,8 @@ import { workspaceColor } from "@/lib/workspace-color"; * zone) live under `/w/[slug]/settings/workspace`; this page is about * moving *between* workspaces plus creating new ones. * - * NOTE: `workspace.create` today seeds only defaults + statuses. Agent C - * will extend it with MinIO bucket seeding and broader onboarding. + * `workspace.create` seeds profile-aware labels and defaults. Personal + * workspaces intentionally skip the initial sprint. */ export default function WorkspacesPage() { const router = useRouter(); @@ -210,6 +210,7 @@ function CreateDialog({ const [slug, setSlug] = useState(""); const [cycleLengthDays, setCycleLengthDays] = useState(7); const [timeTrackingEnabled, setTimeTrackingEnabled] = useState(false); + const [experienceProfile, setExperienceProfile] = useState<"TEAM" | "PERSONAL">("TEAM"); const create = trpc.workspace.create.useMutation({ onSuccess: (ws) => { @@ -227,6 +228,7 @@ function CreateDialog({ setSlug(""); setCycleLengthDays(7); setTimeTrackingEnabled(false); + setExperienceProfile("TEAM"); } function onNameChange(v: string) { @@ -248,7 +250,9 @@ function CreateDialog({ .slice(0, 3) .map((w) => w[0]?.toUpperCase() ?? "") .join(""); + const singleWord = v.replace(/[^a-zA-Z]/g, "").slice(0, 3).toUpperCase(); if (suggested.length >= 2) setKey(suggested.slice(0, 5)); + else if (singleWord.length >= 2) setKey(singleWord); } } @@ -260,10 +264,10 @@ function CreateDialog({ if (!/^[a-z0-9-]{2,48}$/.test(slug)) errs.push("Slug must be 2–48 lowercase alphanumeric chars or dashes."); else if (existingSlugs.has(slug)) errs.push(`Slug ${slug} is already in use.`); - if (cycleLengthDays < 1 || cycleLengthDays > 90) + if (experienceProfile === "TEAM" && (cycleLengthDays < 1 || cycleLengthDays > 90)) errs.push("Sprint length must be between 1 and 90 days."); return errs; - }, [name, key, slug, cycleLengthDays, existingKeys, existingSlugs]); + }, [name, key, slug, cycleLengthDays, experienceProfile, existingKeys, existingSlugs]); return ( @@ -277,6 +281,7 @@ function CreateDialog({ slug, cycleLengthDays, timeTrackingEnabled, + experienceProfile, }); }} className="space-y-3 p-5" @@ -288,12 +293,46 @@ function CreateDialog({ owner via a data migration.

+ +
+ + +
+
+ onNameChange(e.target.value)} maxLength={80} autoFocus + aria-label="Workspace name" />
@@ -304,6 +343,7 @@ function CreateDialog({ maxLength={6} placeholder="ACME" className="font-mono" + aria-label="Workspace key" /> @@ -315,10 +355,11 @@ function CreateDialog({ maxLength={48} placeholder="acme" className="font-mono" + aria-label="Workspace slug" />
-
+ {experienceProfile === "TEAM" &&
Enable timers -
+
}
The key cannot be changed later. Pick something short and stable. diff --git a/src/app/(app)/w/[slug]/dashboard/page.tsx b/src/app/(app)/w/[slug]/dashboard/page.tsx index 9510baf1..41b31021 100644 --- a/src/app/(app)/w/[slug]/dashboard/page.tsx +++ b/src/app/(app)/w/[slug]/dashboard/page.tsx @@ -46,6 +46,7 @@ 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 @@ -83,6 +84,11 @@ const FLOW_ORDER: Record = { }; export default function DashboardPage() { + const workspace = useWorkspace(); + return workspace.experienceProfile === "PERSONAL" ? : ; +} + +function TeamDashboard() { const workspace = useWorkspace(); const slug = workspace.slug; const { data: me } = trpc.workspace.me.useQuery(); diff --git a/src/app/(app)/w/[slug]/layout.tsx b/src/app/(app)/w/[slug]/layout.tsx index 52382539..b959b97d 100644 --- a/src/app/(app)/w/[slug]/layout.tsx +++ b/src/app/(app)/w/[slug]/layout.tsx @@ -49,6 +49,7 @@ export default async function WorkspaceShellLayout({ name: true, key: true, avatarUrl: true, + experienceProfile: true, cycleLengthDays: true, cycleCooldownDays: true, timeTrackingEnabled: true, @@ -101,6 +102,7 @@ export default async function WorkspaceShellLayout({ name: workspace.name, avatarUrl: workspace.avatarUrl, role: membership.role, + experienceProfile: workspace.experienceProfile, membershipCount, cycleLengthDays: workspace.cycleLengthDays, cycleCooldownDays: workspace.cycleCooldownDays, diff --git a/src/app/(app)/w/[slug]/settings/workspace/page.tsx b/src/app/(app)/w/[slug]/settings/workspace/page.tsx index e5c7d6a6..9419c057 100644 --- a/src/app/(app)/w/[slug]/settings/workspace/page.tsx +++ b/src/app/(app)/w/[slug]/settings/workspace/page.tsx @@ -12,10 +12,12 @@ import { Kbd } from "@/components/ui/kbd"; import { Confirm } from "@/components/ui/modal"; import { Section } from "@/components/ui"; import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; import { workspaceColor } from "@/lib/workspace-color"; type DefaultIssueAssigneeMode = "NONE" | "CREATOR" | "USER"; +type WorkspaceExperienceProfile = "TEAM" | "PERSONAL"; type CompletionAutomation = "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE"; type DeliveryTimelinePolicy = "OFF" | "RECOMMEND" | "REQUIRE_ON_PR" | "AUTO_ON_PR"; @@ -30,6 +32,9 @@ export default function WorkspaceSettingsPage() { const canDelete = ws.role === "OWNER"; const [name, setName] = useState(ws.name); + const [experienceProfile, setExperienceProfile] = useState( + ws.experienceProfile, + ); const [avatarUrl, setAvatarUrl] = useState(ws.avatarUrl ?? ""); const [cycleLengthDays, setCycleLengthDays] = useState(ws.cycleLengthDays); const [cycleCooldownDays, setCycleCooldownDays] = useState(ws.cycleCooldownDays); @@ -84,6 +89,7 @@ export default function WorkspaceSettingsPage() { const resetFromCurrent = useCallback(() => { if (!current) return; setName(current.name); + setExperienceProfile(current.experienceProfile); setAvatarUrl(current.avatarUrl ?? ""); setCycleLengthDays(current.cycleLengthDays); setCycleCooldownDays(current.cycleCooldownDays); @@ -167,6 +173,7 @@ export default function WorkspaceSettingsPage() { if (!current) return [] as string[]; const fields: Array<[string, boolean]> = [ ["name", name.trim() !== current.name], + ["experienceProfile", experienceProfile !== current.experienceProfile], ["avatarUrl", (avatarUrl.trim() || "") !== (current.avatarUrl ?? "")], ["cycleLengthDays", cycleLengthDays !== current.cycleLengthDays], ["cycleCooldownDays", cycleCooldownDays !== current.cycleCooldownDays], @@ -233,6 +240,7 @@ export default function WorkspaceSettingsPage() { }, [ current, name, + experienceProfile, avatarUrl, cycleLengthDays, cycleCooldownDays, @@ -279,6 +287,7 @@ export default function WorkspaceSettingsPage() { } update.mutate({ name: name.trim() || undefined, + experienceProfile, avatarUrl: avatarUrl.trim() ? avatarUrl.trim() : null, cycleLengthDays, cycleCooldownDays, @@ -320,6 +329,7 @@ export default function WorkspaceSettingsPage() { dirty, update, name, + experienceProfile, avatarUrl, cycleLengthDays, cycleCooldownDays, @@ -419,6 +429,28 @@ export default function WorkspaceSettingsPage() {
+ + setExperienceProfile("PERSONAL")} + disabled={!canEdit} + /> + setExperienceProfile("TEAM")} + disabled={!canEdit} + /> + +
+ + {experienceProfile === "TEAM" &&
@@ -450,7 +482,7 @@ export default function WorkspaceSettingsPage() { /> -
+ }
void; + disabled: boolean; +}) { + return ( + + ); +} + /** Labeled field with inline help text under the label (no tooltips). */ function Field({ label, diff --git a/src/components/dashboard/personal-dashboard.tsx b/src/components/dashboard/personal-dashboard.tsx new file mode 100644 index 00000000..8d802c81 --- /dev/null +++ b/src/components/dashboard/personal-dashboard.tsx @@ -0,0 +1,522 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowRight, + Bot, + Check, + ChevronRight, + Circle, + Clock3, + CornerDownLeft, + MessageSquare, + NotebookPen, + Plus, + Send, + Sparkles, + Sunrise, +} from "lucide-react"; +import { toast } from "sonner"; +import { AgentAvatar } from "@/components/agents/agent-avatar"; +import { Topbar } from "@/components/topbar"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { trpc } from "@/lib/trpc"; +import { cn, formatIssueId, relativeTime } from "@/lib/utils"; +import type { AppRouter } from "@/server/routers/_app"; + +const AGENT_RAIL_KEY = "forge.personal.agentCompanionCollapsed"; + +type DuePreset = "TODAY" | "TOMORROW" | "SOMEDAY"; + +export function PersonalDashboard() { + const ws = useWorkspace(); + const utils = trpc.useUtils(); + const { data: me } = trpc.workspace.me.useQuery(); + const { data: statuses } = trpc.status.list.useQuery(); + const active = trpc.issue.list.useQuery({ includeDone: false, limit: 100, sort: "updated" }); + const notes = trpc.note.list.useQuery({ archived: false, limit: 3 }); + const [taskTitle, setTaskTitle] = useState(""); + const [duePreset, setDuePreset] = useState("TODAY"); + const [noteBody, setNoteBody] = useState(""); + const [agentPrompt, setAgentPrompt] = useState(""); + const [railCollapsed, setRailCollapsed] = useState(false); + + useEffect(() => { + try { + setRailCollapsed(window.localStorage.getItem(AGENT_RAIL_KEY) === "1"); + } catch { + // Keep the visible default when storage is unavailable. + } + }, []); + + function setAgentRailCollapsed(next: boolean) { + setRailCollapsed(next); + try { + window.localStorage.setItem(AGENT_RAIL_KEY, next ? "1" : "0"); + } catch { + // The preference is optional; the interaction still works. + } + } + + const createTask = trpc.issue.create.useMutation({ + onSuccess: () => { + setTaskTitle(""); + utils.issue.list.invalidate(); + toast.success("Task added."); + }, + onError: (error) => toast.error(error.message), + }); + const completeTask = trpc.issue.update.useMutation({ + onSuccess: () => utils.issue.list.invalidate(), + onError: (error) => toast.error(error.message), + }); + const createNote = trpc.note.create.useMutation({ + onSuccess: () => { + setNoteBody(""); + utils.note.list.invalidate(); + toast.success("Note saved."); + }, + onError: (error) => toast.error(error.message), + }); + + const now = useMemo(() => new Date(), []); + const { startToday, endToday } = useMemo(() => { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { startToday: start, endToday: end }; + }, [now]); + + const buckets = useMemo(() => { + const overdue: PersonalIssue[] = []; + const today: PersonalIssue[] = []; + const upcoming: PersonalIssue[] = []; + const unscheduled: PersonalIssue[] = []; + for (const issue of active.data?.items ?? []) { + if (!issue.dueDate) unscheduled.push(issue); + else if (issue.dueDate < startToday) overdue.push(issue); + else if (issue.dueDate < endToday) today.push(issue); + else upcoming.push(issue); + } + const byDue = (a: (typeof overdue)[number], b: (typeof overdue)[number]) => + (a.dueDate?.getTime() ?? Number.MAX_SAFE_INTEGER) - + (b.dueDate?.getTime() ?? Number.MAX_SAFE_INTEGER); + return { + overdue: overdue.sort(byDue), + today: [...today.sort(byDue), ...unscheduled], + upcoming: upcoming.sort(byDue), + }; + }, [active.data?.items, endToday, startToday]); + + const delegated = useMemo( + () => (active.data?.items ?? []).filter((issue) => issue.assignedAgent).slice(0, 4), + [active.data?.items], + ); + const doneStatus = statuses?.find((status) => status.category === "DONE"); + const firstName = (me?.user.name ?? me?.user.email ?? "there").split(/[\s@]/)[0] || "there"; + + function addTask() { + const title = taskTitle.trim(); + if (!title) return; + const dueDate = new Date(startToday); + if (duePreset === "TOMORROW") dueDate.setDate(dueDate.getDate() + 1); + createTask.mutate({ + title, + dueDate: duePreset === "SOMEDAY" ? undefined : dueDate, + }); + } + + function agentChatHref(prompt: string) { + return `/w/${ws.slug}/chat?draft=${encodeURIComponent(prompt)}`; + } + + return ( + <> + + + Plan my day + + } + /> + +
+
+
+
+
+
+
+ + Personal workspace +
+

+ Good {dayPart(now)}, {firstName}. +

+

+ {buckets.today.length === 0 + ? "Your day is clear. Capture what matters next." + : `${buckets.today.length} ${buckets.today.length === 1 ? "task" : "tasks"} in view for today.`} +

+
+ {railCollapsed && ( + + )} +
+ +
+
+ + setTaskTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addTask(); + } + }} + placeholder="Add a task…" + aria-label="Task title" + className="focus-ring h-10 min-w-0 flex-1 rounded-md bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground/70" + /> + value && setDuePreset(value as DuePreset)} + options={[ + { value: "TODAY", label: "Today" }, + { value: "TOMORROW", label: "Tomorrow" }, + { value: "SOMEDAY", label: "Someday" }, + ]} + ariaLabel="Task due date" + className="hidden h-8 text-xs text-muted-foreground sm:inline-flex" + /> + +
+
+ + {active.isLoading ? ( + + ) : ( +
+ {buckets.overdue.length > 0 && ( + doneStatus && completeTask.mutate({ id, statusId: doneStatus.id })} + canComplete={Boolean(doneStatus)} + /> + )} + doneStatus && completeTask.mutate({ id, statusId: doneStatus.id })} + canComplete={Boolean(doneStatus)} + empty="Nothing scheduled. Add a task above or ask your agent to plan the day." + /> +
+ doneStatus && completeTask.mutate({ id, statusId: doneStatus.id })} + canComplete={Boolean(doneStatus)} + empty="No upcoming deadlines." + /> +
+
+ )} + +
+
+
+ +

Notes

+
+ + Personal scratchpad + +
+
+ setNoteBody(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && noteBody.trim()) { + event.preventDefault(); + createNote.mutate({ body: noteBody.trim() }); + } + }} + placeholder="Capture a note…" + /> + +
+
+ {(notes.data?.items ?? []).map((note) => ( +
+
{note.title || "Quick note"}
+

+ {note.body} +

+
+ ))} + {!notes.isLoading && (notes.data?.items.length ?? 0) === 0 && ( +
+ Notes you capture here stay personal to you in this workspace. +
+ )} +
+
+
+
+ + {!railCollapsed && ( +