@springbrand/message-panel 0.1.3-alpha.0 → 0.1.3-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type PointerEvent as ReactPointerEvent,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ /**
11
+ * 左聊天 / 右工作区的双栏骨架。从 cloudflare-os-main `GadgetEditor.tsx` 的 BODY
12
+ * 段拷来:同一条 1px 的 kumo-line 分隔即拖拽把手、同一套 pointer capture 拖拽
13
+ * (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡、同一条顶部推进条。
14
+ *
15
+ * 适配点:原文件把左右两侧的内容写死成 ChatInterface / GadgetUI,这里换成
16
+ * children 插槽;chatWidth 的 localStorage key 换成 cloud-os 自己的。
17
+ */
18
+
19
+ const CHAT_WIDTH_STORAGE_KEY = "cloud-os:chatWidth";
20
+ const MIN_CHAT_WIDTH = 280;
21
+ const MIN_WORKSPACE_WIDTH = 400;
22
+ const DEFAULT_CHAT_WIDTH = 420;
23
+ const WORKSPACE_TRANSITION_MS = 200;
24
+
25
+ const isBrowser = typeof window !== "undefined";
26
+
27
+ function clampChatWidth(width: number) {
28
+ if (!isBrowser) {
29
+ return Math.max(MIN_CHAT_WIDTH, Math.min(DEFAULT_CHAT_WIDTH, width));
30
+ }
31
+ const max = Math.max(MIN_CHAT_WIDTH, window.innerWidth - MIN_WORKSPACE_WIDTH);
32
+ return Math.max(MIN_CHAT_WIDTH, Math.min(max, width));
33
+ }
34
+
35
+ function getInitialChatWidth() {
36
+ if (!isBrowser) return DEFAULT_CHAT_WIDTH;
37
+ const fallback = Math.min(
38
+ DEFAULT_CHAT_WIDTH,
39
+ Math.floor(window.innerWidth * 0.38),
40
+ );
41
+ let parsed = Number.NaN;
42
+ try {
43
+ const stored = window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY);
44
+ if (stored) parsed = Number(stored);
45
+ } catch {
46
+ // 隐私模式 / 沙箱 iframe 下没有 storage,退回默认值即可。
47
+ }
48
+ return clampChatWidth(Number.isFinite(parsed) ? parsed : fallback);
49
+ }
50
+
51
+ export interface CloudOsWorkspaceSplitProps {
52
+ chat: ReactNode;
53
+ workspace: ReactNode;
54
+ /** 关掉右栏时聊天占满整宽。 */
55
+ workspaceOpen: boolean;
56
+ /** 顶部那条推进条:回合进行中显示。 */
57
+ isAgentActive?: boolean;
58
+ /** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
59
+ railWidth?: number;
60
+ rail?: ReactNode;
61
+ }
62
+
63
+ export function CloudOsWorkspaceSplit({
64
+ chat,
65
+ workspace,
66
+ workspaceOpen,
67
+ isAgentActive = false,
68
+ railWidth = 0,
69
+ rail,
70
+ }: CloudOsWorkspaceSplitProps) {
71
+ const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
72
+ const [isResizing, setIsResizing] = useState(false);
73
+ const [transitionEnabled, setTransitionEnabled] = useState(false);
74
+ const chatWidthRef = useRef(chatWidth);
75
+ chatWidthRef.current = chatWidth;
76
+
77
+ // 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
78
+ useEffect(() => {
79
+ const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
80
+ return () => window.clearTimeout(timer);
81
+ }, []);
82
+
83
+ useEffect(() => {
84
+ const onResize = () => setChatWidth((width) => clampChatWidth(width));
85
+ window.addEventListener("resize", onResize);
86
+ return () => window.removeEventListener("resize", onResize);
87
+ }, []);
88
+
89
+ const persistChatWidth = useCallback((width: number) => {
90
+ try {
91
+ window.localStorage.setItem(CHAT_WIDTH_STORAGE_KEY, String(width));
92
+ } catch {
93
+ // storage 不可用时,本次会话内仍然生效。
94
+ }
95
+ }, []);
96
+
97
+ // 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
98
+ const handleResizePointerDown = useCallback(
99
+ (event: ReactPointerEvent<HTMLDivElement>) => {
100
+ if (!workspaceOpen) return;
101
+ event.preventDefault();
102
+ event.currentTarget.setPointerCapture(event.pointerId);
103
+ setIsResizing(true);
104
+ },
105
+ [workspaceOpen],
106
+ );
107
+ const handleResizePointerMove = useCallback(
108
+ (event: ReactPointerEvent<HTMLDivElement>) => {
109
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
110
+ setChatWidth(clampChatWidth(event.clientX));
111
+ },
112
+ [],
113
+ );
114
+ const handleResizePointerUp = useCallback(
115
+ (event: ReactPointerEvent<HTMLDivElement>) => {
116
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
117
+ event.currentTarget.releasePointerCapture(event.pointerId);
118
+ }
119
+ const width =
120
+ event.type === "pointercancel"
121
+ ? chatWidthRef.current
122
+ : clampChatWidth(event.clientX);
123
+ setChatWidth(width);
124
+ persistChatWidth(width);
125
+ setIsResizing(false);
126
+ },
127
+ [persistChatWidth],
128
+ );
129
+
130
+ useEffect(() => {
131
+ if (!isResizing) return;
132
+ document.body.style.userSelect = "none";
133
+ document.body.style.cursor = "col-resize";
134
+ return () => {
135
+ document.body.style.userSelect = "";
136
+ document.body.style.cursor = "";
137
+ };
138
+ }, [isResizing]);
139
+
140
+ // 类名必须是字面量:Tailwind 扫源码找不到拼出来的 duration-[200ms]。
141
+ // WORKSPACE_TRANSITION_MS 和这里的 duration-200 必须一起改。
142
+ const transitionClass =
143
+ transitionEnabled && !isResizing
144
+ ? "transition-[width,opacity] duration-200 ease-out"
145
+ : "";
146
+
147
+ return (
148
+ // h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
149
+ // 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
150
+ <div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base">
151
+ {isAgentActive && (
152
+ <div
153
+ className="absolute left-0 z-10 h-0"
154
+ style={{ top: 0, right: railWidth }}
155
+ >
156
+ <div className="absolute left-0 right-0 h-0.5 overflow-hidden bg-kumo-fill">
157
+ <div className="cos-progress-sweep absolute inset-y-0 w-1/3 bg-kumo-brand" />
158
+ </div>
159
+ </div>
160
+ )}
161
+
162
+ {/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
163
+ <div
164
+ className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
165
+ workspaceOpen ? "border-r border-kumo-line" : ""
166
+ }`}
167
+ style={{
168
+ width: workspaceOpen ? chatWidth : `calc(100% - ${railWidth}px)`,
169
+ }}
170
+ >
171
+ {chat}
172
+ </div>
173
+
174
+ {/* ── 拖拽把手 ─────────────────────────────────────────────────────── */}
175
+ <div
176
+ className={`relative flex-shrink-0 cursor-col-resize touch-none overflow-visible bg-kumo-line ${transitionClass}`}
177
+ style={{ width: workspaceOpen ? 1 : 0 }}
178
+ onPointerDown={handleResizePointerDown}
179
+ onPointerMove={handleResizePointerMove}
180
+ onPointerUp={handleResizePointerUp}
181
+ onPointerCancel={handleResizePointerUp}
182
+ >
183
+ <div className="absolute inset-y-0 -left-2 -right-2" />
184
+ </div>
185
+
186
+ {/* ── 右:工作区 ───────────────────────────────────────────────────── */}
187
+ <div
188
+ className={`flex h-full min-w-0 flex-shrink-0 overflow-hidden bg-kumo-base ${transitionClass}`}
189
+ style={{
190
+ width: workspaceOpen ? `calc(100% - ${chatWidth}px - 1px)` : 0,
191
+ opacity: workspaceOpen ? 1 : 0,
192
+ }}
193
+ >
194
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
195
+ {workspace}
196
+ </div>
197
+ </div>
198
+
199
+ {rail}
200
+ </div>
201
+ );
202
+ }
@@ -0,0 +1,82 @@
1
+ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
2
+ import {
3
+ cloneElement,
4
+ isValidElement,
5
+ type ComponentProps,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { cn } from "../internal/cn";
10
+ import { useCloudOsMode } from "../internal/theme-context";
11
+
12
+ /**
13
+ * Kumo 的 DropdownMenu 用的是 `<DropdownMenu.Trigger render={<button/>} />`
14
+ * 这种「render prop 传元素」的写法。这里保留同一个调用面(免得改动拷来的 JSX),
15
+ * 内部翻译成 radix 的 asChild。
16
+ */
17
+ function Root({ children }: { children: ReactNode }) {
18
+ return <DropdownMenuPrimitive.Root>{children}</DropdownMenuPrimitive.Root>;
19
+ }
20
+
21
+ function Trigger({ render }: { render: ReactElement }) {
22
+ return (
23
+ <DropdownMenuPrimitive.Trigger asChild>
24
+ {isValidElement(render)
25
+ ? // data-[popup-open] 是 Kumo 的开合标记,拷来的 className 里在用;
26
+ // radix 给的是 data-state="open",这里补一份等价属性。
27
+ cloneElement(render as ReactElement<Record<string, unknown>>, {})
28
+ : render}
29
+ </DropdownMenuPrimitive.Trigger>
30
+ );
31
+ }
32
+
33
+ function Content({
34
+ className,
35
+ collisionPadding = 12,
36
+ align = "start",
37
+ sideOffset = 6,
38
+ children,
39
+ ...props
40
+ }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
41
+ const mode = useCloudOsMode();
42
+ return (
43
+ <DropdownMenuPrimitive.Portal>
44
+ <DropdownMenuPrimitive.Content
45
+ data-cloud-os-portal=""
46
+ data-mode={mode}
47
+ align={align}
48
+ sideOffset={sideOffset}
49
+ collisionPadding={collisionPadding}
50
+ className={cn("z-[1100] outline-none", className)}
51
+ {...props}
52
+ >
53
+ {children}
54
+ </DropdownMenuPrimitive.Content>
55
+ </DropdownMenuPrimitive.Portal>
56
+ );
57
+ }
58
+
59
+ function Item({
60
+ className,
61
+ children,
62
+ ...props
63
+ }: ComponentProps<typeof DropdownMenuPrimitive.Item>) {
64
+ return (
65
+ <DropdownMenuPrimitive.Item
66
+ className={cn(
67
+ "flex cursor-pointer select-none items-center outline-none",
68
+ // Kumo 用 data-highlighted;radix 也叫 data-highlighted,直接通用。
69
+ className,
70
+ )}
71
+ {...props}
72
+ >
73
+ {children}
74
+ </DropdownMenuPrimitive.Item>
75
+ );
76
+ }
77
+
78
+ export const DropdownMenu = Object.assign(Root, {
79
+ Trigger,
80
+ Content,
81
+ Item,
82
+ });
@@ -0,0 +1,68 @@
1
+ import { Tooltip as TooltipPrimitive } from "radix-ui";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../internal/cn";
4
+ import { useCloudOsMode } from "../internal/theme-context";
5
+
6
+ /**
7
+ * 对齐 `@cloudflare/kumo` 的 `<Tooltip content side align asChild>` 调用面,
8
+ * 底座换成 radix —— 这样拷过来的 JSX 一个字都不用改,又不必把整个 Kumo
9
+ * 设计系统拖进本仓(它会和 ui 的 shadcn 主题在同一层 @theme 里打架)。
10
+ */
11
+ export function TooltipProvider({ children }: { children: ReactNode }) {
12
+ return (
13
+ <TooltipPrimitive.Provider delayDuration={250} skipDelayDuration={300}>
14
+ {children}
15
+ </TooltipPrimitive.Provider>
16
+ );
17
+ }
18
+
19
+ export interface TooltipProps {
20
+ content: ReactNode;
21
+ children: ReactNode;
22
+ side?: "top" | "right" | "bottom" | "left";
23
+ align?: "start" | "center" | "end";
24
+ /** Kumo 里 asChild 表示「把触发器渲染成 children 本身」;radix 语义一致。 */
25
+ asChild?: boolean;
26
+ disabled?: boolean;
27
+ }
28
+
29
+ export function Tooltip({
30
+ content,
31
+ children,
32
+ side = "top",
33
+ align = "center",
34
+ asChild = false,
35
+ disabled = false,
36
+ }: TooltipProps) {
37
+ const mode = useCloudOsMode();
38
+ if (disabled || content == null || content === "") return <>{children}</>;
39
+ return (
40
+ // 自带 Provider:radix 的 Root 必须活在 Provider 里,而这些行会被单独渲染
41
+ // (单测、宿主局部挂载)。Provider 可以嵌套,外面再包一层也不冲突。
42
+ <TooltipPrimitive.Provider delayDuration={250} skipDelayDuration={300}>
43
+ <TooltipPrimitive.Root>
44
+ <TooltipPrimitive.Trigger asChild={asChild}>
45
+ {children}
46
+ </TooltipPrimitive.Trigger>
47
+ <TooltipPrimitive.Portal>
48
+ <TooltipPrimitive.Content
49
+ data-cloud-os-portal=""
50
+ data-mode={mode}
51
+ side={side}
52
+ align={align}
53
+ sideOffset={6}
54
+ collisionPadding={12}
55
+ className={cn(
56
+ "z-[1200] max-w-[min(28rem,90vw)] rounded-lg border px-2.5 py-1.5",
57
+ "text-[12px] leading-4 tracking-[-0.2px]",
58
+ "border-kumo-line bg-kumo-base text-kumo-default",
59
+ "shadow-[0_8px_20px_var(--color-kumo-tip-shadow)]",
60
+ )}
61
+ >
62
+ {content}
63
+ </TooltipPrimitive.Content>
64
+ </TooltipPrimitive.Portal>
65
+ </TooltipPrimitive.Root>
66
+ </TooltipPrimitive.Provider>
67
+ );
68
+ }
@@ -0,0 +1,114 @@
1
+ import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
2
+
3
+ /**
4
+ * 从 cloudflare-os-main `components/WorkshopControls.tsx` 拷来。
5
+ * 唯一改动:底座从 `@cloudflare/kumo` 的 <Button>/<Input> 换成原生元素,
6
+ * 因为那几个组件除了 focus ring 之外的视觉全靠这里的 className 覆盖。
7
+ */
8
+
9
+ const buttonBaseClassName =
10
+ "inline-flex cursor-pointer items-center justify-center rounded-lg text-[13px] leading-[18px] font-medium tracking-[-0.25px] transition-[background-color,color,opacity,transform] duration-150 ease-out active:scale-[0.98] disabled:cursor-not-allowed disabled:active:scale-100";
11
+
12
+ const buttonToneClassNames = {
13
+ primary:
14
+ "!h-9 bg-kumo-contrast px-3 text-kumo-inverse enabled:hover:bg-kumo-strong disabled:opacity-50",
15
+ secondary:
16
+ "!h-8 border border-kumo-line bg-kumo-base px-3 text-kumo-default enabled:hover:bg-kumo-elevated disabled:opacity-40",
17
+ danger:
18
+ "!h-8 bg-kumo-danger px-3 text-white enabled:hover:opacity-90 disabled:opacity-50",
19
+ } as const;
20
+
21
+ type WorkshopButtonTone = keyof typeof buttonToneClassNames;
22
+
23
+ type WorkshopButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
24
+ tone?: WorkshopButtonTone;
25
+ };
26
+
27
+ export function WorkshopButton({
28
+ tone = "secondary",
29
+ className = "",
30
+ type = "button",
31
+ ...props
32
+ }: WorkshopButtonProps) {
33
+ return (
34
+ <button
35
+ type={type}
36
+ {...props}
37
+ className={`${buttonBaseClassName} ${buttonToneClassNames[tone]} ${className}`}
38
+ />
39
+ );
40
+ }
41
+
42
+ type WorkshopIconButtonProps = Omit<
43
+ ButtonHTMLAttributes<HTMLButtonElement>,
44
+ "children"
45
+ > & {
46
+ children: ReactNode;
47
+ danger?: boolean;
48
+ tone?: "ghost" | "primary";
49
+ "aria-label": string;
50
+ };
51
+
52
+ export function WorkshopIconButton({
53
+ children,
54
+ danger = false,
55
+ tone = "ghost",
56
+ className = "",
57
+ type = "button",
58
+ ...props
59
+ }: WorkshopIconButtonProps) {
60
+ const toneClassName =
61
+ tone === "primary"
62
+ ? "bg-kumo-contrast text-kumo-inverse enabled:hover:bg-kumo-strong enabled:hover:text-kumo-inverse"
63
+ : danger
64
+ ? "text-kumo-subtle enabled:hover:bg-kumo-danger-tint enabled:hover:text-kumo-danger"
65
+ : "text-kumo-subtle enabled:hover:bg-kumo-tint enabled:hover:text-kumo-default";
66
+
67
+ return (
68
+ <button
69
+ type={type}
70
+ {...props}
71
+ className={`!flex !h-8 !w-8 shrink-0 cursor-pointer items-center justify-center rounded-md !p-0 transition-[background-color,color,opacity,transform] duration-150 ease-out active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 ${toneClassName} ${className}`}
72
+ >
73
+ {children}
74
+ </button>
75
+ );
76
+ }
77
+
78
+ type WorkshopInputProps = InputHTMLAttributes<HTMLInputElement>;
79
+
80
+ export function WorkshopInput({ className = "", ...props }: WorkshopInputProps) {
81
+ return (
82
+ <input
83
+ {...props}
84
+ className={`!h-9 rounded-lg border border-kumo-line bg-kumo-base px-3 text-[13px] leading-[18px] font-normal tracking-[-0.25px] text-kumo-default placeholder:text-kumo-inactive shadow-none focus:border-kumo-ring focus:outline-none focus:ring-1 focus:ring-kumo-ring/15 ${className}`}
85
+ />
86
+ );
87
+ }
88
+
89
+ export function CountBadge({
90
+ count,
91
+ tone = "tint",
92
+ max = 9,
93
+ className = "",
94
+ }: {
95
+ count: number;
96
+ tone?: "solid" | "tint";
97
+ max?: number;
98
+ className?: string;
99
+ }) {
100
+ if (count <= 0) return null;
101
+
102
+ const toneClassName =
103
+ tone === "solid"
104
+ ? "border border-kumo-base bg-kumo-brand text-white"
105
+ : "bg-kumo-brand/15 text-kumo-strong";
106
+
107
+ return (
108
+ <span
109
+ className={`grid h-4 min-w-4 flex-shrink-0 place-items-center rounded-full px-1 text-[10px] font-semibold leading-none tabular-nums ${toneClassName} ${className}`}
110
+ >
111
+ {count > max ? `${max}+` : count}
112
+ </span>
113
+ );
114
+ }