From a1958344a741da9f01c2df6726f6ce3b911b0592 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:53:36 +0200 Subject: [PATCH] chore: status report update editing composer polish (#2886) * chore: dashboard composer activity timeline improvements * refactor: force at least one update on status reports * feat(api): edit and delete status report updates over RPC and MCP Add UpdateStatusReportUpdate and DeleteStatusReportUpdate to the status report service, with update_status_report_update and delete_status_report_update agent tools exposed through MCP and the Slack assistant. list_status_reports now returns each report's full timeline with ids so the new tools can target an entry. Regenerate the OpenAPI spec. * feat(dashboard): render status report update tool calls in chat * docs: document status report and maintenance update methods * fix(dashboard): hide delete on the last report or maintenance update The services refuse to remove the only update, so the timeline no longer offers the action. * fix(dashboard): keep legacy impact rows unselected in the composer Rows without a current impact showed the override as selected even when nothing had been picked yet. * refactor(dashboard): share the OrderedComponent type for page components * refactor(dashboard): move postmortem actions into the composer footer Drop ComposerHeader and its Write/Preview tabs; every composer now toggles the preview from the footer. Copy and draft-with-agent become ComposerIconButtons next to it. * style(dashboard): render GFM task list checkboxes in markdown previews * style(dashboard): always show the remove button on maintenance components * refactor(services): order incidents by declared date only * fix(dashboard): return 404 for unknown incidents Resolve the incident on the server with fetchQueryOrNotFound instead of rendering an inline empty state, and link incidents from the not-found page. * chore(status-page): sync next-env types with next dev * fix: address review findings on status report update editing - reject RPC ids beyond the safe integer range; fail when an edited update vanishes before the re-read - cap list_status_reports timeline at 10 entries and point id guidance at updates[].id - describe list_incidents as newest declared first - keep composer drafts in memory when localStorage throws; show Ctrl on non-Apple platforms; lock the textarea while publishing - reset plain badge font; align update counts; hide the compact-row dot until the time renders - link Slack bind/unbind rows to the channel named in the event - gate the maintenance window text on hydration; diff impacts against the carried-forward state - overlap the postmortem prefetch with the other prefetches - docs: decimal SDK ids, notify required over MCP, resolved differs by surface, comma splices - tests: assert ids present before ordering; sort inserted rows by date * style(dashboard): badge the avatar with the Slack source instead of a via link * feat(dashboard): toggle composer preview with E and leave it with Escape * feat(agent-tools): add get_status_report with the full update timeline Expose the tool over MCP and the dashboard chat with a details-table renderer, parse RPC status report ids strictly, and note the 10-entry cap on list_status_reports in the prompt and docs. * fix: accept resolved in update_status_report_update and address review findings - update_status_report_update accepts status resolved; editing the latest entry re-derives the report to resolved without notifying - get_status_report tenant test inserts a report in another workspace - composer hotkey ignores key repeat and overlays (select, menu, dialog) - ComposerTextarea/ComposerPreview merge a consumer ref with focus-on-mount - chat summary pluralizes updates; MCP docs headline counts reconciled * fix(dashboard): show a dash for reports without affected components * style(dashboard): square the severity dot in the incident timeline Matches the rounded-xs severity dot in the properties sidebar; status dots stay round. * style(dashboard): flatten composer footers like Linear Drop the footer divider and tint, strip borders from the status select, date picker and components menu, and show the current impact as a dot on each component row. Align dots and buttons on the text column and use a 6px gap for rows and actions. * style(dashboard): align the composer text column with the timeline cards Pad the textarea, preview, section rows and footer to the card's 16px column so the status dot and text sit on the avatar edge. * fix(dashboard): address composer review findings Name the component in each remove button's label so screen readers can tell them apart. Replace the postmortem status badge with a dot and mono label aligned on the composer text column, and drop the agent hint since the draft already says so. Clarify that the severity dot only applies in plain rows. * ci: apply automated fixes * style(dashboard): cap the add component menu height Pages with many components pushed the menu to the full viewport; it now scrolls past 320px. * fix: keep the component menu viewport-bound and name get_status_report in the Slack prompt tailwind-merge dropped the Radix available-height class once max-h-80 was added, so short viewports could push the menu off-screen; cap with min() instead. The Slack system prompt now mentions get_status_report, which the prompt coverage test requires for every registry tool. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/app/(dashboard)/agents/mcp/page.tsx | 1 + .../app/(dashboard)/incidents/[id]/client.tsx | 26 +- .../app/(dashboard)/incidents/[id]/page.tsx | 33 +- .../src/app/(dashboard)/incidents/client.tsx | 1 - .../src/app/(dashboard)/not-found.tsx | 6 +- .../maintenances/[maintenanceId]/client.tsx | 38 +- .../[id]/status-reports/[reportId]/client.tsx | 49 +- apps/dashboard/src/app/globals.css | 41 ++ .../chat/tool-renderers/get-status-report.tsx | 74 +++ .../components/chat/tool-renderers/index.tsx | 26 ++ .../tool-renderers/status-report-update.tsx | 31 ++ .../command-menu/use-command-menu-data.ts | 2 +- .../src/components/common/status-badge.tsx | 21 +- .../src/components/content/component-list.tsx | 122 +++-- .../src/components/content/composer.tsx | 437 +++++++++++++++--- .../src/components/content/timeline.tsx | 327 +++++++------ .../data-table/status-reports/columns.tsx | 17 +- .../components/incidents/incident-badge.tsx | 4 +- .../incidents/incident-composer.tsx | 90 ++-- .../incidents/incident-postmortem.tsx | 165 ++++--- .../incidents/incident-timeline.tsx | 408 ++++++++++------ .../maintenances/maintenance-components.tsx | 3 +- .../maintenance-update-composer.tsx | 73 ++- .../maintenance-update-timeline.tsx | 102 ++-- .../status-reports/status-report-composer.tsx | 356 ++++++++------ .../status-reports/status-report-timeline.tsx | 191 ++++---- .../src/data/page-components.client.ts | 3 +- apps/server/src/routes/mcp/evals/run.ts | 2 +- apps/server/src/routes/mcp/handler.test.ts | 3 + .../src/routes/mcp/tools/status-report.ts | 6 + .../server/src/routes/mcp/tools/tools.test.ts | 256 ++++++++++ .../routes/rpc/handlers/maintenance/index.ts | 6 +- .../__tests__/status-report.test.ts | 323 +++++++++++++ .../rpc/handlers/status-report/errors.ts | 12 + .../rpc/handlers/status-report/index.ts | 107 ++++- apps/server/src/routes/slack/system-prompt.ts | 5 +- apps/server/static/openapi-yaml.ts | 2 +- apps/server/static/openapi.json | 177 +++++++ apps/server/static/openapi.yaml | 132 ++++++ apps/status-page/next-env.d.ts | 3 +- .../pages/docs/reference/maintenance.mdx | 2 +- .../pages/docs/reference/mcp-server.mdx | 10 +- .../pages/docs/reference/status-report.mdx | 14 + .../docs/sdk/nodejs/maintenance-service.mdx | 46 +- .../docs/sdk/nodejs/status-report-service.mdx | 44 +- packages/icons/src/lucide/index.ts | 1 + packages/icons/src/nucleo/index.ts | 1 + .../icons/src/nucleo/notification-off.tsx | 54 +++ .../openstatus/status_report/v1/service.proto | 54 +++ packages/proto/gen/openapi.yaml | 132 ++++++ .../openstatus/status_report/v1/service_pb.ts | 144 +++++- packages/services/src/agent-tools/incident.ts | 2 +- packages/services/src/agent-tools/index.ts | 9 + packages/services/src/agent-tools/prompt.ts | 4 +- .../services/src/agent-tools/status-report.ts | 211 ++++++++- .../src/incident/__tests__/incident.test.ts | 14 +- packages/services/src/incident/list.ts | 8 +- .../__tests__/status-report.test.ts | 29 ++ packages/services/src/status-report/delete.ts | 15 +- packages/ui/src/components/ui/toggle.tsx | 48 ++ 60 files changed, 3536 insertions(+), 987 deletions(-) create mode 100644 apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx create mode 100644 apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx create mode 100644 packages/icons/src/nucleo/notification-off.tsx create mode 100644 packages/ui/src/components/ui/toggle.tsx 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 (