@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,272 @@
1
+ import { X, type Icon as PhosphorIconType } from "@phosphor-icons/react";
2
+ import { useEffect, useRef, type ReactNode } from "react";
3
+ import {
4
+ CountBadge,
5
+ WorkshopIconButton,
6
+ } from "../primitives/workshop-controls";
7
+
8
+ /**
9
+ * 右侧工作区面板。从 cloudflare-os-main `GadgetEditor.tsx` 里把
10
+ * PaneLabel / PaneWorkpieceTabs / PaneTab 和它们外面那条 h-12 的 tab bar
11
+ * 整块拷来,className 保持原样。
12
+ *
13
+ * 适配点:原文件里 tab 内容是 GadgetUI / GadgetCodeInterface / Connections
14
+ * 三个写死的组件,这里改成宿主传进来的 `tabs[].content` —— 面板本身只负责
15
+ * 「标题 + tab 条 + 右上角动作 + 内容槽」,不认识任何业务。
16
+ */
17
+
18
+ const TABBAR_H = 48; // h-12
19
+
20
+ export interface CloudOsPaneTab {
21
+ value: string;
22
+ label: string;
23
+ count?: number;
24
+ content: ReactNode;
25
+ }
26
+
27
+ export interface CloudOsWorkpieceTab {
28
+ id: string;
29
+ title: string;
30
+ Icon?: PhosphorIconType;
31
+ badge?: string;
32
+ }
33
+
34
+ // 说明面板现在展示的是什么。workspace 级视图给 icon;workpiece 走 PaneWorkpieceTabs。
35
+ export function PaneLabel({
36
+ icon: LabelIcon,
37
+ title,
38
+ badge,
39
+ }: {
40
+ icon?: PhosphorIconType;
41
+ title: string;
42
+ badge?: string;
43
+ }) {
44
+ return (
45
+ <div
46
+ title={title}
47
+ className="inline-flex w-full min-w-0 max-w-[180px] items-center gap-1.5 overflow-hidden rounded-lg bg-kumo-tint px-2.5 py-1.5 text-[13px] font-medium tracking-[-0.15px] text-kumo-default"
48
+ >
49
+ {LabelIcon && <LabelIcon size={14} weight="bold" className="flex-shrink-0" />}
50
+ <span className="truncate">{title}</span>
51
+ {badge !== undefined && (
52
+ <span className="rounded-full bg-kumo-fill px-1.5 py-0.5 text-[10px] font-medium leading-none text-kumo-subtle">
53
+ {badge}
54
+ </span>
55
+ )}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // 打开的那个 tab 必须完整可读,其余可以滚出视野之外。
61
+ const TAB_REVEAL_MARGIN = 8;
62
+
63
+ export function PaneWorkpieceTabs({
64
+ items,
65
+ activeId,
66
+ onSelect,
67
+ }: {
68
+ items: readonly CloudOsWorkpieceTab[];
69
+ activeId: string | null;
70
+ onSelect: (id: string) => void;
71
+ }) {
72
+ const scrollerRef = useRef<HTMLDivElement>(null);
73
+ const activeRef = useRef<HTMLButtonElement>(null);
74
+
75
+ // 一次不够:面板宽度还在动画、新建 workpiece 的标题也晚于 tab 首帧到达。
76
+ // 所以下一帧再跑一次,并且监听整条 strip 的尺寸变化。
77
+ useEffect(() => {
78
+ const scroller = scrollerRef.current;
79
+ if (!scroller) return;
80
+
81
+ const reveal = () => {
82
+ const active = activeRef.current;
83
+ if (!active) return;
84
+ const tab = active.getBoundingClientRect();
85
+ const view = scroller.getBoundingClientRect();
86
+ if (tab.right > view.right) {
87
+ scroller.scrollLeft += tab.right - view.right + TAB_REVEAL_MARGIN;
88
+ } else if (tab.left < view.left) {
89
+ scroller.scrollLeft -= view.left - tab.left + TAB_REVEAL_MARGIN;
90
+ }
91
+ };
92
+
93
+ reveal();
94
+ const frame = requestAnimationFrame(reveal);
95
+ const observer = new ResizeObserver(reveal);
96
+ observer.observe(scroller);
97
+ return () => {
98
+ cancelAnimationFrame(frame);
99
+ observer.disconnect();
100
+ };
101
+ }, [activeId, items]);
102
+
103
+ return (
104
+ <div
105
+ ref={scrollerRef}
106
+ className="cos-hide-scrollbar flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
107
+ >
108
+ {items.map((item) => {
109
+ const active = item.id === activeId;
110
+ const ItemIcon = item.Icon;
111
+ return (
112
+ <button
113
+ key={item.id}
114
+ ref={active ? activeRef : undefined}
115
+ type="button"
116
+ onClick={() => onSelect(item.id)}
117
+ title={item.title}
118
+ aria-current={active ? "page" : undefined}
119
+ // 打开的那个有权用完整名字,其余先让位。
120
+ className={`inline-flex flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] font-medium tracking-[-0.15px] transition-colors duration-150 ${
121
+ active
122
+ ? "max-w-[240px] bg-kumo-tint text-kumo-default"
123
+ : "max-w-[150px] text-kumo-subtle hover:bg-kumo-tint/50 hover:text-kumo-default"
124
+ }`}
125
+ >
126
+ {ItemIcon && <ItemIcon size={14} className="flex-shrink-0" />}
127
+ <span className="truncate">{item.title}</span>
128
+ {item.badge !== undefined && (
129
+ <span className="flex-shrink-0 rounded-full bg-kumo-fill px-1.5 py-0.5 text-[10px] font-medium leading-none text-kumo-subtle">
130
+ {item.badge}
131
+ </span>
132
+ )}
133
+ </button>
134
+ );
135
+ })}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export function PaneTab({
141
+ active,
142
+ label,
143
+ count,
144
+ onClick,
145
+ }: {
146
+ active: boolean;
147
+ label: string;
148
+ count?: number;
149
+ onClick: () => void;
150
+ }) {
151
+ return (
152
+ <button
153
+ type="button"
154
+ onClick={onClick}
155
+ className={`relative flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] font-medium tracking-[-0.15px] transition-colors duration-150 ${
156
+ active ? "bg-kumo-tint text-kumo-default" : "text-kumo-subtle hover:text-kumo-default"
157
+ }`}
158
+ >
159
+ {label}
160
+ <CountBadge count={count ?? 0} />
161
+ </button>
162
+ );
163
+ }
164
+
165
+ export function NoContentPlaceholder({
166
+ title = "Nothing here yet",
167
+ description = "Ask the agent in chat to build something, and it will appear here.",
168
+ }: {
169
+ title?: string;
170
+ description?: string;
171
+ }) {
172
+ return (
173
+ <div className="flex h-full items-center justify-center px-6 text-center">
174
+ <div className="max-w-[360px]">
175
+ <p className="m-0 text-[15px] leading-[22px] font-semibold tracking-[-0.3px] text-kumo-default">
176
+ {title}
177
+ </p>
178
+ <p className="mt-1.5 mb-0 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle">
179
+ {description}
180
+ </p>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export interface CloudOsWorkspacePanelProps {
187
+ title: string;
188
+ titleIcon?: PhosphorIconType;
189
+ titleBadge?: string;
190
+ /** 传了就用 workpiece 标签条替代单个标题。 */
191
+ workpieces?: readonly CloudOsWorkpieceTab[];
192
+ activeWorkpieceId?: string | null;
193
+ onSelectWorkpiece?: (id: string) => void;
194
+ tabs: readonly CloudOsPaneTab[];
195
+ activeTab: string;
196
+ onTabChange: (value: string) => void;
197
+ /** tab 条右侧的额外动作(全屏、导出…)。 */
198
+ actions?: ReactNode;
199
+ onClose?: () => void;
200
+ }
201
+
202
+ export function CloudOsWorkspacePanel({
203
+ title,
204
+ titleIcon,
205
+ titleBadge,
206
+ workpieces,
207
+ activeWorkpieceId = null,
208
+ onSelectWorkpiece,
209
+ tabs,
210
+ activeTab,
211
+ onTabChange,
212
+ actions,
213
+ onClose,
214
+ }: CloudOsWorkspacePanelProps) {
215
+ return (
216
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-kumo-base">
217
+ <div
218
+ className="flex flex-shrink-0 items-center gap-2 border-b border-kumo-line px-3"
219
+ style={{ height: TABBAR_H }}
220
+ >
221
+ <div className="flex min-w-0 flex-1 items-center overflow-hidden">
222
+ {workpieces && workpieces.length > 1 && onSelectWorkpiece ? (
223
+ <PaneWorkpieceTabs
224
+ items={workpieces}
225
+ activeId={activeWorkpieceId}
226
+ onSelect={onSelectWorkpiece}
227
+ />
228
+ ) : (
229
+ <PaneLabel icon={titleIcon} title={title} badge={titleBadge} />
230
+ )}
231
+ </div>
232
+
233
+ <div className="flex flex-shrink-0 items-center gap-1.5">
234
+ <div className="flex items-center rounded-lg border border-kumo-line p-0.5">
235
+ {tabs.map((tab) => (
236
+ <PaneTab
237
+ key={tab.value}
238
+ active={activeTab === tab.value}
239
+ label={tab.label}
240
+ count={tab.count}
241
+ onClick={() => onTabChange(tab.value)}
242
+ />
243
+ ))}
244
+ </div>
245
+
246
+ {actions}
247
+
248
+ {onClose && (
249
+ <WorkshopIconButton
250
+ aria-label="Close workspace panel"
251
+ title="Close"
252
+ onClick={onClose}
253
+ >
254
+ <X size={16} />
255
+ </WorkshopIconButton>
256
+ )}
257
+ </div>
258
+ </div>
259
+
260
+ <div className="min-h-0 flex-1 overflow-hidden">
261
+ {tabs.map((tab) => (
262
+ <div
263
+ key={tab.value}
264
+ className={activeTab === tab.value ? "h-full overflow-auto" : "hidden"}
265
+ >
266
+ {tab.content}
267
+ </div>
268
+ ))}
269
+ </div>
270
+ </div>
271
+ );
272
+ }