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

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.
Files changed (38) hide show
  1. package/cloud-os/README.md +71 -0
  2. package/cloud-os/capability-chip.tsx +35 -0
  3. package/cloud-os/chat/activity-indicator.tsx +29 -0
  4. package/cloud-os/chat/cloud-os-chat-messages.tsx +660 -0
  5. package/cloud-os/chat/markdown-message.tsx +78 -0
  6. package/cloud-os/chat/rich-blocks.tsx +787 -0
  7. package/cloud-os/chat/tool-presentation.ts +586 -0
  8. package/cloud-os/chat/tool-rows.tsx +216 -0
  9. package/cloud-os/chat/transcript-model.ts +780 -0
  10. package/cloud-os/composer/cloud-os-chat-input.tsx +686 -0
  11. package/cloud-os/file-view.tsx +258 -0
  12. package/cloud-os/index.ts +123 -0
  13. package/cloud-os/internal/cn.ts +6 -0
  14. package/cloud-os/internal/theme-context.tsx +16 -0
  15. package/cloud-os/layout/cloud-os-root.tsx +34 -0
  16. package/cloud-os/layout/cloud-os-workspace-split.tsx +228 -0
  17. package/cloud-os/primitives/dropdown-menu.tsx +82 -0
  18. package/cloud-os/primitives/tooltip.tsx +68 -0
  19. package/cloud-os/primitives/workshop-controls.tsx +114 -0
  20. package/cloud-os/styles/cloud-os.css +553 -0
  21. package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
  22. package/demo/camel-chat-showcase.tsx +13 -917
  23. package/demo/chat-scenarios.ts +926 -0
  24. package/demo/cloud-os-chat-showcase.tsx +481 -0
  25. package/demo/index.ts +2 -0
  26. package/package.json +17 -5
  27. package/src/camel/camel-chat-messages.tsx +55 -20
  28. package/src/camel/camel-prompt-input.tsx +2 -1
  29. package/src/camel/camel-turn.ts +21 -3
  30. package/src/chat-summary-panel.tsx +1 -1
  31. package/src/composer/chat-composer.tsx +2 -1
  32. package/src/composer/composer-trigger-popover.tsx +1 -1
  33. package/src/composer/composer.tsx +2 -1
  34. package/src/composer/index.ts +1 -0
  35. package/src/composer/key-rules.ts +12 -0
  36. package/src/message.tsx +0 -8
  37. package/src/parts/plan.ts +3 -2
  38. package/src/styles/index.css +4 -1
@@ -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,
@@ -71,9 +76,7 @@ export interface CamelChatMessagesProps {
71
76
  finalToolNames?: readonly string[];
72
77
  isHydrating?: boolean;
73
78
  isRecovering?: boolean;
74
- isServerStreaming?: boolean;
75
- /** Show the opt-in activity bar for recovering or server-streaming states. */
76
- showActivityStatus?: boolean;
79
+ recoveryStatusLabel?: string;
77
80
  hasPendingSteer?: boolean;
78
81
  startedWithPending?: boolean;
79
82
  error?: unknown;
@@ -163,6 +166,23 @@ export function CamelChatError({
163
166
  );
164
167
  }
165
168
 
169
+ export function CamelChatRecovery({
170
+ label = "模型响应暂时中断,正在重试…",
171
+ }: {
172
+ label?: string;
173
+ }) {
174
+ return (
175
+ <div
176
+ className="mt-4 flex items-center gap-2 rounded-lg border border-border/70 bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
177
+ data-camel-chat-recovery=""
178
+ role="status"
179
+ >
180
+ <Loader2Icon className="size-4 shrink-0 animate-spin" aria-hidden="true" />
181
+ <span>{label}</span>
182
+ </div>
183
+ );
184
+ }
185
+
166
186
  export function CamelApprovalPrompts({
167
187
  approvals,
168
188
  error,
@@ -403,16 +423,21 @@ function CamelToolRow({
403
423
  onApprove: (approvalId: string, decision: ToolApprovalDecision) => void;
404
424
  getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
405
425
  }) {
406
- const [open, setOpen] = useState(false);
407
426
  const approval = getToolApproval?.(part) ?? toolApprovalOf(part);
408
427
  const pendingApproval = approval && approval.approved === undefined;
409
428
  const presentation = getCamelToolPresentation(part, isActive);
429
+ const autoOpen = presentation.running && (
430
+ presentation.isSubAgent ||
431
+ presentation.kind === "write" ||
432
+ presentation.kind === "edit"
433
+ );
434
+ const [open, setOpen] = useState(autoOpen);
410
435
  const hasDetails = children != null || presentation.hasDetails;
411
436
  const title = presentation.summary;
412
437
 
413
438
  useEffect(() => {
414
- if (presentation.isSubAgent && presentation.running) setOpen(true);
415
- }, [presentation.isSubAgent, presentation.running]);
439
+ if (autoOpen) setOpen(true);
440
+ }, [autoOpen]);
416
441
 
417
442
  return (
418
443
  <div data-part-type={normalizedPartType(part)}>
@@ -1145,6 +1170,7 @@ function RenderAssistantPart({
1145
1170
  function AssistantTurn({
1146
1171
  turn,
1147
1172
  isActive,
1173
+ keepTraceExpanded,
1148
1174
  hasPendingSteer,
1149
1175
  hasPendingApproval,
1150
1176
  onFork,
@@ -1156,6 +1182,7 @@ function AssistantTurn({
1156
1182
  }: {
1157
1183
  turn: CamelAssistantTurn;
1158
1184
  isActive: boolean;
1185
+ keepTraceExpanded: boolean;
1159
1186
  hasPendingSteer: boolean;
1160
1187
  hasPendingApproval: boolean;
1161
1188
  onFork?: (messageId: string) => void | Promise<void>;
@@ -1179,7 +1206,10 @@ function AssistantTurn({
1179
1206
  />
1180
1207
  );
1181
1208
  const showWorkInPlace =
1182
- isActive || hasPendingApproval || turn.terminalState !== undefined;
1209
+ isActive ||
1210
+ keepTraceExpanded ||
1211
+ hasPendingApproval ||
1212
+ turn.terminalState !== undefined;
1183
1213
  const showSummary = !showWorkInPlace && turn.traceParts.length > 0;
1184
1214
  const completedAt = turn.completedAt ?? camelCompletedAt(turn.message);
1185
1215
  const messageTime =
@@ -1189,6 +1219,11 @@ function AssistantTurn({
1189
1219
  const thinkingActivity = deriveThinkingActivity(
1190
1220
  turn.traceParts.at(-1)?.part ?? turn.finalParts.at(-1)?.part,
1191
1221
  );
1222
+ const hasRunningTool = turn.traceParts.some(
1223
+ ({ part }) =>
1224
+ toolNameOfPart(part) !== null &&
1225
+ getCamelToolPresentation(part, isActive).running,
1226
+ );
1192
1227
  const activeActivity = hasPendingSteer
1193
1228
  ? pendingSteerActivity
1194
1229
  : thinkingActivity;
@@ -1269,7 +1304,7 @@ function AssistantTurn({
1269
1304
  {turn.copyText && <ClipboardAction text={turn.copyText} />}
1270
1305
  </div>
1271
1306
  )}
1272
- {isActive && turn.finalParts.length === 0 && (
1307
+ {isActive && turn.finalParts.length === 0 && !hasRunningTool && (
1273
1308
  <ThinkingIndicator {...activeActivity} />
1274
1309
  )}
1275
1310
  </div>
@@ -1283,8 +1318,7 @@ export function CamelChatMessages({
1283
1318
  finalToolNames,
1284
1319
  isHydrating = false,
1285
1320
  isRecovering = false,
1286
- isServerStreaming = false,
1287
- showActivityStatus = false,
1321
+ recoveryStatusLabel,
1288
1322
  hasPendingSteer = false,
1289
1323
  startedWithPending = false,
1290
1324
  error,
@@ -1316,6 +1350,10 @@ export function CamelChatMessages({
1316
1350
  isActive && lastItem?.kind === "assistant"
1317
1351
  ? lastItem.actionMessageId
1318
1352
  : null;
1353
+ const recoveringAssistantMessageId =
1354
+ isRecovering && lastItem?.kind === "assistant"
1355
+ ? lastItem.actionMessageId
1356
+ : null;
1319
1357
 
1320
1358
  const syncScrollButton = useCallback(() => {
1321
1359
  const element = scrollRef.current;
@@ -1329,7 +1367,7 @@ export function CamelChatMessages({
1329
1367
  const element = scrollRef.current;
1330
1368
  if (!element || showScrollButton) return;
1331
1369
  element.scrollTo({ top: element.scrollHeight });
1332
- }, [messages, status, showScrollButton]);
1370
+ }, [isRecovering, messages, status, showScrollButton]);
1333
1371
 
1334
1372
  return (
1335
1373
  <div
@@ -1339,15 +1377,6 @@ export function CamelChatMessages({
1339
1377
  data-status={status}
1340
1378
  aria-busy={isActive || isHydrating}
1341
1379
  >
1342
- {showActivityStatus && (isRecovering || isServerStreaming) && (
1343
- <div
1344
- className="absolute inset-x-0 top-0 z-10 flex h-7 items-center justify-center gap-2 bg-background/85 text-xs text-muted-foreground backdrop-blur-sm"
1345
- role="status"
1346
- >
1347
- <span className="size-1.5 animate-pulse rounded-full bg-blue-500" />
1348
- {isRecovering ? "Recovering…" : "Running…"}
1349
- </div>
1350
- )}
1351
1380
  <div
1352
1381
  ref={scrollRef}
1353
1382
  onScroll={syncScrollButton}
@@ -1368,6 +1397,9 @@ export function CamelChatMessages({
1368
1397
  key={item.key}
1369
1398
  turn={item}
1370
1399
  isActive={item.actionMessageId === activeAssistantMessageId}
1400
+ keepTraceExpanded={
1401
+ item.actionMessageId === recoveringAssistantMessageId
1402
+ }
1371
1403
  hasPendingSteer={hasPendingSteer}
1372
1404
  hasPendingApproval={item.parts.some((partRef) => {
1373
1405
  const approval =
@@ -1386,6 +1418,9 @@ export function CamelChatMessages({
1386
1418
  <StandaloneMessage key={item.key} item={item} />
1387
1419
  ),
1388
1420
  )}
1421
+ {isRecovering && (
1422
+ <CamelChatRecovery label={recoveryStatusLabel} />
1423
+ )}
1389
1424
  {error != null && <CamelChatError error={error} onRetry={onRetry} />}
1390
1425
  {isActive && activeAssistantMessageId === null && (
1391
1426
  <ThinkingIndicator
@@ -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();
@@ -169,11 +169,20 @@ function isTraceWork(
169
169
  if (isVisibleReasoning(part)) return true;
170
170
  const kind = resolvePartKind(part);
171
171
  if (kind === "plan") return false;
172
- const toolName = toolNameOfPart(part);
173
- if (toolName !== null && finalToolNames.includes(toolName)) return false;
172
+ if (isFinalOutputTool(part, finalToolNames)) return false;
174
173
  return TRACE_PART_KINDS.has(kind ?? "");
175
174
  }
176
175
 
176
+ function isFinalOutputTool(
177
+ part: CamelPart,
178
+ finalToolNames: readonly string[],
179
+ ): boolean {
180
+ const toolName = toolNameOfPart(part);
181
+ return toolName !== null &&
182
+ (finalToolNames.includes(toolName) ||
183
+ resolvePartKind(part) === "suggestions");
184
+ }
185
+
177
186
  function isTraceBoundary(
178
187
  part: CamelPart,
179
188
  finalToolNames: readonly string[],
@@ -226,8 +235,17 @@ export function projectCamelAssistantTurn(
226
235
  partIndex,
227
236
  part,
228
237
  }));
229
- let lastTraceBoundary = -1;
238
+ let finalOutputBoundary = parts.length;
230
239
  for (let index = parts.length - 1; index >= 0; index -= 1) {
240
+ if (isFinalOutputTool(parts[index].part, finalToolNames)) {
241
+ finalOutputBoundary = index;
242
+ break;
243
+ }
244
+ }
245
+ let lastTraceBoundary = -1;
246
+ // Provider cleanup steps after final output must not pull the answer back
247
+ // into the collapsible work trace.
248
+ for (let index = finalOutputBoundary - 1; index >= 0; index -= 1) {
231
249
  if (isTraceBoundary(parts[index].part, finalToolNames)) {
232
250
  lastTraceBoundary = index;
233
251
  break;
@@ -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
+ }
package/src/message.tsx CHANGED
@@ -85,13 +85,6 @@ export const MessageAction = forwardRef<HTMLButtonElement, MessageActionProps>(
85
85
  MessageAction.displayName = "MessageAction";
86
86
 
87
87
  const streamdownPlugins = { cjk, code, math, mermaid };
88
- const streamAnimation = {
89
- animation: "fadeIn",
90
- sep: "word",
91
- duration: 260,
92
- stagger: 14,
93
- } as const;
94
-
95
88
  export type MessageResponseProps = StreamdownProps & {
96
89
  streaming?: boolean;
97
90
  resolveUrl?: MessageUrlResolver;
@@ -146,7 +139,6 @@ export const MessageResponse = memo(
146
139
  ? {
147
140
  mode: "streaming" as const,
148
141
  isAnimating: true,
149
- animated: streamAnimation,
150
142
  }
151
143
  : { mode: "static" as const })}
152
144
  {...props}
package/src/parts/plan.ts CHANGED
@@ -26,8 +26,9 @@ export function planStepsOfPart(part: unknown): PlanStep[] {
26
26
  const value = recordOf(step);
27
27
  const text = typeof value.text === "string" ? value.text.trim() : "";
28
28
  if (!text) return [];
29
- const status =
30
- value.status === "in_progress" || value.status === "done"
29
+ const status = value.status === "completed"
30
+ ? "done"
31
+ : value.status === "in_progress" || value.status === "done"
31
32
  ? value.status
32
33
  : "pending";
33
34
  return [{ text, status }];
@@ -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