ai-design-system 0.1.92 → 0.1.93

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,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
+ import { fn } from '@storybook/test'
2
3
  import { FilePreviewDialog } from './FilePreviewDialog'
3
4
 
4
5
  const meta = {
@@ -14,5 +15,26 @@ export default meta
14
15
  type Story = StoryObj<typeof meta>
15
16
 
16
17
  export const Default: Story = {
17
- args: {} as any,
18
+ args: {
19
+ open: true,
20
+ onOpenChange: fn(),
21
+ title: 'Sprint checklist',
22
+ description: 'specs/sprint-plan.md',
23
+ content: [
24
+ '## Tasks',
25
+ '- [x] Draft API contract',
26
+ '- [ ] Land UI stubs',
27
+ '- [ ] Wire preview',
28
+ ].join('\n'),
29
+ },
30
+ }
31
+
32
+ export const Empty: Story = {
33
+ args: {
34
+ open: true,
35
+ onOpenChange: fn(),
36
+ title: 'Untitled spec',
37
+ description: 'specs/untitled.md',
38
+ content: undefined,
39
+ },
18
40
  }
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
+ import { fn } from '@storybook/test'
2
3
  import { ProjectSwitcher } from './ProjectSwitcher'
3
4
 
4
5
  const meta = {
@@ -14,5 +15,23 @@ export default meta
14
15
  type Story = StoryObj<typeof meta>
15
16
 
16
17
  export const Default: Story = {
17
- args: {} as any,
18
+ args: {
19
+ projects: [
20
+ { id: '1', name: 'Atlas' },
21
+ { id: '2', name: 'Beacon' },
22
+ { id: '3', name: 'Cascade' },
23
+ ],
24
+ selectedProjectId: '1',
25
+ onSelectProject: fn(),
26
+ onCreateProject: fn(),
27
+ },
28
+ }
29
+
30
+ export const Empty: Story = {
31
+ args: {
32
+ projects: [],
33
+ selectedProjectId: null,
34
+ onSelectProject: fn(),
35
+ onCreateProject: fn(),
36
+ },
18
37
  }
@@ -31,7 +31,13 @@ export interface ProjectSwitcherProps {
31
31
  }
32
32
 
33
33
  export const ProjectSwitcher = React.memo<ProjectSwitcherProps>(
34
- ({ projects, selectedProjectId, onSelectProject, onCreateProject, className }) => {
34
+ ({
35
+ projects = [],
36
+ selectedProjectId,
37
+ onSelectProject = () => {},
38
+ onCreateProject = () => {},
39
+ className,
40
+ }) => {
35
41
  const [open, setOpen] = React.useState(false)
36
42
 
37
43
  const selectedProject = projects.find((p) => String(p.id) === String(selectedProjectId))
@@ -0,0 +1,241 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { TaskQueue } from "./TaskQueue";
4
+ import type { TaskItem } from "./interfaces";
5
+ import { PromptInput } from "@/components/composites/PromptInput";
6
+ import { Button } from "@/components/primitives/Button";
7
+ import { Icon } from "@/components/primitives/Icon";
8
+
9
+ const meta: Meta<typeof TaskQueue> = {
10
+ title: "Composites/TaskQueue",
11
+ component: TaskQueue,
12
+ parameters: {
13
+ layout: "padded",
14
+ },
15
+ } satisfies Meta<typeof TaskQueue>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ // Sample tasks based on 2.1-jobs.png inspiration
21
+ const singleRunningTask: TaskItem[] = [
22
+ {
23
+ id: "task-1",
24
+ command: "npm install",
25
+ status: "running",
26
+ },
27
+ ];
28
+
29
+ const multipleMixedTasks: TaskItem[] = [
30
+ {
31
+ id: "task-1",
32
+ command: "npm install",
33
+ status: "running",
34
+ },
35
+ {
36
+ id: "task-2",
37
+ command: "npm run build",
38
+ status: "queued",
39
+ },
40
+ {
41
+ id: "task-3",
42
+ command: "git push origin main",
43
+ status: "queued",
44
+ },
45
+ ];
46
+
47
+ const completedAndFailedTasks: TaskItem[] = [
48
+ {
49
+ id: "task-1",
50
+ command: "pnpm install",
51
+ status: "completed",
52
+ duration: "4.2s",
53
+ },
54
+ {
55
+ id: "task-2",
56
+ command: "pnpm test",
57
+ status: "failed",
58
+ duration: "1.1s",
59
+ error: "Test failed with exit code 1",
60
+ },
61
+ {
62
+ id: "task-3",
63
+ command: "pnpm build",
64
+ status: "queued",
65
+ },
66
+ ];
67
+
68
+ /**
69
+ * Single running task — Exact replica of 2.1-jobs.png inspiration.
70
+ * Displays "1 task running", spinner, "npm install", and stop button.
71
+ */
72
+ export const SingleTaskRunning: Story = {
73
+ args: {
74
+ tasks: singleRunningTask,
75
+ variant: "standalone",
76
+ onStop: (id) => console.log(`Stopped task ${id}`),
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Multiple tasks — Running task + queued tasks waiting in the queue.
82
+ */
83
+ export const MultipleTasks: Story = {
84
+ args: {
85
+ tasks: multipleMixedTasks,
86
+ variant: "standalone",
87
+ onStop: (id) => console.log(`Stopped task ${id}`),
88
+ onCancel: (id) => console.log(`Cancelled task ${id}`),
89
+ onStopAll: () => console.log("Stopped all tasks"),
90
+ },
91
+ };
92
+
93
+ /**
94
+ * Docked with PromptInput — Exact recreation of the full antigravity bottom panel
95
+ * seen in 2.1-jobs.png. Shows TaskQueue flush atop PromptInput.
96
+ */
97
+ export const DockedWithPromptInput: Story = {
98
+ render: () => {
99
+ return (
100
+ <div className="max-w-2xl mx-auto w-full p-4 flex flex-col">
101
+ <TaskQueue
102
+ tasks={singleRunningTask}
103
+ variant="docked"
104
+ onStop={(id) => console.log(`Stopped task ${id}`)}
105
+ />
106
+ <PromptInput
107
+ placeholder="Ask anything, @ to mention, / for actions"
108
+ onSubmit={(message) => console.log("Submitted:", message)}
109
+ tools={
110
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
111
+ <Button
112
+ variant="ghost"
113
+ size="sm"
114
+ className="h-7 px-2 text-xs font-normal gap-1 hover:text-foreground"
115
+ type="button"
116
+ >
117
+ <span>Gemini 3.8 Flash High</span>
118
+ <Icon name="chevron-down" size="xs" />
119
+ </Button>
120
+ </div>
121
+ }
122
+ className="rounded-t-none rounded-b-2xl border-t-0 border border-neutral-600 bg-background shadow-sm"
123
+ />
124
+ </div>
125
+ );
126
+ },
127
+ };
128
+
129
+ /**
130
+ * Collapsed state — Collapsed header showing the summary count and chevron.
131
+ */
132
+ export const Collapsed: Story = {
133
+ args: {
134
+ tasks: multipleMixedTasks,
135
+ defaultOpen: false,
136
+ variant: "standalone",
137
+ onStop: (id) => console.log(`Stopped task ${id}`),
138
+ onCancel: (id) => console.log(`Cancelled task ${id}`),
139
+ },
140
+ };
141
+
142
+ /**
143
+ * All queued tasks — When tasks are waiting to execute.
144
+ */
145
+ export const AllQueued: Story = {
146
+ args: {
147
+ tasks: [
148
+ { id: "1", command: "npm test", status: "queued" },
149
+ { id: "2", command: "npm run lint", status: "queued" },
150
+ ],
151
+ variant: "standalone",
152
+ onCancel: (id) => console.log(`Cancelled task ${id}`),
153
+ },
154
+ };
155
+
156
+ /**
157
+ * Completed and failed tasks with retry support.
158
+ */
159
+ export const CompletedAndFailed: Story = {
160
+ args: {
161
+ tasks: completedAndFailedTasks,
162
+ variant: "standalone",
163
+ onRetry: (id) => console.log(`Retried task ${id}`),
164
+ onCancel: (id) => console.log(`Cancelled task ${id}`),
165
+ },
166
+ };
167
+
168
+ /**
169
+ * Interactive story — Allows testing stopping and cancelling tasks with live state.
170
+ */
171
+ export const Interactive: Story = {
172
+ render: () => {
173
+ const [tasks, setTasks] = React.useState<TaskItem[]>([
174
+ { id: "t1", command: "npm install", status: "running" },
175
+ { id: "t2", command: "npm run test", status: "queued" },
176
+ { id: "t3", command: "git push origin feat/task-queue", status: "queued" },
177
+ ]);
178
+
179
+ const handleStop = (id: string) => {
180
+ setTasks((prev) =>
181
+ prev.map((t) => (t.id === id ? { ...t, status: "cancelled" } : t))
182
+ );
183
+ };
184
+
185
+ const handleCancel = (id: string) => {
186
+ setTasks((prev) => prev.filter((t) => t.id !== id));
187
+ };
188
+
189
+ const handleRetry = (id: string) => {
190
+ setTasks((prev) =>
191
+ prev.map((t) => (t.id === id ? { ...t, status: "running" } : t))
192
+ );
193
+ };
194
+
195
+ const handleStopAll = () => {
196
+ setTasks((prev) =>
197
+ prev.map((t) =>
198
+ t.status === "running" ? { ...t, status: "cancelled" } : t
199
+ )
200
+ );
201
+ };
202
+
203
+ const handleReset = () => {
204
+ setTasks([
205
+ { id: "t1", command: "npm install", status: "running" },
206
+ { id: "t2", command: "npm run test", status: "queued" },
207
+ { id: "t3", command: "git push origin feat/task-queue", status: "queued" },
208
+ ]);
209
+ };
210
+
211
+ return (
212
+ <div className="max-w-xl mx-auto space-y-4">
213
+ <div className="flex justify-between items-center">
214
+ <span className="text-xs text-muted-foreground">
215
+ Try stopping or cancelling tasks:
216
+ </span>
217
+ <Button variant="outline" size="sm" onClick={handleReset}>
218
+ Reset Tasks
219
+ </Button>
220
+ </div>
221
+ <TaskQueue
222
+ tasks={tasks}
223
+ onStop={handleStop}
224
+ onCancel={handleCancel}
225
+ onRetry={handleRetry}
226
+ onStopAll={handleStopAll}
227
+ />
228
+ </div>
229
+ );
230
+ },
231
+ };
232
+
233
+ /**
234
+ * Empty state — Gracefully returns null when no tasks are present.
235
+ */
236
+ export const EmptyState: Story = {
237
+ args: {
238
+ tasks: [],
239
+ showEmpty: false,
240
+ },
241
+ };
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ Queue,
6
+ QueueSection,
7
+ QueueSectionTrigger,
8
+ QueueSectionLabel,
9
+ QueueSectionContent,
10
+ QueueList,
11
+ QueueItem,
12
+ QueueItemContent,
13
+ QueueItemActions,
14
+ QueueItemAction,
15
+ } from "@/components/ai-elements/queue";
16
+ import { Button } from "@/components/primitives/Button";
17
+ import { Icon } from "@/components/primitives/Icon";
18
+ import { cn } from "@/lib/utils";
19
+ import type { TaskQueueProps, TaskStatus } from "./interfaces";
20
+
21
+ /**
22
+ * TaskQueue Composite
23
+ *
24
+ * Displays active and queued tasks/jobs with status indicators, command previews,
25
+ * and stop/cancel controls. Uses Queue AI element for structure, following the
26
+ * pattern established in FileQueue.
27
+ *
28
+ * Supports both standalone card mode and docked mode attaching directly above PromptInput.
29
+ * Recreates Antigravity jobs/tasks UI (inspirations/antigravity/2.1-jobs.png).
30
+ */
31
+
32
+ const getStatusIcon = (status: TaskStatus) => {
33
+ switch (status) {
34
+ case "running":
35
+ return (
36
+ <Icon
37
+ name="loader-2"
38
+ size="sm"
39
+ className="animate-spin text-muted-foreground flex-shrink-0"
40
+ />
41
+ );
42
+ case "queued":
43
+ case "pending":
44
+ return (
45
+ <Icon
46
+ name="clock"
47
+ size="sm"
48
+ className="text-muted-foreground/60 flex-shrink-0"
49
+ />
50
+ );
51
+ case "completed":
52
+ return (
53
+ <Icon
54
+ name="check"
55
+ size="sm"
56
+ className="text-emerald-500 flex-shrink-0"
57
+ />
58
+ );
59
+ case "failed":
60
+ return (
61
+ <Icon
62
+ name="x"
63
+ size="sm"
64
+ className="text-destructive flex-shrink-0"
65
+ />
66
+ );
67
+ case "cancelled":
68
+ return (
69
+ <Icon
70
+ name="minus"
71
+ size="sm"
72
+ className="text-muted-foreground/60 flex-shrink-0"
73
+ />
74
+ );
75
+ default:
76
+ return null;
77
+ }
78
+ };
79
+
80
+ export const TaskQueue = React.memo<TaskQueueProps>(
81
+ ({
82
+ tasks,
83
+ title,
84
+ open,
85
+ defaultOpen = true,
86
+ onOpenChange,
87
+ onStop,
88
+ onCancel,
89
+ onRetry,
90
+ onStopAll,
91
+ onTaskClick,
92
+ variant = "standalone",
93
+ className,
94
+ showEmpty = false,
95
+ emptyMessage = "No tasks in queue",
96
+ }) => {
97
+ const runningTasks = React.useMemo(
98
+ () => tasks.filter((t) => t.status === "running"),
99
+ [tasks]
100
+ );
101
+
102
+ const queuedTasks = React.useMemo(
103
+ () => tasks.filter((t) => t.status === "queued" || t.status === "pending"),
104
+ [tasks]
105
+ );
106
+
107
+ const displayTitle = React.useMemo(() => {
108
+ if (title) return title;
109
+ const parts: string[] = [];
110
+ if (runningTasks.length > 0) {
111
+ parts.push(
112
+ `${runningTasks.length} task${runningTasks.length === 1 ? "" : "s"} running`
113
+ );
114
+ }
115
+ if (queuedTasks.length > 0) {
116
+ parts.push(`${queuedTasks.length} queued`);
117
+ }
118
+ if (parts.length > 0) {
119
+ return parts.join(", ");
120
+ }
121
+ if (tasks.length > 0) {
122
+ return `${tasks.length} task${tasks.length === 1 ? "" : "s"}`;
123
+ }
124
+ return emptyMessage;
125
+ }, [title, runningTasks.length, queuedTasks.length, tasks.length, emptyMessage]);
126
+
127
+ // Only display if at least 1 task exists or showEmpty is explicitly enabled
128
+ if (tasks.length === 0 && !showEmpty) {
129
+ return null;
130
+ }
131
+
132
+ const isDocked = variant === "docked";
133
+
134
+ return (
135
+ <Queue
136
+ className={cn(
137
+ "w-full transition-colors",
138
+ isDocked
139
+ ? "rounded-t-2xl rounded-b-none border-b-0 border-t border-x border-border/80 bg-card/95 backdrop-blur-md shadow-xs -mb-px p-1.5"
140
+ : "rounded-2xl border border-border bg-card text-card-foreground shadow-sm p-1.5",
141
+ className
142
+ )}
143
+ >
144
+ <QueueSection
145
+ defaultOpen={defaultOpen}
146
+ open={open}
147
+ onOpenChange={onOpenChange}
148
+ className="w-full"
149
+ >
150
+ <div className="flex items-center justify-between w-full">
151
+ <QueueSectionTrigger
152
+ className={cn(
153
+ "w-full px-3 py-2 bg-transparent hover:bg-muted/40 text-foreground transition-colors",
154
+ isDocked ? "rounded-t-xl" : "rounded-lg"
155
+ )}
156
+ >
157
+ <QueueSectionLabel
158
+ label={displayTitle}
159
+ className="w-full justify-between flex-row-reverse font-sans text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors"
160
+ />
161
+ </QueueSectionTrigger>
162
+
163
+ {runningTasks.length > 1 && onStopAll && (
164
+ <Button
165
+ variant="ghost"
166
+ size="sm"
167
+ type="button"
168
+ className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive flex-shrink-0 mr-2"
169
+ onClick={(e) => {
170
+ e.stopPropagation();
171
+ onStopAll();
172
+ }}
173
+ >
174
+ Stop all
175
+ </Button>
176
+ )}
177
+ </div>
178
+
179
+ <QueueSectionContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden">
180
+ <QueueList className="max-h-[260px] mt-1 -mb-0">
181
+ {tasks.length === 0 ? (
182
+ <div className="px-3 py-2 text-sm text-muted-foreground italic">
183
+ {emptyMessage}
184
+ </div>
185
+ ) : (
186
+ tasks.map((task) => {
187
+ const isClickable = Boolean(onTaskClick);
188
+
189
+ return (
190
+ <QueueItem
191
+ key={task.id}
192
+ className={cn(
193
+ "flex flex-row items-center justify-between gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors",
194
+ "hover:bg-muted/40 group/item",
195
+ isClickable && "cursor-pointer"
196
+ )}
197
+ onClick={isClickable ? () => onTaskClick?.(task) : undefined}
198
+ role={isClickable ? "button" : undefined}
199
+ tabIndex={isClickable ? 0 : undefined}
200
+ >
201
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
202
+ {getStatusIcon(task.status)}
203
+
204
+ <QueueItemContent className="font-mono text-sm text-foreground select-all">
205
+ {task.command || task.title}
206
+ </QueueItemContent>
207
+
208
+ {task.duration && (
209
+ <span className="text-xs text-muted-foreground/60 font-sans ml-1 flex-shrink-0">
210
+ {task.duration}
211
+ </span>
212
+ )}
213
+ </div>
214
+
215
+ <QueueItemActions>
216
+ {task.status === "running" && onStop && (
217
+ <QueueItemAction
218
+ onClick={(e) => {
219
+ e.stopPropagation();
220
+ onStop(task.id);
221
+ }}
222
+ aria-label={`Stop ${task.command || task.title}`}
223
+ title="Stop task"
224
+ className="size-6 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/60 opacity-100 group-hover:opacity-100"
225
+ >
226
+ <Icon name="circle-stop" size="sm" />
227
+ </QueueItemAction>
228
+ )}
229
+
230
+ {(task.status === "queued" || task.status === "pending") && onCancel && (
231
+ <QueueItemAction
232
+ onClick={(e) => {
233
+ e.stopPropagation();
234
+ onCancel(task.id);
235
+ }}
236
+ aria-label={`Cancel ${task.command || task.title}`}
237
+ title="Cancel task"
238
+ className="size-6 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/60 opacity-100 group-hover:opacity-100"
239
+ >
240
+ <Icon name="x" size="sm" />
241
+ </QueueItemAction>
242
+ )}
243
+
244
+ {task.status === "failed" && onRetry && (
245
+ <QueueItemAction
246
+ onClick={(e) => {
247
+ e.stopPropagation();
248
+ onRetry(task.id);
249
+ }}
250
+ aria-label={`Retry ${task.command || task.title}`}
251
+ title="Retry task"
252
+ className="size-6 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/60 opacity-100 group-hover:opacity-100"
253
+ >
254
+ <Icon name="refresh-cw" size="sm" />
255
+ </QueueItemAction>
256
+ )}
257
+ </QueueItemActions>
258
+ </QueueItem>
259
+ );
260
+ })
261
+ )}
262
+ </QueueList>
263
+ </QueueSectionContent>
264
+ </QueueSection>
265
+ </Queue>
266
+ );
267
+ }
268
+ );
269
+
270
+ TaskQueue.displayName = "TaskQueue";
@@ -0,0 +1,2 @@
1
+ export { TaskQueue } from "./TaskQueue";
2
+ export type { TaskQueueProps, TaskItem, TaskStatus } from "./interfaces";
@@ -0,0 +1,103 @@
1
+ export type TaskStatus =
2
+ | "running"
3
+ | "queued"
4
+ | "pending"
5
+ | "completed"
6
+ | "failed"
7
+ | "cancelled";
8
+
9
+ export interface TaskItem {
10
+ /**
11
+ * Unique identifier for the task
12
+ */
13
+ id: string;
14
+ /**
15
+ * Command to display in monospace (e.g. "npm install")
16
+ */
17
+ command: string;
18
+ /**
19
+ * Optional title or label for the task
20
+ */
21
+ title?: string;
22
+ /**
23
+ * Current status of the task
24
+ */
25
+ status: TaskStatus;
26
+ /**
27
+ * Optional description or secondary information
28
+ */
29
+ description?: string;
30
+ /**
31
+ * Optional duration string (e.g. "2.4s" or "35s")
32
+ */
33
+ duration?: string;
34
+ /**
35
+ * Optional error message if the task failed
36
+ */
37
+ error?: string;
38
+ }
39
+
40
+ export interface TaskQueueProps {
41
+ /**
42
+ * List of tasks in the queue
43
+ */
44
+ tasks: TaskItem[];
45
+ /**
46
+ * Custom title or header text (e.g. "1 task running").
47
+ * Defaults to auto-computed summary:
48
+ * e.g. "1 task running", "2 tasks running", "1 task running, 2 queued"
49
+ */
50
+ title?: string;
51
+ /**
52
+ * Whether the queue is expanded/open (controlled)
53
+ */
54
+ open?: boolean;
55
+ /**
56
+ * Default open state (uncontrolled, defaults to true)
57
+ */
58
+ defaultOpen?: boolean;
59
+ /**
60
+ * Callback fired when open state changes
61
+ */
62
+ onOpenChange?: (open: boolean) => void;
63
+ /**
64
+ * Callback fired when stopping a running task
65
+ */
66
+ onStop?: (taskId: string) => void;
67
+ /**
68
+ * Callback fired when cancelling a queued task
69
+ */
70
+ onCancel?: (taskId: string) => void;
71
+ /**
72
+ * Callback fired when retrying a failed task
73
+ */
74
+ onRetry?: (taskId: string) => void;
75
+ /**
76
+ * Callback fired to stop all running tasks
77
+ */
78
+ onStopAll?: () => void;
79
+ /**
80
+ * Callback fired when a task item is clicked (e.g. to inspect terminal output or logs)
81
+ */
82
+ onTaskClick?: (task: TaskItem) => void;
83
+ /**
84
+ * Display mode:
85
+ * - 'standalone': self-contained card with full rounded corners
86
+ * - 'docked': attaches flush directly above PromptInput (matches 2.1-jobs.png)
87
+ * @default 'standalone'
88
+ */
89
+ variant?: "standalone" | "docked";
90
+ /**
91
+ * Additional CSS classes
92
+ */
93
+ className?: string;
94
+ /**
95
+ * Whether to render an empty state when tasks is empty
96
+ * @default false
97
+ */
98
+ showEmpty?: boolean;
99
+ /**
100
+ * Custom message when there are no tasks and showEmpty is true
101
+ */
102
+ emptyMessage?: string;
103
+ }