diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index b174c15a1f..821df4057c 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -809,6 +809,7 @@ export type ChannelActionType = | "unstar" | "edit_context_open" | "new_task_open" + | "select_suggestion_category" | "new_task_suggestion" | "view_context" | "view_history" @@ -827,10 +828,15 @@ export interface ChannelActionProperties { channel_id?: string; /** For file/unfile/archive/open task actions. */ task_id?: string; + /** For open_artifact when the artifact is a canvas. */ + dashboard_id?: string; /** For file_task: destination channel when different from `channel_id`. */ target_channel_id?: string; /** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */ nav_target?: string; + /** For select_suggestion_category / new_task_suggestion: the suggestion + * category id (e.g. "code", "analysis", "debug", "canvas"). */ + category?: string; /** For new_task_suggestion: the starter-prompt card label. */ suggestion_label?: string; /** Whether the underlying mutation resolved successfully. */ diff --git a/packages/ui/src/features/canvas/channelTaskSuggestions.ts b/packages/ui/src/features/canvas/channelTaskSuggestions.ts index 5529b1447e..6cf905dd92 100644 --- a/packages/ui/src/features/canvas/channelTaskSuggestions.ts +++ b/packages/ui/src/features/canvas/channelTaskSuggestions.ts @@ -2,92 +2,182 @@ import { Bug, ChartBar, ChartLine, + ChartPieSlice, ChatCircleText, + Code, Cube, CurrencyDollar, Flask, + type Icon, + SquaresFour, + TrendDown, + WarningOctagon, Wrench, } from "@phosphor-icons/react"; import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; -// Starter prompts shown as cards on the channels (project-bluebird) new-task -// screen. Clicking a card drops its `prompt` into the composer, ready to -// edit/send. Each prompt ends with a "User input:" block of fill-in lines the -// user completes before sending. Channels-only — the /code new-task screen -// keeps its discovery suggestions. Card styling mirrors SuggestedTaskCard -// (icon badge + title + description); the icon/color follow the same -// `var(---N)` token scheme. -export const CHANNEL_TASK_SUGGESTIONS: SuggestedPrompt[] = [ +// Starter prompts for the channels (project-bluebird) new-task surfaces. Picking +// one drops its `prompt` into the composer, ready to edit/send. Each prompt ends +// with a "User input:" block of fill-in lines the user completes before sending. +// Channels-only — the /code new-task screen keeps its discovery suggestions. +// +// On the channel home these are grouped behind the category chips below; the +// flat CHANNEL_TASK_SUGGESTIONS list (derived at the bottom) still feeds the +// new-task screen's card grid. +export interface SuggestionCategory { + id: string; + /** Chip label shown on the channel home. */ + label: string; + icon: Icon; + /** Radix color token base (`var(---N)`) for the chip + row icons. */ + color: string; + suggestions: SuggestedPrompt[]; +} + +export const CHANNEL_SUGGESTION_CATEGORIES: SuggestionCategory[] = [ { - label: "Debug a user issue", - description: "Trace a specific user's events, replays, and errors", - icon: Bug, - color: "red", - mode: "auto", - prompt: - "Help me debug an issue a specific user is hitting. Pull their recent events, session replays, and errors, then figure out what went wrong.\n\n\nUser input:\n- Describe the user issue:\n- User identifier (distinct ID, email address, etc):", + id: "code", + label: "Code", + icon: Code, + color: "orange", + suggestions: [ + { + label: "Fix a bug", + description: "Track down and fix a problem in the code", + icon: Wrench, + color: "orange", + mode: "plan", + prompt: + "Help me fix a bug — track down the root cause in the code and implement a fix. Open a PR if appropriate.\n\n\nUser input:\n- Describe the bug / what's going wrong:\n- Steps to reproduce (optional):\n- Where it happens (file, page, area — optional):", + }, + { + label: "Build a new feature", + description: "Design and implement something new", + icon: Cube, + color: "teal", + mode: "plan", + prompt: + "Help me build a new feature — propose an approach, then implement it. Open a PR if appropriate.\n\n\nUser input:\n- Describe the feature you want:\n- Any constraints or requirements (optional):", + }, + ], }, { - label: "Run a feature analysis", - description: "Adoption, engagement, and retention of a feature", + id: "analysis", + label: "Analysis", icon: ChartLine, color: "blue", - mode: "auto", - prompt: - "Analyze how a feature is performing — adoption, engagement, and retention of users who use it vs. those who don't.\n\n\nUser input:\n- Feature to analyze:\n- Time period (optional):", + suggestions: [ + { + label: "Run a feature analysis", + description: "Adoption, engagement, and retention of a feature", + icon: ChartLine, + color: "blue", + mode: "auto", + prompt: + "Analyze how a feature is performing — adoption, engagement, and retention of users who use it vs. those who don't.\n\n\nUser input:\n- Feature to analyze:\n- Time period (optional):", + }, + { + label: "Understand revenue patterns", + description: "Trends over time, by plan, and by cohort", + icon: CurrencyDollar, + color: "green", + mode: "auto", + prompt: + "Analyze our revenue trends — break it down over time, by plan, and by cohort, and call out notable changes and likely drivers.\n\n\nUser input:\n- What revenue question are you trying to answer:\n- Time period (optional):", + }, + { + label: "Summarize product usage", + description: "Top events, active users, and key funnels", + icon: ChartBar, + color: "violet", + mode: "auto", + prompt: + "Summarize how our product is being used — top events, active users, key funnels, and notable trends.\n\n\nUser input:\n- Product area or feature to focus on (optional):\n- Time period (optional):", + }, + { + label: "Interpret experiment results", + description: "Significance and what to do next", + icon: Flask, + color: "purple", + mode: "auto", + prompt: + "Interpret the results of an experiment — explain what the metrics show, whether it's significant, and what to do next.\n\n\nUser input:\n- Experiment name or key:\n- What decision are you trying to make (optional):", + }, + { + label: "Summarize user & agent feedback", + description: "Common themes across recent feedback", + icon: ChatCircleText, + color: "amber", + mode: "auto", + prompt: + "Summarize recent user and support/agent feedback — surface the common themes, complaints, and requests.\n\n\nUser input:\n- Feedback source or topic to focus on:\n- Time period (optional):", + }, + ], }, { - label: "Understand revenue patterns", - description: "Trends over time, by plan, and by cohort", - icon: CurrencyDollar, - color: "green", - mode: "auto", - prompt: - "Analyze our revenue trends — break it down over time, by plan, and by cohort, and call out notable changes and likely drivers.\n\n\nUser input:\n- What revenue question are you trying to answer:\n- Time period (optional):", + id: "debug", + label: "Debug", + icon: Bug, + color: "red", + suggestions: [ + { + label: "Debug a user issue", + description: "Trace a specific user's events, replays, and errors", + icon: Bug, + color: "red", + mode: "auto", + prompt: + "Help me debug an issue a specific user is hitting. Pull their recent events, session replays, and errors, then figure out what went wrong.\n\n\nUser input:\n- Describe the user issue:\n- User identifier (distinct ID, email address, etc):", + }, + { + label: "Investigate an error", + description: "Root cause, frequency, and who it affects", + icon: WarningOctagon, + color: "red", + mode: "auto", + prompt: + "Investigate an error or exception — find the root cause, how often it happens, and which users it affects.\n\n\nUser input:\n- Error message or issue:\n- Where you're seeing it (optional):", + }, + { + label: "Diagnose a metric change", + description: "Why a metric dropped or spiked", + icon: TrendDown, + color: "amber", + mode: "auto", + prompt: + "Figure out why a metric dropped or spiked — break it down by segment and surface the likely causes.\n\n\nUser input:\n- Which metric changed:\n- When you noticed it (optional):", + }, + ], }, { - label: "Summarize product usage", - description: "Top events, active users, and key funnels", - icon: ChartBar, + id: "canvas", + label: "Canvas", + icon: SquaresFour, color: "violet", - mode: "auto", - prompt: - "Summarize how our product is being used — top events, active users, key funnels, and notable trends.\n\n\nUser input:\n- Product area or feature to focus on (optional):\n- Time period (optional):", - }, - { - label: "Summarize user & agent feedback", - description: "Common themes across recent feedback", - icon: ChatCircleText, - color: "amber", - mode: "auto", - prompt: - "Summarize recent user and support/agent feedback — surface the common themes, complaints, and requests.\n\n\nUser input:\n- Feedback source or topic to focus on:\n- Time period (optional):", - }, - { - label: "Interpret experiment results", - description: "Significance and what to do next", - icon: Flask, - color: "purple", - mode: "auto", - prompt: - "Interpret the results of an experiment — explain what the metrics show, whether it's significant, and what to do next.\n\n\nUser input:\n- Experiment name or key:\n- What decision are you trying to make (optional):", - }, - { - label: "Fix a bug", - description: "Track down and fix a problem in the code", - icon: Wrench, - color: "orange", - mode: "plan", - prompt: - "Help me fix a bug — track down the root cause in the code and implement a fix. Open a PR if appropriate.\n\n\nUser input:\n- Describe the bug / what's going wrong:\n- Steps to reproduce (optional):\n- Where it happens (file, page, area — optional):", - }, - { - label: "Build a new feature", - description: "Design and implement something new", - icon: Cube, - color: "teal", - mode: "plan", - prompt: - "Help me build a new feature — propose an approach, then implement it. Open a PR if appropriate.\n\n\nUser input:\n- Describe the feature you want:\n- Any constraints or requirements (optional):", + suggestions: [ + { + label: "Build a dashboard", + description: "Lay out the key metrics on a canvas", + icon: SquaresFour, + color: "violet", + mode: "auto", + prompt: + "Build a dashboard canvas that brings together the metrics that matter for this work.\n\n\nUser input:\n- What should the dashboard cover:\n- Key metrics or breakdowns to include (optional):", + }, + { + label: "Visualize a metric", + description: "Chart a single metric with the right breakdowns", + icon: ChartPieSlice, + color: "purple", + mode: "auto", + prompt: + "Create a canvas that visualizes a single metric over time, with the breakdowns that make it useful.\n\n\nUser input:\n- Metric to visualize:\n- Breakdowns or filters (optional):", + }, + ], }, ]; + +// Flat list for the new-task screen's card grid (WebsiteNewTask), which shows +// every starter prompt at once rather than grouping them behind chips. +export const CHANNEL_TASK_SUGGESTIONS: SuggestedPrompt[] = + CHANNEL_SUGGESTION_CATEGORIES.flatMap((category) => category.suggestions); diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index d46596a043..28d2f945c0 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -1,8 +1,11 @@ +import { FileText, X } from "@phosphor-icons/react"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import type { Task } from "@posthog/shared/domain-types"; +import { Tooltip } from "@radix-ui/themes"; import { forwardRef, useCallback, + useEffect, useImperativeHandle, useRef, useState, @@ -35,6 +38,9 @@ interface ChannelHomeComposerProps { channelName?: string; /** Channel CONTEXT.md, attached to the created task as background. */ channelContext?: string; + /** Fires as the editor goes empty ⇄ non-empty, so the home page can fade out + * its suggestions / lists while the user is typing. */ + onEmptyChange?: (isEmpty: boolean) => void; onTaskCreated: (task: Task) => void; } @@ -48,7 +54,7 @@ export const ChannelHomeComposer = forwardRef< ChannelHomeComposerHandle, ChannelHomeComposerProps >(function ChannelHomeComposer( - { channelId, channelName, channelContext, onTaskCreated }, + { channelId, channelName, channelContext, onEmptyChange, onTaskCreated }, ref, ) { const sessionId = `channel-home:${channelId}`; @@ -56,6 +62,28 @@ export const ChannelHomeComposer = forwardRef< const [editorIsEmpty, setEditorIsEmpty] = useState(true); const { isOnline } = useConnectivity(); + // The channel CONTEXT.md is attached to new tasks by default; the chip below + // the prompt surfaces that it's included and lets the user drop it from this + // task. Re-include whenever the source context changes (e.g. the doc loads or + // the channel switches) so a dismissal doesn't stick. Mirrors TaskInput. + const [channelContextDismissed, setChannelContextDismissed] = useState(false); + const lastChannelContextRef = useRef(channelContext); + useEffect(() => { + if (lastChannelContextRef.current !== channelContext) { + lastChannelContextRef.current = channelContext; + setChannelContextDismissed(false); + } + }, [channelContext]); + const includeChannelContext = !!channelContext && !channelContextDismissed; + + const handleEmptyChange = useCallback( + (isEmpty: boolean) => { + setEditorIsEmpty(isEmpty); + onEmptyChange?.(isEmpty); + }, + [onEmptyChange], + ); + const { lastUsedAdapter, setLastUsedAdapter, @@ -125,7 +153,7 @@ export const ChannelHomeComposer = forwardRef< model: currentModel, reasoningLevel: currentReasoningLevel, allowNoRepo: true, - channelContext, + channelContext: includeChannelContext ? channelContext : undefined, channelName, onTaskCreated, }); @@ -225,12 +253,34 @@ export const ChannelHomeComposer = forwardRef< /> ) } - onEmptyChange={setEditorIsEmpty} + onEmptyChange={handleEmptyChange} onSubmitClick={handleSubmit} onSubmit={() => { if (canSubmit) handleSubmit(); }} /> + + {includeChannelContext && ( +
+ Using: + + + + {channelName ? `#${channelName} ` : ""}CONTEXT.md + + + + + +
+ )} ); }); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 1bf83a6942..13ee9aaef3 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -1,26 +1,53 @@ +import { ArrowRightIcon, CaretRightIcon } from "@phosphor-icons/react"; +import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + cn, + Menubar, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarTrigger, +} from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; -import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; +import { + CHANNEL_SUGGESTION_CATEGORIES, + type SuggestionCategory, +} from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { ChannelHomeComposer, type ChannelHomeComposerHandle, } from "@posthog/ui/features/canvas/components/ChannelHomeComposer"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { + useChannelTaskMutations, + useChannelTasks, +} from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; -import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; +import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Text } from "@radix-ui/themes"; +import { Flex, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo, useRef, useState } from "react"; -// A channel's homepage: a heading and a composer that files new tasks into the -// channel. The channel's tasks + canvases live behind the "History" tab. +const RECENT_TASK_LIMIT = 5; +const PINNED_ARTIFACT_LIMIT = 5; + +// A channel's homepage: a heading, the composer that files new tasks into the +// channel, the starter-prompt suggestions, and a two-column glance at the +// channel's recent tasks and pinned artifacts. The full lists live behind the +// "Recents" and "Artifacts" tabs. export function WebsiteChannelHome({ channelId }: { channelId: string }) { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -36,12 +63,47 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { ); const composerRef = useRef(null); + // While the user is typing, dim the suggestions + lists so the focus stays on + // the prompt box. + const [composerEmpty, setComposerEmpty] = useState(true); + + // Anchor for the category menus: pointing each popup at the whole menu bar + // (rather than its own trigger) makes Base UI's --anchor-width the bar width, + // so the popup fills the bar. + const menuBarRef = useRef(null); - const handleSuggestionSelect = useCallback( - (prompt: string, mode?: string) => { - composerRef.current?.applySuggestion(prompt, mode); + // Which category menu is open, if any. The menu bar stays put while a menu is + // open, but the recents below fade out so the options have the floor. + const [openCategoryId, setOpenCategoryId] = useState(null); + const handleCategoryOpenChange = useCallback( + (category: SuggestionCategory, open: boolean) => { + setOpenCategoryId((prev) => + open ? category.id : prev === category.id ? null : prev, + ); + if (open) { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "select_suggestion_category", + surface: "channel_home", + channel_id: channelId, + category: category.id, + }); + } }, - [], + [channelId], + ); + + const applySuggestion = useCallback( + (suggestion: SuggestedPrompt, categoryId: string) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_suggestion", + surface: "channel_home", + channel_id: channelId, + category: categoryId, + suggestion_label: suggestion.label, + }); + composerRef.current?.applySuggestion(suggestion.prompt, suggestion.mode); + }, + [channelId], ); const onTaskCreated = useCallback( @@ -79,7 +141,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { return (
-
+

What can I do for you today? @@ -89,32 +151,334 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {

- {/* Starter prompts, always shown directly above the box. */} -
- - Suggestions - -
- {CHANNEL_TASK_SUGGESTIONS.map((suggestion) => ( - - handleSuggestionSelect(suggestion.prompt, suggestion.mode) - } - /> - ))} -
-
- + + {/* Category menu bar + recent/pinned glance. Everything fades out while + the user is typing so the prompt box has the floor. */} +
+ {/* The bar is the anchor for every category popup: anchoring to it + (not the trigger) makes each popup fill the bar's width. */} +
+ + {CHANNEL_SUGGESTION_CATEGORIES.map((category) => ( + + handleCategoryOpenChange(category, open) + } + onSelect={(suggestion) => + applySuggestion(suggestion, category.id) + } + /> + ))} + +
+ + {/* Recents fade out while a category menu is open. */} +
+ + +
+
+
+
+ ); +} + +// One category in the menu bar: a chip-styled trigger, and a popup listing the +// category's suggested actions. The popup is anchored to the whole bar so it +// fills the bar's width (via Base UI's --anchor-width). +function CategoryMenu({ + category, + anchor, + onOpenChange, + onSelect, +}: { + category: SuggestionCategory; + anchor: React.RefObject; + onOpenChange: (open: boolean) => void; + onSelect: (suggestion: SuggestedPrompt) => void; +}) { + const Icon = category.icon; + return ( + + + + {category.label} + + + {category.suggestions.map((suggestion) => ( + onSelect(suggestion)} + /> + ))} + + + ); +} + +// A single suggested action row: icon badge, title, then the description as +// muted text alongside it. Selecting it drops the prompt into the composer. +function SuggestionRow({ + suggestion, + onSelect, +}: { + suggestion: SuggestedPrompt; + onSelect: () => void; +}) { + const Icon = suggestion.icon; + return ( + + + + + + + {suggestion.label} + + + {suggestion.description} + + + + ); +} + +// Left column: the channel's most recently active tasks, with the shared task +// status icons. "All tasks" jumps to the Recents tab. +function RecentTasksColumn({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { tasks: filedTasks, isLoading: filedLoading } = + useChannelTasks(channelId); + const { data: tasks, isLoading: tasksLoading } = useTasks(); + const archivedTaskIds = useArchivedTaskIds(); + + const recentTasks = useMemo(() => { + const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); + return filedTasks + .flatMap((f) => { + const task = taskById.get(f.taskId); + if (!task || archivedTaskIds.has(f.taskId)) return []; + return [{ task, ts: Date.parse(task.updated_at) || 0 }]; + }) + .sort((a, b) => b.ts - a.ts) + .slice(0, RECENT_TASK_LIMIT); + }, [filedTasks, tasks, archivedTaskIds]); + + const openTask = useCallback( + (taskId: string) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_task", + surface: "channel_home", + channel_id: channelId, + task_id: taskId, + }); + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId }, + }); + }, + [channelId, navigate], + ); + + return ( + + All tasks + + + } + empty={ + !filedLoading && !tasksLoading && recentTasks.length === 0 + ? "No tasks yet" + : undefined + } + > + {recentTasks.map(({ task, ts }) => ( + } + title={task.title || "Untitled task"} + subtitle={formatRelativeTimeShort(ts)} + onClick={() => openTask(task.id)} + /> + ))} + + ); +} + +// Right column: the channel's pinned canvases, most recently pinned first. No +// dedicated page yet, so this is the only surface for them. +function PinnedArtifactsColumn({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { dashboards, isLoading } = useDashboards(channelId); + + const pinned = useMemo( + () => + dashboards + .filter((d: DashboardSummary) => d.pinnedAt != null) + .sort((a, b) => (b.pinnedAt ?? 0) - (a.pinnedAt ?? 0)) + .slice(0, PINNED_ARTIFACT_LIMIT), + [dashboards], + ); + + const openCanvas = useCallback( + (dashboardId: string) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_artifact", + surface: "channel_home", + channel_id: channelId, + dashboard_id: dashboardId, + }); + void navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId }, + }); + }, + [channelId, navigate], + ); + + return ( + + {pinned.map((d) => ( + openCanvas(d.id)} + /> + ))} + + ); +} + +function ColumnShell({ + title, + action, + empty, + children, +}: { + title: string; + action?: React.ReactNode; + empty?: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {title} + + {action}
+ {empty ? ( + {empty} + ) : ( +
{children}
+ )}
); } + +function ListRow({ + icon, + title, + subtitle, + onClick, +}: { + icon: React.ReactNode; + title: string; + subtitle: string; + onClick: () => void; +}) { + return ( + + ); +}