@springbrand/message-panel 0.2.0-alpha.56 → 0.3.0-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.
@@ -1,3 +1,4 @@
1
+ import type { ScheduleCardControls } from "./rich-blocks";
1
2
  import type { UIMessage } from "ai";
2
3
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
3
4
  import {
@@ -72,8 +73,6 @@ export interface CloudOsChatMessagesProps {
72
73
  /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
73
74
  turnActive?: boolean;
74
75
  isRecovering?: boolean;
75
- recoveryAttempt?: number;
76
- recoveryMax?: number;
77
76
  recoveryStatusLabel?: string;
78
77
  awaitingApproval?: boolean;
79
78
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
@@ -119,6 +118,7 @@ export interface CloudOsChatMessagesProps {
119
118
  */
120
119
  onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
121
120
  /** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
121
+ scheduleControls?: ScheduleCardControls;
122
122
  onOpenSchedule?: (scheduleId: string) => void;
123
123
  onCopy?: (text: string) => void;
124
124
  }
@@ -375,8 +375,6 @@ export function CloudOsChatMessages({
375
375
  status,
376
376
  turnActive,
377
377
  isRecovering = false,
378
- recoveryAttempt,
379
- recoveryMax,
380
378
  recoveryStatusLabel,
381
379
  awaitingApproval = false,
382
380
  hasPendingSteer = false,
@@ -397,6 +395,7 @@ export function CloudOsChatMessages({
397
395
  onSuggestion,
398
396
  onRespond,
399
397
  onOpenSchedule,
398
+ scheduleControls,
400
399
  onOpenArtifact,
401
400
  onCopy,
402
401
  }: CloudOsChatMessagesProps) {
@@ -550,8 +549,6 @@ export function CloudOsChatMessages({
550
549
  const activity = deriveTurnActivity(entries, {
551
550
  isActive,
552
551
  isRecovering,
553
- recoveryAttempt,
554
- recoveryMax,
555
552
  recoveryStatusLabel,
556
553
  awaitingApproval,
557
554
  hasPendingSteer,
@@ -722,8 +719,7 @@ export function CloudOsChatMessages({
722
719
  );
723
720
  const actionResultBlocks = entry.blocks.filter(
724
721
  (block): block is Extract<AssistantBlock, { kind: "actionPresentation" }> =>
725
- block.kind === "actionPresentation" &&
726
- !(block.presentation.state === "ready" && block.presentation.content.length === 0),
722
+ block.kind === "actionPresentation",
727
723
  );
728
724
  const finalMediaBlocks = entry.blocks.filter(
729
725
  (block): block is Extract<AssistantBlock, { kind: "image" | "file" }> =>
@@ -847,6 +843,7 @@ export function CloudOsChatMessages({
847
843
  key={block.key}
848
844
  call={block.call}
849
845
  onOpen={onOpenSchedule}
846
+ controls={scheduleControls}
850
847
  />
851
848
  );
852
849
  case "approval": {
@@ -4,7 +4,7 @@ import {
4
4
  useSmooth,
5
5
  type SmoothOptions,
6
6
  } from "@assistant-ui/react";
7
- import { memo, useMemo, type ReactNode } from "react";
7
+ import { memo, useMemo, useRef, type ReactNode } from "react";
8
8
  import {
9
9
  defaultRehypePlugins,
10
10
  Streamdown,
@@ -135,8 +135,13 @@ function MarkdownMessageContent({
135
135
  resolveArtifactId?: CloudOsArtifactResolver;
136
136
  onOpenArtifact?: (artifactId: string) => void;
137
137
  }) {
138
- const part = useSmooth(useMessagePartText(), SMOOTH_STREAMING);
139
- const streaming = part.status.type === "running";
138
+ const received = useMessagePartText();
139
+ const initialText = useRef(received.text);
140
+ // A mounted snapshot is already visible history; only later deltas need a reveal.
141
+ const part = useSmooth(received.text === initialText.current
142
+ ? { ...received, status: { type: "complete" } }
143
+ : received, SMOOTH_STREAMING);
144
+ const streaming = received.status.type === "running" || part.status.type === "running";
140
145
  const rehypePlugins = useMemo(
141
146
  () =>
142
147
  resolveUrl
@@ -152,7 +152,7 @@ interface AskQuestion {
152
152
 
153
153
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
154
154
  if (!Array.isArray(input.questions)) return [];
155
- return input.questions.map((item) => {
155
+ return input.questions.flatMap((item) => {
156
156
  const question = (item ?? {}) as Record<string, unknown>;
157
157
  const options = Array.isArray(question.options)
158
158
  ? question.options.filter((option): option is string =>
@@ -164,14 +164,9 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
164
164
  question.responseType === "multi_select" ||
165
165
  question.responseType === "attachment"
166
166
  ? question.responseType
167
- : question.kind === "attachment"
168
- ? "attachment"
169
- : options.length === 0
170
- ? "text"
171
- : question.multiSelect === true
172
- ? "multi_select"
173
- : "single_select";
174
- return {
167
+ : undefined;
168
+ if (!responseType) return [];
169
+ return [{
175
170
  prompt: typeof question.question === "string"
176
171
  ? question.question
177
172
  : "Choose an option",
@@ -179,7 +174,7 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
179
174
  allowCustom: responseType === "text" || question.allowCustom === true,
180
175
  required: question.required === true,
181
176
  responseType,
182
- };
177
+ }];
183
178
  });
184
179
  }
185
180
 
@@ -713,6 +708,14 @@ function scheduleCadence(input: Record<string, unknown>): string {
713
708
  } else if (trigger.kind === "cron" && typeof trigger.cron === "string") {
714
709
  cadence = `Cron ${trigger.cron}`;
715
710
  }
711
+ if (trigger.kind === "local" && typeof trigger.rule === "object" && trigger.rule) {
712
+ const rule = trigger.rule as Record<string, unknown>;
713
+ cadence = rule.kind === "once" ? `Once ${rule.date} at ${rule.time}`
714
+ : rule.kind === "weekly" ? `Weekly (${Array.isArray(rule.weekdays) ? rule.weekdays.map(day => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][Number(day)]).join(", ") : ""}) at ${rule.time}`
715
+ : `Daily at ${rule.time}`;
716
+ if (trigger.startDate) cadence += ` from ${trigger.startDate}`;
717
+ if (trigger.endDate) cadence += ` through ${trigger.endDate}`;
718
+ }
716
719
  if (!cadence) cadence = String(input.cadence ?? input.schedule ?? "Scheduled");
717
720
  const timezone =
718
721
  typeof input.timezone === "string"
@@ -741,9 +744,11 @@ export function ScheduleConfirmation({
741
744
  onCancel: () => void;
742
745
  onConfirm: () => void;
743
746
  }) {
747
+ const authorization = input.authorization as { scope?: string; missing?: string[] } | undefined;
748
+ const missing = authorization?.missing ?? [];
744
749
  const name = String(input.label ?? input.title ?? "Scheduled task");
745
750
  const prompt = typeof input.prompt === "string" && input.prompt.trim()
746
- ? input.prompt.trim()
751
+ ? input.prompt
747
752
  : "Run the scheduled task";
748
753
 
749
754
  return (
@@ -760,10 +765,10 @@ export function ScheduleConfirmation({
760
765
  <Clock size={14} />
761
766
  </span>
762
767
  <div className="flex min-w-0 flex-1 flex-col">
763
- <span className="truncate text-[13.5px] leading-[18px] font-medium">
768
+ <span className="break-words text-[13.5px] leading-[18px] font-medium">
764
769
  {name}
765
770
  </span>
766
- <span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
771
+ <span className="break-words font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
767
772
  {scheduleCadence(input)}
768
773
  </span>
769
774
  </div>
@@ -773,16 +778,20 @@ export function ScheduleConfirmation({
773
778
  <span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
774
779
  task
775
780
  </span>
776
- <span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
781
+ <span className="min-w-0 flex-1 whitespace-pre-wrap text-[13px] leading-[18px] text-kumo-subtle">
777
782
  {prompt}
778
783
  </span>
779
784
  </div>
780
785
 
786
+ {input.id != null && <p className="m-0 text-xs">Task ID: {String(input.id)}</p>}
787
+ {typeof input.nextRunAt === "number" && <p className="m-0 text-xs">Next run: {new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short", timeZone: String(input.timezone) }).format(input.nextRunAt)}</p>}
788
+ {authorization?.scope && <div className="text-sm"><strong>Ongoing execution scope</strong><p className="m-0 whitespace-pre-wrap">{authorization.scope}</p></div>}
789
+ {missing.length > 0 && <div role="alert" className="text-sm text-kumo-danger">{missing.map((item, i) => <p key={i}>{item}</p>)}</div>}
781
790
  <div className="flex items-center justify-end gap-2">
782
791
  <WorkshopButton disabled={disabled} onClick={onCancel}>
783
792
  Cancel
784
793
  </WorkshopButton>
785
- <WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
794
+ <WorkshopButton tone="primary" disabled={disabled || missing.length > 0} onClick={onConfirm}>
786
795
  {disabled ? "Confirming…" : "Confirm schedule"}
787
796
  </WorkshopButton>
788
797
  </div>
@@ -790,81 +799,76 @@ export function ScheduleConfirmation({
790
799
  );
791
800
  }
792
801
 
793
- export function ScheduleBlock({
794
- call,
795
- onOpen,
796
- }: {
802
+ /** Current task facts are supplied by the Host, never inferred from a past Tool result. */
803
+ export interface ScheduleCardState {
804
+ id: string;
805
+ enabled: boolean;
806
+ status: string;
807
+ deleted: boolean;
808
+ canToggle?: boolean;
809
+ }
810
+ export interface ScheduleCardControls {
811
+ load(id: string): Promise<ScheduleCardState>;
812
+ setEnabled(id: string, enabled: boolean): Promise<ScheduleCardState>;
813
+ open(id: string): void;
814
+ }
815
+
816
+ export function ScheduleBlock({ call, onOpen, controls }: {
797
817
  call: CloudOsToolCall;
798
- /** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
799
818
  onOpen?: (scheduleId: string) => void;
819
+ controls?: ScheduleCardControls;
800
820
  }) {
801
- // 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
821
+ const scheduleId = !call.failed && !call.running ? scheduleIdOf(call.output) : "";
822
+ const [current, setCurrent] = useState<ScheduleCardState>();
823
+ const [busy, setBusy] = useState(false);
824
+ const [error, setError] = useState("");
825
+ const generation = useRef(0);
826
+ useEffect(() => {
827
+ const token = ++generation.current;
828
+ setCurrent(undefined); setError(""); setBusy(false);
829
+ if (scheduleId && controls) {
830
+ controls.load(scheduleId).then(value => {
831
+ if (token !== generation.current) return;
832
+ if (value.id !== scheduleId) throw new Error("Host returned a different task");
833
+ setCurrent(value);
834
+ }).catch(cause => { if (token === generation.current) setError(String(cause instanceof Error ? cause.message : cause)); });
835
+ }
836
+ return () => { generation.current++; };
837
+ }, [scheduleId, controls]);
802
838
  if (call.awaitingApproval) return null;
803
- const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
804
- const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
805
- const clickable = Boolean(scheduleId && onOpen);
806
- const Card = clickable ? "button" : "div";
807
- return (
808
- <div className="max-w-[860px]">
809
- <Card
810
- {...(clickable
811
- ? {
812
- type: "button" as const,
813
- onClick: () => onOpen?.(scheduleId),
814
- "aria-label": `Open scheduled task: ${String(call.input.label ?? call.input.title ?? "Scheduled task")}`,
815
- }
816
- : {})}
817
- className={`w-full rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3 text-left${
818
- clickable
819
- ? " cursor-pointer transition-colors duration-150 ease-out hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/50"
820
- : ""
821
- }`}
822
- >
823
- <div className="flex flex-wrap items-start gap-3">
824
- <span
825
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-subtle"
826
- aria-hidden="true"
827
- >
828
- <Bell size={18} />
829
- </span>
830
- <div className="min-w-[10rem] flex-1">
831
- <span className="block truncate text-[14px] leading-5 font-medium tracking-[-0.25px] text-kumo-default">
832
- {String(call.input.label ?? call.input.title ?? "Scheduled task")}
833
- </span>
834
- <p className="mt-1 mb-0 text-[12px] leading-4 text-kumo-inactive">
835
- {scheduleCadence(call.input)}
836
- </p>
837
- </div>
838
- <span
839
- className={`ml-auto flex flex-shrink-0 items-center gap-1 self-center text-[12px] leading-4 font-medium ${
840
- state === "failed"
841
- ? "text-kumo-danger"
842
- : state === "running"
843
- ? "text-kumo-brand"
844
- : "text-kumo-success"
845
- }`}
846
- >
847
- {state === "failed" ? (
848
- <WarningCircle size={13} weight="fill" />
849
- ) : state === "running" ? (
850
- <CircleNotch
851
- size={13}
852
- weight="bold"
853
- className="animate-spin motion-reduce:animate-none"
854
- />
855
- ) : (
856
- <Check size={13} weight="bold" />
857
- )}
858
- {state === "failed"
859
- ? "Failed"
860
- : state === "running"
861
- ? "Scheduling…"
862
- : "Scheduled"}
863
- </span>
864
- </div>
865
- </Card>
866
- </div>
867
- );
839
+ const saved = (call.output as { task?: Record<string, unknown> } | undefined)?.task;
840
+ const task = saved ?? call.input;
841
+ const name = String(task.label ?? task.title ?? "Scheduled task");
842
+ const open = controls ? (current && !current.deleted ? controls.open : undefined) : onOpen;
843
+ const clickable = Boolean(scheduleId && open);
844
+ const Card = clickable && !controls ? "button" : "div";
845
+ const toggle = async () => {
846
+ if (!controls || !current || current.deleted || current.canToggle === false || busy) return;
847
+ const token = generation.current;
848
+ setBusy(true); setError("");
849
+ try {
850
+ const value = await controls.setEnabled(scheduleId, !current.enabled);
851
+ if (value.id !== scheduleId) throw new Error("Host returned a different task");
852
+ if (token === generation.current) setCurrent(value);
853
+ } catch (cause) {
854
+ if (token === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
855
+ } finally { if (token === generation.current) setBusy(false); }
856
+ };
857
+ return <div className="max-w-[860px]">
858
+ <Card className="w-full rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3 text-left"
859
+ {...(clickable && !controls ? { type: "button" as const, onClick: () => open?.(scheduleId), "aria-label": `Open scheduled task: ${name}` } : {})}>
860
+ <div className="flex items-center gap-3"><Bell size={18} /><span className="min-w-0 flex-1 break-words text-sm font-medium">{name}</span>
861
+ <span className="text-xs">{call.failed ? "Failed" : call.running ? "Scheduling…" : "Scheduled"}</span>
862
+ </div>
863
+ <p className="my-1 text-xs text-kumo-inactive">{scheduleCadence(task)}</p>
864
+ {controls && scheduleId && <div className="flex items-center gap-3 text-sm">
865
+ <span>{current?.status ?? (error ? "Status unavailable" : "Loading…")}</span>
866
+ {current && !current.deleted && <button type="button" role="switch" aria-label="Enable scheduled task" aria-checked={current.enabled} disabled={busy || current.canToggle === false} onClick={() => void toggle()}>{current.enabled ? "On" : "Off"}</button>}
867
+ {clickable && <button type="button" aria-label={`Open scheduled task: ${name}`} onClick={() => open?.(scheduleId)}>Scheduled</button>}
868
+ </div>}
869
+ {error && <p role="alert" className="text-xs text-kumo-danger">{error}</p>}
870
+ </Card>
871
+ </div>;
868
872
  }
869
873
 
870
874
  // ── 原位授权 ──────────────────────────────────────────────
@@ -241,7 +241,7 @@ export function canonicalToolKind(name: string): CloudOsToolKind {
241
241
  return "ask-user";
242
242
  }
243
243
  if (["suggest_followups", "suggestions"].includes(normalized)) return "suggestions";
244
- if (["schedule", "cancel_schedule"].includes(normalized)) return "schedule";
244
+ if (["schedule", "update_schedule", "pause_schedule", "resume_schedule", "cancel_schedule", "list_schedules", "change_schedule_agent"].includes(normalized)) return "schedule";
245
245
  if (normalized === "create_agent") return "create-agent";
246
246
  if (normalized === "publish_extension") return "publish-extension";
247
247
  return "generic";
@@ -353,7 +353,7 @@ export function getToolCallSummary(call: CloudOsToolCall): {
353
353
  case "schedule": {
354
354
  const label = stringField(input, "label", "title");
355
355
  return {
356
- verb: toolName === "cancel_schedule" ? "Cancelled schedule" : "Scheduled",
356
+ verb: toolName === "cancel_schedule" ? "Cancelled schedule" : toolName === "update_schedule" ? "Updated schedule" : toolName === "pause_schedule" ? "Paused schedule" : toolName === "resume_schedule" ? "Resumed schedule" : toolName === "list_schedules" ? "Listed schedules" : toolName === "change_schedule_agent" ? "Changed schedule Agent" : "Scheduled",
357
357
  target: label || undefined,
358
358
  };
359
359
  }
@@ -594,7 +594,7 @@ function assistantBlocks(
594
594
  }
595
595
  }
596
596
 
597
- if (call.kind === "schedule" && call.toolName === "schedule") {
597
+ if (call.kind === "schedule" && (call.toolName === "schedule" || call.toolName === "update_schedule")) {
598
598
  flush();
599
599
  blocks.push({ kind: "schedule", key, call });
600
600
  return;
@@ -973,8 +973,6 @@ export function deriveTurnActivity(
973
973
  options: {
974
974
  isActive: boolean;
975
975
  isRecovering?: boolean;
976
- recoveryAttempt?: number;
977
- recoveryMax?: number;
978
976
  recoveryStatusLabel?: string;
979
977
  awaitingApproval?: boolean;
980
978
  hasPendingSteer?: boolean;
@@ -983,10 +981,7 @@ export function deriveTurnActivity(
983
981
  if (options.isRecovering) {
984
982
  return {
985
983
  state: "connecting",
986
- label: options.recoveryStatusLabel ??
987
- (options.recoveryAttempt === undefined || options.recoveryMax === undefined
988
- ? "Recovering model response…"
989
- : `Recovering model response ${options.recoveryAttempt}/${options.recoveryMax}…`),
984
+ label: options.recoveryStatusLabel ?? "Recovering model response…",
990
985
  };
991
986
  }
992
987
  if (!options.isActive) return null;
package/cloud-os/index.ts CHANGED
@@ -149,3 +149,5 @@ export {
149
149
  WorkshopInput,
150
150
  } from "./primitives/workshop-controls";
151
151
  export type { CloudOsMode } from "./internal/theme-context";
152
+
153
+ export type { ScheduleCardState, ScheduleCardControls } from "./chat/rich-blocks";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.2.0-alpha.56",
3
+ "version": "0.3.0-alpha.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -0,0 +1 @@
1
+ {"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":55.58770800000002,"failed":false}],[":bot/skins.test.ts",{"duration":3032.4465840000003,"failed":false}],[":bot/engine.test.ts",{"duration":100.501834,"failed":false}],[":bot/shape.test.ts",{"duration":16.04204200000001,"failed":false}],[":bot/expressions.test.ts",{"duration":8.212458999999996,"failed":false}],[":bot/face.test.ts",{"duration":3.6211250000000064,"failed":false}],[":drag.test.ts",{"duration":1.7712499999999807,"failed":false}],[":animation.test.ts",{"duration":2.3806249999999807,"failed":false}]]}
@@ -673,11 +673,7 @@ function askUserResponseType(question: Record<string, unknown>) {
673
673
  question.responseType === "multi_select" ||
674
674
  question.responseType === "attachment"
675
675
  ) return question.responseType;
676
- if (question.kind === "attachment") return "attachment";
677
- if (!Array.isArray(question.options) || question.options.length === 0) {
678
- return "text";
679
- }
680
- return question.multiSelect === true ? "multi_select" : "single_select";
676
+ return undefined;
681
677
  }
682
678
 
683
679
  function CamelAskUser({
@@ -544,6 +544,9 @@ function summaryFor(
544
544
  ? `Could not publish ${extension}`
545
545
  : `Published ${extension}`;
546
546
  }
547
+ if (name === "schedule" || name === "update_schedule") {
548
+ return running ? "Preparing schedule..." : failed ? "Schedule was not saved" : name === "schedule" ? "Scheduled task" : "Updated schedule";
549
+ }
547
550
  if (name === "cancel_schedule") {
548
551
  return running
549
552
  ? "Cancelling schedule..."
@@ -840,17 +840,6 @@ export interface AskQuestion {
840
840
  required?: boolean;
841
841
  }
842
842
 
843
- function askQuestionResponseType(question: AskQuestion) {
844
- if (question.responseType) return question.responseType;
845
- const legacy = question as AskQuestion & {
846
- kind?: string;
847
- multiSelect?: boolean;
848
- };
849
- if (legacy.kind === "attachment") return "attachment";
850
- if (!question.options?.length) return "text";
851
- return legacy.multiSelect ? "multi_select" : "single_select";
852
- }
853
-
854
843
  interface AskAnswer {
855
844
  selections: string[];
856
845
  text: string;
@@ -878,7 +867,7 @@ export function AskUserPart({
878
867
  const resolvedSubmitted = submittedAnswers.length > 0;
879
868
  const valid = questions.length > 0 && questions.every(
880
869
  (question, index) => {
881
- const responseType = askQuestionResponseType(question);
870
+ const responseType = question.responseType;
882
871
  const answer = answerAt(index);
883
872
  if (
884
873
  answer.selections.length === 0 && !answer.text.trim()
@@ -898,7 +887,7 @@ export function AskUserPart({
898
887
  const answer = next[questionIndex]!;
899
888
  next[questionIndex] = {
900
889
  ...answer,
901
- selections: askQuestionResponseType(questions[questionIndex]!) ===
890
+ selections: questions[questionIndex]!.responseType ===
902
891
  "multi_select"
903
892
  ? answer.selections.includes(option)
904
893
  ? answer.selections.filter((item) => item !== option)
@@ -930,7 +919,7 @@ export function AskUserPart({
930
919
  data-state={resolvedSubmitted ? "submitted" : "pending"}
931
920
  >
932
921
  {questions.map((question, questionIndex) => {
933
- const responseType = askQuestionResponseType(question);
922
+ const responseType = question.responseType;
934
923
  const choice = responseType === "single_select" ||
935
924
  responseType === "multi_select";
936
925
  return (