@djangocfg/ui-tools 2.1.411 → 2.1.412

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 (31) hide show
  1. package/package.json +6 -6
  2. package/src/tools/Chat/composer/AttachContext.tsx +22 -0
  3. package/src/tools/Chat/composer/Composer.tsx +108 -6
  4. package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
  5. package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
  6. package/src/tools/Chat/composer/index.ts +16 -1
  7. package/src/tools/Chat/composer/types.ts +71 -0
  8. package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
  9. package/src/tools/Chat/hooks/useChat.ts +32 -0
  10. package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
  11. package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
  12. package/src/tools/Chat/public.ts +1 -0
  13. package/src/tools/Chat/types/events.ts +50 -0
  14. package/src/tools/Chat/types/index.ts +1 -1
  15. package/src/tools/Chat/types/message.ts +5 -0
  16. package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
  17. package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
  18. package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
  19. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
  20. package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
  21. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
  22. package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
  23. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
  24. package/src/tools/CronScheduler/context/hooks.ts +8 -0
  25. package/src/tools/CronScheduler/context/index.ts +1 -0
  26. package/src/tools/CronScheduler/index.tsx +2 -0
  27. package/src/tools/CronScheduler/lazy.tsx +1 -0
  28. package/src/tools/CronScheduler/types/index.ts +18 -1
  29. package/src/tools/Map/lazy.tsx +11 -4
  30. package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
  31. package/src/tools/index.ts +2 -0
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
4
+
5
+ import { buildAcceptString, useClipboardPaste } from '../../Uploader';
6
+ import type { UseChatComposerReturn } from '../hooks/useChatComposer';
7
+ import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
8
+ import type {
9
+ ComposerAcceptType,
10
+ ComposerAttachConfig,
11
+ ComposerAttachHandle,
12
+ } from './types';
13
+
14
+ const ALL_ACCEPT: ComposerAcceptType[] = ['image', 'audio', 'video', 'document'];
15
+
16
+ /** True when a file's MIME is allowed by the accepted asset categories. */
17
+ function isAcceptedType(file: File, accept: ComposerAcceptType[]): boolean {
18
+ const mime = file.type;
19
+ if (mime.startsWith('image/')) return accept.includes('image');
20
+ if (mime.startsWith('audio/')) return accept.includes('audio');
21
+ if (mime.startsWith('video/')) return accept.includes('video');
22
+ // No / other MIME — treat as a document.
23
+ return accept.includes('document');
24
+ }
25
+
26
+ export interface UseComposerAttachParams {
27
+ composer: UseChatComposerReturn;
28
+ config: ComposerAttachConfig;
29
+ disabled?: boolean;
30
+ /** Paste listener scope. Defaults to `document` when omitted. */
31
+ pasteScopeRef?: RefObject<HTMLElement | null>;
32
+ }
33
+
34
+ /**
35
+ * The unified attach pipeline. One validated path that the paperclip
36
+ * button, the `+` menu, drag-drop and Ctrl+V paste all funnel into:
37
+ *
38
+ * openPicker() / attachFiles(File[])
39
+ * → validate (size / type / count)
40
+ * → fileToAttachment()
41
+ * → composer.addAttachment()
42
+ *
43
+ * Phase 2 stops at `status:'ready'` (object-URL). The `uploadFn`
44
+ * lifecycle lands in phase 5 — `config.uploadFn` is accepted here but
45
+ * not yet consumed.
46
+ *
47
+ * Reuses `useClipboardPaste` + `buildAcceptString` from the Uploader
48
+ * tool rather than re-implementing clipboard / mime handling.
49
+ */
50
+ export function useComposerAttach({
51
+ composer,
52
+ config,
53
+ disabled = false,
54
+ pasteScopeRef,
55
+ }: UseComposerAttachParams): ComposerAttachHandle {
56
+ const {
57
+ accept = ALL_ACCEPT,
58
+ maxFiles,
59
+ maxSizeBytes,
60
+ multiple = true,
61
+ pasteEnabled = true,
62
+ uploadFn,
63
+ onReject,
64
+ } = config;
65
+
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+
68
+ // Stable refs so the paste listener / handlers never go stale without
69
+ // forcing the consumer to memoize anything.
70
+ const composerRef = useRef(composer);
71
+ composerRef.current = composer;
72
+ const onRejectRef = useRef(onReject);
73
+ onRejectRef.current = onReject;
74
+ const uploadFnRef = useRef(uploadFn);
75
+ uploadFnRef.current = uploadFn;
76
+
77
+ const acceptString = useMemo(() => {
78
+ // buildAcceptString expects the Uploader's AssetType — same string
79
+ // union as ComposerAcceptType, so the cast is safe.
80
+ return buildAcceptString(accept as Parameters<typeof buildAcceptString>[0]);
81
+ }, [accept]);
82
+
83
+ // Drive one file through the host `uploadFn`: flip the attachment to
84
+ // `uploading`, stream progress, then settle on `ready` (url rewritten
85
+ // to the remote location) or `error`. Fire-and-forget — failures are
86
+ // surfaced on the attachment, never thrown.
87
+ const runUpload = useCallback(
88
+ (file: File, attachmentId: string, localUrl: string) => {
89
+ const fn = uploadFnRef.current;
90
+ if (!fn) return;
91
+ const c = composerRef.current;
92
+ c.updateAttachment(attachmentId, { status: 'uploading', progress: 0 });
93
+ fn(file, (fraction) => {
94
+ composerRef.current.updateAttachment(attachmentId, {
95
+ progress: Math.max(0, Math.min(1, fraction)),
96
+ });
97
+ })
98
+ .then((result) => {
99
+ composerRef.current.updateAttachment(attachmentId, {
100
+ status: 'ready',
101
+ progress: 1,
102
+ url: result.url,
103
+ thumbnailUrl: result.thumbnailUrl,
104
+ });
105
+ // The remote URL replaced the object-URL — free it.
106
+ revokeAttachmentUrl({ url: localUrl });
107
+ })
108
+ .catch(() => {
109
+ composerRef.current.updateAttachment(attachmentId, { status: 'error' });
110
+ });
111
+ },
112
+ [],
113
+ );
114
+
115
+ // The single validated entry point. Drop / paste / picker all land here.
116
+ const attachFiles = useCallback(
117
+ (files: File[]) => {
118
+ if (disabled || files.length === 0) return;
119
+ const c = composerRef.current;
120
+ const cap = maxFiles ?? Number.POSITIVE_INFINITY;
121
+
122
+ let slots = cap - c.attachments.length;
123
+ for (const file of files) {
124
+ if (slots <= 0) {
125
+ onRejectRef.current?.(file, 'count');
126
+ continue;
127
+ }
128
+ if (maxSizeBytes != null && file.size > maxSizeBytes) {
129
+ onRejectRef.current?.(file, 'size');
130
+ continue;
131
+ }
132
+ if (!isAcceptedType(file, accept)) {
133
+ onRejectRef.current?.(file, 'type');
134
+ continue;
135
+ }
136
+ const attachment = fileToAttachment(file);
137
+ c.addAttachment(attachment);
138
+ // With an uploadFn the attachment starts local then uploads in
139
+ // the background; without one it stays `ready` immediately.
140
+ if (uploadFnRef.current) {
141
+ runUpload(file, attachment.id, attachment.url);
142
+ }
143
+ slots -= 1;
144
+ }
145
+ },
146
+ [disabled, maxFiles, maxSizeBytes, accept, runUpload],
147
+ );
148
+
149
+ const openPicker = useCallback(() => {
150
+ if (disabled) return;
151
+ inputRef.current?.click();
152
+ }, [disabled]);
153
+
154
+ // Remove a staged attachment, freeing its object-URL first so a
155
+ // discarded blob does not leak.
156
+ const removeAttachment = useCallback((id: string) => {
157
+ const c = composerRef.current;
158
+ const found = c.attachments.find((a) => a.id === id);
159
+ if (found) revokeAttachmentUrl(found);
160
+ c.removeAttachment(id);
161
+ }, []);
162
+
163
+ const onInputChange = useCallback(
164
+ (e: React.ChangeEvent<HTMLInputElement>) => {
165
+ const picked = e.target.files;
166
+ if (picked && picked.length > 0) attachFiles(Array.from(picked));
167
+ // Reset so picking the same file twice still fires `change`.
168
+ e.target.value = '';
169
+ },
170
+ [attachFiles],
171
+ );
172
+
173
+ // Ctrl+V / Cmd+V — reuse the Uploader's clipboard resolver. It already
174
+ // skips text fields when the clipboard carries text, so pasting an
175
+ // image into the composer textarea attaches without hijacking typing.
176
+ const pasteAccept = useMemo(
177
+ () => accept.filter((a) => a !== 'document'),
178
+ [accept],
179
+ );
180
+ useClipboardPaste(
181
+ {
182
+ enabled: pasteEnabled && !disabled,
183
+ acceptTypes: pasteAccept.length > 0 ? pasteAccept : undefined,
184
+ maxBytes: maxSizeBytes,
185
+ onFiles: attachFiles,
186
+ },
187
+ pasteScopeRef,
188
+ );
189
+
190
+ // On unmount, free any object-URLs still held by staged attachments
191
+ // (drafts abandoned without sending). `composerRef` gives the latest
192
+ // list without re-running the effect on every attach.
193
+ useEffect(() => {
194
+ return () => {
195
+ for (const a of composerRef.current.attachments) {
196
+ revokeAttachmentUrl(a);
197
+ }
198
+ };
199
+ }, []);
200
+
201
+ const atCap =
202
+ maxFiles != null && composer.attachments.length >= maxFiles;
203
+
204
+ return {
205
+ openPicker,
206
+ attachFiles,
207
+ removeAttachment,
208
+ disabled: disabled || atCap,
209
+ inputProps: {
210
+ ref: inputRef,
211
+ type: 'file',
212
+ accept: acceptString,
213
+ multiple,
214
+ hidden: true,
215
+ onChange: onInputChange,
216
+ },
217
+ };
218
+ }
@@ -407,6 +407,38 @@ export function useChat(config: UseChatConfig): UseChatReturn {
407
407
  sources: ev.sources?.length ?? 0,
408
408
  });
409
409
  return;
410
+ case 'message_metrics':
411
+ // Non-terminal: attach per-turn metrics to the streaming
412
+ // message. The stream stays open until message_end / error.
413
+ dispatch({
414
+ type: 'MESSAGE_PATCH',
415
+ id: targetId,
416
+ patch: {
417
+ metrics: ev.metrics,
418
+ ...(ev.metrics.resolvedModel
419
+ ? { resolvedModel: ev.metrics.resolvedModel }
420
+ : {}),
421
+ },
422
+ });
423
+ log.stream.debug('message_metrics', {
424
+ turns: ev.metrics.turns,
425
+ toolCallCount: ev.metrics.toolCallCount,
426
+ resolvedModel: ev.metrics.resolvedModel,
427
+ });
428
+ return;
429
+ case 'resolved_model':
430
+ // Non-terminal: model alias was resolved mid-run.
431
+ dispatch({
432
+ type: 'MESSAGE_PATCH',
433
+ id: targetId,
434
+ patch: { resolvedModel: ev.resolvedModel },
435
+ });
436
+ log.stream.debug('resolved_model', {
437
+ originalAlias: ev.originalAlias,
438
+ resolvedModel: ev.resolvedModel,
439
+ upgraded: ev.upgraded,
440
+ });
441
+ return;
410
442
  case 'error':
411
443
  tokenBuffer.flush();
412
444
  dispatch({
@@ -50,6 +50,9 @@ export interface UseChatComposerReturn {
50
50
  setValue: (next: string) => void;
51
51
  attachments: ChatAttachment[];
52
52
  addAttachment: (a: ChatAttachment) => void;
53
+ /** Patch an existing attachment in place — used by the upload lifecycle
54
+ * to flip `status` / `progress` / `url` without losing list order. */
55
+ updateAttachment: (id: string, patch: Partial<ChatAttachment>) => void;
53
56
  removeAttachment: (id: string) => void;
54
57
  isSubmitting: boolean;
55
58
  canSubmit: boolean;
@@ -192,6 +195,15 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
192
195
  [maxAttachments],
193
196
  );
194
197
 
198
+ const updateAttachment = useCallback(
199
+ (id: string, patch: Partial<ChatAttachment>) => {
200
+ setAttachments((prev) =>
201
+ prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
202
+ );
203
+ },
204
+ [],
205
+ );
206
+
195
207
  const removeAttachment = useCallback((id: string) => {
196
208
  setAttachments((prev) => prev.filter((a) => a.id !== id));
197
209
  }, []);
@@ -276,6 +288,7 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
276
288
  setValue,
277
289
  attachments,
278
290
  addAttachment,
291
+ updateAttachment,
279
292
  removeAttachment,
280
293
  isSubmitting,
281
294
  canSubmit,
@@ -264,7 +264,7 @@ const MessageBubbleInner = ({
264
264
  {message.blocks?.length ? (
265
265
  <MessageBlocks
266
266
  blocks={message.blocks}
267
- registry={resolvedBlockRegistry}
267
+ registry={resolvedBlockRegistry ?? undefined}
268
268
  appearance={appearance}
269
269
  isUser={isUser}
270
270
  />
@@ -34,6 +34,7 @@ export type {
34
34
  ChatLabels,
35
35
  ChatTransport,
36
36
  ChatStreamEvent,
37
+ ChatMessageMetrics,
37
38
  CreateSessionOptions,
38
39
  SessionInfo,
39
40
  HistoryPage,
@@ -7,6 +7,32 @@
7
7
 
8
8
  import type { ChatSource } from './attachment';
9
9
 
10
+ /**
11
+ * Per-run metrics block. Emitted once per assistant turn alongside the
12
+ * terminal `message_end` event (a separate `message_metrics` event so
13
+ * `message_end` keeps its lean `tokensIn`/`tokensOut`/`sources` shape).
14
+ *
15
+ * All fields optional — the backend omits anything it could not measure.
16
+ */
17
+ export interface ChatMessageMetrics {
18
+ inputTokens?: number;
19
+ outputTokens?: number;
20
+ totalTokens?: number;
21
+ cacheReadTokens?: number;
22
+ cacheWriteTokens?: number;
23
+ processingTimeMs?: number;
24
+ firstTokenMs?: number;
25
+ turns?: number;
26
+ toolCallCount?: number;
27
+ toolNames?: string[];
28
+ /** Model the request was sent with (may be an alias). */
29
+ model?: string;
30
+ /** Concrete model the alias resolved to. */
31
+ resolvedModel?: string;
32
+ /** Short summary of the model's thinking, if available. */
33
+ thinkingSummary?: string;
34
+ }
35
+
10
36
  export type ChatStreamEvent =
11
37
  | { type: 'message_start'; messageId: string; sessionId: string }
12
38
  | { type: 'resume_start' }
@@ -32,4 +58,28 @@ export type ChatStreamEvent =
32
58
  tokensOut?: number;
33
59
  sources?: ChatSource[];
34
60
  }
61
+ | {
62
+ /**
63
+ * Per-turn metrics for the assistant message currently streaming.
64
+ * Non-terminal — arrives near the end of the run but does not
65
+ * close the stream (`message_end` / `error` do that).
66
+ */
67
+ type: 'message_metrics';
68
+ metrics: ChatMessageMetrics;
69
+ }
70
+ | {
71
+ /**
72
+ * One-shot model-alias resolution (e.g. `@code` → `glm-5.1`).
73
+ * Non-terminal — lets the UI update the model chip mid-run.
74
+ */
75
+ type: 'resolved_model';
76
+ /** Alias the user/request originally specified. */
77
+ originalAlias: string;
78
+ /** Concrete model id the alias resolved to. */
79
+ resolvedModel: string;
80
+ /** True when routing upgraded the model (e.g. for a larger context). */
81
+ upgraded?: boolean;
82
+ /** Human-readable reason the router picked this model. */
83
+ routingReason?: string;
84
+ }
35
85
  | { type: 'error'; code: string; message: string };
@@ -40,7 +40,7 @@ export type { ChatMessage } from './message';
40
40
  export { DEFAULT_LABELS } from './labels';
41
41
  export type { ChatLabels } from './labels';
42
42
  export type { ChatConfig, ChatPrefs, ChatDisplayMode } from './config';
43
- export type { ChatStreamEvent } from './events';
43
+ export type { ChatStreamEvent, ChatMessageMetrics } from './events';
44
44
  export type {
45
45
  CreateSessionOptions,
46
46
  SessionInfo,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { ChatAttachment, ChatSource } from './attachment';
9
9
  import type { MessageBlock } from './block';
10
+ import type { ChatMessageMetrics } from './events';
10
11
  import type { ChatPersona, ChatRole } from './persona';
11
12
  import type { ChatToolCall } from './tool-call';
12
13
 
@@ -32,4 +33,8 @@ export interface ChatMessage {
32
33
  sources?: ChatSource[];
33
34
  tokensIn?: number;
34
35
  tokensOut?: number;
36
+ /** Per-turn metrics (from a `message_metrics` stream event). */
37
+ metrics?: ChatMessageMetrics;
38
+ /** Concrete model the request resolved to (from a `resolved_model` event). */
39
+ resolvedModel?: string;
35
40
  }
@@ -18,7 +18,7 @@ import {
18
18
  } from '@djangocfg/ui-core/components';
19
19
  import { cn } from '@djangocfg/ui-core/lib';
20
20
  import { CronSchedulerProvider } from './context/CronSchedulerContext';
21
- import { useCronType, useCronPreview } from './context/hooks';
21
+ import { useCronType, useCronPreview, useCronSize } from './context/hooks';
22
22
  import {
23
23
  ScheduleTypeSelector,
24
24
  TimeSelector,
@@ -39,9 +39,11 @@ interface ScheduleEditorProps {
39
39
 
40
40
  function ScheduleEditor({ timeFormat, disabled }: ScheduleEditorProps) {
41
41
  const { type } = useCronType();
42
+ const size = useCronSize();
43
+ const isSm = size === 'sm';
42
44
 
43
45
  return (
44
- <div className="space-y-3">
46
+ <div className={isSm ? 'space-y-1.5' : 'space-y-3'}>
45
47
  <ScheduleTypeSelector disabled={disabled} />
46
48
 
47
49
  {type !== 'custom' && (
@@ -67,6 +69,7 @@ function CronExpressionLine({
67
69
  }) {
68
70
  const [copied, setCopied] = useState(false);
69
71
  const { cronExpression, isValid } = useCronPreview();
72
+ const isSm = useCronSize() === 'sm';
70
73
 
71
74
  const handleCopy = async (e: React.MouseEvent) => {
72
75
  e.stopPropagation();
@@ -80,8 +83,13 @@ function CronExpressionLine({
80
83
  };
81
84
 
82
85
  return (
83
- <div className={cn('flex items-center gap-1.5', className)}>
84
- <code className="font-mono text-xs text-muted-foreground">
86
+ <div className={cn('flex items-center', isSm ? 'gap-1' : 'gap-1.5', className)}>
87
+ <code
88
+ className={cn(
89
+ 'font-mono text-muted-foreground',
90
+ isSm ? 'text-[11px]' : 'text-xs'
91
+ )}
92
+ >
85
93
  {cronExpression}
86
94
  </code>
87
95
  {allowCopy && (
@@ -131,9 +139,10 @@ function CompactTrigger({
131
139
  }: CompactTriggerProps) {
132
140
  const [open, setOpen] = useState(false);
133
141
  const { humanDescription, isValid } = useCronPreview();
142
+ const isSm = useCronSize() === 'sm';
134
143
 
135
144
  return (
136
- <div className={cn('space-y-1.5', className)}>
145
+ <div className={cn(isSm ? 'space-y-1' : 'space-y-1.5', className)}>
137
146
  <Popover open={open} onOpenChange={setOpen}>
138
147
  <PopoverTrigger asChild>
139
148
  <button
@@ -142,17 +151,19 @@ function CompactTrigger({
142
151
  aria-expanded={open}
143
152
  disabled={disabled}
144
153
  className={cn(
145
- 'flex h-9 w-full items-center gap-2 rounded-md border px-3',
146
- 'border-input bg-transparent text-sm shadow-xs transition-colors',
154
+ 'flex w-full items-center rounded-md border',
155
+ 'border-input bg-transparent shadow-xs transition-colors',
147
156
  'hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
148
157
  'disabled:cursor-not-allowed disabled:opacity-50',
158
+ isSm ? 'h-8 gap-1.5 px-2 text-xs' : 'h-9 gap-2 px-3 text-sm',
149
159
  !isValid && 'border-destructive/50'
150
160
  )}
151
161
  >
152
162
  <Calendar
153
163
  aria-hidden="true"
154
164
  className={cn(
155
- 'h-4 w-4 shrink-0',
165
+ 'shrink-0',
166
+ isSm ? 'h-3.5 w-3.5' : 'h-4 w-4',
156
167
  isValid ? 'text-muted-foreground' : 'text-destructive'
157
168
  )}
158
169
  />
@@ -166,12 +177,15 @@ function CompactTrigger({
166
177
  </span>
167
178
  <ChevronsUpDown
168
179
  aria-hidden="true"
169
- className="h-4 w-4 shrink-0 opacity-50"
180
+ className={cn('shrink-0 opacity-50', isSm ? 'h-3.5 w-3.5' : 'h-4 w-4')}
170
181
  />
171
182
  </button>
172
183
  </PopoverTrigger>
173
184
  <PopoverContent
174
- className="w-[var(--radix-popover-trigger-width)] min-w-[320px] p-3"
185
+ className={cn(
186
+ 'w-[var(--radix-popover-trigger-width)]',
187
+ isSm ? 'min-w-[280px] p-2' : 'min-w-[320px] p-3'
188
+ )}
175
189
  align="start"
176
190
  >
177
191
  <ScheduleEditor timeFormat={timeFormat} disabled={disabled} />
@@ -202,8 +216,10 @@ function InlineScheduler({
202
216
  allowCopy,
203
217
  className,
204
218
  }: InlineSchedulerProps) {
219
+ const isSm = useCronSize() === 'sm';
220
+
205
221
  return (
206
- <div className={cn('space-y-3', className)}>
222
+ <div className={cn(isSm ? 'space-y-1.5' : 'space-y-3', className)}>
207
223
  <ScheduleEditor timeFormat={timeFormat} disabled={disabled} />
208
224
  <InlinePreview showCronExpression={showCronExpression} allowCopy={allowCopy} />
209
225
  </div>
@@ -218,26 +234,35 @@ function InlinePreview({
218
234
  allowCopy: boolean;
219
235
  }) {
220
236
  const { humanDescription, isValid } = useCronPreview();
237
+ const isSm = useCronSize() === 'sm';
221
238
 
222
239
  return (
223
240
  <div
224
241
  role="status"
225
242
  aria-live="polite"
226
243
  className={cn(
227
- 'flex items-center justify-between gap-2 rounded-md border px-3 py-2',
244
+ 'flex items-center justify-between gap-2 rounded-md border',
228
245
  'bg-muted/30 border-border/50',
246
+ isSm ? 'px-2 py-1' : 'px-3 py-2',
229
247
  !isValid && 'border-destructive/30 bg-destructive/5'
230
248
  )}
231
249
  >
232
- <div className="flex min-w-0 items-center gap-2">
250
+ <div className={cn('flex min-w-0 items-center', isSm ? 'gap-1.5' : 'gap-2')}>
233
251
  <Calendar
234
252
  aria-hidden="true"
235
253
  className={cn(
236
- 'h-4 w-4 shrink-0',
254
+ 'shrink-0',
255
+ isSm ? 'h-3 w-3' : 'h-4 w-4',
237
256
  isValid ? 'text-primary' : 'text-destructive'
238
257
  )}
239
258
  />
240
- <span className={cn('truncate text-sm', !isValid && 'text-destructive')}>
259
+ <span
260
+ className={cn(
261
+ 'truncate',
262
+ isSm ? 'text-[11px]' : 'text-sm',
263
+ !isValid && 'text-destructive'
264
+ )}
265
+ >
241
266
  {humanDescription}
242
267
  </span>
243
268
  </div>
@@ -276,6 +301,7 @@ export function CronScheduler({
276
301
  disabled = false,
277
302
  inline = false,
278
303
  placeholder = 'Set a schedule',
304
+ size = 'default',
279
305
  className,
280
306
  }: CronSchedulerProps) {
281
307
  return (
@@ -283,6 +309,7 @@ export function CronScheduler({
283
309
  value={value}
284
310
  onChange={onChange}
285
311
  defaultType={defaultType}
312
+ size={size}
286
313
  >
287
314
  {inline ? (
288
315
  <InlineScheduler
@@ -10,7 +10,7 @@ import { useState, useEffect } from 'react';
10
10
  import { Input } from '@djangocfg/ui-core/components';
11
11
  import { cn } from '@djangocfg/ui-core/lib';
12
12
  import { AlertCircle, CheckCircle2 } from 'lucide-react';
13
- import { useCronCustom } from '../context/hooks';
13
+ import { useCronCustom, useCronSize } from '../context/hooks';
14
14
  import { isValidCron } from '../utils/cron-parser';
15
15
 
16
16
  export interface CustomInputProps {
@@ -20,6 +20,7 @@ export interface CustomInputProps {
20
20
 
21
21
  export function CustomInput({ disabled, className }: CustomInputProps) {
22
22
  const { customCron, isValid, setCustomCron } = useCronCustom();
23
+ const isSm = useCronSize() === 'sm';
23
24
  const [localValue, setLocalValue] = useState(customCron);
24
25
  const [localValid, setLocalValid] = useState(isValid);
25
26
 
@@ -39,8 +40,14 @@ export function CustomInput({ disabled, className }: CustomInputProps) {
39
40
  const showError = !localValid && localValue.trim().length > 0;
40
41
 
41
42
  return (
42
- <div className={cn('space-y-2', className)}>
43
- <label htmlFor="cron-custom-input" className="text-sm text-muted-foreground">
43
+ <div className={cn(isSm ? 'space-y-1.5' : 'space-y-2', className)}>
44
+ <label
45
+ htmlFor="cron-custom-input"
46
+ className={cn(
47
+ 'text-muted-foreground',
48
+ isSm ? 'text-xs' : 'text-sm'
49
+ )}
50
+ >
44
51
  Cron expression
45
52
  </label>
46
53
 
@@ -59,16 +66,28 @@ export function CustomInput({ disabled, className }: CustomInputProps) {
59
66
  aria-invalid={showError}
60
67
  aria-describedby={showError ? 'cron-custom-error' : undefined}
61
68
  className={cn(
62
- 'font-mono text-base pr-10 h-11',
69
+ 'font-mono',
70
+ isSm ? 'text-xs h-8 pr-8' : 'text-base h-11 pr-10',
63
71
  showError && 'border-destructive focus-visible:ring-destructive/50'
64
72
  )}
65
73
  />
66
- <div className="absolute right-3 top-1/2 -translate-y-1/2">
74
+ <div
75
+ className={cn(
76
+ 'absolute top-1/2 -translate-y-1/2',
77
+ isSm ? 'right-2' : 'right-3'
78
+ )}
79
+ >
67
80
  {localValue.trim() && (
68
81
  localValid ? (
69
- <CheckCircle2 aria-hidden="true" className="h-5 w-5 text-success" />
82
+ <CheckCircle2
83
+ aria-hidden="true"
84
+ className={cn('text-success', isSm ? 'h-4 w-4' : 'h-5 w-5')}
85
+ />
70
86
  ) : (
71
- <AlertCircle aria-hidden="true" className="h-5 w-5 text-destructive" />
87
+ <AlertCircle
88
+ aria-hidden="true"
89
+ className={cn('text-destructive', isSm ? 'h-4 w-4' : 'h-5 w-5')}
90
+ />
72
91
  )
73
92
  )}
74
93
  </div>