@springbrand/message-panel 0.1.3-alpha.34 → 0.1.3-alpha.36

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.
@@ -64,7 +64,8 @@ pnpm dev:cloud-os # http://localhost:9992
64
64
 
65
65
  ## 宿主接线要求
66
66
 
67
- 1. Tailwind 入口里 **`@import` 本包的 css 必须和其它 `@import` 挤在最前面**。排到
67
+ 1. Tailwind 入口先 `@import "tw-animate-css"`,再 **`@import` 本包的 css,
68
+ 且必须和其它 `@import` 挤在最前面**。排到
68
69
  `@source` / `@layer` 之后,Tailwind 就不会内联它,`@theme` 块会被整块忽略,
69
70
  kumo 的 utility 一个都生成不出来(踩过一次)。
70
71
  2. 把本目录加进 `@source`,且必须写显式 glob:`"…/cloud-os/**/*.{ts,tsx}"`。
@@ -0,0 +1,9 @@
1
+ <svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="12.2369" height="12.2369" viewBox="0 0 12.2369 12.2369" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path id="Star 14" d="M5.18061 0.652963C5.50276 -0.217655 6.73415 -0.217654 7.05631 0.652964L7.64089 2.23277C8.04603 3.32764 8.90927 4.19089 10.0042 4.59603L11.584 5.18061C12.4546 5.50276 12.4546 6.73415 11.584 7.05631L10.0041 7.64089C8.90927 8.04603 8.04603 8.90927 7.64089 10.0042L7.05631 11.584C6.73415 12.4546 5.50276 12.4546 5.18061 11.584L4.59603 10.0041C4.19089 8.90927 3.32764 8.04603 2.23277 7.64089L0.652963 7.05631C-0.217655 6.73415 -0.217654 5.50276 0.652964 5.18061L2.23277 4.59603C3.32764 4.19089 4.19089 3.32764 4.59603 2.23277L5.18061 0.652963Z" fill="url(#paint0_radial_0_26)"/>
3
+ <defs>
4
+ <radialGradient id="paint0_radial_0_26" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(6.11846 2.11846) rotate(90) scale(12)">
5
+ <stop stop-color="#FF9938"/>
6
+ <stop offset="1" stop-color="#FFE46C"/>
7
+ </radialGradient>
8
+ </defs>
9
+ </svg>
@@ -1,4 +1,5 @@
1
1
  import type { UIMessage } from "ai";
2
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
2
3
  import {
3
4
  ArrowDown,
4
5
  Brain,
@@ -17,6 +18,11 @@ import {
17
18
  type ReactNode,
18
19
  } from "react";
19
20
  import { Tooltip } from "../primitives/tooltip";
21
+ import {
22
+ Collapsible,
23
+ CollapsibleContent,
24
+ CollapsibleTrigger,
25
+ } from "../primitives/collapsible";
20
26
  import { WorkshopIconButton } from "../primitives/workshop-controls";
21
27
  import {
22
28
  MarkdownMessage,
@@ -35,6 +41,7 @@ import {
35
41
  type ApprovalDecision,
36
42
  } from "./rich-blocks";
37
43
  import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
44
+ import { collapsePanel } from "./surfaces";
38
45
  import { CloudOsActivityIndicator } from "./activity-indicator";
39
46
  import { ProductGeneration } from "./image-generation";
40
47
  import { GenerationLoader } from "./loading-state";
@@ -49,6 +56,7 @@ import {
49
56
  formatClockTime,
50
57
  formatFullTimestamp,
51
58
  rhythmTopClass,
59
+ type AssistantBlock,
52
60
  type CloudOsEntry,
53
61
  type CloudOsActionPresentation,
54
62
  } from "./transcript-model";
@@ -61,6 +69,8 @@ export interface CloudOsChatMessagesProps {
61
69
  /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
62
70
  turnActive?: boolean;
63
71
  isRecovering?: boolean;
72
+ recoveryAttempt?: number;
73
+ recoveryMax?: number;
64
74
  recoveryStatusLabel?: string;
65
75
  awaitingApproval?: boolean;
66
76
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
@@ -184,7 +194,10 @@ function UserBubble({
184
194
  </div>
185
195
  )}
186
196
  {entry.text && (
187
- <div className="themed-user-bubble-shadow cos-markdown cos-user-markdown w-fit max-w-[min(680px,78%)] rounded-[12px] border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
197
+ <div
198
+ data-user-message-bubble=""
199
+ className="themed-user-bubble-shadow cos-markdown cos-user-markdown w-fit max-w-[min(680px,78%)] rounded-[12px] border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
200
+ >
188
201
  <MarkdownMessage
189
202
  message={entry.text}
190
203
  resolveUrl={resolveUrl}
@@ -315,11 +328,49 @@ function ActionPresentationBlock({
315
328
  );
316
329
  }
317
330
 
331
+ function TurnWorkDisclosure({
332
+ running,
333
+ open,
334
+ onOpenChange,
335
+ children,
336
+ }: {
337
+ running: boolean;
338
+ open: boolean;
339
+ onOpenChange: (open: boolean) => void;
340
+ children: ReactNode;
341
+ }) {
342
+ return (
343
+ <Collapsible
344
+ data-slot="turn-work-disclosure"
345
+ open={open}
346
+ onOpenChange={onOpenChange}
347
+ >
348
+ <CollapsibleTrigger
349
+ aria-disabled={running}
350
+ className="group/trigger flex items-center gap-1.5 rounded-md py-1 text-[13.5px] text-kumo-inactive transition-colors outline-none hover:text-kumo-subtle"
351
+ >
352
+ <CaretRight
353
+ size={14}
354
+ className="shrink-0 transition-transform duration-200 group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none"
355
+ />
356
+ <span>{running ? "Working…" : "Work"}</span>
357
+ </CollapsibleTrigger>
358
+ <CollapsibleContent className={collapsePanel}>
359
+ <div className="cos-trace-chain relative space-y-4 pt-2">
360
+ {children}
361
+ </div>
362
+ </CollapsibleContent>
363
+ </Collapsible>
364
+ );
365
+ }
366
+
318
367
  export function CloudOsChatMessages({
319
368
  messages,
320
369
  status,
321
370
  turnActive,
322
371
  isRecovering = false,
372
+ recoveryAttempt,
373
+ recoveryMax,
323
374
  recoveryStatusLabel,
324
375
  awaitingApproval = false,
325
376
  hasPendingSteer = false,
@@ -345,9 +396,13 @@ export function CloudOsChatMessages({
345
396
  const scrollRef = useRef<HTMLDivElement>(null);
346
397
  const endRef = useRef<HTMLDivElement>(null);
347
398
  const [showScrollButton, setShowScrollButton] = useState(false);
399
+ const reducedMotion = useReducedMotion();
348
400
  const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
349
401
  () => new Set(),
350
402
  );
403
+ const [expandedTurnWork, setExpandedTurnWork] = useState<ReadonlySet<string>>(
404
+ () => new Set(),
405
+ );
351
406
  const [expandedCompactions, setExpandedCompactions] = useState<
352
407
  ReadonlySet<string>
353
408
  >(() => new Set());
@@ -365,6 +420,20 @@ export function CloudOsChatMessages({
365
420
  }),
366
421
  [approvalIdOf, isActive, messages, showThinkingTraces],
367
422
  );
423
+ const latestUserIndex = entries.findLastIndex((entry) => entry.type === "user");
424
+ const activeTurnEntry = isActive
425
+ ? entries.findLast(
426
+ (entry, index): entry is Extract<CloudOsEntry, { type: "assistant" }> =>
427
+ index > latestUserIndex && entry.type === "assistant",
428
+ )
429
+ : undefined;
430
+ const currentPlan = activeTurnEntry?.blocks.findLast(
431
+ (block): block is Extract<AssistantBlock, { kind: "plan" }> =>
432
+ block.kind === "plan",
433
+ );
434
+ const completedPlanSteps = currentPlan?.steps.filter(
435
+ (step) => step.status === "done",
436
+ ).length ?? 0;
368
437
 
369
438
  const entryTopClasses = useMemo(
370
439
  () =>
@@ -383,6 +452,15 @@ export function CloudOsChatMessages({
383
452
  });
384
453
  }, []);
385
454
 
455
+ const setTurnWorkOpen = useCallback((key: string, open: boolean) => {
456
+ setExpandedTurnWork((current) => {
457
+ const next = new Set(current);
458
+ if (open) next.add(key);
459
+ else next.delete(key);
460
+ return next;
461
+ });
462
+ }, []);
463
+
386
464
  const toggleCompaction = useCallback((key: string) => {
387
465
  setExpandedCompactions((current) => {
388
466
  const next = new Set(current);
@@ -409,6 +487,8 @@ export function CloudOsChatMessages({
409
487
  const activity = deriveTurnActivity(entries, {
410
488
  isActive,
411
489
  isRecovering,
490
+ recoveryAttempt,
491
+ recoveryMax,
412
492
  recoveryStatusLabel,
413
493
  awaitingApproval,
414
494
  hasPendingSteer,
@@ -425,14 +505,15 @@ export function CloudOsChatMessages({
425
505
  data-status={status}
426
506
  aria-busy={isActive || isRecovering}
427
507
  >
428
- <div
429
- ref={scrollRef}
430
- onScroll={syncScrollButton}
431
- tabIndex={0}
432
- role="region"
433
- aria-label="Chat messages"
434
- className="cos-chat-panel flex-1 overflow-y-auto"
435
- >
508
+ <div className="relative flex min-h-0 flex-1">
509
+ <div
510
+ ref={scrollRef}
511
+ onScroll={syncScrollButton}
512
+ tabIndex={0}
513
+ role="region"
514
+ aria-label="Chat messages"
515
+ className="cos-chat-panel flex-1 overflow-y-auto"
516
+ >
436
517
  <div
437
518
  className={`flex flex-col px-6 pb-8 pt-8 ${
438
519
  constrainWidth ? "mx-auto w-full max-w-[920px]" : ""
@@ -449,7 +530,11 @@ export function CloudOsChatMessages({
449
530
 
450
531
  if (entry.type === "user") {
451
532
  return (
452
- <div key={entry.key} className={topClass}>
533
+ <div
534
+ key={entry.key}
535
+ data-user-message-entry=""
536
+ className={topClass}
537
+ >
453
538
  <UserBubble
454
539
  entry={entry}
455
540
  onCopy={copyText}
@@ -545,9 +630,34 @@ export function CloudOsChatMessages({
545
630
  );
546
631
  }
547
632
 
548
- const hasNarrative = entry.blocks.some(
549
- (block) => block.kind === "text",
633
+ const entryIsActive =
634
+ isActive && entryIndex === entries.length - 1;
635
+ const finalTextBlocks = entryIsActive
636
+ ? []
637
+ : entry.blocks.filter(
638
+ (block): block is Extract<AssistantBlock, { kind: "text" }> =>
639
+ block.kind === "text" && block.final,
640
+ );
641
+ const finalActionBlocks = entryIsActive
642
+ ? []
643
+ : entry.blocks.filter(
644
+ (block): block is Extract<AssistantBlock, { kind: "actionPresentation" }> =>
645
+ block.kind === "actionPresentation" &&
646
+ block.presentation.state === "ready",
647
+ );
648
+ const finalMediaBlocks = entry.blocks.filter(
649
+ (block): block is Extract<AssistantBlock, { kind: "image" | "file" }> =>
650
+ block.kind === "image" || block.kind === "file",
651
+ );
652
+ const workBlocks = entry.blocks.filter(
653
+ (block) =>
654
+ block.kind !== "suggestions" &&
655
+ block.kind !== "image" &&
656
+ block.kind !== "file" &&
657
+ !(block.kind === "text" && block.final && !entryIsActive) &&
658
+ !(block.kind === "actionPresentation" && block.presentation.state === "ready"),
550
659
  );
660
+ const hasNarrative = finalTextBlocks.length > 0;
551
661
  const insufficientFailures = entry.blocks.filter(
552
662
  (block) => block.kind === "actionPresentation" &&
553
663
  block.presentation.state === "failed" &&
@@ -558,14 +668,33 @@ export function CloudOsChatMessages({
558
668
  (block) => block.kind === "actionPresentation" &&
559
669
  block.presentation.state === "ready",
560
670
  );
671
+ const followUpBlocks = entry.blocks.filter(
672
+ (block) => block.kind === "suggestions",
673
+ );
674
+ const footerTimestamp =
675
+ hasNarrative &&
676
+ entry.timestamp !== undefined &&
677
+ !entryIsActive &&
678
+ !entry.blocks.some(
679
+ (block) => block.kind === "reasoning" && block.running,
680
+ )
681
+ ? entry.timestamp
682
+ : undefined;
561
683
 
562
684
  return (
563
685
  <div
564
686
  key={entry.key}
565
- className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
687
+ className={`${topClass} group/agentMessage min-w-0 w-full max-w-[860px] space-y-2`}
566
688
  >
567
- <div className="group/agentMessage relative space-y-1.5">
568
- {entry.blocks.map((block) => {
689
+ {workBlocks.length > 0 && (
690
+ <TurnWorkDisclosure
691
+ running={entryIsActive}
692
+ open={entryIsActive || expandedTurnWork.has(entry.key)}
693
+ onOpenChange={(open) => {
694
+ if (!entryIsActive) setTurnWorkOpen(entry.key, open);
695
+ }}
696
+ >
697
+ {workBlocks.map((block) => {
569
698
  switch (block.kind) {
570
699
  case "reasoning":
571
700
  return (
@@ -577,13 +706,11 @@ export function CloudOsChatMessages({
577
706
  );
578
707
  case "text": {
579
708
  const streaming =
580
- isActive &&
581
- entryIndex === entries.length - 1 &&
582
- block === entry.blocks.at(-1);
709
+ entryIsActive && block === entry.blocks.at(-1);
583
710
  return (
584
711
  <div
585
712
  key={block.key}
586
- className="cos-markdown text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
713
+ className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default"
587
714
  >
588
715
  <MarkdownMessage
589
716
  message={block.text}
@@ -606,6 +733,7 @@ export function CloudOsChatMessages({
606
733
  />
607
734
  );
608
735
  case "plan":
736
+ if (currentPlan?.key === block.key) return null;
609
737
  return (
610
738
  <PlanBlock
611
739
  key={block.key}
@@ -622,13 +750,7 @@ export function CloudOsChatMessages({
622
750
  />
623
751
  );
624
752
  case "suggestions":
625
- return (
626
- <SuggestionsBlock
627
- key={block.key}
628
- items={block.items}
629
- onSuggestion={(text) => onSuggestion?.(text)}
630
- />
631
- );
753
+ return null;
632
754
  case "schedule":
633
755
  return (
634
756
  <ScheduleBlock
@@ -671,39 +793,6 @@ export function CloudOsChatMessages({
671
793
  return (
672
794
  <SubAgentsBlock key={block.key} agents={block.agents} />
673
795
  );
674
- case "image":
675
- {
676
- const url = resolveUrl?.(block.src) ?? block.src;
677
- const artifactId = resolveArtifactId?.(url);
678
- return (
679
- <Fragment key={block.key}>
680
- {renderFileView(renderFile, {
681
- file: {
682
- url,
683
- filename: block.alt,
684
- mediaType: block.mediaType ?? "image/*",
685
- },
686
- placement: "assistant-message",
687
- onOpen: artifactId && onOpenArtifact
688
- ? () => onOpenArtifact(artifactId)
689
- : undefined,
690
- })}
691
- </Fragment>
692
- );
693
- }
694
- case "file":
695
- return (
696
- <Fragment key={block.key}>
697
- {renderFileView(renderFile, {
698
- file: {
699
- ...block.file,
700
- url:
701
- resolveUrl?.(block.file.url) ?? block.file.url,
702
- },
703
- placement: "assistant-message",
704
- })}
705
- </Fragment>
706
- );
707
796
  case "actionPresentation":
708
797
  if (
709
798
  block.presentation.failureCode === "insufficient_credits" &&
@@ -731,8 +820,67 @@ export function CloudOsChatMessages({
731
820
  );
732
821
  }
733
822
  })}
823
+ </TurnWorkDisclosure>
824
+ )}
825
+
826
+ {(finalMediaBlocks.length > 0 || finalActionBlocks.length > 0 || finalTextBlocks.length > 0) && (
827
+ <div data-final-message="" className="space-y-4">
828
+ {finalMediaBlocks.map((block) => {
829
+ if (block.kind === "image") {
830
+ const url = resolveUrl?.(block.src) ?? block.src;
831
+ const artifactId = resolveArtifactId?.(url);
832
+ return (
833
+ <Fragment key={block.key}>
834
+ {renderFileView(renderFile, {
835
+ file: {
836
+ url,
837
+ filename: block.alt,
838
+ mediaType: block.mediaType ?? "image/*",
839
+ },
840
+ placement: "assistant-message",
841
+ onOpen: artifactId && onOpenArtifact
842
+ ? () => onOpenArtifact(artifactId)
843
+ : undefined,
844
+ })}
845
+ </Fragment>
846
+ );
847
+ }
848
+ return (
849
+ <Fragment key={block.key}>
850
+ {renderFileView(renderFile, {
851
+ file: {
852
+ ...block.file,
853
+ url: resolveUrl?.(block.file.url) ?? block.file.url,
854
+ },
855
+ placement: "assistant-message",
856
+ })}
857
+ </Fragment>
858
+ );
859
+ })}
860
+ {finalActionBlocks.map((block) => (
861
+ <ActionPresentationBlock
862
+ key={block.key}
863
+ presentation={block.presentation}
864
+ renderFile={renderFile}
865
+ resolveUrl={resolveUrl}
866
+ resolveArtifactId={resolveArtifactId}
867
+ onOpenArtifact={onOpenArtifact}
868
+ />
869
+ ))}
870
+ {finalTextBlocks.map((block) => (
871
+ <div key={block.key} className="cos-markdown px-1.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
872
+ <MarkdownMessage
873
+ message={block.text}
874
+ resolveUrl={resolveUrl}
875
+ resolveArtifactId={resolveArtifactId}
876
+ onOpenArtifact={onOpenArtifact}
877
+ />
878
+ </div>
879
+ ))}
880
+ </div>
881
+ )}
734
882
 
735
- {entry.terminal?.kind === "error" && (
883
+ {entry.terminal?.kind === "error" && (
736
884
  <ErrorBlock
737
885
  title={entry.terminal.title}
738
886
  body={entry.terminal.body}
@@ -740,7 +888,7 @@ export function CloudOsChatMessages({
740
888
  />
741
889
  )}
742
890
 
743
- {entry.terminal?.kind === "interrupted" && (
891
+ {entry.terminal?.kind === "interrupted" && (
744
892
  <div
745
893
  className="px-1.5 py-1 text-[13px] leading-[19px] text-kumo-inactive"
746
894
  role="status"
@@ -749,8 +897,11 @@ export function CloudOsChatMessages({
749
897
  </div>
750
898
  )}
751
899
 
752
- {hasNarrative && entry.timestamp !== undefined && (
753
- <div className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100">
900
+ {footerTimestamp !== undefined && (
901
+ <div
902
+ data-agent-message-footer=""
903
+ className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100"
904
+ >
754
905
  <Tooltip
755
906
  content={copied === entry.copyText ? "Copied!" : "Copy message"}
756
907
  asChild
@@ -768,14 +919,21 @@ export function CloudOsChatMessages({
768
919
  )}
769
920
  </button>
770
921
  </Tooltip>
771
- <Tooltip content={formatFullTimestamp(entry.timestamp)} asChild>
922
+ <Tooltip content={formatFullTimestamp(footerTimestamp)} asChild>
772
923
  <span className="px-1 font-mono text-[11px] leading-4 text-kumo-inactive">
773
- {formatClockTime(entry.timestamp)}
924
+ {formatClockTime(footerTimestamp)}
774
925
  </span>
775
926
  </Tooltip>
776
927
  </div>
777
928
  )}
778
- </div>
929
+
930
+ {followUpBlocks.map((block) => (
931
+ <SuggestionsBlock
932
+ key={block.key}
933
+ items={block.items}
934
+ onSuggestion={(text) => onSuggestion?.(text)}
935
+ />
936
+ ))}
779
937
  </div>
780
938
  );
781
939
  })}
@@ -804,19 +962,46 @@ export function CloudOsChatMessages({
804
962
 
805
963
  <div ref={endRef} />
806
964
  </div>
965
+ </div>
966
+
967
+ <WorkshopIconButton
968
+ aria-label="Scroll to bottom"
969
+ onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
970
+ className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
971
+ showScrollButton
972
+ ? "translate-y-0 opacity-100"
973
+ : "pointer-events-none translate-y-2 opacity-0"
974
+ }`}
975
+ >
976
+ <ArrowDown size={15} />
977
+ </WorkshopIconButton>
807
978
  </div>
808
979
 
809
- <WorkshopIconButton
810
- aria-label="Scroll to bottom"
811
- onClick={() => endRef.current?.scrollIntoView({ behavior: "smooth" })}
812
- className={`themed-floating-shadow absolute bottom-3 left-1/2 z-20 -translate-x-1/2 !rounded-full border border-kumo-line bg-kumo-base transition-all duration-200 ${
813
- showScrollButton
814
- ? "translate-y-0 opacity-100"
815
- : "pointer-events-none translate-y-2 opacity-0"
816
- }`}
817
- >
818
- <ArrowDown size={15} />
819
- </WorkshopIconButton>
980
+ <AnimatePresence initial={false}>
981
+ {currentPlan && completedPlanSteps < currentPlan.steps.length && (
982
+ <motion.div
983
+ key="task-progress"
984
+ initial={reducedMotion ? { opacity: 1 } : { opacity: 0, y: 8, scale: 0.98 }}
985
+ animate={{ opacity: 1, y: 0, scale: 1 }}
986
+ exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
987
+ transition={{ duration: reducedMotion ? 0 : 0.2, ease: [0.32, 0.72, 0, 1] }}
988
+ className="relative z-10 mx-auto w-full max-w-3xl shrink-0 px-4"
989
+ >
990
+ <section
991
+ data-cloud-os-plan-progress=""
992
+ aria-label="Task progress"
993
+ className="mx-auto border border-b-0 border-kumo-line bg-kumo-base px-4 py-2.5"
994
+ style={{ width: "90%", borderRadius: "1rem 1rem 0 0" }}
995
+ >
996
+ <PlanBlock
997
+ steps={currentPlan.steps}
998
+ running={isActive}
999
+ defaultOpen={false}
1000
+ />
1001
+ </section>
1002
+ </motion.div>
1003
+ )}
1004
+ </AnimatePresence>
820
1005
 
821
1006
  </div>
822
1007
  );
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { Loader2Icon, PlayIcon } from "lucide-react";
5
+ import { cn } from "../internal/cn";
6
+ import { codeScroll, codeSurface, ghostButton, mono, paper } from "./surfaces";
7
+
8
+ export type RunState = "idle" | "running" | "ok" | "error";
9
+
10
+ export function CodeRunner({
11
+ language,
12
+ code,
13
+ state,
14
+ output,
15
+ durationMs,
16
+ onRun,
17
+ className,
18
+ ...props
19
+ }: Omit<
20
+ ComponentProps<"div">,
21
+ "children" | "language" | "code" | "state" | "output" | "durationMs" | "onRun"
22
+ > & {
23
+ language: string;
24
+ code: string;
25
+ state: RunState;
26
+ output: readonly string[];
27
+ durationMs?: number;
28
+ onRun?: () => void;
29
+ }) {
30
+ return (
31
+ <div
32
+ data-slot="code-runner"
33
+ className={cn(
34
+ paper,
35
+ "flex w-full max-w-md flex-col overflow-hidden rounded-2xl",
36
+ className,
37
+ )}
38
+ {...props}
39
+ >
40
+ <div className="flex items-center gap-2 px-3.5 py-2">
41
+ <span className={cn(mono, "text-foreground/35 min-w-0 flex-1")}>
42
+ {language}
43
+ </span>
44
+ {durationMs !== undefined && state !== "running" && (
45
+ <span
46
+ className={cn(mono, "text-foreground/30 shrink-0 tabular-nums")}
47
+ >
48
+ {durationMs}ms
49
+ </span>
50
+ )}
51
+ {onRun && (
52
+ <button
53
+ type="button"
54
+ aria-label="Run this snippet"
55
+ onClick={onRun}
56
+ disabled={state === "running"}
57
+ className={cn(
58
+ ghostButton,
59
+ "size-7 shrink-0 disabled:pointer-events-none",
60
+ )}
61
+ >
62
+ {state === "running" ? (
63
+ <Loader2Icon className="size-3.5 animate-spin motion-reduce:animate-none" />
64
+ ) : (
65
+ <PlayIcon className="size-3.5 translate-x-px" />
66
+ )}
67
+ </button>
68
+ )}
69
+ </div>
70
+
71
+ <pre className={cn(codeScroll, "border-foreground/[0.07] border-t px-3.5 py-2.5 font-mono text-xs leading-relaxed")}>
72
+ <code className="text-foreground/75">{code}</code>
73
+ </pre>
74
+
75
+ {state !== "idle" && (
76
+ <div className="border-foreground/[0.07] fade-in animate-in flex flex-col border-t px-3.5 py-2.5 duration-300">
77
+ <span className={cn(mono, "text-foreground/30 pb-1")}>output</span>
78
+ <div className={codeScroll}>
79
+ <div className={cn(codeSurface, "flex flex-col")}>
80
+ {output.map((line, index) => (
81
+ <span
82
+ key={`${index}-${line}`}
83
+ className={cn(
84
+ "fade-in animate-in fill-mode-both font-mono text-xs leading-relaxed whitespace-pre",
85
+ state === "error"
86
+ ? "text-red-600 dark:text-red-400"
87
+ : "text-foreground/70",
88
+ )}
89
+ style={{ animationDelay: `${index * 80}ms` }}
90
+ >
91
+ {line}
92
+ </span>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ {state === "running" && (
97
+ <span
98
+ aria-hidden
99
+ className="bg-foreground/40 h-[1em] w-[2px] animate-pulse rounded-full motion-reduce:animate-none"
100
+ />
101
+ )}
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,8 @@
1
+ export function clamp(value: number, min: number, max: number) {
2
+ if (Number.isNaN(value)) return min;
3
+ return Math.min(max, Math.max(min, value));
4
+ }
5
+
6
+ export function take<T>(items: readonly T[], count: number) {
7
+ return items.slice(0, Math.floor(clamp(count, 0, items.length)));
8
+ }