From c50223f68fddda95c9a7e7bb2384ccc76bbebd74 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 13:12:39 -0600 Subject: [PATCH] feat: add New Goal dialog and Sub Goal button - Added NewGoalDialog component with title, description (markdown), status, level, and parent goal selection - Integrated dialog into DialogContext with parentId defaults support - Added "+ New Goal" button on /goals page (both empty state and header) - Added "+ Sub Goal" button on goal detail sub-goals tab that pre-fills the parent goal Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 2 + ui/src/components/NewGoalDialog.tsx | 282 ++++++++++++++++++++++++++++ ui/src/context/DialogContext.tsx | 24 +++ ui/src/pages/GoalDetail.tsx | 12 +- ui/src/pages/Goals.tsx | 17 +- 5 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/NewGoalDialog.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a3ab08d6..b7af003d 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; +import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { OnboardingWizard } from "./OnboardingWizard"; import { useDialog } from "../context/DialogContext"; @@ -84,6 +85,7 @@ export function Layout() { + diff --git a/ui/src/components/NewGoalDialog.tsx b/ui/src/components/NewGoalDialog.tsx new file mode 100644 index 00000000..97338c64 --- /dev/null +++ b/ui/src/components/NewGoalDialog.tsx @@ -0,0 +1,282 @@ +import { useRef, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { GOAL_STATUSES, GOAL_LEVELS } from "@paperclip/shared"; +import { useDialog } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { goalsApi } from "../api/goals"; +import { assetsApi } from "../api/assets"; +import { queryKeys } from "../lib/queryKeys"; +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Maximize2, + Minimize2, + Target, + Layers, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; +import { StatusBadge } from "./StatusBadge"; + +const levelLabels: Record = { + company: "Company", + team: "Team", + agent: "Agent", + task: "Task", +}; + +export function NewGoalDialog() { + const { newGoalOpen, newGoalDefaults, closeNewGoal } = useDialog(); + const { selectedCompanyId, selectedCompany } = useCompany(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState("planned"); + const [level, setLevel] = useState("task"); + const [parentId, setParentId] = useState(""); + const [expanded, setExpanded] = useState(false); + + const [statusOpen, setStatusOpen] = useState(false); + const [levelOpen, setLevelOpen] = useState(false); + const [parentOpen, setParentOpen] = useState(false); + const descriptionEditorRef = useRef(null); + + // Apply defaults when dialog opens + const appliedParentId = parentId || newGoalDefaults.parentId || ""; + + const { data: goals } = useQuery({ + queryKey: queryKeys.goals.list(selectedCompanyId!), + queryFn: () => goalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newGoalOpen, + }); + + const createGoal = useMutation({ + mutationFn: (data: Record) => + goalsApi.create(selectedCompanyId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(selectedCompanyId!) }); + reset(); + closeNewGoal(); + }, + }); + + const uploadDescriptionImage = useMutation({ + mutationFn: async (file: File) => { + if (!selectedCompanyId) throw new Error("No company selected"); + return assetsApi.uploadImage(selectedCompanyId, file, "goals/drafts"); + }, + }); + + function reset() { + setTitle(""); + setDescription(""); + setStatus("planned"); + setLevel("task"); + setParentId(""); + setExpanded(false); + } + + function handleSubmit() { + if (!selectedCompanyId || !title.trim()) return; + createGoal.mutate({ + title: title.trim(), + description: description.trim() || undefined, + status, + level, + ...(appliedParentId ? { parentId: appliedParentId } : {}), + }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + } + + const currentParent = (goals ?? []).find((g) => g.id === appliedParentId); + + return ( + { + if (!open) { + reset(); + closeNewGoal(); + } + }} + > + + {/* Header */} +
+
+ {selectedCompany && ( + + {selectedCompany.name.slice(0, 3).toUpperCase()} + + )} + + {newGoalDefaults.parentId ? "New sub-goal" : "New goal"} +
+
+ + +
+
+ + {/* Title */} +
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + descriptionEditorRef.current?.focus(); + } + }} + autoFocus + /> +
+ + {/* Description */} +
+ { + const asset = await uploadDescriptionImage.mutateAsync(file); + return asset.contentPath; + }} + /> +
+ + {/* Property chips */} +
+ {/* Status */} + + + + + + {GOAL_STATUSES.map((s) => ( + + ))} + + + + {/* Level */} + + + + + + {GOAL_LEVELS.map((l) => ( + + ))} + + + + {/* Parent goal */} + + + + + + + {(goals ?? []).map((g) => ( + + ))} + + +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index 4aea7ff2..e0a98539 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -7,6 +7,10 @@ interface NewIssueDefaults { assigneeAgentId?: string; } +interface NewGoalDefaults { + parentId?: string; +} + interface DialogContextValue { newIssueOpen: boolean; newIssueDefaults: NewIssueDefaults; @@ -15,6 +19,10 @@ interface DialogContextValue { newProjectOpen: boolean; openNewProject: () => void; closeNewProject: () => void; + newGoalOpen: boolean; + newGoalDefaults: NewGoalDefaults; + openNewGoal: (defaults?: NewGoalDefaults) => void; + closeNewGoal: () => void; newAgentOpen: boolean; openNewAgent: () => void; closeNewAgent: () => void; @@ -29,6 +37,8 @@ export function DialogProvider({ children }: { children: ReactNode }) { const [newIssueOpen, setNewIssueOpen] = useState(false); const [newIssueDefaults, setNewIssueDefaults] = useState({}); const [newProjectOpen, setNewProjectOpen] = useState(false); + const [newGoalOpen, setNewGoalOpen] = useState(false); + const [newGoalDefaults, setNewGoalDefaults] = useState({}); const [newAgentOpen, setNewAgentOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); @@ -50,6 +60,16 @@ export function DialogProvider({ children }: { children: ReactNode }) { setNewProjectOpen(false); }, []); + const openNewGoal = useCallback((defaults: NewGoalDefaults = {}) => { + setNewGoalDefaults(defaults); + setNewGoalOpen(true); + }, []); + + const closeNewGoal = useCallback(() => { + setNewGoalOpen(false); + setNewGoalDefaults({}); + }, []); + const openNewAgent = useCallback(() => { setNewAgentOpen(true); }, []); @@ -76,6 +96,10 @@ export function DialogProvider({ children }: { children: ReactNode }) { newProjectOpen, openNewProject, closeNewProject, + newGoalOpen, + newGoalDefaults, + openNewGoal, + closeNewGoal, newAgentOpen, openNewAgent, closeNewAgent, diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx index 75d70c81..155e3bdd 100644 --- a/ui/src/pages/GoalDetail.tsx +++ b/ui/src/pages/GoalDetail.tsx @@ -6,6 +6,7 @@ import { projectsApi } from "../api/projects"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { GoalProperties } from "../components/GoalProperties"; @@ -13,12 +14,15 @@ import { GoalTree } from "../components/GoalTree"; import { StatusBadge } from "../components/StatusBadge"; import { InlineEditor } from "../components/InlineEditor"; import { EntityRow } from "../components/EntityRow"; +import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus } from "lucide-react"; import type { Goal, Project } from "@paperclip/shared"; export function GoalDetail() { const { goalId } = useParams<{ goalId: string }>(); const { selectedCompanyId } = useCompany(); + const { openNewGoal } = useDialog(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); @@ -117,7 +121,13 @@ export function GoalDetail() { Projects ({linkedProjects.length}) - + +
+ +
{childGoals.length === 0 ? (

No sub-goals.

) : ( diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx index 718e4aa8..ad7998f6 100644 --- a/ui/src/pages/Goals.tsx +++ b/ui/src/pages/Goals.tsx @@ -3,14 +3,17 @@ import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { GoalTree } from "../components/GoalTree"; import { EmptyState } from "../components/EmptyState"; -import { Target } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Target, Plus } from "lucide-react"; export function Goals() { const { selectedCompanyId } = useCompany(); + const { openNewGoal } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); @@ -38,12 +41,20 @@ export function Goals() { icon={Target} message="No goals yet." action="Add Goal" - onAction={() => {/* TODO: goal creation */}} + onAction={() => openNewGoal()} /> )} {goals && goals.length > 0 && ( - navigate(`/goals/${goal.id}`)} /> + <> +
+ +
+ navigate(`/goals/${goal.id}`)} /> + )} );