ai-design-system 0.1.102 → 0.1.104

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.
@@ -360,5 +360,28 @@ export const WithSpeechInput: Story = {
360
360
  },
361
361
  };
362
362
 
363
+ /**
364
+ * Row / Inline Variant
365
+ * Single row where the input box and send button are side-by-side, without tools icons.
366
+ */
367
+ export const RowVariant: Story = {
368
+ args: {
369
+ variant: "row",
370
+ placeholder: "Ask a question or provide instructions...",
371
+ onSubmit: (message) => {
372
+ console.log("Submitted in row mode:", message);
373
+ alert(`Submitted: ${message.text}`);
374
+ },
375
+ },
376
+ parameters: {
377
+ docs: {
378
+ description: {
379
+ story:
380
+ "Compact single-row PromptInput variant where input box and send button are in the same line without toolbar tool icons (attachments, speech mic, etc.).",
381
+ },
382
+ },
383
+ },
384
+ };
385
+
363
386
 
364
387
 
@@ -44,11 +44,14 @@ export interface PromptInputContextProps {
44
44
  modelId?: string;
45
45
  }
46
46
 
47
+ export type PromptInputVariant = "default" | "row" | "inline";
48
+
47
49
  export interface PromptInputBlockProps
48
50
  extends Omit<
49
51
  AIPromptInputProps,
50
52
  "globalDrop" | "syncHiddenInput" | "onSubmit" | "onChange"
51
53
  > {
54
+ variant?: PromptInputVariant;
52
55
  disabled?: boolean;
53
56
  placeholder?: string;
54
57
  value?: string;
@@ -62,6 +65,7 @@ export interface PromptInputBlockProps
62
65
  onStop?: () => void;
63
66
  context?: PromptInputContextProps | ReactNode;
64
67
  tools?: ReactNode;
68
+ enableAttachments?: boolean;
65
69
  enableSpeech?: boolean;
66
70
  speechProps?: SpeechInputProps;
67
71
  attachIcon?: "paperclip" | "plus";
@@ -69,6 +73,7 @@ export interface PromptInputBlockProps
69
73
 
70
74
  export const PromptInput = React.memo<PromptInputBlockProps>(
71
75
  ({
76
+ variant = "default",
72
77
  disabled = false,
73
78
  placeholder,
74
79
  value,
@@ -79,7 +84,8 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
79
84
  onStop,
80
85
  context,
81
86
  tools,
82
- enableSpeech = true,
87
+ enableAttachments,
88
+ enableSpeech,
83
89
  speechProps,
84
90
  attachIcon = "paperclip",
85
91
  accept = "image/*,audio/*",
@@ -89,6 +95,10 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
89
95
  onError,
90
96
  ...props
91
97
  }) => {
98
+ const isRowVariant = variant === "row" || variant === "inline";
99
+ const showAttachments = enableAttachments ?? !isRowVariant;
100
+ const showSpeech = enableSpeech ?? !isRowVariant;
101
+
92
102
  const existingController = useOptionalPromptInputController();
93
103
 
94
104
  const handleSubmit = React.useCallback(
@@ -122,7 +132,40 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
122
132
 
123
133
  const isStopping = loading && Boolean(onStop);
124
134
 
125
- const promptInputContent = (
135
+ const promptInputContent = isRowVariant ? (
136
+ <AIPromptInput
137
+ onSubmit={handleSubmit}
138
+ accept={accept}
139
+ multiple={multiple}
140
+ maxFiles={maxFiles}
141
+ maxFileSize={maxFileSize}
142
+ onError={onError}
143
+ {...props}
144
+ >
145
+ <PromptInputBody className="contents">
146
+ {showAttachments && <AttachmentPreviews />}
147
+ <PromptInputTextarea
148
+ placeholder={placeholder}
149
+ disabled={disabled}
150
+ onChange={isControlled ? handleControlledChange : undefined}
151
+ className="min-h-9 py-2 px-3 text-sm resize-none border-0 shadow-none focus-visible:ring-0 bg-transparent flex-1 field-sizing-content"
152
+ />
153
+ </PromptInputBody>
154
+ <div className="pr-1.5 shrink-0 flex items-center self-center">
155
+ <PromptInputSubmit
156
+ disabled={disabled || (loading && !onStop)}
157
+ status={loading ? (onStop ? "streaming" : "submitted") : undefined}
158
+ onClick={isStopping ? (e: React.MouseEvent) => { e.preventDefault(); onStop?.(); } : undefined}
159
+ className={cn(
160
+ "h-8 w-8 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors shrink-0 p-0 flex items-center justify-center cursor-pointer",
161
+ isStopping && "bg-transparent hover:bg-accent"
162
+ )}
163
+ >
164
+ {loading ? undefined : <Icon name="corner-down-left" size="sm" />}
165
+ </PromptInputSubmit>
166
+ </div>
167
+ </AIPromptInput>
168
+ ) : (
126
169
  <AIPromptInput
127
170
  onSubmit={handleSubmit}
128
171
  accept={accept}
@@ -142,8 +185,10 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
142
185
  </PromptInputBody>
143
186
  <PromptInputFooter>
144
187
  <PromptInputTools>
145
- <AttachButton disabled={disabled || loading} icon={attachIcon} />
146
- {enableSpeech && (
188
+ {showAttachments && (
189
+ <AttachButton disabled={disabled || loading} icon={attachIcon} />
190
+ )}
191
+ {showSpeech && (
147
192
  <SpeechInput
148
193
  disabled={disabled || loading}
149
194
  {...speechProps}
@@ -1,5 +1,5 @@
1
1
  export { PromptInput } from "./PromptInput";
2
- export type { PromptInputBlockProps, PromptInputContextProps } from "./PromptInput";
2
+ export type { PromptInputBlockProps, PromptInputContextProps, PromptInputVariant } from "./PromptInput";
3
3
 
4
4
  export { SpeechInput } from "@/components/ai-elements/speech-input";
5
5
  export type { SpeechInputProps } from "@/components/ai-elements/speech-input";
@@ -19,6 +19,8 @@ const meta = {
19
19
  onSubmit: fn(),
20
20
  onPublish: fn(),
21
21
  onSceneChange: fn(),
22
+ onPlayingChange: fn(),
23
+ onDownload: fn(),
22
24
  },
23
25
  } satisfies Meta<typeof ScenePlayer>;
24
26
 
@@ -4,7 +4,7 @@ import * as React from "react";
4
4
  import type { FormEvent } from "react";
5
5
  import { Button } from "@/components/primitives/Button";
6
6
  import { Icon } from "@/components/primitives/Icon";
7
- import { PromptInput } from "@/components/composites/PromptInput";
7
+ import { PromptInput, type PromptInputVariant } from "@/components/composites/PromptInput";
8
8
  import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
9
9
  import { cn } from "@/lib/utils";
10
10
 
@@ -28,6 +28,11 @@ export interface ScenePlayerProps extends Omit<React.HTMLAttributes<HTMLDivEleme
28
28
  avatarColor?: string;
29
29
  initialSceneIndex?: number;
30
30
  isMuted?: boolean;
31
+ isPlaying?: boolean;
32
+ onPlayingChange?: (isPlaying: boolean) => void;
33
+ videoUrl?: string;
34
+ downloadUrl?: string;
35
+ onDownload?: () => void;
31
36
  onBack?: () => void;
32
37
  onClose?: () => void;
33
38
  placeholder?: string;
@@ -40,6 +45,8 @@ export interface ScenePlayerProps extends Omit<React.HTMLAttributes<HTMLDivEleme
40
45
  loading?: boolean;
41
46
  onStop?: () => void;
42
47
  enableSpeech?: boolean;
48
+ enableAttachments?: boolean;
49
+ promptInputVariant?: PromptInputVariant;
43
50
  onPublish?: (currentScene: SceneItem, text?: string) => void;
44
51
  publishPlaceholder?: string;
45
52
  onSceneChange?: (sceneIndex: number, scene: SceneItem) => void;
@@ -54,6 +61,11 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
54
61
  avatarColor = "bg-indigo-600",
55
62
  initialSceneIndex = 0,
56
63
  isMuted: initialMuted = false,
64
+ isPlaying: isPlayingProp,
65
+ onPlayingChange,
66
+ videoUrl,
67
+ downloadUrl,
68
+ onDownload,
57
69
  onBack,
58
70
  onClose,
59
71
  placeholder,
@@ -62,7 +74,9 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
62
74
  onSubmit,
63
75
  loading = false,
64
76
  onStop,
65
- enableSpeech = true,
77
+ enableSpeech = false,
78
+ enableAttachments = false,
79
+ promptInputVariant = "row",
66
80
  onPublish,
67
81
  publishPlaceholder = "Ask a question or provide instructions...",
68
82
  onSceneChange,
@@ -80,6 +94,22 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
80
94
  return 0;
81
95
  });
82
96
 
97
+ const [isPlayingState, setIsPlayingState] = React.useState<boolean>(isPlayingProp ?? true);
98
+ const isPlaying = isPlayingProp !== undefined ? isPlayingProp : isPlayingState;
99
+
100
+ const setIsPlaying = React.useCallback(
101
+ (value: boolean | ((prev: boolean) => boolean)) => {
102
+ const nextVal = typeof value === "function" ? value(isPlaying) : value;
103
+ setIsPlayingState(nextVal);
104
+ onPlayingChange?.(nextVal);
105
+ },
106
+ [isPlaying, onPlayingChange]
107
+ );
108
+
109
+ const togglePlay = React.useCallback(() => {
110
+ setIsPlaying((prev) => !prev);
111
+ }, [setIsPlaying]);
112
+
83
113
  const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
84
114
  const videoRef = React.useRef<HTMLVideoElement | null>(null);
85
115
 
@@ -117,41 +147,91 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
117
147
  }, [scenes, onSceneChange]);
118
148
 
119
149
  const handleSceneEnded = React.useCallback(() => {
120
- if (!scenes.length) return;
150
+ if (!scenes.length || !isPlaying) return;
121
151
  setCurrentSceneIndex((prev) => {
122
152
  const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
123
153
  onSceneChange?.(nextIdx, scenes[nextIdx]);
124
154
  return nextIdx;
125
155
  });
126
- }, [scenes, onSceneChange]);
156
+ }, [scenes, onSceneChange, isPlaying]);
157
+
158
+ const handleDownload = React.useCallback(async () => {
159
+ if (onDownload) {
160
+ onDownload();
161
+ return;
162
+ }
163
+ const targetUrl = downloadUrl || videoUrl || activeScene?.scene_clip_url;
164
+ if (!targetUrl) return;
127
165
 
128
- // Autoplay & mute handling
166
+ try {
167
+ const response = await fetch(targetUrl);
168
+ if (!response.ok) throw new Error("Fetch failed");
169
+ const blob = await response.blob();
170
+ const blobUrl = URL.createObjectURL(blob);
171
+ const link = document.createElement("a");
172
+ link.href = blobUrl;
173
+ link.download = targetUrl.split("/").pop()?.split("?")[0] || "video.mp4";
174
+ document.body.appendChild(link);
175
+ link.click();
176
+ document.body.removeChild(link);
177
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
178
+ } catch {
179
+ const link = document.createElement("a");
180
+ link.href = targetUrl;
181
+ link.download = targetUrl.split("/").pop()?.split("?")[0] || "video.mp4";
182
+ link.target = "_blank";
183
+ link.rel = "noopener noreferrer";
184
+ document.body.appendChild(link);
185
+ link.click();
186
+ document.body.removeChild(link);
187
+ }
188
+ }, [onDownload, downloadUrl, videoUrl, activeScene?.scene_clip_url]);
189
+
190
+ // Playback & mute synchronization
129
191
  React.useEffect(() => {
130
192
  const video = videoRef.current;
131
193
  if (!video) return;
132
194
 
133
195
  video.muted = isMuted;
134
- const playPromise = video.play();
135
- if (playPromise !== undefined) {
136
- playPromise.catch(() => {
137
- if (!isMuted) {
138
- video.muted = true;
139
- setIsMuted(true);
140
- video.play().catch(() => {});
141
- }
142
- });
196
+
197
+ if (isPlaying) {
198
+ const playPromise = video.play();
199
+ if (playPromise !== undefined) {
200
+ playPromise.catch(() => {
201
+ if (!isMuted) {
202
+ video.muted = true;
203
+ setIsMuted(true);
204
+ video.play().catch(() => {});
205
+ }
206
+ });
207
+ }
208
+ } else {
209
+ video.pause();
143
210
  }
144
- }, [activeScene?.scene_clip_url, isMuted]);
211
+ }, [activeScene?.scene_clip_url, isMuted, isPlaying]);
145
212
 
146
- // Keyboard navigation
213
+ // Keyboard navigation & playback controls
147
214
  React.useEffect(() => {
148
215
  const handleKeyDown = (e: KeyboardEvent) => {
216
+ const target = e.target as HTMLElement | null;
217
+ if (
218
+ target &&
219
+ (target.tagName === "INPUT" ||
220
+ target.tagName === "TEXTAREA" ||
221
+ target.isContentEditable)
222
+ ) {
223
+ return;
224
+ }
225
+
149
226
  if (e.key === "ArrowLeft") {
150
227
  e.preventDefault();
151
228
  handlePrev();
152
- } else if (e.key === "ArrowRight" || e.key === " ") {
229
+ } else if (e.key === "ArrowRight") {
153
230
  e.preventDefault();
154
231
  handleNext();
232
+ } else if (e.key === " " || e.key === "k" || e.key === "K") {
233
+ e.preventDefault();
234
+ togglePlay();
155
235
  } else if (e.key === "Escape") {
156
236
  e.preventDefault();
157
237
  handleBack?.();
@@ -163,7 +243,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
163
243
 
164
244
  window.addEventListener("keydown", handleKeyDown);
165
245
  return () => window.removeEventListener("keydown", handleKeyDown);
166
- }, [handlePrev, handleNext, handleBack]);
246
+ }, [handlePrev, handleNext, handleBack, togglePlay]);
167
247
 
168
248
  if (!scenes || scenes.length === 0) {
169
249
  return (
@@ -236,14 +316,6 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
236
316
  </Button>
237
317
  )}
238
318
 
239
- <div
240
- className={cn(
241
- "w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm text-white shrink-0 ring-1.5 ring-white/80 overflow-hidden",
242
- avatarColor
243
- )}
244
- >
245
- {avatarInitials}
246
- </div>
247
319
  <div className="flex flex-col min-w-0 justify-center">
248
320
  <span className="font-bold text-sm text-white truncate max-w-[200px]">
249
321
  {title}
@@ -255,6 +327,18 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
255
327
  </div>
256
328
 
257
329
  <div className="flex items-center gap-2 shrink-0">
330
+ {/* Play/Pause Toggle Button */}
331
+ <Button
332
+ variant="ghost"
333
+ size="icon"
334
+ data-testid="btn-toggle-play"
335
+ aria-label={isPlaying ? "Pause scene" : "Play scene"}
336
+ onClick={togglePlay}
337
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
338
+ >
339
+ <Icon name={isPlaying ? "pause" : "play"} className="w-4 h-4" />
340
+ </Button>
341
+
258
342
  {/* Mute/Unmute Toggle Button */}
259
343
  <Button
260
344
  variant="ghost"
@@ -266,6 +350,18 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
266
350
  >
267
351
  <Icon name={isMuted ? "volume-x" : "volume-2"} className="w-4 h-4" />
268
352
  </Button>
353
+
354
+ {/* Download Video Button */}
355
+ <Button
356
+ variant="ghost"
357
+ size="icon"
358
+ data-testid="btn-download"
359
+ aria-label="Download video"
360
+ onClick={handleDownload}
361
+ className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
362
+ >
363
+ <Icon name="download" className="w-4 h-4" />
364
+ </Button>
269
365
  </div>
270
366
  </div>
271
367
 
@@ -303,8 +399,20 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
303
399
  </div>
304
400
 
305
401
  {/* Bottom PromptInput Composite from Design System */}
306
- <div className="p-3 bg-gradient-to-t from-black via-black/90 to-transparent pt-4 z-20 shrink-0">
402
+ <div
403
+ className="p-3 bg-gradient-to-t from-black via-black/90 to-transparent pt-4 z-20 shrink-0"
404
+ onFocusCapture={() => {
405
+ setIsPlaying(false);
406
+ }}
407
+ onClickCapture={() => {
408
+ setIsPlaying(false);
409
+ }}
410
+ onTouchStartCapture={() => {
411
+ setIsPlaying(false);
412
+ }}
413
+ >
307
414
  <PromptInput
415
+ variant={promptInputVariant}
308
416
  placeholder={placeholder || publishPlaceholder}
309
417
  value={promptValue}
310
418
  onChange={onPromptValueChange}
@@ -312,6 +420,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
312
420
  loading={loading}
313
421
  onStop={onStop}
314
422
  enableSpeech={enableSpeech}
423
+ enableAttachments={enableAttachments}
315
424
  className="border border-border bg-background shadow-md overflow-hidden rounded-2xl"
316
425
  />
317
426
  </div>
@@ -231,6 +231,7 @@ export {
231
231
  } from './PromptInput'
232
232
  export type {
233
233
  PromptInputBlockProps,
234
+ PromptInputVariant,
234
235
  SpeechInputProps,
235
236
  PromptInputContextProps,
236
237
  PromptInputControllerProps,
@@ -3,7 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { AIConversation } from "@/components/blocks/AIConversation";
5
5
  import { FileChangeQueue } from "@/components/blocks/FileChangeQueue";
6
- import { PromptInput, type PromptInputContextProps } from "@/components/composites/PromptInput";
6
+ import { PromptInput, type PromptInputContextProps, type PromptInputVariant } from "@/components/composites/PromptInput";
7
7
  import { TaskQueue, type TaskItem } from "@/components/composites/TaskQueue";
8
8
  import { ApprovalCard } from "@/components/composites/ApprovalCard";
9
9
  import { cn } from "@/lib/utils";
@@ -194,6 +194,18 @@ export interface ChatPanelProps {
194
194
  * Handler when a task item is clicked (e.g. to inspect terminal output or logs)
195
195
  */
196
196
  onTaskClick?: (task: TaskItem) => void;
197
+ /**
198
+ * Layout variant for PromptInput:
199
+ * - "default": multi-line textarea with toolbar buttons below (attachment, mic)
200
+ * - "row" | "inline": single row where input box and send button are side-by-side without tools icons
201
+ * @default "default"
202
+ */
203
+ promptInputVariant?: PromptInputVariant;
204
+ /**
205
+ * Whether to enable the attachment button in prompt
206
+ * @default true for default variant, false for row variant
207
+ */
208
+ enableAttachments?: boolean;
197
209
  /**
198
210
  * Whether to enable the voice-note microphone button in prompt
199
211
  * @default true
@@ -268,6 +280,8 @@ export const ChatPanel = React.memo<ChatPanelProps>(
268
280
  onTaskRetry,
269
281
  onTaskStopAll,
270
282
  onTaskClick,
283
+ promptInputVariant = "default",
284
+ enableAttachments,
271
285
  enableSpeech = true,
272
286
  showHeader = true,
273
287
  sessionTitle,
@@ -465,6 +479,7 @@ export const ChatPanel = React.memo<ChatPanelProps>(
465
479
  />
466
480
  )}
467
481
  <PromptInput
482
+ variant={promptInputVariant}
468
483
  dialog={dialog}
469
484
  placeholder={placeholder}
470
485
  value={promptValue}
@@ -474,6 +489,7 @@ export const ChatPanel = React.memo<ChatPanelProps>(
474
489
  onStop={onStop}
475
490
  context={context}
476
491
  enableSpeech={enableSpeech}
492
+ enableAttachments={enableAttachments}
477
493
  className={cn(
478
494
  "border border-neutral-600 bg-background shadow-sm overflow-hidden",
479
495
  tasks.length > 0 && !dialog
@@ -53,6 +53,7 @@ export type {
53
53
  AppBreadcrumbProps,
54
54
  BreadcrumbItemData,
55
55
  PromptInputBlockProps,
56
+ PromptInputVariant,
56
57
  PromptInputContextProps,
57
58
  PromptInputControllerProps,
58
59
  AttachmentsContext,