diff --git a/apps/dashboard/src/app/(dashboard)/agents/mcp/page.tsx b/apps/dashboard/src/app/(dashboard)/agents/mcp/page.tsx index ded7745be..6cdbc1be4 100644 --- a/apps/dashboard/src/app/(dashboard)/agents/mcp/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/agents/mcp/page.tsx @@ -195,6 +195,7 @@ const tools = [ group: "Status reports", items: [ "list_status_reports", + "get_status_report", "create_status_report", "add_status_report_update", "update_status_report", diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index 85cf6295f..82a6f1415 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -17,10 +17,6 @@ import { DetailSection, DetailSectionTitle, } from "@/components/content/detail"; -import { - EmptyStateContainer, - EmptyStateTitle, -} from "@/components/content/empty-state"; import { SectionGroup } from "@/components/content/section"; import { Timeline } from "@/components/content/timeline"; import { @@ -40,24 +36,12 @@ import { useTRPC } from "@/lib/trpc/client"; export function Client({ id }: { id: number }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const { data: incident, isError } = useQuery({ - ...trpc.incident.get.queryOptions({ id }), - retry: false, - }); + const { data: incident } = useQuery(trpc.incident.get.queryOptions({ id })); const { data: events } = useQuery( trpc.incident.listEvents.queryOptions({ id }), ); const [followUp, setFollowUp] = useState<{ note: string } | null>(null); - if (isError) { - return ( - - - Incident not found - - - ); - } if (!incident) return null; const canNotify = workspace?.limits["status-subscribers"] === true; @@ -84,7 +68,7 @@ export function Client({ id }: { id: number }) { const timeline = ( - Timeline + Activity {events ? ( {events.length} @@ -99,7 +83,11 @@ export function Client({ id }: { id: number }) { /> )} {events?.map((event) => ( - + ))} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx index f22a46c58..ffe3d1bdb 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx @@ -1,6 +1,11 @@ import { notFound } from "next/navigation"; -import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { + HydrateClient, + fetchQueryOrNotFound, + getQueryClient, + trpc, +} from "@/lib/trpc/server"; import { Client } from "./client"; @@ -14,24 +19,18 @@ export default async function Page({ if (!Number.isInteger(incidentId)) return notFound(); const queryClient = getQueryClient(); - // Throws NOT_FOUND for a foreign id; prefetchQuery swallows it and the - // client renders the empty state. await Promise.all([ - queryClient - .prefetchQuery(trpc.incident.get.queryOptions({ id: incidentId })) - .then(() => - queryClient.getQueryData( - trpc.incident.get.queryKey({ id: incidentId }), - ), - ) + (async () => { + const incident = await fetchQueryOrNotFound( + trpc.incident.get.queryOptions({ id: incidentId }), + ); // the postmortem query is only enabled once resolved - .then((incident) => - incident?.status === "resolved" - ? queryClient.prefetchQuery( - trpc.incident.getPostmortem.queryOptions({ id: incidentId }), - ) - : undefined, - ), + if (incident.status === "resolved") { + await queryClient.prefetchQuery( + trpc.incident.getPostmortem.queryOptions({ id: incidentId }), + ); + } + })(), queryClient.prefetchQuery( trpc.incident.listEvents.queryOptions({ id: incidentId }), ), diff --git a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx index acabe597d..b0bf111b2 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx @@ -42,7 +42,6 @@ export function Client() { ) : ( - // incident.list already sorts open first, then newest declared. - - - {updates.map((update, i) => ( - + + Updates + + {updates.length} + + + + - ))} - + {updates.map((update, i) => ( + + ))} + + + diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx index e40058b32..84d0bd0f1 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx @@ -33,7 +33,10 @@ import { Notifications } from "@/components/status-pages/notifications"; import { StatusReportActions } from "@/components/status-reports/status-report-actions"; import { StatusReportComposer } from "@/components/status-reports/status-report-composer"; import { StatusReportProperties } from "@/components/status-reports/status-report-properties"; -import { StatusReportTimelineItem } from "@/components/status-reports/status-report-timeline"; +import { + StatusReportOpenedTimelineItem, + StatusReportTimelineItem, +} from "@/components/status-reports/status-report-timeline"; import { useInvalidateStatusReport } from "@/components/status-reports/use-invalidate-status-report"; import { toGroupNameLookup } from "@/data/page-components.client"; import { getPageUrl } from "@/data/status-pages.client"; @@ -105,24 +108,34 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { - - - {updates.map((update, i) => ( - + + Updates + + {updates.length} + + + + - ))} - + {updates.map((update, i) => ( + + ))} + + + @@ -150,7 +163,7 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { )}

- Change impact per component in the composer. + Change impact in the composer.

diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 3e52ce32c..2765154d1 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -36,6 +36,47 @@ } } +@layer components { + /* GFM task lists: the checkbox replaces the bullet, like GitHub. remark-gfm + * emits it disabled; drawn by hand so it mirrors the UI Checkbox instead of + * the browser's greyed-out native control. */ + .prose .task-list-item { + list-style: none; + } + .prose .task-list-item > input[type="checkbox"] { + appearance: none; + position: relative; + display: inline-block; + width: 1em; + height: 1em; + /* hangs in the marker gutter; the markdown's own space after the box + * fills the rest, so wrapped lines align with the text */ + margin-inline-start: -1.5em; + margin-inline-end: 0.25em; + vertical-align: -0.125em; + border: 1px solid var(--input); + border-radius: 0.25rem; + background-color: var(--background); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + cursor: default; + } + .dark .prose .task-list-item > input[type="checkbox"] { + background-color: color-mix(in oklab, var(--input) 30%, transparent); + } + .prose .task-list-item > input[type="checkbox"]:checked { + border-color: var(--primary); + background-color: var(--primary); + } + .prose .task-list-item > input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + inset: 1px; + background-color: var(--primary-foreground); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") + center / contain no-repeat; + } +} + @layer utilities { /* Scoped to the onboarding status-page preview so `rounded-full` collapses * to 0 when the active theme sets `--radius: 0` (every theme except the diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx b/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx new file mode 100644 index 000000000..d674af124 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx @@ -0,0 +1,74 @@ +import type { AgentToolOutput } from "@openstatus/services/agent-tools"; +import { cn } from "@openstatus/ui/lib/utils"; + +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { TableCellText } from "@/components/data-table/table-cell-text"; +import { colors } from "@/data/status-report-updates.client"; + +import type { DetailsTableData } from "./details-table"; + +function Status({ value }: { value: string }) { + return ( +
+ {value} +
+ ); +} + +export function getStatusReportDetails( + output: AgentToolOutput<"get_status_report">, +): DetailsTableData { + return { + sections: [ + { + rows: [ + { label: "ID", value: }, + { + label: "Title", + value: + output.pageId !== null ? ( + + ) : ( + + ), + }, + { label: "Status", value: }, + { + label: "Page", + value: + output.pageId !== null ? ( + + ) : ( + + ), + }, + ], + }, + { + title: "Timeline", + rows: output.updates.map((u) => ({ + label: `#${u.id}`, + value: ( +
+
+ + {u.date ? : null} +
+ +
+ ), + })), + }, + ], + }; +} diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index 9989b2da5..6458574fa 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -19,6 +19,7 @@ import { getMonitorDetails } from "./get-monitor"; import { getMonitorStatusTable } from "./get-monitor-status"; import { getMonitorSummaryDetails } from "./get-monitor-summary"; import { getResponseLogDetails } from "./get-response-log"; +import { getStatusReportDetails } from "./get-status-report"; import { declareIncidentChanges, getIncidentDetails, @@ -44,6 +45,10 @@ import { resolveStatusReportChanges } from "./resolve-status-report"; import { ResultTable } from "./result-table"; import { searchContentTable } from "./search-content"; import { searchDocsTable } from "./search-docs"; +import { + deleteStatusReportUpdateChanges, + updateStatusReportUpdateChanges, +} from "./status-report-update"; import { updateStatusReportChanges } from "./update-status-report"; /** @@ -89,6 +94,13 @@ export const toolRenderers: ToolRendererRegistry = { ), summary: (o) => itemsCountSummary(o.items), }, + get_status_report: { + renderResult: ({ output }) => ( + + ), + summary: (o) => + `${o.status} · ${o.updates.length} update${o.updates.length === 1 ? "" : "s"}`, + }, list_maintenances: { renderResult: ({ output }) => ( @@ -141,6 +153,20 @@ export const toolRenderers: ToolRendererRegistry = { ), summary: (o) => `resolved · update #${o.statusReportUpdateId}`, }, + update_status_report_update: { + renderDraft: (input) => updateStatusReportUpdateChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `update #${o.id}`, + }, + delete_status_report_update: { + renderDraft: (input) => deleteStatusReportUpdateChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `deleted #${o.id}`, + }, create_maintenance: { renderDraft: (input) => createMaintenanceChanges(input), renderResult: ({ input, output }) => ( diff --git a/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx b/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx new file mode 100644 index 000000000..c7be2c6c5 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx @@ -0,0 +1,31 @@ +import type { AgentToolInput } from "@openstatus/services/agent-tools"; +import { formatComponentImpacts } from "@openstatus/services/status-report/utils"; + +import type { ChangeRow } from "@/components/common/changes-table"; + +export function updateStatusReportUpdateChanges( + input: AgentToolInput<"update_status_report_update">, +): ChangeRow[] { + return [ + { field: "id", after: input.id }, + ...(input.status ? [{ field: "status", after: input.status }] : []), + ...(input.message ? [{ field: "message", after: input.message }] : []), + ...(input.date ? [{ field: "date", after: input.date }] : []), + ...(input.componentImpacts + ? [ + { + field: "componentImpacts", + after: input.componentImpacts.length + ? formatComponentImpacts(input.componentImpacts) + : [], + }, + ] + : []), + ]; +} + +export function deleteStatusReportUpdateChanges( + input: AgentToolInput<"delete_status_report_update">, +): ChangeRow[] { + return [{ field: "id", before: input.id }]; +} diff --git a/apps/dashboard/src/components/command-menu/use-command-menu-data.ts b/apps/dashboard/src/components/command-menu/use-command-menu-data.ts index 60ff92db9..2c8ecd29e 100644 --- a/apps/dashboard/src/components/command-menu/use-command-menu-data.ts +++ b/apps/dashboard/src/components/command-menu/use-command-menu-data.ts @@ -29,7 +29,7 @@ export function useCommandMenuData({ open }: { open: boolean }) { const { data: maintenances } = useQuery( trpc.maintenance.list.queryOptions(undefined, { enabled: open }), ); - // Server order is already open → mitigated → newest declared. + // Server order is newest declared first. const { data: incidents } = useQuery( trpc.incident.list.queryOptions(undefined, { enabled: open }), ); diff --git a/apps/dashboard/src/components/common/status-badge.tsx b/apps/dashboard/src/components/common/status-badge.tsx index b215ee5e9..583153a06 100644 --- a/apps/dashboard/src/components/common/status-badge.tsx +++ b/apps/dashboard/src/components/common/status-badge.tsx @@ -19,26 +19,39 @@ const statusBadgeVariants = cva("", { }, }); -/** Tinted by `variant`; with `dot` it stays neutral and the dot carries the color. */ +/** + * Tinted by `variant`; with `dot` it stays neutral and the dot carries the + * color. `plain` drops the chrome for inline use in a sentence. + */ export function StatusBadge({ children, className, variant, dot = false, + plain = false, + dotClassName, ...props }: Omit, "variant"> & - VariantProps & { dot?: boolean }) { + VariantProps & { + dot?: boolean; + plain?: boolean; + dotClassName?: string; + }) { return ( - {dot ? : null} + {dot || plain ? ( + + ) : null} {children} ); diff --git a/apps/dashboard/src/components/content/component-list.tsx b/apps/dashboard/src/components/content/component-list.tsx index 366a87ea4..ead8d6d62 100644 --- a/apps/dashboard/src/components/content/component-list.tsx +++ b/apps/dashboard/src/components/content/component-list.tsx @@ -1,29 +1,26 @@ import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { Add } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@openstatus/ui/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@openstatus/ui/components/ui/dropdown-menu"; +import { SelectTrigger } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; import { Fragment } from "react"; import { StatusDot } from "@/components/common/status-dot"; -import { toComponentSections } from "@/data/page-components.client"; +import { + type OrderedComponent, + toComponentSections, +} from "@/data/page-components.client"; import { impactDisplay } from "@/data/status-report-updates.client"; -type Component = { - id: number; - name: string; - groupId?: number | null; - order?: number | null; - groupOrder?: number | null; -}; - export function ComponentList({ children, className, @@ -32,7 +29,7 @@ export function ComponentList({ return (
    {children} @@ -110,7 +107,7 @@ export function ComponentListActions({ return (
    {children} @@ -152,9 +149,10 @@ export function ComponentListSelectTrigger({ } /** - * Picker for `components` not yet on the list, in page order: a group's + * Adds one of `components` not yet on the list, in page order: a group's * members sit under its label, runs of ungrouped components in between, - * separated. + * separated. Renders nothing once everything is added, unless a + * `placeholder` explains the empty state. */ export function ComponentListAdd({ components, @@ -164,56 +162,78 @@ export function ComponentListAdd({ placeholder, className, }: { - components: Component[]; + components: OrderedComponent[]; groups?: { id: number; name: string }[]; onAdd: (id: number) => void; disabled?: boolean; - /** Replaces the "Add component" / "All components added" copy. */ + /** Shown as a disabled button when there is nothing to add. */ placeholder?: string; className?: string; }) { const sections = toComponentSections(components, groups); - const option = (c: Component) => ( - + // -ml-2.5 puts the label on the names' edge + const buttonClassName = cn( + "text-muted-foreground -ml-2.5 h-7 self-start font-normal", + className, + ); + + if (components.length === 0) { + if (!placeholder) return null; + return ( + + ); + } + + const item = (c: OrderedComponent) => ( + onAdd(c.id)} + > {c.name} - + ); return ( - + + ); } diff --git a/apps/dashboard/src/components/content/composer.tsx b/apps/dashboard/src/components/content/composer.tsx index 288c5534c..0e99767fb 100644 --- a/apps/dashboard/src/components/content/composer.tsx +++ b/apps/dashboard/src/components/content/composer.tsx @@ -1,103 +1,224 @@ "use client"; +import { + Edit, + Notification, + NotificationOff, + Send, + Show, +} from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupTextarea, } from "@openstatus/ui/components/ui/input-group"; +import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; +import { Tabs, TabsContent } from "@openstatus/ui/components/ui/tabs"; +import { Toggle } from "@openstatus/ui/components/ui/toggle"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@openstatus/ui/components/ui/tabs"; + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { ProcessMessage } from "@/components/content/process-message"; +import { useHydrated } from "@/hooks/use-hydrated"; +import { useComposedRefs } from "@/lib/composition"; + +type ComposerView = "write" | "preview"; + +const DRAFT_EVENT = "composer-draft"; +// Drafts localStorage refused (quota, private mode) live here instead, so the +// controlled textarea never snaps back to the stored snapshot. +const memoryDrafts = new Map(); /** - * Markdown composer: write/preview tabs around a textarea, with a footer for - * actions. `size="lg"` is for long-form documents (message, postmortem). + * Message text that survives navigation, like Linear's unsent comments. + * Stored per `key` (e.g. `incident:12`) in localStorage; set "" to clear. */ -export function Composer({ - children, - className, - size = "default", - ...props -}: React.ComponentProps & { size?: "default" | "lg" }) { - return ( - - - {children} - - +export function useComposerDraft(key: string) { + const storageKey = `composer-draft:${key}`; + const value = useSyncExternalStore( + (onChange) => { + window.addEventListener("storage", onChange); + window.addEventListener(DRAFT_EVENT, onChange); + return () => { + window.removeEventListener("storage", onChange); + window.removeEventListener(DRAFT_EVENT, onChange); + }; + }, + () => { + const memory = memoryDrafts.get(storageKey); + if (memory !== undefined) return memory; + try { + return localStorage.getItem(storageKey) ?? ""; + } catch { + return ""; + } + }, + () => "", + ); + const setValue = useCallback( + (next: string) => { + try { + if (next) localStorage.setItem(storageKey, next); + else localStorage.removeItem(storageKey); + memoryDrafts.delete(storageKey); + } catch { + memoryDrafts.set(storageKey, next); + } + window.dispatchEvent(new Event(DRAFT_EVENT)); + }, + [storageKey], ); + return [value, setValue] as const; } -/** Write/Preview tabs on the left; `children` replaces the "Markdown" hint on the right. */ -export function ComposerHeader({ - children, - className, - ...props -}: React.ComponentProps) { - return ( - - - {children ?? Markdown} - +// Shared by the preview toggle and the Tabs; Radix keeps its own private. +const ComposerContext = createContext<{ + view: ComposerView; + setView: (view: ComposerView) => void; + /** Switch views and focus the pane that mounts, so a follow-up E lands. */ + toggleView: () => void; + /** True once after `toggleView`; the pane mounting next takes it. */ + claimFocus: () => boolean; +} | null>(null); + +// Radix mounts tab content a render after the switch, so each pane claims +// focus itself on mount instead of the switcher reaching for it. +function useFocusOnMount() { + const { claimFocus } = useComposer(); + return useCallback( + (el: HTMLElement | null) => { + if (el && claimFocus()) el.focus(); + }, + [claimFocus], ); } -const tabsTriggerClassName = - "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground dark:data-[state=active]:bg-transparent h-7 flex-none data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:border-transparent"; - -function ComposerTabs({ - className, - ...props -}: Omit, "children">) { - return ( - - - Write - - - Preview - - +// A bare E anywhere on the page flips write/preview, unless a field has +// focus and the letter is typed instead, or an overlay (select, menu, +// dialog) owns the keyboard. +function isPreviewHotkey(e: KeyboardEvent) { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false; + if (e.key.toLowerCase() !== "e" || e.isComposing || e.repeat) return false; + return !( + e.target instanceof HTMLElement && + (e.target.isContentEditable || + e.target.matches("input, textarea, select") || + e.target.closest( + '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="combobox"]', + )) ); } -export function ComposerHint({ +// With several composers on a page (incident notes and postmortem) the key +// goes to the one last focused or clicked, else the first mounted. +const composers: Array<() => void> = []; +let activeComposer: (() => void) | null = null; + +/** "⌘" on Apple platforms, "Ctrl" elsewhere; ⌘ until hydrated. */ +function useModifierKey() { + const hydrated = useHydrated(); + return hydrated && !/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) + ? "Ctrl" + : "⌘"; +} + +function useComposer() { + const ctx = useContext(ComposerContext); + if (!ctx) + throw new Error("Composer parts must be rendered inside "); + return ctx; +} + +/** + * Markdown composer: a textarea with a footer for actions, switchable to a + * rendered preview via `ComposerPreviewToggle`. `size="lg"` is for long-form + * documents (postmortem) and only raises the minimum height. + */ +export function Composer({ children, className, + size = "default", + defaultValue = "write", + onKeyDown, + onFocusCapture, + onPointerDownCapture, ...props -}: React.ComponentProps<"span">) { +}: Omit, "value" | "onValueChange"> & { + size?: "default" | "lg"; + defaultValue?: ComposerView; +}) { + const [view, setView] = useState(defaultValue); + const focusNext = useRef(false); + const toggleView = useCallback(() => { + focusNext.current = true; + setView((v) => (v === "write" ? "preview" : "write")); + }, []); + const claimFocus = useCallback(() => { + if (!focusNext.current) return false; + focusNext.current = false; + return true; + }, []); + useEffect(() => { + composers.push(toggleView); + const onKeyDown = (e: KeyboardEvent) => { + if (!isPreviewHotkey(e)) return; + if ((activeComposer ?? composers[0]) !== toggleView) return; + e.preventDefault(); + toggleView(); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + composers.splice(composers.indexOf(toggleView), 1); + if (activeComposer === toggleView) activeComposer = null; + document.removeEventListener("keydown", onKeyDown); + }; + }, [toggleView]); return ( - - {children} - + + setView(value as ComposerView)} + data-size={size} + className={cn("min-w-0 flex-1", className)} + // Escape leaves the composer so the page's own keys (E, F, …) apply; + // an open menu or tooltip has already consumed it by then + onKeyDown={(e) => { + onKeyDown?.(e); + if (e.defaultPrevented || e.nativeEvent.isComposing) return; + if (e.key !== "Escape") return; + e.preventDefault(); + if (document.activeElement instanceof HTMLElement) + document.activeElement.blur(); + }} + onFocusCapture={(e) => { + onFocusCapture?.(e); + activeComposer = toggleView; + }} + onPointerDownCapture={(e) => { + onPointerDownCapture?.(e); + activeComposer = toggleView; + }} + {...props} + > + + {children} + + + ); } @@ -106,14 +227,22 @@ export function ComposerTextarea({ className, onSubmit, onKeyDown, + ref, ...props }: React.ComponentProps & { onSubmit?: () => void; }) { + const focusOnMount = useComposedRefs(ref, useFocusOnMount()); return ( { onKeyDown?.(e); if (e.defaultPrevented || e.nativeEvent.isComposing) return; @@ -131,14 +260,19 @@ export function ComposerTextarea({ export function ComposerPreview({ value, className, + ref, ...props }: Omit, "children"> & { value: string }) { + const focusOnMount = useComposedRefs(ref, useFocusOnMount()); return (
    ); } + +/** Right-hand footer cluster; takes its own row under the fields on phones. */ +export function ComposerActions({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + {children} +
    + ); +} + +/** Icon send button; the tooltip carries the label and the ⌘/Ctrl+↵ shortcut. */ +export function ComposerSubmit({ + label, + className, + ...props +}: Omit, "children"> & { label: string }) { + const modifier = useModifierKey(); + return ( + + + + + + {label} + + {modifier} + ↵ + + + + ); +} + +/** Strips border/shadow from a select trigger or outline button in the footer. */ +export const composerControlClassName = + "text-foreground hover:bg-muted border-0 bg-transparent px-2 font-mono shadow-none dark:bg-transparent dark:hover:bg-muted"; + +// on: tinted like a hovered control; off: flat and muted +const footerToggleClassName = + "text-muted-foreground aria-pressed:bg-muted aria-pressed:text-foreground aria-disabled:opacity-50"; + +/** Ghost icon button for the footer; the tooltip carries the label and keys. */ +export function ComposerIconButton({ + label, + shortcut, + className, + children, + ...props +}: React.ComponentProps & { + label: string; + shortcut?: React.ReactNode; +}) { + return ( + + + + + + {label} + {shortcut} + + + ); +} + +/** + * Swaps the textarea for the rendered markdown. The icon is the next action + * (eye = preview, pencil = write), so it is a plain button, not a pressed toggle. + */ +export function ComposerPreviewToggle() { + const { view, toggleView } = useComposer(); + const previewing = view === "preview"; + return ( + E} + onClick={toggleView} + > + {previewing ? : } + + ); +} + +/** Bell toggle; without `canNotify` it stays off and the hover explains why. */ +export function ComposerNotifyToggle({ + canNotify, + pressed, + onPressedChange, +}: { + canNotify: boolean; + pressed: boolean; + onPressedChange: (pressed: boolean) => void; +}) { + const notify = canNotify && pressed; + return ( + + + {/* aria-disabled (not disabled) so the plan hint still opens */} + canNotify && onPressedChange(value)} + className={footerToggleClassName} + > + {notify ? : } + + + + {!canNotify + ? "Subscriber notifications are not included in your plan." + : notify + ? "Subscribers will be notified" + : "Subscribers will not be notified"} + + + ); +} diff --git a/apps/dashboard/src/components/content/timeline.tsx b/apps/dashboard/src/components/content/timeline.tsx index 2cc0b3210..e535f122d 100644 --- a/apps/dashboard/src/components/content/timeline.tsx +++ b/apps/dashboard/src/components/content/timeline.tsx @@ -6,13 +6,17 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; -import { type VariantProps, cva } from "class-variance-authority"; import { formatDistanceToNowStrict } from "date-fns"; import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { UserAvatar } from "@/components/common/user-avatar"; import { useHydrated } from "@/hooks/use-hydrated"; +/** + * Two kinds of item share the list: a compact row (`TimelineIndicator` + + * `TimelineHeader`) for state changes, and a `TimelineCard` for authored + * content. The indicator and the card's avatar sit on the same column. + */ export function Timeline({ children, className, @@ -21,7 +25,7 @@ export function Timeline({ return (
      {children} @@ -29,7 +33,8 @@ export function Timeline({ ); } -// The rail is drawn per item, from below its indicator down to the next one. +// The rail runs up through the gap to the item above, on the column shared +// by row icons and card avatars; the composer at the head is part of it. export function TimelineItem({ children, className, @@ -39,8 +44,9 @@ export function TimelineItem({
    1. svg]:size-3.5", - { - variants: { - variant: { - default: "border-border bg-background text-muted-foreground", - success: "border-success/20 bg-success/10 text-success", - warning: "border-warning/20 bg-warning/10 text-warning", - destructive: "border-destructive/20 bg-destructive/10 text-destructive", - info: "border-info/20 bg-info/10 text-info", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - +// ml-4 matches the card's horizontal padding so the icon lines up with the +// card avatar. export function TimelineIndicator({ children, className, - variant, ...props -}: React.ComponentProps<"div"> & - VariantProps) { - return ( - - ); -} - -// Same size as the indicator so the rail starts below it; children render -// outside the avatar's overflow clip (see `TimelineAvatarBadge`). -export function TimelineAvatar({ - children, - className, - ...props -}: React.ComponentProps & { children?: React.ReactNode }) { - return ( - - - {children} - - ); -} - -// Event icon pinned to the avatar's corner; the ring lifts it off the photo. -export function TimelineAvatarBadge({ - children, - className, - variant, - ...props -}: React.ComponentProps<"span"> & - VariantProps) { +}: React.ComponentProps<"span">) { return (