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

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.
@@ -10,7 +10,7 @@ import {
10
10
  /**
11
11
  * 左聊天 / 右工作区的双栏骨架。从 cloudflare-os-main `GadgetEditor.tsx` 的 BODY
12
12
  * 段拷来:同一条 1px 的 kumo-line 分隔即拖拽把手、同一套 pointer capture 拖拽
13
- * (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡、同一条顶部推进条。
13
+ * (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡。
14
14
  *
15
15
  * 适配点:原文件把左右两侧的内容写死成 ChatInterface / GadgetUI,这里换成
16
16
  * children 插槽;chatWidth 的 localStorage key 换成 cloud-os 自己的。
@@ -24,11 +24,12 @@ const WORKSPACE_TRANSITION_MS = 200;
24
24
 
25
25
  const isBrowser = typeof window !== "undefined";
26
26
 
27
- function clampChatWidth(width: number) {
27
+ function clampChatWidth(width: number, containerWidth?: number) {
28
28
  if (!isBrowser) {
29
29
  return Math.max(MIN_CHAT_WIDTH, Math.min(DEFAULT_CHAT_WIDTH, width));
30
30
  }
31
- const max = Math.max(MIN_CHAT_WIDTH, window.innerWidth - MIN_WORKSPACE_WIDTH);
31
+ const available = containerWidth ?? window.innerWidth;
32
+ const max = Math.max(MIN_CHAT_WIDTH, available - MIN_WORKSPACE_WIDTH);
32
33
  return Math.max(MIN_CHAT_WIDTH, Math.min(max, width));
33
34
  }
34
35
 
@@ -48,13 +49,16 @@ function getInitialChatWidth() {
48
49
  return clampChatWidth(Number.isFinite(parsed) ? parsed : fallback);
49
50
  }
50
51
 
52
+ type ResizeSession = {
53
+ startX: number;
54
+ startWidth: number;
55
+ };
56
+
51
57
  export interface CloudOsWorkspaceSplitProps {
52
58
  chat: ReactNode;
53
59
  workspace: ReactNode;
54
60
  /** 关掉右栏时聊天占满整宽。 */
55
61
  workspaceOpen: boolean;
56
- /** 顶部那条推进条:回合进行中显示。 */
57
- isAgentActive?: boolean;
58
62
  /** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
59
63
  railWidth?: number;
60
64
  rail?: ReactNode;
@@ -64,27 +68,43 @@ export function CloudOsWorkspaceSplit({
64
68
  chat,
65
69
  workspace,
66
70
  workspaceOpen,
67
- isAgentActive = false,
68
71
  railWidth = 0,
69
72
  rail,
70
73
  }: CloudOsWorkspaceSplitProps) {
74
+ const containerRef = useRef<HTMLDivElement>(null);
75
+ const resizeSessionRef = useRef<ResizeSession | null>(null);
71
76
  const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
72
77
  const [isResizing, setIsResizing] = useState(false);
73
78
  const [transitionEnabled, setTransitionEnabled] = useState(false);
74
79
  const chatWidthRef = useRef(chatWidth);
75
80
  chatWidthRef.current = chatWidth;
76
81
 
82
+ const getContainerWidth = useCallback(
83
+ () => containerRef.current?.getBoundingClientRect().width,
84
+ [],
85
+ );
86
+
87
+ const clampToContainer = useCallback(
88
+ (width: number) => clampChatWidth(width, getContainerWidth()),
89
+ [getContainerWidth],
90
+ );
91
+
77
92
  // 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
78
93
  useEffect(() => {
79
94
  const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
80
95
  return () => window.clearTimeout(timer);
81
96
  }, []);
82
97
 
98
+ // 挂载后按真实容器宽度重新 clamp 一次(宿主可能有左侧 sidebar 等偏移)。
99
+ useEffect(() => {
100
+ setChatWidth((width) => clampToContainer(width));
101
+ }, [clampToContainer]);
102
+
83
103
  useEffect(() => {
84
- const onResize = () => setChatWidth((width) => clampChatWidth(width));
104
+ const onResize = () => setChatWidth((width) => clampToContainer(width));
85
105
  window.addEventListener("resize", onResize);
86
106
  return () => window.removeEventListener("resize", onResize);
87
- }, []);
107
+ }, [clampToContainer]);
88
108
 
89
109
  const persistChatWidth = useCallback((width: number) => {
90
110
  try {
@@ -94,11 +114,24 @@ export function CloudOsWorkspaceSplit({
94
114
  }
95
115
  }, []);
96
116
 
117
+ const widthFromPointer = useCallback(
118
+ (clientX: number) => {
119
+ const session = resizeSessionRef.current;
120
+ if (!session) return chatWidthRef.current;
121
+ return clampToContainer(session.startWidth + clientX - session.startX);
122
+ },
123
+ [clampToContainer],
124
+ );
125
+
97
126
  // 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
98
127
  const handleResizePointerDown = useCallback(
99
128
  (event: ReactPointerEvent<HTMLDivElement>) => {
100
129
  if (!workspaceOpen) return;
101
130
  event.preventDefault();
131
+ resizeSessionRef.current = {
132
+ startX: event.clientX,
133
+ startWidth: chatWidthRef.current,
134
+ };
102
135
  event.currentTarget.setPointerCapture(event.pointerId);
103
136
  setIsResizing(true);
104
137
  },
@@ -107,9 +140,9 @@ export function CloudOsWorkspaceSplit({
107
140
  const handleResizePointerMove = useCallback(
108
141
  (event: ReactPointerEvent<HTMLDivElement>) => {
109
142
  if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
110
- setChatWidth(clampChatWidth(event.clientX));
143
+ setChatWidth(widthFromPointer(event.clientX));
111
144
  },
112
- [],
145
+ [widthFromPointer],
113
146
  );
114
147
  const handleResizePointerUp = useCallback(
115
148
  (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -119,12 +152,13 @@ export function CloudOsWorkspaceSplit({
119
152
  const width =
120
153
  event.type === "pointercancel"
121
154
  ? chatWidthRef.current
122
- : clampChatWidth(event.clientX);
155
+ : widthFromPointer(event.clientX);
156
+ resizeSessionRef.current = null;
123
157
  setChatWidth(width);
124
158
  persistChatWidth(width);
125
159
  setIsResizing(false);
126
160
  },
127
- [persistChatWidth],
161
+ [persistChatWidth, widthFromPointer],
128
162
  );
129
163
 
130
164
  useEffect(() => {
@@ -147,18 +181,10 @@ export function CloudOsWorkspaceSplit({
147
181
  return (
148
182
  // h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
149
183
  // 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
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
-
184
+ <div
185
+ ref={containerRef}
186
+ className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
187
+ >
162
188
  {/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
163
189
  <div
164
190
  className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
@@ -356,23 +356,6 @@
356
356
  background: transparent;
357
357
  }
358
358
 
359
- /* ── 顶部推进条(isAgentActive)──────────────────────────────────────────── */
360
- @keyframes cos-thinking {
361
- 0% {
362
- left: -33%;
363
- }
364
- 50% {
365
- left: 100%;
366
- }
367
- 100% {
368
- left: -33%;
369
- }
370
- }
371
-
372
- .cos-progress-sweep {
373
- animation: cos-thinking 1.5s ease-in-out infinite;
374
- }
375
-
376
359
  /* ============================================================================
377
360
  * 以下整段来自 ChatInterface.module.css —— CSS Module 类名改成 cos- 全局前缀
378
361
  * ========================================================================== */
@@ -505,6 +488,20 @@
505
488
  }
506
489
  }
507
490
 
491
+ /* ── 活动指示:回合在跑但屏幕上没东西在动时 ──────────────────────────────── */
492
+ .cos-activity {
493
+ display: flex;
494
+ align-items: center;
495
+ gap: 8px;
496
+ min-height: 20px;
497
+ color: var(--text-color-kumo-subtle);
498
+ }
499
+
500
+ /* 球是固定 20px 的画布,不许被 flex 压扁。 */
501
+ .cos-activity canvas {
502
+ flex: none;
503
+ }
504
+
508
505
  /* ── 流式占位:Thinking 扫光 ──────────────────────────────────────────────── */
509
506
  .cos-thinking-shimmer {
510
507
  color: var(--text-color-kumo-inactive);
@@ -536,9 +533,6 @@
536
533
  background: none;
537
534
  -webkit-text-fill-color: currentColor;
538
535
  }
539
- .cos-progress-sweep {
540
- animation: none;
541
- }
542
536
  }
543
537
 
544
538
  /* ── 观察类 markdown(行内、紧凑)────────────────────────────────────────── */
@@ -283,7 +283,6 @@ export function CloudOsChatShowcase() {
283
283
 
284
284
  <CloudOsWorkspaceSplit
285
285
  workspaceOpen={workspaceOpen}
286
- isAgentActive={status === "submitted" || status === "streaming"}
287
286
  chat={
288
287
  <div className="flex h-full min-h-0 flex-col bg-kumo-base">
289
288
  <aside
@@ -376,20 +375,21 @@ export function CloudOsChatShowcase() {
376
375
  setValue("");
377
376
  setLastEvent(`已发送后续问题:${text}`);
378
377
  }}
379
- onAnswer={(payload) => {
378
+ onRespond={async (_toolCallId, response) => {
380
379
  const data =
381
- payload && typeof payload === "object"
382
- ? (payload as { selections?: string[][]; text?: string })
380
+ response && typeof response === "object"
381
+ ? (response as { selections?: string[]; text?: string })
383
382
  : {};
384
383
  const answer = [
385
- ...(data.selections?.flat() ?? []),
384
+ ...(data.selections ?? []),
386
385
  data.text?.trim() ?? "",
387
386
  ]
388
387
  .filter(Boolean)
389
- .join("");
388
+ .join(" / ");
390
389
  if (answer) setHumanAnswer(answer);
391
390
  selectScenario("planning");
392
391
  setLastEvent(`已回答并继续:${answer || humanAnswer}`);
392
+ return true;
393
393
  }}
394
394
  />
395
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.2",
3
+ "version": "0.1.3-alpha.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -1,3 +1,8 @@
1
+ // camel 是 dev-only 的展示变体(App.tsx 的 `/__camel-chat` 路由),没有接生产宿主。
2
+ // 它的交互卡片仍用旧的 `onAnswer(payload)` 回调,**没有**迁到 client-settled tool 的
3
+ // `onRespond(toolCallId, response)` —— 生产聊天面板走的是 cloud-os,已经迁完。
4
+ // 哪天 camel 要上生产,这里必须一起改,否则卡片点了不会把答案回写给那次工具调用。
5
+
1
6
  import type { UIMessage } from "ai";
2
7
  import {
3
8
  ArrowDownIcon,
@@ -39,6 +39,7 @@ import {
39
39
  type ReactNode,
40
40
  } from "react";
41
41
  import { ComposerTriggerPopover } from "../composer/composer-trigger-popover";
42
+ import { isComposingKeyEvent } from "../composer/key-rules";
42
43
  import type { ComposerCommand } from "../composer/types";
43
44
  import { cn } from "../internal/cn";
44
45
 
@@ -345,7 +346,7 @@ function CamelPromptInputContent({
345
346
  if (
346
347
  event.key === "Enter" &&
347
348
  !event.shiftKey &&
348
- !event.nativeEvent.isComposing
349
+ !isComposingKeyEvent(event.nativeEvent)
349
350
  ) {
350
351
  event.preventDefault();
351
352
  submit();
@@ -103,7 +103,7 @@ export function ChatSummaryPanel({
103
103
  return (
104
104
  <aside
105
105
  className={cn(
106
- "w-[19rem] min-w-0 max-w-full overflow-hidden rounded-2xl border border-border bg-popover shadow-card",
106
+ "absolute right-3 top-11 z-30 w-[19rem] min-w-0 max-w-[calc(100%-1.5rem)] overflow-hidden rounded-2xl border border-border bg-popover shadow-card",
107
107
  "animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200 motion-reduce:animate-none",
108
108
  className,
109
109
  )}
@@ -10,6 +10,7 @@ import {
10
10
  type KeyboardEvent,
11
11
  } from "react";
12
12
  import { cn } from "../internal/cn";
13
+ import { isComposingKeyEvent } from "./key-rules";
13
14
  import { Composer } from "./composer";
14
15
  import type {
15
16
  ChatComposerProps,
@@ -152,7 +153,7 @@ function ChatComposerInner<TAttachment>(
152
153
  if (
153
154
  (event.key === "Enter" &&
154
155
  !event.shiftKey &&
155
- !event.nativeEvent.isComposing) ||
156
+ !isComposingKeyEvent(event.nativeEvent)) ||
156
157
  event.key === "Tab"
157
158
  ) {
158
159
  const command = filtered[active] ?? filtered[0];
@@ -156,7 +156,7 @@ function Items({
156
156
  {item.label}
157
157
  </span>
158
158
  {item.description && (
159
- <span className="ms-5.5 text-xs leading-tight text-muted-foreground">
159
+ <span className="ms-5.5 w-full truncate text-xs leading-tight text-muted-foreground">
160
160
  {item.description}
161
161
  </span>
162
162
  )}
@@ -26,6 +26,7 @@ import {
26
26
  Attachments,
27
27
  } from "./composer-attachments";
28
28
  import { useComposerDrop } from "./drop-controller";
29
+ import { isComposingKeyEvent } from "./key-rules";
29
30
  import type {
30
31
  ComposerAttachmentView,
31
32
  ComposerProps,
@@ -247,7 +248,7 @@ function ComposerInner<TAttachment>(
247
248
  if (
248
249
  event.key === "Enter" &&
249
250
  !event.shiftKey &&
250
- !event.nativeEvent.isComposing
251
+ !isComposingKeyEvent(event.nativeEvent)
251
252
  ) {
252
253
  event.preventDefault();
253
254
  if (canSend) void submit();
@@ -3,4 +3,5 @@ export * from "./composer-attachments";
3
3
  export * from "./composer-trigger-popover";
4
4
  export * from "./chat-composer";
5
5
  export * from "./drop-controller";
6
+ export * from "./key-rules";
6
7
  export * from "./types";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Whether Enter belongs to an input method rather than to the Composer.
3
+ *
4
+ * Both signals are honoured on their own: some Chinese input methods report only the
5
+ * legacy `keyCode === 229`, and either one alone means a word is still being composed.
6
+ */
7
+ export function isComposingKeyEvent(event: {
8
+ isComposing?: boolean;
9
+ keyCode?: number;
10
+ }): boolean {
11
+ return event.isComposing === true || event.keyCode === 229;
12
+ }
@@ -2128,7 +2128,10 @@
2128
2128
  min-height: 120px;
2129
2129
  gap: 0;
2130
2130
  border-radius: 16px;
2131
- background: #1c1c1a;
2131
+ /* 这里曾写死 #1c1c1a,于是 light 档下输入框还是近黑的。
2132
+ 表面色一律走 token:--mp-card → --card(light #fff / dark #1c1c1c)。
2133
+ 本节其余几个写死色是状态/图标强调色(琥珀、蓝),两档背景上都读得出来,不在此列。 */
2134
+ background: var(--mp-card);
2132
2135
  box-shadow: none;
2133
2136
  }
2134
2137