Skip to content

Commit e262849

Browse files
authored
Feat (new element): implement reusable task queue and panel (message and todo default) (#131)
* feat(hook): add useTaskQueue context and hook for unified task and message queue management * feat: add reusable TaskQueuePanel component for queue and todo management * chore (example): add TaskQueuePanel usage example with shared task queue context * Feat (example): add TaskQueuePanel to example workflow page * chore: add sample todo and message data to task queue * fix: prevent double-seeding of demo tasks in React Strict Mode with useRef guard
1 parent 7d36fdd commit e262849

File tree

4 files changed

+386
-0
lines changed

4 files changed

+386
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"use client";
2+
3+
import { useTaskQueue, TaskQueueProvider } from "@repo/elements/useTaskQueue";
4+
import { TaskQueuePanel } from "@repo/elements/task-queue-panel";
5+
import { useEffect, useRef } from "react";
6+
import type { TaskQueueItem } from "@repo/elements/useTaskQueue";
7+
8+
const DUMMY_TASKS: TaskQueueItem[] = [
9+
{
10+
id: 'todo-1',
11+
type: 'todo',
12+
title: 'Write project documentation',
13+
description: 'Complete the README and API docs',
14+
status: 'completed',
15+
},
16+
{
17+
id: 'todo-2',
18+
type: 'todo',
19+
title: 'Implement authentication',
20+
status: 'pending',
21+
},
22+
{
23+
id: 'todo-3',
24+
type: 'todo',
25+
title: 'Fix bug #42',
26+
description: 'Resolve crash on settings page',
27+
status: 'pending',
28+
},
29+
{
30+
id: 'todo-4',
31+
type: 'todo',
32+
title: 'Refactor queue logic',
33+
description: 'Unify queue and todo state management',
34+
status: 'pending',
35+
},
36+
{
37+
id: 'todo-5',
38+
type: 'todo',
39+
title: 'Add unit tests',
40+
description: 'Increase test coverage for hooks',
41+
status: 'pending',
42+
},
43+
// Sample pending messages
44+
{
45+
id: 'msg-1',
46+
type: 'message',
47+
parts: [
48+
{ type: 'text', text: 'How do I set up the project?' }
49+
]
50+
},
51+
{
52+
id: 'msg-2',
53+
type: 'message',
54+
parts: [
55+
{ type: 'text', text: 'What is the roadmap for Q4?' }
56+
]
57+
},
58+
{
59+
id: 'msg-3',
60+
type: 'message',
61+
parts: [
62+
{ type: 'text', text: 'Can you review my PR?' }
63+
]
64+
},
65+
{
66+
id: 'msg-4',
67+
type: 'message',
68+
parts: [
69+
{ type: 'text', text: 'Please generate a changelog.' }
70+
]
71+
},
72+
{
73+
id: 'msg-5',
74+
type: 'message',
75+
parts: [
76+
{ type: 'text', text: 'Add dark mode support.' }
77+
]
78+
},
79+
{
80+
id: 'msg-6',
81+
type: 'message',
82+
parts: [
83+
{ type: 'text', text: 'Optimize database queries.' }
84+
]
85+
},
86+
{
87+
id: 'msg-7',
88+
type: 'message',
89+
parts: [
90+
{ type: 'text', text: 'Set up CI/CD pipeline.' }
91+
]
92+
}
93+
]
94+
95+
const ExampleInner = () => {
96+
const { tasks, removeTask , addTask} = useTaskQueue();
97+
// prevent demo data from being seeded twice due to React 18 Strict Mode
98+
const seededRef = useRef(false);
99+
100+
// filter tasks for queue and todo
101+
const queueTasks = tasks.filter((task: typeof tasks[number]) => task.type === "message");
102+
const todoTasks = tasks.filter((task: typeof tasks[number]) => task.type === "todo");
103+
104+
105+
function getQueueMessages() {
106+
return queueTasks.map((message: Extract<typeof tasks[number], { type: "message" }>) => ({
107+
id: message.id,
108+
parts: message.parts,
109+
}));
110+
}
111+
112+
function getTodoItems() {
113+
return todoTasks.map((task: Extract<typeof tasks[number], { type: "todo" }>) => ({
114+
id: task.id,
115+
title: task.title,
116+
description: task.description,
117+
status: task.status,
118+
}));
119+
}
120+
121+
useEffect(() => {
122+
if (!seededRef.current && tasks.length === 0) {
123+
DUMMY_TASKS.forEach(addTask);
124+
seededRef.current = true;
125+
}
126+
}, [tasks.length, addTask]);
127+
128+
129+
return (
130+
<TaskQueuePanel
131+
messages={getQueueMessages()}
132+
todos={getTodoItems()}
133+
onRemoveQueue={(id: string) => removeTask(id)}
134+
onRemoveTodo={(id: string) => removeTask(id)}
135+
/>
136+
);
137+
};
138+
139+
140+
const Example = () => (
141+
<TaskQueueProvider>
142+
<ExampleInner />
143+
</TaskQueueProvider>
144+
);
145+
146+
147+
export default Example;

apps/test/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Loader from "@/app/examples/loader";
1717
import Message from "@/app/examples/message";
1818
import OpenInChat from "@/app/examples/open-in-chat";
1919
import PromptInput from "@/app/examples/prompt-input";
20+
import TaskQueuePanel from "@/app/examples/task-queue-panel";
2021
import Reasoning from "@/app/examples/reasoning";
2122
import Response from "@/app/examples/response";
2223
import Sources from "@/app/examples/sources";
@@ -42,6 +43,7 @@ const components = [
4243
{ name: "Message", Component: Message },
4344
{ name: "OpenInChat", Component: OpenInChat },
4445
{ name: "PromptInput", Component: PromptInput },
46+
{ name: "TaskQueuePanel", Component: TaskQueuePanel },
4547
{ name: "Reasoning", Component: Reasoning },
4648
{ name: "Response", Component: Response },
4749
{ name: "Sources", Component: Sources },
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { memo, useState } from "react";
2+
import { ScrollArea } from "@repo/shadcn-ui/components/ui/scroll-area";
3+
import { Trash2, Paperclip, ChevronRight, ArrowUp } from "lucide-react";
4+
import { cn } from "@repo/shadcn-ui/lib/utils";
5+
6+
export type MessagePart = { type: string; text?: string; url?: string; filename?: string; mediaType?: string };
7+
export type Message = { id: string; parts: MessagePart[] };
8+
export type TodoItem = { id: string; title: string; description?: string; status?: 'pending' | 'completed' };
9+
10+
11+
interface MessageListProps {
12+
items: Message[] | TodoItem[];
13+
type: 'message' | 'todo';
14+
onRemove: (id: string) => void;
15+
onSendNow?: (id: string) => void;
16+
}
17+
18+
function MessageList({ items, type, onRemove, onSendNow }: MessageListProps) {
19+
return (
20+
<ScrollArea className="mt-2 -mb-1">
21+
<div className="max-h-40 pr-4">
22+
<ul>
23+
{type === 'message'
24+
? (items as Message[]).map((msg) => {
25+
const summary = (() => {
26+
const textParts = msg.parts.filter((p) => 'type' in p && p.type === 'text');
27+
const text = textParts.map((p) => p.text).join(' ').trim();
28+
if (text) return text;
29+
return '(queued message)'; // (file only message)
30+
})();
31+
return (
32+
<li key={msg.id} className="group flex flex-col gap-1 py-1 px-3 text-sm text-muted-foreground rounded-md hover:bg-muted transition-colors">
33+
<div className="flex items-center gap-2">
34+
<span className="mt-0.5 inline-block size-2.5 rounded-full border border-muted-foreground/50"></span>
35+
<span className="line-clamp-1 break-words grow">{summary}</span>
36+
<div className="flex gap-1">
37+
<button
38+
type="button"
39+
className="invisible group-hover:visible rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10"
40+
onClick={(e) => {
41+
e.preventDefault();
42+
e.stopPropagation();
43+
onRemove(msg.id);
44+
}}
45+
aria-label="Remove from queue"
46+
title="Remove from queue"
47+
>
48+
<Trash2 size={12} />
49+
</button>
50+
<button
51+
type="button"
52+
className="invisible group-hover:visible rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10"
53+
onClick={(e) => {
54+
e.preventDefault();
55+
e.stopPropagation();
56+
if (onSendNow) onSendNow(msg.id);
57+
}}
58+
aria-label="Send now"
59+
>
60+
<ArrowUp size={14} />
61+
</button>
62+
</div>
63+
</div>
64+
{/* Media/file previews */}
65+
{msg.parts.some((p) => p.type === 'file' && p.url) && (
66+
<div className="flex flex-wrap gap-2 mt-1">
67+
{msg.parts.filter((p) => p.type === 'file' && p.url).map((file, i) => {
68+
if (file.mediaType && file.mediaType.startsWith('image/') && file.url) {
69+
return (
70+
<img
71+
key={i}
72+
src={file.url}
73+
alt={file.filename || 'attachment'}
74+
className="h-8 w-8 object-cover rounded border"
75+
/>
76+
);
77+
}
78+
// Other file types: show icon and filename
79+
return (
80+
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs border">
81+
<Paperclip size={12} />
82+
<span className="truncate max-w-[100px]">{file.filename || 'file'}</span>
83+
</span>
84+
);
85+
})}
86+
</div>
87+
)}
88+
</li>
89+
);
90+
})
91+
: (items as TodoItem[]).map((item) => {
92+
const isCompleted = item.status === 'completed';
93+
return (
94+
<li key={item.id} className="group flex flex-col gap-1 py-1 px-3 text-sm rounded-md hover:bg-muted transition-colors">
95+
<div className="flex items-center gap-2">
96+
<span className={`mt-0.5 inline-block size-2.5 rounded-full border ${isCompleted ? 'border-muted-foreground/20 bg-muted-foreground/10' : 'border-muted-foreground/50'}`}></span>
97+
<span className={`line-clamp-1 break-words grow font-medium ${isCompleted ? 'line-through text-muted-foreground/50' : 'text-muted-foreground'}`}>{item.title}</span>
98+
<button
99+
type="button"
100+
className="invisible group-hover:visible rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10"
101+
onClick={() => onRemove(item.id)}
102+
aria-label="Remove todo"
103+
>
104+
<Trash2 size={12} />
105+
</button>
106+
</div>
107+
{item.description && (
108+
<div className={`ml-6 text-xs ${isCompleted ? 'line-through text-muted-foreground/40' : 'text-muted-foreground'}`}>{item.description}</div>
109+
)}
110+
</li>
111+
);
112+
})}
113+
</ul>
114+
</div>
115+
</ScrollArea>
116+
);
117+
}
118+
119+
export interface TaskQueuePanelProps {
120+
messages: Message[];
121+
todos: TodoItem[];
122+
onRemoveQueue?: (id: string) => void;
123+
onRemoveTodo?: (id: string) => void;
124+
onSendNow?: (id: string) => void;
125+
}
126+
127+
// Reusable collapsible panel for both queue and todo
128+
function CollapsiblePanel({
129+
label,
130+
count,
131+
isOpen,
132+
onToggle,
133+
children,
134+
icon
135+
}: {
136+
label: string;
137+
count: number;
138+
isOpen: boolean;
139+
onToggle: () => void;
140+
children: React.ReactNode;
141+
icon?: React.ReactNode;
142+
}) {
143+
if (count === 0) return null;
144+
return (
145+
<div>
146+
<button
147+
type="button"
148+
className="flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted"
149+
onClick={onToggle}
150+
>
151+
<span className="flex items-center gap-2">
152+
<span
153+
className={cn('transition-transform', isOpen ? ' rotate-90' : ' rotate-0')}
154+
>
155+
<ChevronRight size={14} />
156+
</span>
157+
{icon}
158+
<span>{count} {label}</span>
159+
</span>
160+
</button>
161+
{isOpen && children}
162+
</div>
163+
);
164+
}
165+
166+
export const TaskQueuePanel = memo(function TaskQueuePanel({ messages, todos, onRemoveQueue, onRemoveTodo, onSendNow }: TaskQueuePanelProps) {
167+
const [queueOpen, setQueueOpen] = useState(true);
168+
const [todoOpen, setTodoOpen] = useState(true);
169+
170+
if (messages.length === 0 && todos.length === 0) return null;
171+
172+
return (
173+
<div className="rounded-t-xl border border-border border-b-0 bg-background px-3 pt-2 pb-2 shadow-xs flex flex-col gap-2">
174+
<CollapsiblePanel
175+
label="Queued"
176+
count={messages?.length ?? 0}
177+
isOpen={queueOpen}
178+
onToggle={() => setQueueOpen((v) => !v)}
179+
icon={null}
180+
>
181+
<MessageList items={messages ?? []} type="message" onRemove={onRemoveQueue || (() => {})} onSendNow={onSendNow} />
182+
</CollapsiblePanel>
183+
<CollapsiblePanel
184+
label="Todo"
185+
count={todos?.length ?? 0}
186+
isOpen={todoOpen}
187+
onToggle={() => setTodoOpen((v) => !v)}
188+
>
189+
<MessageList items={todos ?? []} type="todo" onRemove={onRemoveTodo || (() => {})}/>
190+
</CollapsiblePanel>
191+
</div>
192+
);
193+
});

0 commit comments

Comments
 (0)