fix: preserve Fast Refresh component boundaries

This commit is contained in:
2026-07-22 10:38:53 +08:00
parent b7b8f60cb2
commit 70916e21dd
16 changed files with 248 additions and 235 deletions
@@ -23,7 +23,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
@@ -0,0 +1,60 @@
"use client";
import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
export function showMapNotice(options: MapNoticeOptions) {
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
return toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
}
);
}
export function dismissMapNotice(id?: string | number) {
toast.dismiss(id);
}
@@ -0,0 +1,154 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
import type { MapNoticeOptions, MapNoticeTone } from "./notice-types";
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export function MapNoticeContent({
title,
message,
tone = "info",
actionLabel,
onAction
}: MapNoticeOptions) {
const Icon = getNoticeIcon(tone);
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
/>
</MapNoticeFrame>
);
}
export function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
);
}
function MapNoticeBody({
title,
message,
action
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{action}
</span>
);
}
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
);
}
function getNoticeIcon(tone: MapNoticeTone) {
if (tone === "success") {
return CheckCircle2;
}
if (tone === "warning") {
return AlertTriangle;
}
if (tone === "error") {
return XCircle;
}
if (tone === "loading") {
return Loader2;
}
return Info;
}
@@ -0,0 +1,14 @@
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
export type MapNoticeOptions = {
id?: string | number;
title?: string;
message: string;
tone?: MapNoticeTone;
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
onAction?: () => void;
dismissible?: boolean;
};
+5 -219
View File
@@ -1,80 +1,11 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { useEffect } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
export type MapNoticeOptions = {
id?: string | number;
title?: string;
message: string;
tone?: MapNoticeTone;
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
onAction?: () => void;
dismissible?: boolean;
};
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
const toneClassNames: Record<MapNoticeTone, string> = {
info: "acrylic-panel text-slate-900",
success: "acrylic-panel text-slate-900",
warning: "acrylic-panel text-slate-900",
error: "acrylic-panel text-slate-900",
loading: "acrylic-panel text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notice-types";
export function MapToaster() {
return (
@@ -82,7 +13,7 @@ export function MapToaster() {
closeButton={false}
expand
visibleToasts={4}
position={sonnerPositions[DEFAULT_POSITION]}
position="top-center"
toastOptions={{
unstyled: true,
classNames: {
@@ -95,40 +26,6 @@ export function MapToaster() {
);
}
export function showMapNotice(options: MapNoticeOptions) {
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
return toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
}
);
}
export function dismissMapNotice(id?: string | number) {
toast.dismiss(id);
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useEffect(() => {
showMapNotice({
@@ -206,117 +103,6 @@ export function MapSourceStatusNotice({
return null;
}
function MapNoticeContent({
title,
message,
tone = "info",
actionLabel,
onAction
}: MapNoticeOptions) {
const Icon = getNoticeIcon(tone);
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
/>
</MapNoticeFrame>
);
}
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
);
}
function MapNoticeBody({
title,
message,
action
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{action}
</span>
);
}
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
);
}
function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
function getNoticeIcon(tone: MapNoticeTone) {
if (tone === "success") {
return CheckCircle2;
}
if (tone === "warning") {
return AlertTriangle;
}
if (tone === "error") {
return XCircle;
}
if (tone === "loading") {
return Loader2;
}
return Info;
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
@@ -7,7 +7,7 @@ import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/lib/utils";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
@@ -21,7 +21,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import {
getRunningWorkflowDefinition,
getRunningWorkflowState,
@@ -11,7 +11,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
@@ -6,7 +6,7 @@ import { DefaultChatTransport } from "ai";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { showMapNotice } from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import type {
AgentApprovalMode,
AgentChatMessage,
@@ -25,9 +25,9 @@ import {
MapErrorNotice,
MapLoadingNotice,
MapSourceStatusNotice,
showMapNotice,
type MapSourceStatus
} from "@/features/map/core/components/notice";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { MapScaleLine } from "@/features/map/core/components/scale-line";
import { MapToolbar, type MapToolbarItem } from "@/features/map/core/components/toolbar";
import { MapZoom } from "@/features/map/core/components/zoom";
+1 -1
View File
@@ -181,7 +181,7 @@ const createRawTokens = (code: string): TokenizedCode => ({
});
// Synchronous highlight with callback for async results
export const highlightCode = (
const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
+1 -1
View File
@@ -125,7 +125,7 @@ const defaultFormatMessage = (message: UIMessage): string => {
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
+5 -5
View File
@@ -210,7 +210,7 @@ const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
export const usePromptInputController = () => {
const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
@@ -224,7 +224,7 @@ export const usePromptInputController = () => {
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
@@ -371,7 +371,7 @@ export const PromptInputProvider = ({
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext);
@@ -395,10 +395,10 @@ export interface ReferencedSourcesContext {
clear: () => void;
}
export const LocalReferencedSourcesContext =
const LocalReferencedSourcesContext =
createContext<ReferencedSourcesContext | null>(null);
export const usePromptInputReferencedSources = () => {
const usePromptInputReferencedSources = () => {
const ctx = useContext(LocalReferencedSourcesContext);
if (!ctx) {
throw new Error(
+1 -1
View File
@@ -33,4 +33,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
)
}
export { Badge, badgeVariants }
export { Badge }
-1
View File
@@ -79,5 +79,4 @@ export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+1 -1
View File
@@ -55,4 +55,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button }