@copilotz/chat-ui 0.1.34 → 0.1.35

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.
package/dist/index.cjs CHANGED
@@ -96,6 +96,8 @@ var defaultChatConfig = {
96
96
  voiceFinishing: "Finishing capture...",
97
97
  voiceReview: "Ready to send",
98
98
  voiceSending: "Sending...",
99
+ voiceReviewArmedHint: "Still listening. Speak to add more before it sends.",
100
+ voiceReviewPausedHint: "Tap the mic to keep adding to this message.",
99
101
  voiceStart: "Start recording",
100
102
  voiceStop: "Stop recording",
101
103
  voiceSendNow: "Send now",
@@ -171,6 +173,7 @@ var defaultChatConfig = {
171
173
  voiceCompose: {
172
174
  enabled: false,
173
175
  defaultMode: "text",
176
+ reviewMode: "manual",
174
177
  autoSendDelayMs: 5e3,
175
178
  persistComposer: true,
176
179
  showTranscriptPreview: true,
@@ -3256,11 +3259,13 @@ var VoiceComposer = ({
3256
3259
  countdownMs,
3257
3260
  autoSendDelayMs,
3258
3261
  isAutoSendActive,
3262
+ reviewMode,
3259
3263
  errorMessage,
3260
3264
  disabled = false,
3261
3265
  labels,
3262
3266
  onStart,
3263
3267
  onStop,
3268
+ onPauseReview,
3264
3269
  onCancelAutoSend,
3265
3270
  onDiscard,
3266
3271
  onRecordAgain,
@@ -3272,9 +3277,26 @@ var VoiceComposer = ({
3272
3277
  const countdownValue = autoSendDelayMs > 0 ? Math.min(100, Math.max(0, (autoSendDelayMs - countdownMs) / autoSendDelayMs * 100)) : 100;
3273
3278
  const isBusy = state === "preparing" || state === "finishing" || state === "sending";
3274
3279
  const isCapturing = state === "waiting_for_speech" || state === "listening";
3275
- const isReviewing = state === "review";
3280
+ const hasDraft = Boolean(attachment);
3281
+ const isDraftLayout = hasDraft;
3282
+ const isArmedDraft = isDraftLayout && reviewMode === "armed" && (state === "waiting_for_speech" || state === "listening");
3276
3283
  const levelValue = isCapturing || state === "preparing" || state === "finishing" ? Math.max(8, Math.round(audioLevel * 100)) : 0;
3277
- const headerLabel = state === "error" ? labels?.voiceCaptureError || "Unable to capture audio." : resolveStateLabel(state, labels, errorMessage);
3284
+ const headerLabel = hasDraft && state !== "sending" && state !== "error" ? labels?.voiceReview || "Ready to send" : state === "error" ? labels?.voiceCaptureError || "Unable to capture audio." : resolveStateLabel(state, labels, errorMessage);
3285
+ const reviewHelperText = isArmedDraft ? labels?.voiceReviewArmedHint || "Speak to add more before it sends." : labels?.voiceReviewPausedHint || labels?.voiceRecordAgain || "Tap the mic to continue this message.";
3286
+ const orbIsListening = state === "listening";
3287
+ const orbCanStop = !isDraftLayout && (state === "waiting_for_speech" || state === "listening");
3288
+ const orbIsReviewBusy = state === "preparing" || state === "finishing" || state === "sending";
3289
+ const handleReviewOrbClick = () => {
3290
+ if (state === "listening") {
3291
+ onStop();
3292
+ return;
3293
+ }
3294
+ if (isArmedDraft) {
3295
+ onPauseReview();
3296
+ return;
3297
+ }
3298
+ onRecordAgain();
3299
+ };
3278
3300
  return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "w-full max-w-3xl rounded-xl border bg-background p-3 shadow-sm sm:p-4 md:min-w-3xl", children: [
3279
3301
  /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex items-center justify-between gap-2 sm:gap-3", children: [
3280
3302
  /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex min-w-0 items-center gap-2", children: [
@@ -3297,7 +3319,7 @@ var VoiceComposer = ({
3297
3319
  }
3298
3320
  )
3299
3321
  ] }),
3300
- !isReviewing ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "mt-3 rounded-xl border border-dashed border-primary/30 bg-primary/5 px-3 py-3 text-center sm:px-4 sm:py-4", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "mx-auto flex w-full max-w-sm flex-col items-center gap-3", children: [
3322
+ !isDraftLayout ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "mt-3 rounded-xl border border-dashed border-primary/30 bg-primary/5 px-3 py-3 text-center sm:px-4 sm:py-4", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "mx-auto flex w-full max-w-sm flex-col items-center gap-3", children: [
3301
3323
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3302
3324
  Button,
3303
3325
  {
@@ -3339,6 +3361,27 @@ var VoiceComposer = ({
3339
3361
  }
3340
3362
  )
3341
3363
  ] }),
3364
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "mt-4 flex flex-col items-center gap-3 text-center", children: [
3365
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3366
+ Button,
3367
+ {
3368
+ type: "button",
3369
+ size: "icon",
3370
+ variant: orbCanStop ? "destructive" : "outline",
3371
+ className: `h-16 w-16 rounded-full sm:h-20 sm:w-20 ${orbIsListening ? "border-red-500 bg-red-500 text-white hover:bg-red-600" : isArmedDraft ? "border-red-200 bg-red-50 text-red-600 shadow-[0_0_0_10px_rgba(239,68,68,0.08)] hover:bg-red-100 hover:text-red-700" : "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"}`,
3372
+ onClick: handleReviewOrbClick,
3373
+ disabled: disabled || orbIsReviewBusy,
3374
+ children: orbIsReviewBusy ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Loader2, { className: "h-7 w-7 animate-spin" }) : orbIsListening ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Square, { className: "h-7 w-7" }) : isArmedDraft ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Mic, { className: "h-7 w-7 animate-pulse" }) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Mic, { className: "h-7 w-7" })
3375
+ }
3376
+ ),
3377
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "w-full max-w-sm space-y-2", children: [
3378
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(Progress, { value: levelValue, className: "h-2" }),
3379
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex items-center justify-between text-xs text-muted-foreground", children: [
3380
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { children: formatDuration(durationMs) }),
3381
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "max-w-[15rem] text-right", children: reviewHelperText })
3382
+ ] })
3383
+ ] })
3384
+ ] }),
3342
3385
  attachment && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "mt-3 rounded-lg bg-background p-2", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("audio", { controls: true, preload: "metadata", className: "w-full", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("source", { src: attachment.dataUrl, type: attachment.mimeType }) }) }),
3343
3386
  showTranscriptPreview && transcriptMode !== "none" && transcriptText && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "mt-3 rounded-lg border bg-background px-3 py-2 text-left text-sm", children: transcriptText }),
3344
3387
  isAutoSendActive && autoSendDelayMs > 0 && /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "mt-3 space-y-2", children: [
@@ -3350,19 +3393,6 @@ var VoiceComposer = ({
3350
3393
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.X, { className: "h-4 w-4" }),
3351
3394
  labels?.voiceCancel || "Cancel"
3352
3395
  ] }),
3353
- !isAutoSendActive && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
3354
- Button,
3355
- {
3356
- type: "button",
3357
- variant: "outline",
3358
- size: "icon",
3359
- onClick: onRecordAgain,
3360
- disabled,
3361
- "aria-label": labels?.voiceRecordAgain || "Record again",
3362
- title: labels?.voiceRecordAgain || "Record again",
3363
- children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Mic, { className: "h-4 w-4" })
3364
- }
3365
- ),
3366
3396
  /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(Button, { type: "button", size: "sm", onClick: onSendNow, disabled, children: [
3367
3397
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_lucide_react9.Send, { className: "h-4 w-4" }),
3368
3398
  labels?.voiceSendNow || "Send now"
@@ -3640,6 +3670,7 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3640
3670
  }) {
3641
3671
  const voiceComposeEnabled = config?.voiceCompose?.enabled === true;
3642
3672
  const voiceDefaultMode = config?.voiceCompose?.defaultMode ?? "text";
3673
+ const voiceReviewMode = config?.voiceCompose?.reviewMode ?? "manual";
3643
3674
  const voiceAutoSendDelayMs = config?.voiceCompose?.autoSendDelayMs ?? 5e3;
3644
3675
  const voicePersistComposer = config?.voiceCompose?.persistComposer ?? true;
3645
3676
  const voiceShowTranscriptPreview = config?.voiceCompose?.showTranscriptPreview ?? true;
@@ -3874,13 +3905,30 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3874
3905
  setIsVoiceAutoSendActive(false);
3875
3906
  setVoiceError(null);
3876
3907
  }, []);
3908
+ const armVoiceDraftForAppend = (0, import_react5.useCallback)((segment) => {
3909
+ voiceAppendBaseRef.current = segment;
3910
+ voiceAppendBaseDurationRef.current = segment ? resolveVoiceSegmentDuration(segment) : 0;
3911
+ }, []);
3912
+ const handleVoiceProviderStateChange = (0, import_react5.useCallback)((nextState) => {
3913
+ if (voiceReviewMode === "armed" && (nextState === "waiting_for_speech" || nextState === "listening")) {
3914
+ const currentDraft = voiceDraftRef.current;
3915
+ if (currentDraft) {
3916
+ armVoiceDraftForAppend(currentDraft);
3917
+ }
3918
+ }
3919
+ if (voiceReviewMode === "armed" && nextState === "listening" && voiceDraftRef.current) {
3920
+ setVoiceCountdownMs(voiceAutoSendDelayMs);
3921
+ setIsVoiceAutoSendActive(false);
3922
+ }
3923
+ setVoiceState(nextState);
3924
+ }, [armVoiceDraftForAppend, voiceAutoSendDelayMs, voiceReviewMode]);
3877
3925
  const ensureVoiceProvider = (0, import_react5.useCallback)(async () => {
3878
3926
  if (voiceProviderRef.current) {
3879
3927
  return voiceProviderRef.current;
3880
3928
  }
3881
3929
  const createProvider = resolveVoiceProviderFactory(config?.voiceCompose?.createProvider);
3882
3930
  const provider = await createProvider({
3883
- onStateChange: setVoiceState,
3931
+ onStateChange: handleVoiceProviderStateChange,
3884
3932
  onAudioLevelChange: setVoiceAudioLevel,
3885
3933
  onDurationChange: (durationMs) => {
3886
3934
  setVoiceDurationMs(voiceAppendBaseDurationRef.current + durationMs);
@@ -3896,8 +3944,6 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3896
3944
  const previousSegment = voiceAppendBaseRef.current;
3897
3945
  try {
3898
3946
  const nextSegment = previousSegment ? await appendVoiceSegments(previousSegment, segment) : segment;
3899
- voiceAppendBaseRef.current = null;
3900
- voiceAppendBaseDurationRef.current = 0;
3901
3947
  voiceDraftRef.current = nextSegment;
3902
3948
  setVoiceDraft(nextSegment);
3903
3949
  setVoiceTranscript(nextSegment.transcript ?? clearVoiceTranscript());
@@ -3906,11 +3952,15 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3906
3952
  setVoiceCountdownMs(voiceAutoSendDelayMs);
3907
3953
  setIsVoiceAutoSendActive(voiceAutoSendDelayMs > 0);
3908
3954
  setVoiceError(null);
3909
- setVoiceState("review");
3955
+ if (voiceReviewMode === "armed") {
3956
+ armVoiceDraftForAppend(nextSegment);
3957
+ } else {
3958
+ armVoiceDraftForAppend(null);
3959
+ }
3960
+ setVoiceState((currentState) => voiceReviewMode === "armed" && (currentState === "waiting_for_speech" || currentState === "listening") ? currentState : "review");
3910
3961
  } catch (error) {
3911
3962
  const resolvedError = resolveVoiceErrorMessage(error, config);
3912
- voiceAppendBaseRef.current = null;
3913
- voiceAppendBaseDurationRef.current = 0;
3963
+ armVoiceDraftForAppend(null);
3914
3964
  setVoiceAudioLevel(0);
3915
3965
  setVoiceCountdownMs(0);
3916
3966
  setIsVoiceAutoSendActive(false);
@@ -3934,8 +3984,7 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3934
3984
  },
3935
3985
  onError: (error) => {
3936
3986
  const previousSegment = voiceAppendBaseRef.current;
3937
- voiceAppendBaseRef.current = null;
3938
- voiceAppendBaseDurationRef.current = 0;
3987
+ armVoiceDraftForAppend(null);
3939
3988
  setVoiceError(resolveVoiceErrorMessage(error, config));
3940
3989
  setVoiceAudioLevel(0);
3941
3990
  setVoiceCountdownMs(0);
@@ -3959,7 +4008,7 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
3959
4008
  });
3960
4009
  voiceProviderRef.current = provider;
3961
4010
  return provider;
3962
- }, [config, voiceAutoSendDelayMs, voiceMaxRecordingMs]);
4011
+ }, [armVoiceDraftForAppend, config, handleVoiceProviderStateChange, voiceAutoSendDelayMs, voiceMaxRecordingMs, voiceReviewMode]);
3963
4012
  const closeVoiceComposer = (0, import_react5.useCallback)(async () => {
3964
4013
  voiceAppendBaseRef.current = null;
3965
4014
  voiceAppendBaseDurationRef.current = 0;
@@ -4051,16 +4100,21 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
4051
4100
  void closeVoiceComposer();
4052
4101
  }, [voicePersistComposer, resetVoiceComposerState, closeVoiceComposer]);
4053
4102
  const sendVoiceDraft = (0, import_react5.useCallback)(() => {
4054
- if (!voiceDraft || disabled || isGenerating) {
4055
- return;
4056
- }
4057
- setVoiceState("sending");
4058
- setVoiceCountdownMs(0);
4059
- setIsVoiceAutoSendActive(false);
4060
- onSubmit("", [...attachments, voiceDraft.attachment]);
4061
- onChange("");
4062
- onAttachmentsChange([]);
4063
- finalizeVoiceComposerAfterSend();
4103
+ void (async () => {
4104
+ if (!voiceDraft || disabled || isGenerating) {
4105
+ return;
4106
+ }
4107
+ setVoiceState("sending");
4108
+ setVoiceCountdownMs(0);
4109
+ setIsVoiceAutoSendActive(false);
4110
+ if (voiceProviderRef.current) {
4111
+ await voiceProviderRef.current.cancel();
4112
+ }
4113
+ onSubmit("", [...attachments, voiceDraft.attachment]);
4114
+ onChange("");
4115
+ onAttachmentsChange([]);
4116
+ finalizeVoiceComposerAfterSend();
4117
+ })();
4064
4118
  }, [
4065
4119
  voiceDraft,
4066
4120
  disabled,
@@ -4072,25 +4126,51 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
4072
4126
  finalizeVoiceComposerAfterSend
4073
4127
  ]);
4074
4128
  const cancelVoiceAutoSend = (0, import_react5.useCallback)(() => {
4129
+ void (async () => {
4130
+ if (voiceReviewMode === "armed" && voiceProviderRef.current) {
4131
+ await voiceProviderRef.current.cancel();
4132
+ }
4133
+ armVoiceDraftForAppend(null);
4134
+ setVoiceAudioLevel(0);
4135
+ setVoiceState("review");
4136
+ })();
4075
4137
  setVoiceCountdownMs(0);
4076
4138
  setIsVoiceAutoSendActive(false);
4077
- }, []);
4139
+ }, [armVoiceDraftForAppend, voiceReviewMode]);
4140
+ const pauseVoiceReview = (0, import_react5.useCallback)(async () => {
4141
+ if (voiceState === "listening") {
4142
+ await stopVoiceCapture();
4143
+ return;
4144
+ }
4145
+ if (voiceReviewMode === "armed" && voiceProviderRef.current) {
4146
+ await voiceProviderRef.current.cancel();
4147
+ }
4148
+ armVoiceDraftForAppend(null);
4149
+ setVoiceAudioLevel(0);
4150
+ setVoiceState("review");
4151
+ }, [armVoiceDraftForAppend, stopVoiceCapture, voiceReviewMode, voiceState]);
4078
4152
  (0, import_react5.useEffect)(() => {
4079
- if (voiceState !== "review" || !voiceDraft || voiceAutoSendDelayMs <= 0 || !isVoiceAutoSendActive) {
4153
+ if (!voiceDraft || voiceAutoSendDelayMs <= 0 || !isVoiceAutoSendActive) {
4154
+ return;
4155
+ }
4156
+ const canContinueCounting = voiceState === "review" || voiceReviewMode === "armed" && voiceState === "waiting_for_speech";
4157
+ if (!canContinueCounting) {
4080
4158
  return;
4081
4159
  }
4082
- const startedAt = Date.now();
4083
- setVoiceCountdownMs(voiceAutoSendDelayMs);
4084
4160
  const timer = setInterval(() => {
4085
- const remaining = Math.max(0, voiceAutoSendDelayMs - (Date.now() - startedAt));
4086
- setVoiceCountdownMs(remaining);
4087
- if (remaining <= 0) {
4088
- clearInterval(timer);
4089
- sendVoiceDraft();
4090
- }
4161
+ setVoiceCountdownMs((previous) => {
4162
+ const remaining = Math.max(0, previous - 100);
4163
+ if (remaining <= 0) {
4164
+ clearInterval(timer);
4165
+ queueMicrotask(() => {
4166
+ sendVoiceDraft();
4167
+ });
4168
+ }
4169
+ return remaining;
4170
+ });
4091
4171
  }, 100);
4092
4172
  return () => clearInterval(timer);
4093
- }, [voiceState, voiceDraft, voiceAutoSendDelayMs, isVoiceAutoSendActive, sendVoiceDraft]);
4173
+ }, [voiceState, voiceDraft, voiceReviewMode, voiceAutoSendDelayMs, isVoiceAutoSendActive, sendVoiceDraft]);
4094
4174
  const removeAttachment = (index) => {
4095
4175
  const newAttachments = attachments.filter((_, i) => i !== index);
4096
4176
  onAttachmentsChange(newAttachments);
@@ -4145,6 +4225,7 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
4145
4225
  countdownMs: voiceCountdownMs,
4146
4226
  autoSendDelayMs: voiceAutoSendDelayMs,
4147
4227
  isAutoSendActive: isVoiceAutoSendActive,
4228
+ reviewMode: voiceReviewMode,
4148
4229
  errorMessage: voiceError,
4149
4230
  disabled: disabled || isGenerating,
4150
4231
  labels: config?.labels,
@@ -4154,6 +4235,9 @@ var ChatInput = (0, import_react5.memo)(function ChatInput2({
4154
4235
  onStop: () => {
4155
4236
  void stopVoiceCapture();
4156
4237
  },
4238
+ onPauseReview: () => {
4239
+ void pauseVoiceReview();
4240
+ },
4157
4241
  onCancelAutoSend: () => {
4158
4242
  cancelVoiceAutoSend();
4159
4243
  },