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.
- package/components/composites/FilePreviewDialog/FilePreviewDialog.stories.tsx +23 -1
- package/components/composites/ProjectSwitcher/ProjectSwitcher.stories.tsx +20 -1
- package/components/composites/ProjectSwitcher/ProjectSwitcher.tsx +7 -1
- package/components/composites/TaskQueue/TaskQueue.stories.tsx +241 -0
- package/components/composites/TaskQueue/TaskQueue.tsx +270 -0
- package/components/composites/TaskQueue/index.ts +2 -0
- package/components/composites/TaskQueue/interfaces.ts +103 -0
- package/components/composites/index.ts +4 -0
- package/components/index.ts +4 -0
- package/dist/index.cjs +276 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +65 -0
- package/dist/index.d.ts +223 -116
- package/dist/index.js +276 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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: {
|
|
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: {
|
|
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
|
-
({
|
|
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,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
|
+
}
|